From c65d2cb75dfa29a56ced2c74c2d9ac86adcb06b1 Mon Sep 17 00:00:00 2001 From: oxpoe <324556608+oxpoe@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:27:16 +0700 Subject: [PATCH] =?UTF-8?q?feat(config):=20internal/config=20(load/save=20?= =?UTF-8?q?atomik=20+=20validasi)=20=E2=80=94=20wiring=20bahasa/suara/anim?= =?UTF-8?q?asi=20+=20doctor=20baca=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/e2e_test.go | 65 ++++++++++++++++- internal/app/app.go | 30 ++++++-- internal/cli/doctor.go | 19 ++++- internal/config/config.go | 128 +++++++++++++++++++++++++++++++++ internal/config/config_test.go | 93 ++++++++++++++++++++++++ internal/i18n/i18n.go | 4 -- internal/i18n/locales/en.yaml | 2 + internal/i18n/locales/id.yaml | 2 + 8 files changed, 328 insertions(+), 15 deletions(-) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 5e3f8b8..baf6e1a 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -58,7 +58,31 @@ type tui struct { waited bool } -func startTUI(t *testing.T) *tui { return startTUIEnv(t) } +// startTUI memakai TIA_LANG=id eksplisit — deterministik walau mesin +// pengembang/testing punya config.yaml sendiri. +func startTUI(t *testing.T) *tui { return startTUIEnv(t, "TIA_LANG=id") } + +// envWith — gabung env proses + override tanpa duplikat key +// (Linux getenv memakai kemunculan pertama, jadi duplikat berbahaya). +func envWith(env []string) []string { + if len(env) == 0 { + return nil + } + overridden := map[string]bool{} + for _, kv := range env { + k, _, _ := strings.Cut(kv, "=") + overridden[k+"="] = true + } + out := make([]string, 0, len(os.Environ())+len(env)) + for _, kv := range os.Environ() { + k, _, _ := strings.Cut(kv, "=") + if overridden[k+"="] { + continue + } + out = append(out, kv) + } + return append(out, env...) +} func startTUIEnv(t *testing.T, env ...string) *tui { t.Helper() @@ -66,8 +90,8 @@ func startTUIEnv(t *testing.T, env ...string) *tui { t.Skip("e2e pty tidak didukung di Windows") } cmd := exec.Command(binPath) - if len(env) > 0 { - cmd.Env = append(os.Environ(), env...) + if e := envWith(env); len(e) > 0 { + cmd.Env = e } ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: rows, Cols: cols}) if err != nil { @@ -216,6 +240,41 @@ func TestDoctorCommand(t *testing.T) { } } +func TestConfigFileRespected(t *testing.T) { + xdg := t.TempDir() + if err := os.MkdirAll(filepath.Join(xdg, "tia"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(xdg, "tia", "config.yaml"), + []byte("version: 1\nui:\n language: en\n"), 0o644); err != nil { + t.Fatal(err) + } + u := startTUIEnv(t, "XDG_CONFIG_HOME="+xdg) + u.waitContains("· home", 5*time.Second) + u.send("q") + if code := u.waitExit(5 * time.Second); code != 0 { + t.Fatalf("exit code %d, mau 0", code) + } +} + +func TestDoctorUsesConfigStacksDir(t *testing.T) { + xdg := t.TempDir() + if err := os.MkdirAll(filepath.Join(xdg, "tia"), 0o755); err != nil { + t.Fatal(err) + } + stacks := filepath.Join(xdg, "stacks-custom") + if err := os.WriteFile(filepath.Join(xdg, "tia", "config.yaml"), + []byte("version: 1\nstacks_dir: "+stacks+"\n"), 0o644); err != nil { + t.Fatal(err) + } + cmd := exec.Command(binPath, "doctor") + cmd.Env = envWith([]string{"XDG_CONFIG_HOME=" + xdg}) + out, _ := cmd.CombinedOutput() + if !strings.Contains(string(out), "stacks-custom") { + t.Fatalf("doctor tidak memakai stacks_dir dari config:\n%s", out) + } +} + func TestEnglishLanguage(t *testing.T) { u := startTUIEnv(t, "TIA_LANG=en") u.waitContains("· home", 5*time.Second) diff --git a/internal/app/app.go b/internal/app/app.go index 2aca5f8..a096708 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -11,6 +11,7 @@ import ( "charm.land/lipgloss/v2" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio" + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/config" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/sysinfo" @@ -64,12 +65,24 @@ type Model struct { } func New() Model { + cfg, cfgErr := config.Load() + // shortcut: TIA_LANG = override khusus dev/tes; sumber resmi bahasa + // tetap config (ui.language) — dihapus saat layar Pengaturan jadi. + if l := os.Getenv("TIA_LANG"); l != "" { + cfg.UI.Language = l + } + i18n.SetLang(cfg.UI.Language) + cl, err := docker.New() host, _ := os.Hostname() m := Model{ - sound: audio.New(audio.Options{Enabled: true, Volume: 70, MuteOnSSH: true}), + sound: audio.New(audio.Options{ + Enabled: cfg.UI.SoundEnabled, + Volume: cfg.UI.SoundVolume, + MuteOnSSH: cfg.UI.SoundMuteOnSSH, + }), tickID: animation.NewID(), - animating: true, + animating: cfg.UI.AnimationEnabled, toast: components.NewToast(), view: viewHome, hostname: host, @@ -77,6 +90,9 @@ func New() Model { cl: cl, loading: true, } + if cfgErr != nil { + m.toast.Show(i18n.T("toast.configError"), cfgErr.Error()) + } if err != nil { m.loadErr = err m.engineErr = err @@ -86,10 +102,14 @@ func New() Model { } func (m Model) Init() tea.Cmd { - if m.cl == nil { - return animation.Next(m.tickID) + var cmds []tea.Cmd + if m.animating { + cmds = append(cmds, animation.Next(m.tickID)) } - return tea.Batch(animation.Next(m.tickID), loadContainers(m.cl), loadInfo(m.cl)) + if m.cl != nil { + cmds = append(cmds, loadContainers(m.cl), loadInfo(m.cl)) + } + return tea.Batch(cmds...) } func loadContainers(cl *docker.Client) tea.Cmd { diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 0255270..02f0a71 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -7,11 +7,11 @@ import ( "fmt" "io" "os" - "path/filepath" "runtime" "time" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio" + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/config" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n" ) @@ -51,8 +51,21 @@ func Doctor(w io.Writer) int { fmt.Fprintf(w, "go : %s (%s/%s)\n", runtime.Version(), runtime.GOOS, runtime.GOARCH) - home, _ := os.UserHomeDir() - stacks := filepath.Join(home, ".tia", "stacks") + // Konfigurasi & direktori stack. + cfg, cfgErr := config.Load() + cfgPath := config.Path() + switch { + case cfgErr != nil: + fmt.Fprintf(w, "config : %s %v\n", i18n.T("error.warn"), cfgErr) + default: + if _, err := os.Stat(cfgPath); err != nil { + fmt.Fprintf(w, "config : %s\n", i18n.Tf("doctor.configMissing", cfgPath)) + } else { + fmt.Fprintf(w, "config : %s\n", cfgPath) + } + } + + stacks := config.ExpandPath(cfg.StacksDir) if st, err := os.Stat(stacks); err == nil && st.IsDir() { fmt.Fprintf(w, "stacks : %s\n", stacks) } else { diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..675f4d4 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,128 @@ +// Package config — baca/tulis config tia (PRD §7.1). +// +// Sesuai prinsip ponytail: struct ini memuat subset schema PRD §7.2 yang +// SUDAH dipakai; field lain ditambahkan saat fiturnya benar-benar masuk +// (supaya config tidak pernah berisi opsi mati). +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Version int `yaml:"version"` + StacksDir string `yaml:"stacks_dir"` + UI UI `yaml:"ui"` +} + +type UI struct { + Language string `yaml:"language"` // id | en + SoundEnabled bool `yaml:"sound_enabled"` + SoundVolume int `yaml:"sound_volume"` // 0-100 + SoundMuteOnSSH bool `yaml:"sound_mute_on_ssh"` + AnimationEnabled bool `yaml:"animation_enabled"` +} + +func Default() Config { + home, _ := os.UserHomeDir() + return Config{ + Version: 1, + StacksDir: filepath.Join(home, ".tia", "stacks"), + UI: UI{ + Language: "id", + SoundEnabled: true, + SoundVolume: 70, + SoundMuteOnSSH: true, + AnimationEnabled: true, + }, + } +} + +// Path — ~/.config/tia/config.yaml (mengikuti XDG_CONFIG_HOME). +func Path() string { + dir, err := os.UserConfigDir() + if err != nil { + dir = filepath.Join(os.Getenv("HOME"), ".config") + } + return filepath.Join(dir, "tia", "config.yaml") +} + +// Load membaca config; file belum ada BUKAN error (memakai default). +func Load() (Config, error) { return LoadFrom(Path()) } + +func LoadFrom(path string) (Config, error) { + c := Default() + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return c, nil + } + return c, err + } + if err := yaml.Unmarshal(b, &c); err != nil { + return Default(), fmt.Errorf("config: parse %s: %w", path, err) + } + c.normalize() + return c, nil +} + +// Save menulis config secara atomik (temp + rename) — tidak pernah setengah jadi. +func Save(c Config) error { return SaveTo(Path(), c) } + +func SaveTo(path string, c Config) error { + c.normalize() + b, err := yaml.Marshal(c) + if err != nil { + return fmt.Errorf("config: marshal: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} + +// normalize — validasi nilai (file config adalah batas kepercayaan: +// bisa diedit tangan; nilai rusak tidak boleh menjatuhkan aplikasi). +func (c *Config) normalize() { + switch c.UI.Language { + case "id", "en": + default: + c.UI.Language = "id" + } + if c.UI.SoundVolume < 0 { + c.UI.SoundVolume = 0 + } + if c.UI.SoundVolume > 100 { + c.UI.SoundVolume = 100 + } + if c.StacksDir == "" { + c.StacksDir = Default().StacksDir + } + if c.Version == 0 { + c.Version = 1 + } +} + +// ExpandPath — ubah "~/..." jadi absolut (config menyimpan path gaya user). +func ExpandPath(p string) string { + if p == "~" || strings.HasPrefix(p, "~/") { + home, err := os.UserHomeDir() + if err == nil { + return filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(p, "~"), "/")) + } + } + return p +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..04c6c37 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,93 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadMissingUsesDefaults(t *testing.T) { + c, err := LoadFrom(filepath.Join(t.TempDir(), "tidak-ada.yaml")) + if err != nil { + t.Fatalf("file tidak ada harusnya bukan error: %v", err) + } + d := Default() + if c.UI.Language != d.UI.Language || c.UI.SoundVolume != d.UI.SoundVolume { + t.Fatalf("default salah: %+v", c.UI) + } + if c.StacksDir == "" { + t.Fatal("stacks_dir default kosong") + } +} + +func TestSaveLoadRoundtrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "sub", "config.yaml") + in := Default() + in.UI.Language = "en" + in.UI.SoundVolume = 42 + in.StacksDir = "/data/stacks" + + if err := SaveTo(path, in); err != nil { + t.Fatalf("SaveTo: %v", err) + } + out, err := LoadFrom(path) + if err != nil { + t.Fatalf("LoadFrom: %v", err) + } + if out.UI.Language != "en" || out.UI.SoundVolume != 42 || out.StacksDir != "/data/stacks" { + t.Fatalf("roundtrip salah: %+v", out) + } +} + +func TestPartialYamlKeepsDefaults(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + os.WriteFile(path, []byte("ui:\n language: en\n"), 0o644) + + c, err := LoadFrom(path) + if err != nil { + t.Fatalf("LoadFrom: %v", err) + } + if c.UI.Language != "en" { + t.Fatalf("language: %s", c.UI.Language) + } + if c.UI.SoundVolume != 70 || !c.UI.SoundEnabled || !c.UI.AnimationEnabled { + t.Fatalf("field lain harus default: %+v", c.UI) + } +} + +func TestNormalizeInvalidValues(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + os.WriteFile(path, []byte("ui:\n language: klingon\n sound_volume: 999\n"), 0o644) + + c, err := LoadFrom(path) + if err != nil { + t.Fatalf("LoadFrom: %v", err) + } + if c.UI.Language != "id" { + t.Fatalf("language rusak harus fallback id: %s", c.UI.Language) + } + if c.UI.SoundVolume != 100 { + t.Fatalf("volume harus diklamp 100: %d", c.UI.SoundVolume) + } +} + +func TestMalformedYamlIsError(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + os.WriteFile(path, []byte("ui: [ini: rusak\n"), 0o644) + if _, err := LoadFrom(path); err == nil { + t.Fatal("yaml rusak harusnya error") + } +} + +func TestExpandPath(t *testing.T) { + home, _ := os.UserHomeDir() + if got := ExpandPath("~/x"); got != filepath.Join(home, "x") { + t.Fatalf("~/x = %s", got) + } + if got := ExpandPath("~"); got != home { + t.Fatalf("~ = %s", got) + } + if got := ExpandPath("/abs/path"); got != "/abs/path" { + t.Fatalf("abs berubah: %s", got) + } +} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index dfb7354..28469de 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -6,7 +6,6 @@ package i18n import ( "embed" "fmt" - "os" "sort" "sync" "time" @@ -34,9 +33,6 @@ func init() { catalogs[lang] = c } } - if l := os.Getenv("TIA_LANG"); l != "" { - active = l - } } // shortcut: bahasa hanya dari TIA_LANG + default id — akan dibaca dari diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 104902e..51f5446 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -61,6 +61,7 @@ toast: demoTitle: "✔ [ SUCCESS ] Conversion Saved" demoBody: "Stack 'web-app' saved to ~/.tia/stacks/web-app/" dockerError: "✖ [ FAILED ] Docker" + configError: "⚠ [ WARNING ] Config problem — using defaults" activity: coming: "Docker events listener coming (Phase 2)." @@ -84,6 +85,7 @@ doctor: dockerDaemonUnreachable: "daemon not responding: %v" dockerOk: "engine %s (API %s) · %d/%d containers running · %d images" stacksMissing: "%s (missing — created during onboarding)" + configMissing: "%s (missing — using defaults)" cli: unknownCommand: "unknown command: %q" diff --git a/internal/i18n/locales/id.yaml b/internal/i18n/locales/id.yaml index 94b426b..7e8f69f 100644 --- a/internal/i18n/locales/id.yaml +++ b/internal/i18n/locales/id.yaml @@ -61,6 +61,7 @@ toast: demoTitle: "✔ [ SUKSES ] Konversi Berhasil Disimpan" demoBody: "Stack 'web-app' tersimpan di ~/.tia/stacks/web-app/" dockerError: "✖ [ GAGAL ] Docker" + configError: "⚠ [ PERINGATAN ] Config bermasalah — memakai default" activity: coming: "Listener Docker events menyusul (Phase 2)." @@ -84,6 +85,7 @@ doctor: dockerDaemonUnreachable: "daemon tidak merespons: %v" dockerOk: "engine %s (API %s) · %d/%d kontainer berjalan · %d image" stacksMissing: "%s (belum ada — dibuat saat onboarding)" + configMissing: "%s (belum ada — memakai default)" cli: unknownCommand: "perintah tidak dikenal: %q"