From 9650cd0c82cfcc1f34164456c1056af80fdaef6f Mon Sep 17 00:00:00 2001 From: MDietermann Date: Tue, 14 Apr 2026 09:03:49 +0200 Subject: [PATCH] feat(modules): port themes module to Go --- internal/modules/themes/themes.go | 115 +++++++++++++++++++++++++ internal/modules/themes/themes_test.go | 66 ++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 internal/modules/themes/themes.go create mode 100644 internal/modules/themes/themes_test.go diff --git a/internal/modules/themes/themes.go b/internal/modules/themes/themes.go new file mode 100644 index 0000000..b35fdd0 --- /dev/null +++ b/internal/modules/themes/themes.go @@ -0,0 +1,115 @@ +// Package themes installs archduino theme files into ~/.config/archduino/themes. +package themes + +import ( + "bytes" + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" +) + +type Module struct{ cfg config.ThemesConfig } + +func New(cfg config.ThemesConfig) Module { return Module{cfg: cfg} } + +type filePair struct{ src, dst string } +type state struct{ Missing []filePair } + +func (Module) Meta() module.Meta { + return module.Meta{ID: "themes", Name: "Colorscheme themes", Description: "Install archduino themes."} +} + +func destRoot() (string, error) { + h, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(h, ".config", "archduino", "themes"), nil +} + +func (m Module) Detect(ctx context.Context) (module.State, error) { + dst, err := destRoot() + if err != nil { + return nil, err + } + var missing []filePair + err = filepath.WalkDir(m.cfg.Source, func(p string, d fs.DirEntry, werr error) error { + if werr != nil { + return werr + } + if d.IsDir() { + return nil + } + rel, _ := filepath.Rel(m.cfg.Source, p) + target := filepath.Join(dst, rel) + a, errA := os.ReadFile(p) + if errA != nil { + return errA + } + b, errB := os.ReadFile(target) + if errB != nil || !bytes.Equal(a, b) { + missing = append(missing, filePair{src: p, dst: target}) + } + return nil + }) + if err != nil { + if os.IsNotExist(err) { + return state{}, nil + } + return nil, err + } + return state{Missing: missing}, nil +} + +func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) { + s, _ := cur.(state) + if len(s.Missing) == 0 { + return module.Plan{}, nil + } + pairs := append([]filePair{}, s.Missing...) + return module.Plan{Actions: []module.Action{{ + Description: fmt.Sprintf("copy %d theme files", len(pairs)), + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + for _, fp := range pairs { + if err := os.MkdirAll(filepath.Dir(fp.dst), 0o755); err != nil { + return err + } + data, err := os.ReadFile(fp.src) + if err != nil { + return err + } + if err := os.WriteFile(fp.dst, data, 0o644); err != nil { + return err + } + } + return nil + }, + }}}, nil +} + +func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error { + for i, a := range p.Actions { + r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description) + if err := a.Exec(ctx); err != nil { + return err + } + } + return nil +} + +func (m Module) Verify(ctx context.Context) error { + st, err := m.Detect(ctx) + if err != nil { + return err + } + if miss := st.(state).Missing; len(miss) > 0 { + return fmt.Errorf("themes still missing: %d files", len(miss)) + } + return nil +} diff --git a/internal/modules/themes/themes_test.go b/internal/modules/themes/themes_test.go new file mode 100644 index 0000000..72ca8c4 --- /dev/null +++ b/internal/modules/themes/themes_test.go @@ -0,0 +1,66 @@ +package themes + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" +) + +type nop struct{} + +func (nop) Progress(string, int, int, string) {} +func (nop) Log(string, string) {} + +func TestThemesCopiesFiles(t *testing.T) { + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, "one.toml"), []byte("A"), 0o644); err != nil { + t.Fatal(err) + } + os.MkdirAll(filepath.Join(src, "sub"), 0o755) + os.WriteFile(filepath.Join(src, "sub", "two.toml"), []byte("B"), 0o644) + + home := t.TempDir() + t.Setenv("HOME", home) + m := New(config.ThemesConfig{Source: src}) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if err := m.Apply(context.Background(), p, nop{}); err != nil { + t.Fatal(err) + } + + got, _ := os.ReadFile(filepath.Join(home, ".config", "archduino", "themes", "one.toml")) + if string(got) != "A" { + t.Fatalf("one.toml: %q", got) + } + got, _ = os.ReadFile(filepath.Join(home, ".config", "archduino", "themes", "sub", "two.toml")) + if string(got) != "B" { + t.Fatalf("two.toml: %q", got) + } +} + +func TestThemesIdempotent(t *testing.T) { + src := t.TempDir() + os.WriteFile(filepath.Join(src, "x.toml"), []byte("X"), 0o644) + home := t.TempDir() + t.Setenv("HOME", home) + m := New(config.ThemesConfig{Source: src}) + _ = applyOnce(t, m) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if !p.IsEmpty() { + t.Fatalf("second plan not empty: %+v", p.Actions) + } +} + +func applyOnce(t *testing.T, m Module) error { + t.Helper() + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + return m.Apply(context.Background(), p, nop{}) +} + +var _ module.Module = Module{}