diff --git a/internal/modules/zsh/zsh.go b/internal/modules/zsh/zsh.go new file mode 100644 index 0000000..cc314b0 --- /dev/null +++ b/internal/modules/zsh/zsh.go @@ -0,0 +1,132 @@ +// Package zsh installs zsh, oh-my-zsh, and configured plugins. +package zsh + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type Module struct { + cfg config.ZshConfig + pm platform.PackageManager + git platform.GitClient +} + +func New(cfg config.ZshConfig, pm platform.PackageManager, git platform.GitClient) Module { + return Module{cfg: cfg, pm: pm, git: git} +} + +type state struct { + ZshInstalled bool + OMZInstalled bool + MissingPlugins []config.ZshPlugin +} + +func omzHome() (string, error) { + h, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(h, ".oh-my-zsh"), nil +} + +func (Module) Meta() module.Meta { + return module.Meta{ID: "zsh", Name: "Zsh + oh-my-zsh", Description: "Install zsh, oh-my-zsh, and plugins."} +} + +func (m Module) Detect(ctx context.Context) (module.State, error) { + var s state + ok, err := m.pm.IsInstalled(ctx, "zsh") + if err != nil { + return nil, err + } + s.ZshInstalled = ok + omz, err := omzHome() + if err != nil { + return nil, err + } + if _, err := os.Stat(omz); err == nil { + s.OMZInstalled = true + } + for _, p := range m.cfg.Plugins { + path := filepath.Join(omz, "custom", "plugins", p.Name, ".git") + if _, err := os.Stat(path); err != nil { + s.MissingPlugins = append(s.MissingPlugins, p) + } + } + return s, nil +} + +func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) { + s, _ := cur.(state) + var actions []module.Action + if !s.ZshInstalled { + actions = append(actions, module.Action{ + Description: "pacman install zsh", + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"zsh"}) }, + }) + } + if !s.OMZInstalled { + actions = append(actions, module.Action{ + Description: "clone oh-my-zsh from " + m.cfg.OhMyZshRepo, + Kind: module.ActionGitClone, + Exec: func(ctx context.Context) error { + omz, err := omzHome() + if err != nil { + return err + } + return m.git.EnsureRepo(ctx, m.cfg.OhMyZshRepo, omz) + }, + }) + } + for _, p := range s.MissingPlugins { + p := p // capture + actions = append(actions, module.Action{ + Description: "clone plugin " + p.Name, + Kind: module.ActionGitClone, + Exec: func(ctx context.Context) error { + omz, err := omzHome() + if err != nil { + return err + } + return m.git.EnsureRepo(ctx, p.Repo, filepath.Join(omz, "custom", "plugins", p.Name)) + }, + }) + } + return module.Plan{Actions: actions}, 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 + } + s := st.(state) + if !s.ZshInstalled { + return fmt.Errorf("zsh not installed") + } + if !s.OMZInstalled { + return fmt.Errorf("oh-my-zsh not installed") + } + if len(s.MissingPlugins) > 0 { + return fmt.Errorf("plugins missing: %v", s.MissingPlugins) + } + return nil +} diff --git a/internal/modules/zsh/zsh_test.go b/internal/modules/zsh/zsh_test.go new file mode 100644 index 0000000..fd3c609 --- /dev/null +++ b/internal/modules/zsh/zsh_test.go @@ -0,0 +1,117 @@ +package zsh + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type fakeRunner struct { + runCalls [][]string + outCalls [][]string + installed map[string]bool +} + +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { + f.runCalls = append(f.runCalls, append([]string{name}, args...)) + return nil +} +func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.outCalls = append(f.outCalls, append([]string{name}, args...)) + if name == "pacman" && len(args) == 2 && args[0] == "-Q" { + if f.installed[args[1]] { + return []byte("ok"), nil + } + return nil, errOne + } + return nil, nil +} + +type eo struct{} + +func (eo) Error() string { return "exit 1" } + +var errOne = eo{} + +type nopReporter struct{} + +func (nopReporter) Progress(string, int, int, string) {} +func (nopReporter) Log(string, string) {} + +func TestZshDetectAbsent(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + r := &fakeRunner{installed: map[string]bool{}} + m := New(config.ZshConfig{ + OhMyZshRepo: "https://x/omz.git", + Plugins: []config.ZshPlugin{{Name: "zsh-autosuggestions", Repo: "https://x/a.git"}}, + }, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, _ := m.Detect(context.Background()) + s := st.(state) + if s.ZshInstalled { + t.Error("want zsh absent") + } + if s.OMZInstalled { + t.Error("want omz absent") + } + if len(s.MissingPlugins) != 1 { + t.Errorf("want 1 plugin missing, got %v", s.MissingPlugins) + } +} + +func TestZshDetectAllPresent(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + os.MkdirAll(filepath.Join(home, ".oh-my-zsh"), 0o755) + os.MkdirAll(filepath.Join(home, ".oh-my-zsh", "custom", "plugins", "zsh-autosuggestions", ".git"), 0o755) + r := &fakeRunner{installed: map[string]bool{"zsh": true}} + m := New(config.ZshConfig{ + OhMyZshRepo: "https://x/omz.git", + Plugins: []config.ZshPlugin{{Name: "zsh-autosuggestions", Repo: "https://x/a.git"}}, + }, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, _ := m.Detect(context.Background()) + s := st.(state) + if !s.ZshInstalled || !s.OMZInstalled || len(s.MissingPlugins) != 0 { + t.Fatalf("want all present, got %+v", s) + } +} + +func TestZshPlanApplyInstallsEverything(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + r := &fakeRunner{installed: map[string]bool{}} + m := New(config.ZshConfig{ + OhMyZshRepo: "https://x/omz.git", + Plugins: []config.ZshPlugin{{Name: "zsh-autosuggestions", Repo: "https://x/a.git"}}, + }, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if len(p.Actions) == 0 { + t.Fatal("want non-empty plan") + } + if err := m.Apply(context.Background(), p, nopReporter{}); err != nil { + t.Fatal(err) + } + sawPacman, sawOMZ, sawPlugin := false, false, false + for _, c := range r.runCalls { + if len(c) >= 3 && c[0] == "sudo" && c[1] == "pacman" { + sawPacman = true + } + if len(c) >= 4 && c[0] == "git" && c[3] == "https://x/omz.git" { + sawOMZ = true + } + if len(c) >= 4 && c[0] == "git" && c[3] == "https://x/a.git" { + sawPlugin = true + } + } + if !sawPacman || !sawOMZ || !sawPlugin { + t.Fatalf("missing step; calls=%v", r.runCalls) + } +} + +var _ module.Module = Module{}