Lapisan terakhir yang belum dibedah adalah storage.go — bagian yang membuat tugas bertahan setelah program ditutup. Desainnya sengaja dibuat tipis: satu struct kecil bernama Penyimpanan yang tahu lokasi berkas, dua method untuk membaca dan menulis, dan satu jaminan penting bahwa berkas tidak pernah rusak setengah jalan.

Format Berkas #

Seluruh daftar tugas disimpan sebagai satu array JSON dengan indentasi dua spasi. Berikut isi tugas.json untuk tiga tugas dari sesi Bagian 3 — waktunya berasal dari fixture test yang dibekukan agar contoh ini deterministik; pada pemakaian nyata, kolom dibuat berisi waktu aktual saat tugas dibuat:

json
[
  {
    "id": 1,
    "judul": "Belajar Go",
    "selesai": false,
    "dibuat": "2026-01-15T08:00:00Z"
  },
  {
    "id": 2,
    "judul": "Menulis dokumentasi",
    "selesai": true,
    "dibuat": "2026-01-15T10:00:00Z"
  },
  {
    "id": 3,
    "judul": "Rilis aplikasi",
    "selesai": false,
    "dibuat": "2026-01-15T12:00:00Z"
  }
]

Nama kunci id, judul, selesai, dan dibuat berasal dari struct tag pada Task di Bagian 2, sedangkan format waktu seperti 2026-01-15T08:00:00Z adalah hasil time.Time yang di-encode sebagai RFC 3339 — standar yang juga dipakai API modern.

Membaca: Method Muat #

Pembacaan menangani dua keadaan khusus sebelum mengembalikan data:

go
// 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
}
  1. Baris 26 (os.ReadFile(p.berkas)): seluruh isi berkas dibaca sekaligus — cukup untuk berkas kecil seperti daftar tugas.
  2. Baris 28-30: berkas yang belum ada bukanlah galat. errors.Is(err, fs.ErrNotExist) mengenali kondisi tersebut dan mengembalikan daftar kosong, karena absennya berkas berarti aplikasi dipakai pertama kali.
  3. Baris 31: kegagalan baca lain — misalnya izin ditolak — tetap dilaporkan sebagai error dengan konteks path berkasnya.
  4. Baris 34-37: json.Unmarshal mengubah teks JSON menjadi []Task; isi yang rusak menghasilkan error yang dibungkus dengan %w sehingga penyebabnya terbawa.

Penulisan memakai pola tulis-ke-berkas-sementara lalu ganti-nama:

go
// 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')

Baris 47-49 mengganti slice nil dengan slice kosong sebelum di-encode. Tanpa penggantian ini, json.MarshalIndent(nil) menghasilkan teks null alih-alih [] — dua makna yang berbeda bagi pembaca berkas. Baris 55 menambahkan baris baru di akhir berkas sesuai konvensi berkas teks Unix.

Kemudian bagian atomiknya:

go
	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
}
  1. Baris 57 (os.CreateTemp(filepath.Dir(p.berkas), "tugas-*.tmp")): berkas sementara dibuat di direktori yang sama dengan berkas asli — syarat penting agar os.Rename pada baris 72 tidak melintasi sistem berkas yang berbeda.
  2. Baris 63-71: data ditulis dan berkas sementara ditutup. Setiap kegagalan membersihkan dirinya sendiri lewat os.Remove(namaTmp) sehingga tidak ada sampah .tmp tertinggal.
  3. Baris 72 (os.Rename(namaTmp, p.berkas)): penggantian nama bersifat atomik pada sistem berkas yang sama — pembaca lain tidak pernah melihat tugas.json dalam keadaan setengah tertulis.

Berkas sementara diberi awalan tugas- dan akhiran .tmp agar mudah dikenali; Bagian 5 memverifikasi bahwa tidak ada berkas semacam itu tertinggal setelah Simpan.

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

Ringkasan Bagian 4 #

Penyimpanan selesai: Muat membedakan aplikasi baru dari berkas bermasalah, Simpan menulis secara atomik lewat temp-rename, dan format JSON-nya menjaga kunci serta waktu yang rapi. Satu lapisan lagi yang belum teruji: seluruh janji dari keempat berkas produksi dibuktikan oleh test pada Bagian 5.

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
}