Model dari Bagian 2 belum bisa apa-apa sampai ada lapisan yang menerjemahkan perintah terminal menjadi operasi atas daftar tugas. Pada bagian ini kita menulis cli.go: satu fungsi Run yang memilah subperintah, empat fungsi operasi yang mengikuti pola sama, dan pencetak tabel yang merapikan hasil. Kontrak exit code dari halaman overview diterapkan di setiap cabang kegagalan.

Dispatch Subperintah #

Seluruh perintah masuk lewat satu pintu bernama Run:

go
// Run menjalankan satu subperintah dan mengembalikan exit code.
// Parameter sekarang menyediakan waktu saat ini sehingga test dapat
// membekukannya; out menampung hasil perintah dan errOut menampung
// pesan galat agar keduanya tidak tercampur.
func Run(args []string, penyimpanan *Penyimpanan, sekarang func() time.Time, out, errOut io.Writer) int {
	if len(args) == 0 {
		tampilkanPenggunaan(errOut)
		return exitPenggunaan
	}

	perintah, sisa := args[0], args[1:]
	switch perintah {
	case "tambah":
		return perintahTambah(sisa, penyimpanan, sekarang, out, errOut)
	case "list":
		return perintahList(sisa, penyimpanan, out, errOut)
	case "selesai":
		return perintahSelesai(sisa, penyimpanan, out, errOut)
	case "hapus":
		return perintahHapus(sisa, penyimpanan, out, errOut)
	default:
		fmt.Fprintf(errOut, "subperintah %q tidak dikenal\n", perintah)
		tampilkanPenggunaan(errOut)
		return exitPenggunaan
	}
}

Mari kita telaah baris-baris pentingnya:

  1. Baris 25 (func Run(args []string, penyimpanan *Penyimpanan, sekarang func() time.Time, out, errOut io.Writer) int): seluruh dependency luar masuk sebagai parameter — argumen, penyimpanan, jam, dan dua jalur keluaran. Nilai balik int adalah exit code yang diteruskan main ke os.Exit.
  2. Baris 26-29: aplikasi tanpa subperintah menampilkan pesan penggunaan dan mengembalikan exitPenggunaan.
  3. Baris 31-32 (perintah, sisa := args[0], args[1:]): argumen pertama menjadi nama subperintah, sisanya diteruskan ke fungsi operasi. switch kemudian memetakan nama ke fungsinya.
  4. Baris 41-44: nama yang tidak dikenal jatuh ke cabang default — pesan galat ditulis ke errOut lalu exitPenggunaan dikembalikan.

Perintah tambah #

perintahTambah mengikuti alur validasi-muat-ubah-simpan:

go
func perintahTambah(args []string, p *Penyimpanan, sekarang func() time.Time, out, errOut io.Writer) int {
	if len(args) != 1 {
		fmt.Fprintln(errOut, "perintah tambah membutuhkan satu judul tugas.")
		tampilkanPenggunaan(errOut)
		return exitPenggunaan
	}

	judul := args[0]
	if err := ValidasiJudul(judul); err != nil {
		fmt.Fprintln(errOut, err)
		return exitPenggunaan
	}

	tasks, err := p.Muat()
	if err != nil {
		fmt.Fprintln(errOut, "galat memuat tugas:", err)
		return exitData
	}

	t := Task{
		ID:     IDBerikutnya(tasks),
		Judul:  judul,
		Dibuat: sekarang(),
	}
	tasks = append(tasks, t)

	if err := p.Simpan(tasks); err != nil {
		fmt.Fprintln(errOut, "galat menyimpan tugas:", err)
		return exitData
	}

	fmt.Fprintf(out, "Tugas %d \"%s\" tersimpan.\n", t.ID, t.Judul)
	return exitSukses
}
  1. Baris 57-61: jumlah argumen diperiksa lebih dulu — judul harus tepat satu.
  2. Baris 63-67: ValidasiJudul dari Bagian 2 menolak judul kosong dengan sentinel error-nya.
  3. Baris 69-73: daftar tugas dimuat dari penyimpanan; kegagalan pembacaan dilaporkan sebagai exitData.
  4. Baris 75-80: Task baru dibangun dengan IDBerikutnya dan Dibuat: sekarang() — jam tidak dipanggil langsung dari time.Now melainkan lewat parameter, sesuai rancangan Bagian 1.
  5. Baris 87 (fmt.Fprintf(out, ...)): konfirmasi ditulis ke out sehingga pemanggil skrip dapat menyaringnya dari pesan galat.

Perintah list dan Tabel Rapi #

list memakai flag.FlagSet miliknya sendiri untuk flag --semua:

go
func perintahList(args []string, p *Penyimpanan, out, errOut io.Writer) int {
	semua := false
	fs := flag.NewFlagSet("list", flag.ContinueOnError)
	fs.SetOutput(errOut)
	fs.BoolVar(&semua, "semua", false, "tampilkan juga tugas yang sudah selesai")
	if err := fs.Parse(args); err != nil {
		return exitPenggunaan
	}

	tasks, err := p.Muat()
	if err != nil {
		fmt.Fprintln(errOut, "galat memuat tugas:", err)
		return exitData
	}

	terpilih := tasks
	if !semua {
		terpilih = TugasAktif(tasks)
	}

	switch {
	case len(tasks) == 0:
		fmt.Fprintln(out, "Belum ada tugas tersimpan.")
	case len(terpilih) == 0:
		fmt.Fprintln(out, "Tidak ada tugas yang belum selesai.")
	default:
		slices.SortStableFunc(terpilih, Bandingkan)
		cetakTabel(out, terpilih)
	}
	return exitSukses
}

Tiga keadaan ditangani terpisah pada baris 111-119: penyimpanan kosong, semua tugas sudah selesai, dan keadaan normal yang mencetak tabel. Sebelum dicetak, baris 117 mengurutkan hasil dengan slices.SortStableFunc(terpilih, Bandingkan) — pembanding dari Bagian 2 — sehingga tampilan tetap terurut meski urutan di berkas berubah. Pencetaknya memakai text/tabwriter:

go
func cetakTabel(out io.Writer, tasks []Task) {
	w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
	fmt.Fprintln(w, "ID\tSTATUS\tJUDUL")
	for _, t := range tasks {
		status := "[ ]"
		if t.Selesai {
			status = "[x]"
		}
		fmt.Fprintf(w, "%d\t%s\t%s\n", t.ID, status, t.Judul)
	}
	w.Flush()
}

Tabwriter menghitung lebar tiap kolom dari isi terpanjang lalu merapatkan seluruh baris; fungsi kita hanya perlu mengirim sel dipisah tab \t seperti pada baris 131. Hasilnya kolom sejajar sempurna tanpa format manual.

Perintah selesai dan hapus #

Kedua subperintah ini mengubah satu tugas berdasarkan id. Pola perintahSelesai:

go
func perintahSelesai(args []string, p *Penyimpanan, out, errOut io.Writer) int {
	id, ok := parseID(args, "selesai", errOut)
	if !ok {
		return exitPenggunaan
	}

	tasks, err := p.Muat()
	if err != nil {
		fmt.Fprintln(errOut, "galat memuat tugas:", err)
		return exitData
	}

	i := CariIndeks(tasks, id)
	if i < 0 {
		fmt.Fprintf(errOut, "tugas dengan id %d tidak ditemukan.\n", id)
		return exitData
	}

	tasks[i].Selesai = true
	if err := p.Simpan(tasks); err != nil {
		fmt.Fprintln(errOut, "galat menyimpan tugas:", err)
		return exitData
	}

	fmt.Fprintf(out, "Tugas %d \"%s\" ditandai selesai.\n", tasks[i].ID, tasks[i].Judul)
	return exitSukses
}

Alurnya empat tahap: parseID memvalidasi argumen (baris 137), Muat memuat data (baris 142), CariIndeks mencari posisi tugas (baris 148) dengan id tak ditemukan dilaporkan sebagai exitData, lalu field Selesai diubah dan seluruh daftar disimpan kembali (baris 154-158). perintahHapus mengikuti pola yang sama persis dengan dua perbedaan: salinan tugas disimpan dulu ke terhapus sebelum elemen dibuang lewat slices.Delete(tasks, i, i+1), dan pesan konfirmasinya menyebut tugas yang dihapus.

Validasi id terkumpul di satu fungsi bantu:

go
func parseID(args []string, perintah string, errOut io.Writer) (int, bool) {
	if len(args) != 1 {
		fmt.Fprintf(errOut, "perintah %s membutuhkan satu id tugas.\n", perintah)
		tampilkanPenggunaan(errOut)
		return 0, false
	}

	id, err := strconv.Atoi(args[0])
	if err != nil || id <= 0 {
		fmt.Fprintf(errOut, "id tugas harus berupa angka bulat positif, bukan %q.\n", args[0])
		return 0, false
	}
	return id, true
}

strconv.Atoi menolak teks non-angka, dan pemeriksaan id <= 0 menolak nol dan angka negatif — keduanya jenis kesalahan penggunaan dengan exit code 1.

Sesi Uji Lengkap #

Mari kita jalankan seluruh alur dengan penyimpanan di berkas sementara:

bash
T=$(mktemp -d)
go run . -data "$T/tugas.json" tambah "Belajar Go"
go run . -data "$T/tugas.json" tambah "Tulis dokumentasi"
go run . -data "$T/tugas.json" tambah "Rilis aplikasi"
go run . -data "$T/tugas.json" selesai 2
go run . -data "$T/tugas.json" list
go run . -data "$T/tugas.json" list --semua
go run . -data "$T/tugas.json" hapus 1
go run . -data "$T/tugas.json" list

Output yang Dihasilkan:

text
Tugas 1 "Belajar Go" tersimpan.
Tugas 2 "Tulis dokumentasi" tersimpan.
Tugas 3 "Rilis aplikasi" tersimpan.
Tugas 2 "Tulis dokumentasi" ditandai selesai.
ID  STATUS  JUDUL
1   [ ]     Belajar Go
3   [ ]     Rilis aplikasi
ID  STATUS  JUDUL
1   [ ]     Belajar Go
2   [x]     Tulis dokumentasi
3   [ ]     Rilis aplikasi
Tugas 1 "Belajar Go" dihapus.
ID  STATUS  JUDUL
3   [ ]     Rilis aplikasi

Perhatikan tiga perilaku pada keluaran: list tanpa --semua menyembunyikan tugas id 2 yang sudah selesai; list --semua menampilkannya dengan penanda [x]; dan setelah tugas 1 dihapus, tugas 3 tetap mempertahankan id aslinya karena IDBerikutnya tidak pernah memakai ulang nomor.

Kode lengkap cli.go tersedia pada bagian Berkas Sumber di bawah halaman ini.

Ringkasan Bagian 3 #

Empat subperintah kini utuh: dispatch satu pintu di Run, alur validasi-muat-ubah-simpan yang konsisten, tabel rapi dari tabwriter, dan id yang divalidasi di satu tempat. Yang belum dibedah adalah isi Muat dan Simpan yang selama ini dipanggil terus-menerus — Bagian 4 membuka penyimpanan JSON tersebut.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

cli_test.go

Unduh berkas
go
package main

import (
	"bytes"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"
)

// penggunaUji menjalankan Run dengan penyimpanan baru berisi t.TempDir(),
// waktu beku, dan penampung keluaran. Hasilnya stdout, stderr, dan exit code.
func penggunaUji(t *testing.T, args ...string) (string, string, int) {
	t.Helper()
	p := PenyimpananBaru(filepath.Join(t.TempDir(), "tugas.json"))
	var out, errOut bytes.Buffer
	exit := Run(args, p, func() time.Time { return waktuBeku }, &out, &errOut)
	return out.String(), errOut.String(), exit
}

func TestRunTanpaSubperintah(t *testing.T) {
	stdout, stderr, exit := penggunaUji(t)

	if exit != exitPenggunaan {
		t.Errorf("exit = %d; want %d", exit, exitPenggunaan)
	}
	if stdout != "" {
		t.Errorf("stdout = %q; want kosong", stdout)
	}
	if !strings.Contains(stderr, "Penggunaan:") {
		t.Errorf("stderr = %q; want pesan penggunaan", stderr)
	}
}

func TestRunAlurLengkap(t *testing.T) {
	p := PenyimpananBaru(filepath.Join(t.TempDir(), "tugas.json"))
	sekarang := func() time.Time { return waktuBeku }

	jalankan := func(wantExit int, args ...string) string {
		t.Helper()
		var out, errOut bytes.Buffer
		if got := Run(args, p, sekarang, &out, &errOut); got != wantExit {
			t.Fatalf("Run(%v) exit = %d; want %d", args, got, wantExit)
		}
		if errOut.Len() > 0 {
			t.Fatalf("Run(%v) stderr tak terduga: %q", args, errOut.String())
		}
		return out.String()
	}

	stdout := jalankan(exitSukses, "tambah", "Belajar Go")
	if stdout != "Tugas 1 \"Belajar Go\" tersimpan.\n" {
		t.Errorf("tambah stdout = %q", stdout)
	}
	jalankan(exitSukses, "tambah", "Tulis dokumentasi")
	jalankan(exitSukses, "selesai", "2")

	stdout = jalankan(exitSukses, "list")
	want := "ID  STATUS  JUDUL\n1   [ ]     Belajar Go\n"
	if stdout != want {
		t.Errorf("list stdout = %q; want %q", stdout, want)
	}

	stdout = jalankan(exitSukses, "list", "--semua")
	want = "ID  STATUS  JUDUL\n1   [ ]     Belajar Go\n2   [x]     Tulis dokumentasi\n"
	if stdout != want {
		t.Errorf("list --semua stdout = %q; want %q", stdout, want)
	}

	jalankan(exitSukses, "hapus", "1")
	stdout = jalankan(exitSukses, "list")
	if stdout != "Tidak ada tugas yang belum selesai.\n" {
		t.Errorf("list setelah hapus stdout = %q", stdout)
	}
}

func TestRunTulisJSON(t *testing.T) {
	berkas := filepath.Join(t.TempDir(), "tugas.json")
	p := PenyimpananBaru(berkas)
	var out, errOut bytes.Buffer

	if got := Run([]string{"tambah", "Belajar Go"}, p, func() time.Time { return waktuBeku }, &out, &errOut); got != exitSukses {
		t.Fatalf("Run tambah exit = %d; want %d", got, exitSukses)
	}

	data, err := os.ReadFile(berkas)
	if err != nil {
		t.Fatalf("membaca berkas hasil tambah: %v", err)
	}
	want := `[
  {
    "id": 1,
    "judul": "Belajar Go",
    "selesai": false,
    "dibuat": "2026-01-15T08:00:00Z"
  }
]
`
	if string(data) != want {
		t.Errorf("isi berkas = %q; want %q", data, want)
	}
}

func TestRunKesalahan(t *testing.T) {
	cases := []struct {
		nama             string
		args             []string
		wantExit         int
		wantErrSepenggal string
	}{
		{"subperintah tak dikenal", []string{"lihat"}, exitPenggunaan, "tidak dikenal"},
		{"tambah tanpa judul", []string{"tambah"}, exitPenggunaan, "membutuhkan satu judul"},
		{"tambah dua judul", []string{"tambah", "a", "b"}, exitPenggunaan, "membutuhkan satu judul"},
		{"tambah judul kosong", []string{"tambah", "   "}, exitPenggunaan, "judul tugas tidak boleh kosong"},
		{"selesai id bukan angka", []string{"selesai", "abc"}, exitPenggunaan, "harus berupa angka"},
		{"selesai id negatif", []string{"selesai", "-1"}, exitPenggunaan, "harus berupa angka"},
		{"selesai id tidak ada", []string{"selesai", "9"}, exitData, "tidak ditemukan"},
		{"hapus id tidak ada", []string{"hapus", "9"}, exitData, "tidak ditemukan"},
		{"list flag tak dikenal", []string{"list", "--salah"}, exitPenggunaan, "flag provided but not defined"},
	}

	for _, c := range cases {
		t.Run(c.nama, func(t *testing.T) {
			stdout, stderr, exit := penggunaUji(t, c.args...)

			if exit != c.wantExit {
				t.Errorf("exit = %d; want %d", exit, c.wantExit)
			}
			if stdout != "" {
				t.Errorf("stdout = %q; want kosong", stdout)
			}
			if !strings.Contains(stderr, c.wantErrSepenggal) {
				t.Errorf("stderr = %q; want pesan memuat %q", stderr, c.wantErrSepenggal)
			}
		})
	}
}

func TestRunListKosong(t *testing.T) {
	stdout, _, exit := penggunaUji(t, "list")

	if exit != exitSukses {
		t.Errorf("exit = %d; want %d", exit, exitSukses)
	}
	if stdout != "Belum ada tugas tersimpan.\n" {
		t.Errorf("list pada penyimpanan kosong stdout = %q", stdout)
	}
}

cli.go

Unduh berkas
go
package main

import (
	"flag"
	"fmt"
	"io"
	"slices"
	"strconv"
	"text/tabwriter"
	"time"
)

// Kontrak exit code program. Nilai ini dibaca oleh skrip lain
// atau pipeline yang memanggil tugas.
const (
	exitSukses     = 0 // perintah berhasil
	exitPenggunaan = 1 // subperintah tak dikenal atau argumen salah
	exitData       = 2 // id tak ditemukan atau kegagalan penyimpanan
)

// Run menjalankan satu subperintah dan mengembalikan exit code.
// Parameter sekarang menyediakan waktu saat ini sehingga test dapat
// membekukannya; out menampung hasil perintah dan errOut menampung
// pesan galat agar keduanya tidak tercampur.
func Run(args []string, penyimpanan *Penyimpanan, sekarang func() time.Time, out, errOut io.Writer) int {
	if len(args) == 0 {
		tampilkanPenggunaan(errOut)
		return exitPenggunaan
	}

	perintah, sisa := args[0], args[1:]
	switch perintah {
	case "tambah":
		return perintahTambah(sisa, penyimpanan, sekarang, out, errOut)
	case "list":
		return perintahList(sisa, penyimpanan, out, errOut)
	case "selesai":
		return perintahSelesai(sisa, penyimpanan, out, errOut)
	case "hapus":
		return perintahHapus(sisa, penyimpanan, out, errOut)
	default:
		fmt.Fprintf(errOut, "subperintah %q tidak dikenal\n", perintah)
		tampilkanPenggunaan(errOut)
		return exitPenggunaan
	}
}

func tampilkanPenggunaan(errOut io.Writer) {
	fmt.Fprintln(errOut, "Penggunaan:")
	fmt.Fprintln(errOut, "  tugas [-data <berkas>] tambah \"<judul>\"")
	fmt.Fprintln(errOut, "  tugas [-data <berkas>] list [--semua]")
	fmt.Fprintln(errOut, "  tugas [-data <berkas>] selesai <id>")
	fmt.Fprintln(errOut, "  tugas [-data <berkas>] hapus <id>")
}

func perintahTambah(args []string, p *Penyimpanan, sekarang func() time.Time, out, errOut io.Writer) int {
	if len(args) != 1 {
		fmt.Fprintln(errOut, "perintah tambah membutuhkan satu judul tugas.")
		tampilkanPenggunaan(errOut)
		return exitPenggunaan
	}

	judul := args[0]
	if err := ValidasiJudul(judul); err != nil {
		fmt.Fprintln(errOut, err)
		return exitPenggunaan
	}

	tasks, err := p.Muat()
	if err != nil {
		fmt.Fprintln(errOut, "galat memuat tugas:", err)
		return exitData
	}

	t := Task{
		ID:     IDBerikutnya(tasks),
		Judul:  judul,
		Dibuat: sekarang(),
	}
	tasks = append(tasks, t)

	if err := p.Simpan(tasks); err != nil {
		fmt.Fprintln(errOut, "galat menyimpan tugas:", err)
		return exitData
	}

	fmt.Fprintf(out, "Tugas %d \"%s\" tersimpan.\n", t.ID, t.Judul)
	return exitSukses
}

func perintahList(args []string, p *Penyimpanan, out, errOut io.Writer) int {
	semua := false
	fs := flag.NewFlagSet("list", flag.ContinueOnError)
	fs.SetOutput(errOut)
	fs.BoolVar(&semua, "semua", false, "tampilkan juga tugas yang sudah selesai")
	if err := fs.Parse(args); err != nil {
		return exitPenggunaan
	}

	tasks, err := p.Muat()
	if err != nil {
		fmt.Fprintln(errOut, "galat memuat tugas:", err)
		return exitData
	}

	terpilih := tasks
	if !semua {
		terpilih = TugasAktif(tasks)
	}

	switch {
	case len(tasks) == 0:
		fmt.Fprintln(out, "Belum ada tugas tersimpan.")
	case len(terpilih) == 0:
		fmt.Fprintln(out, "Tidak ada tugas yang belum selesai.")
	default:
		slices.SortStableFunc(terpilih, Bandingkan)
		cetakTabel(out, terpilih)
	}
	return exitSukses
}

func cetakTabel(out io.Writer, tasks []Task) {
	w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
	fmt.Fprintln(w, "ID\tSTATUS\tJUDUL")
	for _, t := range tasks {
		status := "[ ]"
		if t.Selesai {
			status = "[x]"
		}
		fmt.Fprintf(w, "%d\t%s\t%s\n", t.ID, status, t.Judul)
	}
	w.Flush()
}

func perintahSelesai(args []string, p *Penyimpanan, out, errOut io.Writer) int {
	id, ok := parseID(args, "selesai", errOut)
	if !ok {
		return exitPenggunaan
	}

	tasks, err := p.Muat()
	if err != nil {
		fmt.Fprintln(errOut, "galat memuat tugas:", err)
		return exitData
	}

	i := CariIndeks(tasks, id)
	if i < 0 {
		fmt.Fprintf(errOut, "tugas dengan id %d tidak ditemukan.\n", id)
		return exitData
	}

	tasks[i].Selesai = true
	if err := p.Simpan(tasks); err != nil {
		fmt.Fprintln(errOut, "galat menyimpan tugas:", err)
		return exitData
	}

	fmt.Fprintf(out, "Tugas %d \"%s\" ditandai selesai.\n", tasks[i].ID, tasks[i].Judul)
	return exitSukses
}

func perintahHapus(args []string, p *Penyimpanan, out, errOut io.Writer) int {
	id, ok := parseID(args, "hapus", errOut)
	if !ok {
		return exitPenggunaan
	}

	tasks, err := p.Muat()
	if err != nil {
		fmt.Fprintln(errOut, "galat memuat tugas:", err)
		return exitData
	}

	i := CariIndeks(tasks, id)
	if i < 0 {
		fmt.Fprintf(errOut, "tugas dengan id %d tidak ditemukan.\n", id)
		return exitData
	}

	terhapus := tasks[i]
	tasks = slices.Delete(tasks, i, i+1)
	if err := p.Simpan(tasks); err != nil {
		fmt.Fprintln(errOut, "galat menyimpan tugas:", err)
		return exitData
	}

	fmt.Fprintf(out, "Tugas %d \"%s\" dihapus.\n", terhapus.ID, terhapus.Judul)
	return exitSukses
}

func parseID(args []string, perintah string, errOut io.Writer) (int, bool) {
	if len(args) != 1 {
		fmt.Fprintf(errOut, "perintah %s membutuhkan satu id tugas.\n", perintah)
		tampilkanPenggunaan(errOut)
		return 0, false
	}

	id, err := strconv.Atoi(args[0])
	if err != nil || id <= 0 {
		fmt.Fprintf(errOut, "id tugas harus berupa angka bulat positif, bukan %q.\n", args[0])
		return 0, false
	}
	return id, true
}

main.go

Unduh berkas
go
package main

import (
	"flag"
	"os"
	"time"
)

// berkasDefault adalah lokasi penyimpanan JSON ketika flag -data tidak dipakai.
const berkasDefault = "tugas.json"

func main() {
	berkas := flag.String("data", berkasDefault, "berkas penyimpanan JSON")
	flag.Parse()

	penyimpanan := PenyimpananBaru(*berkas)

	// os.Exit dipanggil di luar Run agar defer di dalam Run tetap bekerja.
	os.Exit(Run(flag.Args(), penyimpanan, time.Now, os.Stdout, os.Stderr))
}

storage_test.go

Unduh berkas
go
package main

import (
	"os"
	"path/filepath"
	"testing"
)

func TestSimpanMuat(t *testing.T) {
	p := PenyimpananBaru(filepath.Join(t.TempDir(), "tugas.json"))
	asli := tugasContoh()

	if err := p.Simpan(asli); err != nil {
		t.Fatalf("Simpan error tak terduga: %v", err)
	}

	dimuat, err := p.Muat()
	if err != nil {
		t.Fatalf("Muat error tak terduga: %v", err)
	}
	if len(dimuat) != len(asli) {
		t.Fatalf("Muat mengembalikan %d tugas; want %d", len(dimuat), len(asli))
	}
	for i := range asli {
		if !dimuat[i].Equal(asli[i]) {
			t.Errorf("tugas ke-%d berbeda setelah muat: got %+v; want %+v", i, dimuat[i], asli[i])
		}
	}
}

func TestMuatBerkasBelumAda(t *testing.T) {
	p := PenyimpananBaru(filepath.Join(t.TempDir(), "belum-ada.json"))

	tasks, err := p.Muat()
	if err != nil {
		t.Fatalf("Muat pada berkas yang belum ada error: %v", err)
	}
	if len(tasks) != 0 {
		t.Errorf("Muat mengembalikan %d tugas; want 0", len(tasks))
	}
}

func TestMuatJSONRusak(t *testing.T) {
	berkas := filepath.Join(t.TempDir(), "rusak.json")
	if err := os.WriteFile(berkas, []byte("bukan json"), 0o600); err != nil {
		t.Fatalf("menulis berkas uji: %v", err)
	}

	p := PenyimpananBaru(berkas)
	if _, err := p.Muat(); err == nil {
		t.Fatal("Muat pada JSON rusak harus error, tetapi berhasil")
	}
}

func TestSimpanIsiBerkas(t *testing.T) {
	direktori := t.TempDir()
	p := PenyimpananBaru(filepath.Join(direktori, "tugas.json"))

	if err := p.Simpan([]Task{{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}}); err != nil {
		t.Fatalf("Simpan error tak terduga: %v", err)
	}

	data, err := os.ReadFile(filepath.Join(direktori, "tugas.json"))
	if err != nil {
		t.Fatalf("membaca berkas hasil Simpan: %v", err)
	}

	want := `[
  {
    "id": 1,
    "judul": "Belajar Go",
    "selesai": false,
    "dibuat": "2026-01-15T08:00:00Z"
  }
]
`
	if string(data) != want {
		t.Errorf("isi berkas = %q; want %q", data, want)
	}

	sisa, err := filepath.Glob(filepath.Join(direktori, "tugas-*.tmp"))
	if err != nil {
		t.Fatalf("Glob error tak terduga: %v", err)
	}
	if len(sisa) != 0 {
		t.Errorf("berkas sementara tertinggal setelah Simpan: %v", sisa)
	}
}

func TestSimpanDaftarKosong(t *testing.T) {
	berkas := filepath.Join(t.TempDir(), "tugas.json")
	p := PenyimpananBaru(berkas)

	if err := p.Simpan(nil); err != nil {
		t.Fatalf("Simpan(nil) error tak terduga: %v", err)
	}

	data, err := os.ReadFile(berkas)
	if err != nil {
		t.Fatalf("membaca berkas hasil Simpan: %v", err)
	}
	if string(data) != "[]\n" {
		t.Errorf("isi berkas = %q; want %q", data, "[]\n")
	}
}

func TestSimpanMenggantiIsi(t *testing.T) {
	p := PenyimpananBaru(filepath.Join(t.TempDir(), "tugas.json"))

	if err := p.Simpan(tugasContoh()); err != nil {
		t.Fatalf("Simpan pertama error: %v", err)
	}
	if err := p.Simpan(nil); err != nil {
		t.Fatalf("Simpan kedua error: %v", err)
	}

	dimuat, err := p.Muat()
	if err != nil {
		t.Fatalf("Muat error tak terduga: %v", err)
	}
	if len(dimuat) != 0 {
		t.Errorf("Muat setelah Simpan kedua mengembalikan %d tugas; want 0", len(dimuat))
	}
}

storage.go

Unduh berkas
go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
)

// Penyimpanan mengurus pembacaan dan penulisan daftar tugas
// ke satu berkas JSON.
type Penyimpanan struct {
	berkas string
}

// PenyimpananBaru membuat penyimpanan yang menunjuk ke path berkas JSON.
func PenyimpananBaru(berkas string) *Penyimpanan {
	return &Penyimpanan{berkas: berkas}
}

// Muat membaca seluruh tugas dari berkas JSON.
// Berkas yang belum ada dianggap daftar kosong untuk aplikasi baru.
func (p *Penyimpanan) Muat() ([]Task, error) {
	isi, err := os.ReadFile(p.berkas)
	if err != nil {
		if errors.Is(err, fs.ErrNotExist) {
			return nil, nil
		}
		return nil, fmt.Errorf("membaca berkas %s: %w", p.berkas, err)
	}

	var tasks []Task
	if err := json.Unmarshal(isi, &tasks); err != nil {
		return nil, fmt.Errorf("mengurai JSON dari %s: %w", p.berkas, err)
	}
	return tasks, nil
}

// Simpan menulis seluruh tugas ke berkas JSON secara atomik:
// data ditulis dulu ke berkas sementara di direktori yang sama,
// lalu berkas sementara menggantikan berkas asli lewat rename.
// Dengan urutan ini, berkas asli tidak pernah berada dalam keadaan
// setengah tertulis.
func (p *Penyimpanan) Simpan(tasks []Task) error {
	if tasks == nil {
		tasks = []Task{}
	}

	data, err := json.MarshalIndent(tasks, "", "  ")
	if err != nil {
		return fmt.Errorf("menyusun JSON: %w", err)
	}
	data = append(data, '\n')

	berkasTmp, err := os.CreateTemp(filepath.Dir(p.berkas), "tugas-*.tmp")
	if err != nil {
		return fmt.Errorf("membuat berkas sementara: %w", err)
	}
	namaTmp := berkasTmp.Name()

	if _, err := berkasTmp.Write(data); err != nil {
		berkasTmp.Close()
		os.Remove(namaTmp)
		return fmt.Errorf("menulis berkas sementara: %w", err)
	}
	if err := berkasTmp.Close(); err != nil {
		os.Remove(namaTmp)
		return fmt.Errorf("menutup berkas sementara: %w", err)
	}
	if err := os.Rename(namaTmp, p.berkas); err != nil {
		os.Remove(namaTmp)
		return fmt.Errorf("mengganti berkas penyimpanan: %w", err)
	}
	return nil
}

task_test.go

Unduh berkas
go
package main

import (
	"encoding/json"
	"errors"
	"testing"
	"time"
)

// waktuBeku dipakai seluruh test agar hasil perbandingan deterministik.
var waktuBeku = time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC)

// tugasContoh mengembalikan salinan segar daftar tugas untuk test.
func tugasContoh() []Task {
	return []Task{
		{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku},
		{ID: 2, Judul: "Menulis dokumentasi", Selesai: true, Dibuat: waktuBeku.Add(2 * time.Hour)},
		{ID: 3, Judul: "Rilis aplikasi", Dibuat: waktuBeku.Add(4 * time.Hour)},
	}
}

func TestValidasiJudul(t *testing.T) {
	cases := []struct {
		nama        string
		judul       string
		wantErrSent error
	}{
		{"judul normal", "Belajar Go", nil},
		{"judul dengan spasi di tepi", "  Belajar Go  ", nil},
		{"judul kosong", "", ErrJudulKosong},
		{"judul hanya spasi", "   ", ErrJudulKosong},
		{"judul hanya tab", "\t", ErrJudulKosong},
	}

	for _, c := range cases {
		t.Run(c.nama, func(t *testing.T) {
			err := ValidasiJudul(c.judul)

			if c.wantErrSent == nil {
				if err != nil {
					t.Fatalf("ValidasiJudul(%q) error tak terduga: %v", c.judul, err)
				}
				return
			}

			if !errors.Is(err, c.wantErrSent) {
				t.Errorf("ValidasiJudul(%q) error = %v; want %v", c.judul, err, c.wantErrSent)
			}
		})
	}
}

func TestEqual(t *testing.T) {
	cases := []struct {
		nama      string
		t, other  Task
		wantEqual bool
	}{
		{"isi sama persis", Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}, Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}, true},
		{"id berbeda", Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}, Task{ID: 2, Judul: "Belajar Go", Dibuat: waktuBeku}, false},
		{"judul berbeda", Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}, Task{ID: 1, Judul: "Belajar PHP", Dibuat: waktuBeku}, false},
		{"status berbeda", Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}, Task{ID: 1, Judul: "Belajar Go", Selesai: true, Dibuat: waktuBeku}, false},
		{"waktu berbeda", Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}, Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku.Add(time.Hour)}, false},
		{"instan sama zona beda", Task{ID: 1, Dibuat: waktuBeku}, Task{ID: 1, Dibuat: time.Date(2026, 1, 15, 15, 0, 0, 0, time.FixedZone("WIB", 7*60*60))}, true},
	}

	for _, c := range cases {
		t.Run(c.nama, func(t *testing.T) {
			if got := c.t.Equal(c.other); got != c.wantEqual {
				t.Errorf("Equal = %v; want %v", got, c.wantEqual)
			}
		})
	}
}

func TestBandingkan(t *testing.T) {
	cases := []struct {
		nama     string
		t, other Task
		wantSign int // -1, 0, atau 1
	}{
		{"dibuat lebih dulu", Task{ID: 1, Dibuat: waktuBeku}, Task{ID: 2, Dibuat: waktuBeku.Add(time.Hour)}, -1},
		{"dibuat lebih lambat", Task{ID: 2, Dibuat: waktuBeku.Add(time.Hour)}, Task{ID: 1, Dibuat: waktuBeku}, 1},
		{"waktu sama id lebih kecil", Task{ID: 1, Dibuat: waktuBeku}, Task{ID: 2, Dibuat: waktuBeku}, -1},
		{"waktu sama id lebih besar", Task{ID: 3, Dibuat: waktuBeku}, Task{ID: 2, Dibuat: waktuBeku}, 1},
	}

	for _, c := range cases {
		t.Run(c.nama, func(t *testing.T) {
			got := Bandingkan(c.t, c.other)
			if got == 0 || (got < 0) != (c.wantSign < 0) {
				t.Errorf("Bandingkan = %d; want tanda %d", got, c.wantSign)
			}
		})
	}
}

func TestIDBerikutnya(t *testing.T) {
	cases := []struct {
		nama  string
		tasks []Task
		want  int
	}{
		{"daftar kosong", nil, 1},
		{"berurutan", tugasContoh(), 4},
		{"id terbesar di tengah", []Task{{ID: 7}, {ID: 3}, {ID: 5}}, 8},
	}

	for _, c := range cases {
		t.Run(c.nama, func(t *testing.T) {
			if got := IDBerikutnya(c.tasks); got != c.want {
				t.Errorf("IDBerikutnya = %d; want %d", got, c.want)
			}
		})
	}
}

func TestTugasAktif(t *testing.T) {
	aktif := TugasAktif(tugasContoh())

	if len(aktif) != 2 {
		t.Fatalf("TugasAktif mengembalikan %d tugas; want 2", len(aktif))
	}
	for _, tsk := range aktif {
		if tsk.Selesai {
			t.Errorf("tugas %d selesai tetapi ikut terpilih", tsk.ID)
		}
	}

	if got := TugasAktif(nil); len(got) != 0 {
		t.Errorf("TugasAktif(nil) mengembalikan %d tugas; want 0", len(got))
	}
}

func TestCariIndeks(t *testing.T) {
	tasks := tugasContoh()

	if got := CariIndeks(tasks, 2); got != 1 {
		t.Errorf("CariIndeks(tasks, 2) = %d; want 1", got)
	}
	if got := CariIndeks(tasks, 9); got != -1 {
		t.Errorf("CariIndeks(tasks, 9) = %d; want -1", got)
	}
	if got := CariIndeks(nil, 1); got != -1 {
		t.Errorf("CariIndeks(nil, 1) = %d; want -1", got)
	}
}

func TestTaskMarshalJSON(t *testing.T) {
	b, err := json.Marshal(Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku})
	if err != nil {
		t.Fatalf("Marshal error tak terduga: %v", err)
	}

	want := `{"id":1,"judul":"Belajar Go","selesai":false,"dibuat":"2026-01-15T08:00:00Z"}`
	if string(b) != want {
		t.Errorf("Marshal = %s; want %s", b, want)
	}
}

task.go

Unduh berkas
go
package main

import (
	"errors"
	"strings"
	"time"
)

// Task menyimpan satu tugas pada daftar tugas.
type Task struct {
	ID      int       `json:"id"`
	Judul   string    `json:"judul"`
	Selesai bool      `json:"selesai"`
	Dibuat  time.Time `json:"dibuat"`
}

// ErrJudulKosong adalah sentinel error untuk judul yang kosong
// atau hanya berisi spasi.
var ErrJudulKosong = errors.New("judul tugas tidak boleh kosong")

// ValidasiJudul memeriksa bahwa judul berisi teks yang berarti,
// bukan string kosong ataupun hanya spasi.
func ValidasiJudul(judul string) error {
	if strings.TrimSpace(judul) == "" {
		return ErrJudulKosong
	}
	return nil
}

// Equal melaporkan apakah dua Task memiliki isi field yang sama persis.
// Perbandingan waktu memakai time.Time.Equal sehingga dua instan waktu
// yang sama tetap dianggap setara meski zona waktunya berbeda.
func (t Task) Equal(other Task) bool {
	return t.ID == other.ID &&
		t.Judul == other.Judul &&
		t.Selesai == other.Selesai &&
		t.Dibuat.Equal(other.Dibuat)
}

// Bandingkan membandingkan dua Task menurut waktu dibuat, dengan ID
// sebagai pemecah seri. Hasil negatif bila t dibuat lebih dulu, nol
// bila keduanya setara, dan positif bila t dibuat lebih lambat.
func Bandingkan(t, other Task) int {
	if c := t.Dibuat.Compare(other.Dibuat); c != 0 {
		return c
	}
	return t.ID - other.ID
}

// IDBerikutnya menghitung id untuk tugas baru: id terbesar saat ini + 1.
// Id tidak pernah dipakai ulang meskipun ada tugas yang dihapus.
func IDBerikutnya(tasks []Task) int {
	terbesar := 0
	for _, t := range tasks {
		if t.ID > terbesar {
			terbesar = t.ID
		}
	}
	return terbesar + 1
}

// TugasAktif mengembalikan hanya tugas yang belum selesai.
func TugasAktif(tasks []Task) []Task {
	var aktif []Task
	for _, t := range tasks {
		if !t.Selesai {
			aktif = append(aktif, t)
		}
	}
	return aktif
}

// CariIndeks mencari indeks tugas berdasarkan id pada slice.
// Fungsi mengembalikan -1 bila id tidak ditemukan.
func CariIndeks(tasks []Task, id int) int {
	for i, t := range tasks {
		if t.ID == id {
			return i
		}
	}
	return -1
}