feat(config): internal/config (load/save atomik + validasi) — wiring bahasa/suara/animasi + doctor baca config
CI / test (push) Successful in 13m3s

This commit is contained in:
oxpoe committed 2026-10-15 13:27:16 +07:00
1 parent 36bc33f904
commit c65d2cb75d
8 files changed
+328 -15

No files matched your search

+62 -3
View File
@@ -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)
+25 -5
View File
@@ -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 {
+16 -3
View File
@@ -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 {
+128
View File
@@ -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
}
+93
View File
@@ -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)
}
}
-4
View File
@@ -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
+2
View File
@@ -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"
+2
View File
@@ -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"