diff --git a/cmd/archduino/main.go b/cmd/archduino/main.go index 36c336e..58120d1 100644 --- a/cmd/archduino/main.go +++ b/cmd/archduino/main.go @@ -13,7 +13,16 @@ import ( "github.com/MDietermann/ArduinoArchSetup/internal/config" "github.com/MDietermann/ArduinoArchSetup/internal/engine" "github.com/MDietermann/ArduinoArchSetup/internal/module" - "github.com/MDietermann/ArduinoArchSetup/internal/modules/hello" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/arduinocli" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/fonts" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/gittools" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/packages" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/shellconfig" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/starship" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/themes" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/yazi" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/zellij" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/zsh" "github.com/MDietermann/ArduinoArchSetup/internal/platform" "github.com/MDietermann/ArduinoArchSetup/internal/privilege" "github.com/MDietermann/ArduinoArchSetup/internal/state" @@ -24,14 +33,35 @@ import ( var version = "dev" -// Registry is the global module registry. Tasks in Plan 3 will register -// real modules into it via init(). Task 12 registers the hello toy module. -var Registry = module.NewRegistry() +// buildRegistry constructs a module registry populated with all Plan 3a +// modules, wired against the provided config and platform dependencies. +// It replaces the earlier init()-time hello-module registration now that +// the real modules need runtime config to be instantiated. +func buildRegistry(cfg config.Config) (*module.Registry, error) { + reg := module.NewRegistry() + runner := platform.ExecRunner{} + pm := platform.NewPacman(runner) + yay := platform.NewYay(runner) + git := platform.GitClient{Runner: runner} -func init() { - if err := Registry.Register(hello.Module{}); err != nil { - panic(err) + mods := []module.Module{ + packages.New(cfg.Packages.Base, pm), + gittools.New([]string{"lazygit"}, pm), + starship.New([]string{"starship"}, pm), + yazi.New([]string{"yazi"}, pm), + zsh.New(cfg.Zsh, pm, git), + fonts.New(cfg.Fonts, pm, yay), + zellij.New(cfg.Zellij, pm), + themes.New(cfg.Themes), + arduinocli.New(cfg.ArduinoCLI, runner), + shellconfig.New(cfg.ShellConfig), } + for _, m := range mods { + if err := reg.Register(m); err != nil { + return nil, err + } + } + return reg, nil } func main() { @@ -92,7 +122,12 @@ func newInstallCmd() *cobra.Command { return err } - selected, err := resolveSelection(Registry, cfg, only, skip) + reg, err := buildRegistry(cfg) + if err != nil { + return fmt.Errorf("build registry: %w", err) + } + + selected, err := resolveSelection(reg, cfg, only, skip) if err != nil { return err } @@ -117,7 +152,7 @@ func newInstallCmd() *cobra.Command { // always uses plain output because the dashboard has nothing // to animate when no Apply happens. reporter := engine.NewPlainReporter(os.Stdout) - sum, runErr = engine.Run(ctx, Registry, engine.RunOptions{ + sum, runErr = engine.Run(ctx, reg, engine.RunOptions{ Selected: selected, DryRun: dryRun, HaltOnError: halt, @@ -126,7 +161,7 @@ func newInstallCmd() *cobra.Command { } else { // TUI path: wizard → dashboard. If the user does not confirm, // we exit cleanly with a zero-summary and no error. - initial := tui.NewWizardState(cfg, Registry) + initial := tui.NewWizardState(cfg, reg) // Pre-filter the wizard's module list by --only/--skip so the // user cannot accidentally widen selection in the TUI beyond // what they asked for on the CLI. @@ -140,7 +175,7 @@ func newInstallCmd() *cobra.Command { } } - finalState, confirmed, werr := wizard.Run(ctx, Registry, initial) + finalState, confirmed, werr := wizard.Run(ctx, reg, initial) if werr != nil { return werr } @@ -149,7 +184,7 @@ func newInstallCmd() *cobra.Command { return nil } // Either the CLI flag or the wizard toggle can force halt-on-error. - sum, runErr = dashboard.Run(ctx, Registry, finalState, halt || finalState.HaltOnError) + sum, runErr = dashboard.Run(ctx, reg, finalState, halt || finalState.HaltOnError) } ended := time.Now().UTC() diff --git a/internal/config/config.go b/internal/config/config.go index 316f8a1..1ff0a1f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,12 +14,50 @@ import ( //go:embed defaults.yaml var defaultsYAML []byte +type Packages struct { + Base []string `yaml:"base"` +} +type ArduinoCLIConfig struct { + Version string `yaml:"version"` + URL string `yaml:"url"` + Cores []string `yaml:"cores"` +} +type ZshPlugin struct { + Name string `yaml:"name"` + Repo string `yaml:"repo"` +} +type ZshConfig struct { + OhMyZshRepo string `yaml:"oh_my_zsh_repo"` + Plugins []ZshPlugin `yaml:"plugins"` +} +type ZellijConfig struct { + ConfigURL string `yaml:"config_url"` +} +type ThemesConfig struct { + Source string `yaml:"source"` +} +type ShellConfigBlock struct { + Name string `yaml:"name"` + File string `yaml:"file"` + Content string `yaml:"content"` +} +type ShellConfigSection struct { + Blocks []ShellConfigBlock `yaml:"blocks"` +} + // Config is the fully-resolved runtime configuration. type Config struct { - Version int `yaml:"version"` - ExtraPackages []string `yaml:"extra_packages"` - DisabledModules []string `yaml:"disabled_modules"` - ThemeOverrides map[string]string `yaml:"theme_overrides"` + Version int `yaml:"version"` + ExtraPackages []string `yaml:"extra_packages"` + DisabledModules []string `yaml:"disabled_modules"` + ThemeOverrides map[string]string `yaml:"theme_overrides"` + Packages Packages `yaml:"packages"` + Fonts []string `yaml:"fonts"` + ArduinoCLI ArduinoCLIConfig `yaml:"arduino_cli"` + Zsh ZshConfig `yaml:"zsh"` + Zellij ZellijConfig `yaml:"zellij"` + Themes ThemesConfig `yaml:"themes"` + ShellConfig ShellConfigSection `yaml:"shell_config"` } const currentVersion = 1 @@ -78,5 +116,26 @@ func merge(base, over Config) Config { } out.ThemeOverrides = merged } + if len(over.Packages.Base) > 0 { + out.Packages = over.Packages + } + if len(over.Fonts) > 0 { + out.Fonts = over.Fonts + } + if over.ArduinoCLI.Version != "" { + out.ArduinoCLI = over.ArduinoCLI + } + if over.Zsh.OhMyZshRepo != "" { + out.Zsh = over.Zsh + } + if over.Zellij.ConfigURL != "" { + out.Zellij = over.Zellij + } + if over.Themes.Source != "" { + out.Themes = over.Themes + } + if len(over.ShellConfig.Blocks) > 0 { + out.ShellConfig = over.ShellConfig + } return out } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9bc6db8..db6cf95 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -48,6 +48,31 @@ theme_overrides: } } +func TestDefaultsCarryModuleInputs(t *testing.T) { + cfg, err := Load("/nonexistent/overlay.yaml") + if err != nil { + t.Fatal(err) + } + if len(cfg.Packages.Base) == 0 { + t.Error("Packages.Base empty") + } + if len(cfg.Fonts) == 0 { + t.Error("Fonts empty") + } + if cfg.ArduinoCLI.Version == "" { + t.Error("ArduinoCLI.Version empty") + } + if cfg.ArduinoCLI.URL == "" { + t.Error("ArduinoCLI.URL empty") + } + if cfg.Zsh.OhMyZshRepo == "" { + t.Error("Zsh.OhMyZshRepo empty") + } + if len(cfg.ShellConfig.Blocks) == 0 { + t.Error("ShellConfig.Blocks empty") + } +} + func TestLoadRejectsUnknownVersion(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "overlay.yaml") diff --git a/internal/config/defaults.yaml b/internal/config/defaults.yaml index 94293d7..e25e5ff 100644 --- a/internal/config/defaults.yaml +++ b/internal/config/defaults.yaml @@ -2,3 +2,81 @@ version: 1 extra_packages: [] disabled_modules: [] theme_overrides: {} + +packages: + base: + - base-devel + - git + - curl + - wget + - unzip + - zip + - tree + - htop + - neofetch + - ripgrep + - fd + - bat + - eza + - jq + - zsh + - clang + - ca-certificates + +fonts: + - ttf-jetbrains-mono-nerd + +arduino_cli: + version: "0.35.3" + url: "https://github.com/arduino/arduino-cli/releases/download/v0.35.3/arduino-cli_0.35.3_Linux_64bit.tar.gz" + cores: + - arduino:avr + +zsh: + oh_my_zsh_repo: "https://github.com/ohmyzsh/ohmyzsh.git" + plugins: + - name: zsh-autosuggestions + repo: https://github.com/zsh-users/zsh-autosuggestions + - name: zsh-syntax-highlighting + repo: https://github.com/zsh-users/zsh-syntax-highlighting.git + +zellij: + config_url: "" + +themes: + source: "lib/themes" + +shell_config: + blocks: + - name: archduino-path + file: ".zshrc" + content: | + export PATH="$HOME/.local/bin:$PATH" + - name: archduino-editor + file: ".zshrc" + content: | + export EDITOR="nvim" + export VISUAL="nvim" + - name: archduino-starship + file: ".zshrc" + content: | + eval "$(starship init zsh)" + - name: archduino-zellij + file: ".zshrc" + content: | + if [[ -z "$ZELLIJ" ]]; then + zellij attach --create archduino + fi + - name: archduino-aliases + file: ".zshrc" + content: | + alias adev="zellij --layout arduino" + alias ac="arduino-cli" + alias acc="arduino-cli compile" + alias acu="arduino-cli upload" + alias acm="arduino-cli monitor" + alias acb="arduino-cli board list" + alias lg="lazygit" + alias v="nvim" + alias y="yazi" + alias theme="archduino-theme" diff --git a/internal/modules/arduinocli/arduinocli.go b/internal/modules/arduinocli/arduinocli.go new file mode 100644 index 0000000..ac78f96 --- /dev/null +++ b/internal/modules/arduinocli/arduinocli.go @@ -0,0 +1,138 @@ +// Package arduinocli installs arduino-cli and its configured cores. +package arduinocli + +import ( + "bytes" + "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.ArduinoCLIConfig + runner platform.Runner +} + +func New(cfg config.ArduinoCLIConfig, r platform.Runner) Module { + return Module{cfg: cfg, runner: r} +} + +type state struct { + BinaryPath string + BinaryInstalled bool + MissingCores []string +} + +func (Module) Meta() module.Meta { + return module.Meta{ID: "arduinocli", Name: "arduino-cli", Description: "Install arduino-cli and cores."} +} + +func binaryPath() (string, error) { + h, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(h, ".local", "bin", "arduino-cli"), nil +} + +func (m Module) Detect(ctx context.Context) (module.State, error) { + bp, err := binaryPath() + if err != nil { + return nil, err + } + s := state{BinaryPath: bp} + if _, err := os.Stat(bp); err == nil { + s.BinaryInstalled = true + out, _ := m.runner.Output(ctx, bp, "core", "list") + for _, core := range m.cfg.Cores { + if !bytes.Contains(out, []byte(core)) { + s.MissingCores = append(s.MissingCores, core) + } + } + } else { + s.MissingCores = append([]string{}, m.cfg.Cores...) + } + 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.BinaryInstalled { + bp := s.BinaryPath + actions = append(actions, module.Action{ + Description: "download + install arduino-cli " + m.cfg.Version, + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + tmpDir, err := os.MkdirTemp("", "arduinocli-") + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + archive := filepath.Join(tmpDir, "arduino-cli.tar.gz") + if err := platform.Download(ctx, m.cfg.URL, archive, 0o644); err != nil { + return err + } + if err := platform.ExtractTarGz(ctx, archive, tmpDir); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(bp), 0o755); err != nil { + return err + } + data, err := os.ReadFile(filepath.Join(tmpDir, "arduino-cli")) + if err != nil { + return err + } + return os.WriteFile(bp, data, 0o755) + }, + }) + } + for _, core := range s.MissingCores { + core := core + actions = append(actions, module.Action{ + Description: "arduino-cli core update-index (for " + core + ")", + Kind: module.ActionCommand, + Exec: func(ctx context.Context) error { + return m.runner.Run(ctx, s.BinaryPath, "core", "update-index") + }, + }) + actions = append(actions, module.Action{ + Description: "arduino-cli core install " + core, + Kind: module.ActionCommand, + Exec: func(ctx context.Context) error { + return m.runner.Run(ctx, s.BinaryPath, "core", "install", core) + }, + }) + } + 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.BinaryInstalled { + return fmt.Errorf("arduino-cli binary missing") + } + if len(s.MissingCores) > 0 { + return fmt.Errorf("cores missing: %v", s.MissingCores) + } + return nil +} diff --git a/internal/modules/arduinocli/arduinocli_test.go b/internal/modules/arduinocli/arduinocli_test.go new file mode 100644 index 0000000..f43ae9a --- /dev/null +++ b/internal/modules/arduinocli/arduinocli_test.go @@ -0,0 +1,94 @@ +package arduinocli + +import ( + "archive/tar" + "compress/gzip" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" +) + +type fakeRunner struct { + runCalls [][]string + outCalls [][]string + coreListOut []byte +} + +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 filepath.Base(name) == "arduino-cli" && len(args) >= 2 && args[0] == "core" && args[1] == "list" { + return f.coreListOut, nil + } + return nil, nil +} + +type nop struct{} + +func (nop) Progress(string, int, int, string) {} +func (nop) Log(string, string) {} + +func makeTarGz(t *testing.T, entries map[string][]byte) string { + t.Helper() + p := filepath.Join(t.TempDir(), "a.tgz") + f, _ := os.Create(p) + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + for name, data := range entries { + _ = tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(data))}) + _, _ = tw.Write(data) + } + tw.Close() + gz.Close() + f.Close() + return p +} + +func TestArduinoCLIDownloadsAndInstallsCore(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + archive := makeTarGz(t, map[string][]byte{"arduino-cli": []byte("#!/bin/sh\n")}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := os.ReadFile(archive) + w.Write(data) + })) + defer srv.Close() + + r := &fakeRunner{coreListOut: []byte("ID Installed\n")} + m := New(config.ArduinoCLIConfig{ + Version: "1.0.0", + URL: srv.URL + "/arduino-cli.tgz", + Cores: []string{"arduino:avr"}, + }, r) + + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if err := m.Apply(context.Background(), p, nop{}); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(filepath.Join(home, ".local", "bin", "arduino-cli")); err != nil { + t.Fatalf("binary not installed: %v", err) + } + sawCore := false + for _, c := range r.runCalls { + if filepath.Base(c[0]) == "arduino-cli" && len(c) >= 4 && c[1] == "core" && c[2] == "install" && c[3] == "arduino:avr" { + sawCore = true + } + } + if !sawCore { + t.Fatalf("core install not run; calls=%v", r.runCalls) + } +} + +var _ module.Module = Module{} diff --git a/internal/modules/fonts/fonts.go b/internal/modules/fonts/fonts.go new file mode 100644 index 0000000..9dc7c9c --- /dev/null +++ b/internal/modules/fonts/fonts.go @@ -0,0 +1,82 @@ +// Package fonts installs AUR fonts via the yay helper. +package fonts + +import ( + "context" + "fmt" + "strings" + + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type Module struct { + fonts []string + pm platform.PackageManager + aur platform.AURHelper +} + +func New(fonts []string, pm platform.PackageManager, aur platform.AURHelper) Module { + return Module{fonts: fonts, pm: pm, aur: aur} +} + +type state struct{ Missing []string } + +func (Module) Meta() module.Meta { + return module.Meta{ID: "fonts", Name: "Nerd fonts", Description: "Install AUR nerd fonts."} +} + +func (m Module) Detect(ctx context.Context) (module.State, error) { + var missing []string + for _, f := range m.fonts { + ok, err := m.pm.IsInstalled(ctx, f) + if err != nil { + return nil, err + } + if !ok { + missing = append(missing, f) + } + } + 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 + } + missing := append([]string{}, s.Missing...) + return module.Plan{Actions: []module.Action{ + { + Description: "ensure yay installed", + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.aur.EnsureInstalled(ctx) }, + }, + { + Description: "yay install " + strings.Join(missing, ", "), + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.aur.Install(ctx, missing) }, + }, + }}, 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("fonts still missing: %v", miss) + } + return nil +} diff --git a/internal/modules/fonts/fonts_test.go b/internal/modules/fonts/fonts_test.go new file mode 100644 index 0000000..52c5150 --- /dev/null +++ b/internal/modules/fonts/fonts_test.go @@ -0,0 +1,72 @@ +package fonts + +import ( + "context" + "testing" + + "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 nop struct{} + +func (nop) Progress(string, int, int, string) {} +func (nop) Log(string, string) {} + +func TestFontsPlanApplyUsesYay(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{"yay": true}} + m := New([]string{"ttf-jetbrains-mono-nerd"}, platform.NewPacman(r), platform.NewYay(r)) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if err := m.Apply(context.Background(), p, nop{}); err != nil { + t.Fatal(err) + } + sawYay := false + for _, c := range r.runCalls { + if len(c) >= 2 && c[0] == "yay" && c[1] == "-S" { + sawYay = true + } + } + if !sawYay { + t.Fatalf("no yay install; calls=%v", r.runCalls) + } +} + +func TestFontsDetectPresentNoActions(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{"yay": true, "ttf-jetbrains-mono-nerd": true}} + m := New([]string{"ttf-jetbrains-mono-nerd"}, platform.NewPacman(r), platform.NewYay(r)) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if !p.IsEmpty() { + t.Fatal("want empty plan when already installed") + } +} + +var _ module.Module = Module{} diff --git a/internal/modules/gittools/gittools.go b/internal/modules/gittools/gittools.go new file mode 100644 index 0000000..dfad873 --- /dev/null +++ b/internal/modules/gittools/gittools.go @@ -0,0 +1,74 @@ +// Package gittools installs a declarative list of pacman packages. +package gittools + +import ( + "context" + "fmt" + "strings" + + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type Module struct { + pkgs []string + pm platform.PackageManager +} + +func New(pkgs []string, pm platform.PackageManager) Module { + return Module{pkgs: pkgs, pm: pm} +} + +type state struct{ Missing []string } + +func (Module) Meta() module.Meta { + return module.Meta{ID: "gittools", Name: "Git tools", Description: "Install lazygit."} +} + +func (m Module) Detect(ctx context.Context) (module.State, error) { + var missing []string + for _, p := range m.pkgs { + ok, err := m.pm.IsInstalled(ctx, p) + if err != nil { + return nil, err + } + if !ok { + missing = append(missing, p) + } + } + 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 + } + missing := append([]string{}, s.Missing...) + return module.Plan{Actions: []module.Action{{ + Description: "pacman install " + strings.Join(missing, ", "), + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) }, + }}}, 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 missing := st.(state).Missing; len(missing) > 0 { + return fmt.Errorf("packages not installed after apply: %v", missing) + } + return nil +} diff --git a/internal/modules/gittools/gittools_test.go b/internal/modules/gittools/gittools_test.go new file mode 100644 index 0000000..7041ab9 --- /dev/null +++ b/internal/modules/gittools/gittools_test.go @@ -0,0 +1,113 @@ +package gittools + +import ( + "context" + "strings" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type fakeRunner struct { + runCalls [][]string + outCalls [][]string + installed map[string]bool // key: pkg name +} + +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(args[1] + " 1.0-1\n"), nil + } + return nil, errExitOne + } + return nil, nil +} + +type exitOneErr struct{} + +func (exitOneErr) Error() string { return "exit status 1" } + +var errExitOne = exitOneErr{} + +type nopReporter struct{} + +func (nopReporter) Progress(string, int, int, string) {} +func (nopReporter) Log(string, string) {} + +func TestDetectAbsent(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{}} + m := New([]string{"lazygit"}, platform.NewPacman(r)) + st, err := m.Detect(context.Background()) + if err != nil { + t.Fatal(err) + } + s := st.(state) + if len(s.Missing) != 1 { + t.Fatalf("want 1 missing, got %v", s.Missing) + } +} + +func TestDetectPresent(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{"lazygit": true}} + m := New([]string{"lazygit"}, platform.NewPacman(r)) + st, _ := m.Detect(context.Background()) + if len(st.(state).Missing) != 0 { + t.Fatalf("want 0 missing, got %v", st.(state).Missing) + } +} + +func TestPlanAbsentEmitsInstallAction(t *testing.T) { + m := New([]string{"lazygit"}, nil) + p, _ := m.Plan(context.Background(), state{Missing: []string{"lazygit"}}) + if len(p.Actions) != 1 { + t.Fatalf("want 1 action, got %d", len(p.Actions)) + } + if !strings.Contains(p.Actions[0].Description, "lazygit") { + t.Fatalf("desc: %s", p.Actions[0].Description) + } +} + +func TestPlanPresentEmpty(t *testing.T) { + m := New([]string{"lazygit"}, nil) + p, _ := m.Plan(context.Background(), state{Missing: nil}) + if !p.IsEmpty() { + t.Fatal("want empty plan") + } +} + +func TestApplyShellsOut(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{}} + m := New([]string{"lazygit"}, platform.NewPacman(r)) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if err := m.Apply(context.Background(), p, nopReporter{}); err != nil { + t.Fatal(err) + } + sawInstall := false + for _, c := range r.runCalls { + if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" { + sawInstall = true + } + } + if !sawInstall { + t.Fatalf("no pacman install call; calls=%v", r.runCalls) + } +} + +func TestVerifyAfterApply(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{"lazygit": true}} + m := New([]string{"lazygit"}, platform.NewPacman(r)) + if err := m.Verify(context.Background()); err != nil { + t.Fatal(err) + } +} + +var _ module.Module = Module{} diff --git a/internal/modules/packages/packages.go b/internal/modules/packages/packages.go new file mode 100644 index 0000000..d334339 --- /dev/null +++ b/internal/modules/packages/packages.go @@ -0,0 +1,74 @@ +// Package packages installs a declarative list of pacman packages. +package packages + +import ( + "context" + "fmt" + "strings" + + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type Module struct { + pkgs []string + pm platform.PackageManager +} + +func New(pkgs []string, pm platform.PackageManager) Module { + return Module{pkgs: pkgs, pm: pm} +} + +type state struct{ Missing []string } + +func (Module) Meta() module.Meta { + return module.Meta{ID: "packages", Name: "Base packages", Description: "Install base pacman package list."} +} + +func (m Module) Detect(ctx context.Context) (module.State, error) { + var missing []string + for _, p := range m.pkgs { + ok, err := m.pm.IsInstalled(ctx, p) + if err != nil { + return nil, err + } + if !ok { + missing = append(missing, p) + } + } + 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 + } + missing := append([]string{}, s.Missing...) + return module.Plan{Actions: []module.Action{{ + Description: "pacman install " + strings.Join(missing, ", "), + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) }, + }}}, 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 missing := st.(state).Missing; len(missing) > 0 { + return fmt.Errorf("packages not installed after apply: %v", missing) + } + return nil +} diff --git a/internal/modules/packages/packages_test.go b/internal/modules/packages/packages_test.go new file mode 100644 index 0000000..28982c4 --- /dev/null +++ b/internal/modules/packages/packages_test.go @@ -0,0 +1,113 @@ +package packages + +import ( + "context" + "strings" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type fakeRunner struct { + runCalls [][]string + outCalls [][]string + installed map[string]bool // key: pkg name +} + +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(args[1] + " 1.0-1\n"), nil + } + return nil, errExitOne + } + return nil, nil +} + +type exitOneErr struct{} + +func (exitOneErr) Error() string { return "exit status 1" } + +var errExitOne = exitOneErr{} + +type nopReporter struct{} + +func (nopReporter) Progress(string, int, int, string) {} +func (nopReporter) Log(string, string) {} + +func TestDetectAbsent(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{}} + m := New([]string{"curl", "wget"}, platform.NewPacman(r)) + st, err := m.Detect(context.Background()) + if err != nil { + t.Fatal(err) + } + s := st.(state) + if len(s.Missing) != 2 { + t.Fatalf("want 2 missing, got %v", s.Missing) + } +} + +func TestDetectPresent(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{"curl": true, "wget": true}} + m := New([]string{"curl", "wget"}, platform.NewPacman(r)) + st, _ := m.Detect(context.Background()) + if len(st.(state).Missing) != 0 { + t.Fatalf("want 0 missing, got %v", st.(state).Missing) + } +} + +func TestPlanAbsentEmitsInstallAction(t *testing.T) { + m := New([]string{"curl", "wget"}, nil) + p, _ := m.Plan(context.Background(), state{Missing: []string{"curl", "wget"}}) + if len(p.Actions) != 1 { + t.Fatalf("want 1 action, got %d", len(p.Actions)) + } + if !strings.Contains(p.Actions[0].Description, "curl") { + t.Fatalf("desc: %s", p.Actions[0].Description) + } +} + +func TestPlanPresentEmpty(t *testing.T) { + m := New([]string{"curl"}, nil) + p, _ := m.Plan(context.Background(), state{Missing: nil}) + if !p.IsEmpty() { + t.Fatal("want empty plan") + } +} + +func TestApplyShellsOut(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{}} + m := New([]string{"curl"}, platform.NewPacman(r)) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if err := m.Apply(context.Background(), p, nopReporter{}); err != nil { + t.Fatal(err) + } + sawInstall := false + for _, c := range r.runCalls { + if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" { + sawInstall = true + } + } + if !sawInstall { + t.Fatalf("no pacman install call; calls=%v", r.runCalls) + } +} + +func TestVerifyAfterApply(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{"curl": true}} + m := New([]string{"curl"}, platform.NewPacman(r)) + if err := m.Verify(context.Background()); err != nil { + t.Fatal(err) + } +} + +var _ module.Module = Module{} diff --git a/internal/modules/shellconfig/shellconfig.go b/internal/modules/shellconfig/shellconfig.go new file mode 100644 index 0000000..45b793a --- /dev/null +++ b/internal/modules/shellconfig/shellconfig.go @@ -0,0 +1,90 @@ +// Package shellconfig writes marker-delimited blocks into rc files. +package shellconfig + +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.ShellConfigSection } + +func New(cfg config.ShellConfigSection) Module { return Module{cfg: cfg} } + +type state struct{ Missing []config.ShellConfigBlock } + +func (Module) Meta() module.Meta { + return module.Meta{ID: "shellconfig", Name: "Shell config blocks", Description: "Write archduino blocks into rc files."} +} + +func resolveFile(name string) (string, error) { + h, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(h, name), nil +} + +func (m Module) Detect(ctx context.Context) (module.State, error) { + var missing []config.ShellConfigBlock + for _, b := range m.cfg.Blocks { + p, err := resolveFile(b.File) + if err != nil { + return nil, err + } + has, err := platform.HasBlock(p, b.Name) + if err != nil { + return nil, err + } + if !has { + missing = append(missing, b) + } + } + return state{Missing: missing}, nil +} + +func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) { + s, _ := cur.(state) + var actions []module.Action + for _, b := range s.Missing { + b := b + actions = append(actions, module.Action{ + Description: fmt.Sprintf("write block %s into %s", b.Name, b.File), + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + p, err := resolveFile(b.File) + if err != nil { + return err + } + return platform.EnsureBlock(p, b.Name, b.Content) + }, + }) + } + 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 + } + if miss := st.(state).Missing; len(miss) > 0 { + return fmt.Errorf("shell blocks still missing: %d", len(miss)) + } + return nil +} diff --git a/internal/modules/shellconfig/shellconfig_test.go b/internal/modules/shellconfig/shellconfig_test.go new file mode 100644 index 0000000..2292ec5 --- /dev/null +++ b/internal/modules/shellconfig/shellconfig_test.go @@ -0,0 +1,58 @@ +package shellconfig + +import ( + "context" + "os" + "path/filepath" + "strings" + "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 TestShellConfigWritesBlocks(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + m := New(config.ShellConfigSection{ + Blocks: []config.ShellConfigBlock{ + {Name: "path", File: ".zshrc", Content: `export PATH="$HOME/.local/bin:$PATH"`}, + }, + }) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if err := m.Apply(context.Background(), p, nop{}); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(filepath.Join(home, ".zshrc")) + if !strings.Contains(string(data), ">>> path >>>") { + t.Fatalf("block not present: %q", data) + } + if !strings.Contains(string(data), `export PATH="$HOME/.local/bin:$PATH"`) { + t.Fatalf("content missing: %q", data) + } +} + +func TestShellConfigIdempotent(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + cfg := config.ShellConfigSection{Blocks: []config.ShellConfigBlock{ + {Name: "x", File: ".zshrc", Content: "echo x"}, + }} + m := New(cfg) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + _ = m.Apply(context.Background(), p, nop{}) + st2, _ := m.Detect(context.Background()) + p2, _ := m.Plan(context.Background(), st2) + if !p2.IsEmpty() { + t.Fatalf("second plan not empty: %+v", p2.Actions) + } +} + +var _ module.Module = Module{} diff --git a/internal/modules/starship/starship.go b/internal/modules/starship/starship.go new file mode 100644 index 0000000..1df23dc --- /dev/null +++ b/internal/modules/starship/starship.go @@ -0,0 +1,74 @@ +// Package starship installs a declarative list of pacman packages. +package starship + +import ( + "context" + "fmt" + "strings" + + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type Module struct { + pkgs []string + pm platform.PackageManager +} + +func New(pkgs []string, pm platform.PackageManager) Module { + return Module{pkgs: pkgs, pm: pm} +} + +type state struct{ Missing []string } + +func (Module) Meta() module.Meta { + return module.Meta{ID: "starship", Name: "Starship prompt", Description: "Install starship prompt."} +} + +func (m Module) Detect(ctx context.Context) (module.State, error) { + var missing []string + for _, p := range m.pkgs { + ok, err := m.pm.IsInstalled(ctx, p) + if err != nil { + return nil, err + } + if !ok { + missing = append(missing, p) + } + } + 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 + } + missing := append([]string{}, s.Missing...) + return module.Plan{Actions: []module.Action{{ + Description: "pacman install " + strings.Join(missing, ", "), + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) }, + }}}, 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 missing := st.(state).Missing; len(missing) > 0 { + return fmt.Errorf("packages not installed after apply: %v", missing) + } + return nil +} diff --git a/internal/modules/starship/starship_test.go b/internal/modules/starship/starship_test.go new file mode 100644 index 0000000..39a963c --- /dev/null +++ b/internal/modules/starship/starship_test.go @@ -0,0 +1,113 @@ +package starship + +import ( + "context" + "strings" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type fakeRunner struct { + runCalls [][]string + outCalls [][]string + installed map[string]bool // key: pkg name +} + +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(args[1] + " 1.0-1\n"), nil + } + return nil, errExitOne + } + return nil, nil +} + +type exitOneErr struct{} + +func (exitOneErr) Error() string { return "exit status 1" } + +var errExitOne = exitOneErr{} + +type nopReporter struct{} + +func (nopReporter) Progress(string, int, int, string) {} +func (nopReporter) Log(string, string) {} + +func TestDetectAbsent(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{}} + m := New([]string{"starship"}, platform.NewPacman(r)) + st, err := m.Detect(context.Background()) + if err != nil { + t.Fatal(err) + } + s := st.(state) + if len(s.Missing) != 1 { + t.Fatalf("want 1 missing, got %v", s.Missing) + } +} + +func TestDetectPresent(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{"starship": true}} + m := New([]string{"starship"}, platform.NewPacman(r)) + st, _ := m.Detect(context.Background()) + if len(st.(state).Missing) != 0 { + t.Fatalf("want 0 missing, got %v", st.(state).Missing) + } +} + +func TestPlanAbsentEmitsInstallAction(t *testing.T) { + m := New([]string{"starship"}, nil) + p, _ := m.Plan(context.Background(), state{Missing: []string{"starship"}}) + if len(p.Actions) != 1 { + t.Fatalf("want 1 action, got %d", len(p.Actions)) + } + if !strings.Contains(p.Actions[0].Description, "starship") { + t.Fatalf("desc: %s", p.Actions[0].Description) + } +} + +func TestPlanPresentEmpty(t *testing.T) { + m := New([]string{"starship"}, nil) + p, _ := m.Plan(context.Background(), state{Missing: nil}) + if !p.IsEmpty() { + t.Fatal("want empty plan") + } +} + +func TestApplyShellsOut(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{}} + m := New([]string{"starship"}, platform.NewPacman(r)) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if err := m.Apply(context.Background(), p, nopReporter{}); err != nil { + t.Fatal(err) + } + sawInstall := false + for _, c := range r.runCalls { + if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" { + sawInstall = true + } + } + if !sawInstall { + t.Fatalf("no pacman install call; calls=%v", r.runCalls) + } +} + +func TestVerifyAfterApply(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{"starship": true}} + m := New([]string{"starship"}, platform.NewPacman(r)) + if err := m.Verify(context.Background()); err != nil { + t.Fatal(err) + } +} + +var _ module.Module = Module{} 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{} diff --git a/internal/modules/yazi/yazi.go b/internal/modules/yazi/yazi.go new file mode 100644 index 0000000..f451860 --- /dev/null +++ b/internal/modules/yazi/yazi.go @@ -0,0 +1,74 @@ +// Package yazi installs a declarative list of pacman packages. +package yazi + +import ( + "context" + "fmt" + "strings" + + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type Module struct { + pkgs []string + pm platform.PackageManager +} + +func New(pkgs []string, pm platform.PackageManager) Module { + return Module{pkgs: pkgs, pm: pm} +} + +type state struct{ Missing []string } + +func (Module) Meta() module.Meta { + return module.Meta{ID: "yazi", Name: "Yazi file manager", Description: "Install yazi."} +} + +func (m Module) Detect(ctx context.Context) (module.State, error) { + var missing []string + for _, p := range m.pkgs { + ok, err := m.pm.IsInstalled(ctx, p) + if err != nil { + return nil, err + } + if !ok { + missing = append(missing, p) + } + } + 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 + } + missing := append([]string{}, s.Missing...) + return module.Plan{Actions: []module.Action{{ + Description: "pacman install " + strings.Join(missing, ", "), + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.pm.Install(ctx, missing) }, + }}}, 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 missing := st.(state).Missing; len(missing) > 0 { + return fmt.Errorf("packages not installed after apply: %v", missing) + } + return nil +} diff --git a/internal/modules/yazi/yazi_test.go b/internal/modules/yazi/yazi_test.go new file mode 100644 index 0000000..e640fc1 --- /dev/null +++ b/internal/modules/yazi/yazi_test.go @@ -0,0 +1,113 @@ +package yazi + +import ( + "context" + "strings" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type fakeRunner struct { + runCalls [][]string + outCalls [][]string + installed map[string]bool // key: pkg name +} + +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(args[1] + " 1.0-1\n"), nil + } + return nil, errExitOne + } + return nil, nil +} + +type exitOneErr struct{} + +func (exitOneErr) Error() string { return "exit status 1" } + +var errExitOne = exitOneErr{} + +type nopReporter struct{} + +func (nopReporter) Progress(string, int, int, string) {} +func (nopReporter) Log(string, string) {} + +func TestDetectAbsent(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{}} + m := New([]string{"yazi"}, platform.NewPacman(r)) + st, err := m.Detect(context.Background()) + if err != nil { + t.Fatal(err) + } + s := st.(state) + if len(s.Missing) != 1 { + t.Fatalf("want 1 missing, got %v", s.Missing) + } +} + +func TestDetectPresent(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{"yazi": true}} + m := New([]string{"yazi"}, platform.NewPacman(r)) + st, _ := m.Detect(context.Background()) + if len(st.(state).Missing) != 0 { + t.Fatalf("want 0 missing, got %v", st.(state).Missing) + } +} + +func TestPlanAbsentEmitsInstallAction(t *testing.T) { + m := New([]string{"yazi"}, nil) + p, _ := m.Plan(context.Background(), state{Missing: []string{"yazi"}}) + if len(p.Actions) != 1 { + t.Fatalf("want 1 action, got %d", len(p.Actions)) + } + if !strings.Contains(p.Actions[0].Description, "yazi") { + t.Fatalf("desc: %s", p.Actions[0].Description) + } +} + +func TestPlanPresentEmpty(t *testing.T) { + m := New([]string{"yazi"}, nil) + p, _ := m.Plan(context.Background(), state{Missing: nil}) + if !p.IsEmpty() { + t.Fatal("want empty plan") + } +} + +func TestApplyShellsOut(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{}} + m := New([]string{"yazi"}, platform.NewPacman(r)) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if err := m.Apply(context.Background(), p, nopReporter{}); err != nil { + t.Fatal(err) + } + sawInstall := false + for _, c := range r.runCalls { + if len(c) >= 4 && c[0] == "sudo" && c[1] == "pacman" && c[2] == "-S" { + sawInstall = true + } + } + if !sawInstall { + t.Fatalf("no pacman install call; calls=%v", r.runCalls) + } +} + +func TestVerifyAfterApply(t *testing.T) { + r := &fakeRunner{installed: map[string]bool{"yazi": true}} + m := New([]string{"yazi"}, platform.NewPacman(r)) + if err := m.Verify(context.Background()); err != nil { + t.Fatal(err) + } +} + +var _ module.Module = Module{} diff --git a/internal/modules/zellij/zellij.go b/internal/modules/zellij/zellij.go new file mode 100644 index 0000000..d42ba29 --- /dev/null +++ b/internal/modules/zellij/zellij.go @@ -0,0 +1,110 @@ +// Package zellij installs zellij and optionally writes a remote config. +package zellij + +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.ZellijConfig + pm platform.PackageManager +} + +func New(cfg config.ZellijConfig, pm platform.PackageManager) Module { + return Module{cfg: cfg, pm: pm} +} + +type state struct { + Installed bool + ConfigOK bool + ConfigPath string +} + +func (Module) Meta() module.Meta { + return module.Meta{ID: "zellij", Name: "Zellij terminal multiplexer", Description: "Install zellij and config."} +} + +func configPath() (string, error) { + h, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(h, ".config", "zellij", "config.kdl"), nil +} + +func (m Module) Detect(ctx context.Context) (module.State, error) { + var s state + ok, err := m.pm.IsInstalled(ctx, "zellij") + if err != nil { + return nil, err + } + s.Installed = ok + p, err := configPath() + if err != nil { + return nil, err + } + s.ConfigPath = p + if m.cfg.ConfigURL == "" { + s.ConfigOK = true + } else if _, err := os.Stat(p); err == nil { + s.ConfigOK = true + } + 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.Installed { + actions = append(actions, module.Action{ + Description: "pacman install zellij", + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"zellij"}) }, + }) + } + if !s.ConfigOK { + actions = append(actions, module.Action{ + Description: "download zellij config", + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + if err := os.MkdirAll(filepath.Dir(s.ConfigPath), 0o755); err != nil { + return err + } + return platform.Download(ctx, m.cfg.ConfigURL, s.ConfigPath, 0o644) + }, + }) + } + 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.Installed { + return fmt.Errorf("zellij not installed") + } + if !s.ConfigOK { + return fmt.Errorf("zellij config missing") + } + return nil +} diff --git a/internal/modules/zellij/zellij_test.go b/internal/modules/zellij/zellij_test.go new file mode 100644 index 0000000..71d06bf --- /dev/null +++ b/internal/modules/zellij/zellij_test.go @@ -0,0 +1,84 @@ +package zellij + +import ( + "context" + "net/http" + "net/http/httptest" + "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 nop struct{} + +func (nop) Progress(string, int, int, string) {} +func (nop) Log(string, string) {} + +func TestZellijPlanAppliesInstallAndConfig(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte("keybinds {}\n")) + })) + defer srv.Close() + r := &fakeRunner{installed: map[string]bool{}} + m := New(config.ZellijConfig{ConfigURL: srv.URL}, platform.NewPacman(r)) + 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, err := os.ReadFile(filepath.Join(home, ".config", "zellij", "config.kdl")) + if err != nil { + t.Fatalf("config not written: %v", err) + } + if string(got) != "keybinds {}\n" { + t.Fatalf("unexpected content %q", got) + } +} + +func TestZellijInstalledPresentEmptyPlan(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + r := &fakeRunner{installed: map[string]bool{"zellij": true}} + m := New(config.ZellijConfig{ConfigURL: ""}, platform.NewPacman(r)) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if !p.IsEmpty() { + t.Fatalf("want empty plan, got %+v", p) + } +} + +var _ module.Module = Module{} 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{} diff --git a/internal/platform/aur.go b/internal/platform/aur.go new file mode 100644 index 0000000..fc337ad --- /dev/null +++ b/internal/platform/aur.go @@ -0,0 +1,57 @@ +package platform + +import ( + "context" + "os" + "path/filepath" + "strings" +) + +// AURHelper abstracts an AUR front-end (currently only yay). +type AURHelper interface { + EnsureInstalled(ctx context.Context) error + Install(ctx context.Context, pkgs []string) error +} + +type Yay struct { + Runner Runner + pm *Pacman +} + +func NewYay(r Runner) *Yay { return &Yay{Runner: r, pm: NewPacman(r)} } + +func (y *Yay) EnsureInstalled(ctx context.Context) error { + ok, _ := y.pm.IsInstalled(ctx, "yay") + if ok { + return nil + } + // Bootstrap: install build deps, clone AUR yay, makepkg -si. + if err := y.pm.Install(ctx, []string{"base-devel", "git"}); err != nil { + return err + } + tmp, err := os.MkdirTemp("", "archduino-yay-") + if err != nil { + return err + } + defer os.RemoveAll(tmp) + clone := filepath.Join(tmp, "yay") + if err := y.Runner.Run(ctx, "git", "clone", "https://aur.archlinux.org/yay.git", clone); err != nil { + return err + } + // `makepkg -si --noconfirm` needs to cd into the cloned dir. os/exec does + // not have a per-command cwd without exec.Cmd; our Runner interface does + // not expose that, so we invoke via sh -c with a pushd-style prefix. + return y.Runner.Run(ctx, "sh", "-c", "cd "+shellQuote(clone)+" && makepkg -si --noconfirm") +} + +func (y *Yay) Install(ctx context.Context, pkgs []string) error { + if len(pkgs) == 0 { + return nil + } + args := append([]string{"-S", "--needed", "--noconfirm"}, pkgs...) + return y.Runner.Run(ctx, "yay", args...) +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/internal/platform/aur_test.go b/internal/platform/aur_test.go new file mode 100644 index 0000000..0726211 --- /dev/null +++ b/internal/platform/aur_test.go @@ -0,0 +1,89 @@ +package platform + +import ( + "context" + "strings" + "testing" +) + +type aurFakeRunner struct { + runCalls [][]string + outCalls [][]string + // yayInstalled toggles what `pacman -Q yay` returns. + yayInstalled bool +} + +func (f *aurFakeRunner) Run(_ context.Context, name string, args ...string) error { + f.runCalls = append(f.runCalls, append([]string{name}, args...)) + return nil +} + +func (f *aurFakeRunner) 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" && args[1] == "yay" { + if f.yayInstalled { + return []byte("yay 12.0-1\n"), nil + } + return nil, &fakeExitErr{} + } + return nil, nil +} + +type fakeExitErr struct{} + +func (fakeExitErr) Error() string { return "exit 1" } + +func TestAUREnsureInstalledSkipsWhenYayPresent(t *testing.T) { + r := &aurFakeRunner{yayInstalled: true} + y := NewYay(r) + if err := y.EnsureInstalled(context.Background()); err != nil { + t.Fatal(err) + } + if len(r.runCalls) != 0 { + t.Fatalf("no installs expected, got %v", r.runCalls) + } +} + +func TestAUREnsureInstalledBootstrapsYay(t *testing.T) { + r := &aurFakeRunner{yayInstalled: false} + y := NewYay(r) + if err := y.EnsureInstalled(context.Background()); err != nil { + t.Fatal(err) + } + // Expect: pacman install base-devel+git, git clone yay, makepkg -si in that dir. + sawPacman := false + sawClone := false + sawMakepkg := false + for _, c := range r.runCalls { + if len(c) >= 2 && c[0] == "sudo" && c[1] == "pacman" { + sawPacman = true + } + if len(c) >= 2 && c[0] == "git" && c[1] == "clone" { + sawClone = true + } + if len(c) >= 3 && c[0] == "sh" && c[1] == "-c" && strings.Contains(c[2], "makepkg") { + sawMakepkg = true + } + } + if !sawPacman || !sawClone || !sawMakepkg { + t.Fatalf("missing bootstrap step; calls=%v", r.runCalls) + } +} + +func TestAURInstall(t *testing.T) { + r := &aurFakeRunner{yayInstalled: true} + y := NewYay(r) + if err := y.Install(context.Background(), []string{"ttf-jetbrains-mono-nerd"}); err != nil { + t.Fatal(err) + } + if len(r.runCalls) == 0 { + t.Fatal("expected yay install call") + } + c := r.runCalls[len(r.runCalls)-1] + want := []string{"yay", "-S", "--needed", "--noconfirm", "ttf-jetbrains-mono-nerd"} + for i, w := range want { + if i >= len(c) || c[i] != w { + t.Fatalf("call = %v, want %v", c, want) + } + } +} diff --git a/internal/platform/download.go b/internal/platform/download.go new file mode 100644 index 0000000..682de92 --- /dev/null +++ b/internal/platform/download.go @@ -0,0 +1,42 @@ +package platform + +import ( + "context" + "fmt" + "io" + "net/http" + "os" +) + +// Download fetches url into destPath atomically (temp file + rename) with +// the given file mode. Any non-2xx response returns an error and leaves +// no file behind. +func Download(ctx context.Context, url, destPath string, mode os.FileMode) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("download %s: http %d", url, resp.StatusCode) + } + tmp := destPath + ".tmp" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + if _, err := io.Copy(f, resp.Body); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + return os.Rename(tmp, destPath) +} diff --git a/internal/platform/download_test.go b/internal/platform/download_test.go new file mode 100644 index 0000000..6c62cd5 --- /dev/null +++ b/internal/platform/download_test.go @@ -0,0 +1,45 @@ +package platform + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestDownloadHappyPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("payload")) + })) + defer srv.Close() + dir := t.TempDir() + dest := filepath.Join(dir, "out.bin") + if err := Download(context.Background(), srv.URL, dest, 0o755); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(dest) + if string(got) != "payload" { + t.Fatalf("got %q want payload", got) + } + info, _ := os.Stat(dest) + if info.Mode().Perm() != 0o755 { + t.Fatalf("mode=%v want 0755", info.Mode().Perm()) + } +} + +func TestDownload404Errors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusNotFound) + })) + defer srv.Close() + dir := t.TempDir() + dest := filepath.Join(dir, "out.bin") + if err := Download(context.Background(), srv.URL, dest, 0o644); err == nil { + t.Fatal("want error on 404") + } + if _, err := os.Stat(dest); !os.IsNotExist(err) { + t.Fatal("dest should not exist after failed download") + } +} diff --git a/internal/platform/extract.go b/internal/platform/extract.go new file mode 100644 index 0000000..d205530 --- /dev/null +++ b/internal/platform/extract.go @@ -0,0 +1,114 @@ +package platform + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +func safeJoin(dest, name string) (string, error) { + p := filepath.Join(dest, name) + cleanDest := filepath.Clean(dest) + string(os.PathSeparator) + if !strings.HasPrefix(filepath.Clean(p)+string(os.PathSeparator), cleanDest) { + return "", fmt.Errorf("unsafe path %q escapes %q", name, dest) + } + return p, nil +} + +func ExtractTarGz(ctx context.Context, archivePath, destDir string) error { + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gz.Close() + tr := tar.NewReader(gz) + for { + if err := ctx.Err(); err != nil { + return err + } + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + target, err := safeJoin(destDir, h.Name) + if err != nil { + return err + } + switch h.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, os.FileMode(h.Mode)); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + w, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode)) + if err != nil { + return err + } + if _, err := io.Copy(w, tr); err != nil { + w.Close() + return err + } + w.Close() + } + } + return nil +} + +func ExtractZip(ctx context.Context, archivePath, destDir string) error { + zr, err := zip.OpenReader(archivePath) + if err != nil { + return err + } + defer zr.Close() + for _, zf := range zr.File { + if err := ctx.Err(); err != nil { + return err + } + target, err := safeJoin(destDir, zf.Name) + if err != nil { + return err + } + if zf.FileInfo().IsDir() { + if err := os.MkdirAll(target, zf.Mode()); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + rc, err := zf.Open() + if err != nil { + return err + } + w, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, zf.Mode()) + if err != nil { + rc.Close() + return err + } + _, err = io.Copy(w, rc) + rc.Close() + w.Close() + if err != nil { + return err + } + } + return nil +} diff --git a/internal/platform/extract_test.go b/internal/platform/extract_test.go new file mode 100644 index 0000000..7a652a1 --- /dev/null +++ b/internal/platform/extract_test.go @@ -0,0 +1,80 @@ +package platform + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "os" + "path/filepath" + "testing" +) + +func makeTarGz(t *testing.T, entries map[string]string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "a.tar.gz") + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + for name, body := range entries { + tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))}) + tw.Write([]byte(body)) + } + tw.Close() + gz.Close() + f.Close() + return p +} + +func makeZip(t *testing.T, entries map[string]string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "a.zip") + f, _ := os.Create(p) + zw := zip.NewWriter(f) + for name, body := range entries { + w, _ := zw.Create(name) + w.Write([]byte(body)) + } + zw.Close() + f.Close() + return p +} + +func TestExtractTarGz(t *testing.T) { + src := makeTarGz(t, map[string]string{"hello.txt": "world", "sub/x.txt": "y"}) + dest := t.TempDir() + if err := ExtractTarGz(context.Background(), src, dest); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(filepath.Join(dest, "hello.txt")) + if string(got) != "world" { + t.Fatalf("got %q", got) + } + got, _ = os.ReadFile(filepath.Join(dest, "sub", "x.txt")) + if string(got) != "y" { + t.Fatalf("got %q", got) + } +} + +func TestExtractZip(t *testing.T) { + src := makeZip(t, map[string]string{"a.txt": "A"}) + dest := t.TempDir() + if err := ExtractZip(context.Background(), src, dest); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(filepath.Join(dest, "a.txt")) + if string(got) != "A" { + t.Fatalf("got %q", got) + } +} + +func TestExtractRejectsPathTraversal(t *testing.T) { + src := makeTarGz(t, map[string]string{"../evil.txt": "x"}) + dest := t.TempDir() + if err := ExtractTarGz(context.Background(), src, dest); err == nil { + t.Fatal("want error on path traversal") + } +} diff --git a/internal/platform/git.go b/internal/platform/git.go new file mode 100644 index 0000000..33bc074 --- /dev/null +++ b/internal/platform/git.go @@ -0,0 +1,23 @@ +package platform + +import ( + "context" + "os" + "path/filepath" +) + +// GitClient wraps `git` via a Runner. +type GitClient struct{ Runner Runner } + +// Clone always runs `git clone --depth=1 url dest`. +func (g GitClient) Clone(ctx context.Context, url, dest string) error { + return g.Runner.Run(ctx, "git", "clone", "--depth=1", url, dest) +} + +// EnsureRepo clones url into dest if dest/.git is missing; otherwise noop. +func (g GitClient) EnsureRepo(ctx context.Context, url, dest string) error { + if _, err := os.Stat(filepath.Join(dest, ".git")); err == nil { + return nil + } + return g.Clone(ctx, url, dest) +} diff --git a/internal/platform/git_test.go b/internal/platform/git_test.go new file mode 100644 index 0000000..34d0e10 --- /dev/null +++ b/internal/platform/git_test.go @@ -0,0 +1,55 @@ +package platform + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +type gitFakeRunner struct { + calls [][]string + err error +} + +func (f *gitFakeRunner) Run(_ context.Context, name string, args ...string) error { + f.calls = append(f.calls, append([]string{name}, args...)) + return f.err +} +func (f *gitFakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + return nil, nil +} + +func TestGitEnsureRepoClonesWhenMissing(t *testing.T) { + r := &gitFakeRunner{} + g := GitClient{Runner: r} + dest := filepath.Join(t.TempDir(), "repo") + if err := g.EnsureRepo(context.Background(), "https://x/y.git", dest); err != nil { + t.Fatal(err) + } + if len(r.calls) != 1 { + t.Fatalf("want 1 call, got %d: %v", len(r.calls), r.calls) + } + got := r.calls[0] + want := []string{"git", "clone", "--depth=1", "https://x/y.git", dest} + for i := range want { + if i >= len(got) || got[i] != want[i] { + t.Fatalf("call = %v, want %v", got, want) + } + } +} + +func TestGitEnsureRepoSkipsWhenPresent(t *testing.T) { + dest := t.TempDir() + if err := os.MkdirAll(filepath.Join(dest, ".git"), 0o755); err != nil { + t.Fatal(err) + } + r := &gitFakeRunner{} + g := GitClient{Runner: r} + if err := g.EnsureRepo(context.Background(), "https://x/y.git", dest); err != nil { + t.Fatal(err) + } + if len(r.calls) != 0 { + t.Fatalf("expected no calls, got %v", r.calls) + } +} diff --git a/internal/platform/textblock.go b/internal/platform/textblock.go new file mode 100644 index 0000000..68f85f3 --- /dev/null +++ b/internal/platform/textblock.go @@ -0,0 +1,78 @@ +package platform + +import ( + "fmt" + "os" + "strings" +) + +func blockMarkers(marker string) (string, string) { + return fmt.Sprintf("# >>> %s >>>", marker), fmt.Sprintf("# <<< %s <<<", marker) +} + +// EnsureBlock writes a marker-delimited block into path. If the file does +// not exist it is created. If a block with the same marker already exists +// it is replaced in place. Surrounding content is preserved. +func EnsureBlock(path, marker, content string) error { + start, end := blockMarkers(marker) + existing, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return err + } + block := start + "\n" + strings.TrimRight(content, "\n") + "\n" + end + "\n" + + text := string(existing) + si := strings.Index(text, start) + ei := strings.Index(text, end) + if si >= 0 && ei > si { + endPos := ei + len(end) + if endPos < len(text) && text[endPos] == '\n' { + endPos++ + } + text = text[:si] + block + text[endPos:] + } else { + if text != "" && !strings.HasSuffix(text, "\n") { + text += "\n" + } + text += block + } + return os.WriteFile(path, []byte(text), 0o644) +} + +// HasBlock reports whether path contains a block with the given marker. +// A missing file yields (false, nil). +func HasBlock(path, marker string) (bool, error) { + start, _ := blockMarkers(marker) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return strings.Contains(string(data), start), nil +} + +// RemoveBlock deletes a marker-delimited block if present. +func RemoveBlock(path, marker string) error { + start, end := blockMarkers(marker) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + text := string(data) + si := strings.Index(text, start) + ei := strings.Index(text, end) + if si < 0 || ei <= si { + return nil + } + endPos := ei + len(end) + if endPos < len(text) && text[endPos] == '\n' { + endPos++ + } + text = text[:si] + text[endPos:] + return os.WriteFile(path, []byte(text), 0o644) +} diff --git a/internal/platform/textblock_test.go b/internal/platform/textblock_test.go new file mode 100644 index 0000000..73da431 --- /dev/null +++ b/internal/platform/textblock_test.go @@ -0,0 +1,88 @@ +package platform + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureBlockInsertsIntoMissingFile(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "rc") + if err := EnsureBlock(p, "archduino", "export FOO=1"); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(p) + want := "# >>> archduino >>>\nexport FOO=1\n# <<< archduino <<<\n" + if string(got) != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestEnsureBlockReplacesExisting(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "rc") + initial := "alias ll='ls -l'\n# >>> archduino >>>\nold content\n# <<< archduino <<<\necho tail\n" + if err := os.WriteFile(p, []byte(initial), 0o644); err != nil { + t.Fatal(err) + } + if err := EnsureBlock(p, "archduino", "new content"); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(p) + want := "alias ll='ls -l'\n# >>> archduino >>>\nnew content\n# <<< archduino <<<\necho tail\n" + if string(got) != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestEnsureBlockIdempotent(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "rc") + _ = EnsureBlock(p, "a", "x") + before, _ := os.ReadFile(p) + _ = EnsureBlock(p, "a", "x") + after, _ := os.ReadFile(p) + if string(before) != string(after) { + t.Fatalf("not idempotent: %q vs %q", before, after) + } +} + +func TestHasBlockTrueFalse(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "rc") + has, err := HasBlock(p, "a") + if err != nil { + t.Fatal(err) + } + if has { + t.Fatal("missing file should report has=false") + } + _ = os.WriteFile(p, []byte("no blocks here\n"), 0o644) + has, _ = HasBlock(p, "a") + if has { + t.Fatal("unblocked file should report has=false") + } + _ = EnsureBlock(p, "a", "x") + has, _ = HasBlock(p, "a") + if !has { + t.Fatal("blocked file should report has=true") + } +} + +func TestRemoveBlock(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "rc") + _ = os.WriteFile(p, []byte("head\n# >>> a >>>\nmid\n# <<< a <<<\ntail\n"), 0o644) + if err := RemoveBlock(p, "a"); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(p) + if strings.Contains(string(got), ">>> a >>>") { + t.Fatalf("block not removed: %q", got) + } + if !strings.Contains(string(got), "head") || !strings.Contains(string(got), "tail") { + t.Fatalf("surrounding content lost: %q", got) + } +} diff --git a/internal/tui/wizard/wizard.go b/internal/tui/wizard/wizard.go index a6242fa..a3ec35a 100644 --- a/internal/tui/wizard/wizard.go +++ b/internal/tui/wizard/wizard.go @@ -44,6 +44,7 @@ func Run(ctx context.Context, reg *module.Registry, initial tui.WizardState) (tu selectedModules := defaults parallelismStr := fmt.Sprintf("%d", s.Parallelism) var proceed bool + cache := newPlanCache() form := huh.NewForm( huh.NewGroup(huh.NewNote(). @@ -74,7 +75,7 @@ func Run(ctx context.Context, reg *module.Registry, initial tui.WizardState) (tu Title("Review plan"). DescriptionFunc(func() string { s.Modules = syncSelected(s.Modules, selectedModules) - return renderPlan(ctx, reg, s.SelectedIDs()) + return renderPlanCached(ctx, reg, s.SelectedIDs(), cache) }, &selectedModules)), huh.NewGroup(huh.NewConfirm(). @@ -115,38 +116,75 @@ func syncSelected(mods []tui.ModuleChoice, selected []string) []tui.ModuleChoice return out } -// renderPlan runs Detect + Plan for every selected module and renders a -// human-readable summary. A failing Detect/Plan is shown inline but does -// not abort the wizard. -func renderPlan(ctx context.Context, reg *module.Registry, selectedIDs []string) string { +// planCache memoises rendered Detect+Plan output per module ID so +// DescriptionFunc (which fires on every redraw) does not re-run +// potentially expensive Detect/Plan calls. Entries are evicted when a +// module leaves the current selection. +type planCacheEntry struct{ out string } +type planCache struct{ entries map[string]planCacheEntry } + +func newPlanCache() *planCache { return &planCache{entries: map[string]planCacheEntry{}} } + +func (c *planCache) get(id string) (string, bool) { + e, ok := c.entries[id] + return e.out, ok +} +func (c *planCache) put(id, out string) { c.entries[id] = planCacheEntry{out: out} } +func (c *planCache) prune(keepIDs []string) { + keep := map[string]bool{} + for _, id := range keepIDs { + keep[id] = true + } + for k := range c.entries { + if !keep[k] { + delete(c.entries, k) + } + } +} + +// renderPlanCached is the cached equivalent of running Detect+Plan for +// every selected module and joining the rendered lines. IDs no longer in +// the selection are evicted so a deselect+reselect re-runs Detect. +func renderPlanCached(ctx context.Context, reg *module.Registry, selectedIDs []string, cache *planCache) string { + cache.prune(selectedIDs) if len(selectedIDs) == 0 { return "No modules selected." } var b strings.Builder for _, id := range selectedIDs { - m, ok := reg.Get(id) - if !ok { - fmt.Fprintf(&b, "• %s — (unknown)\n", id) + if cached, ok := cache.get(id); ok { + b.WriteString(cached) continue } - state, err := m.Detect(ctx) - if err != nil { - fmt.Fprintf(&b, "• %s — detect failed: %v\n", id, err) - continue - } - plan, err := m.Plan(ctx, state) - if err != nil { - fmt.Fprintf(&b, "• %s — plan failed: %v\n", id, err) - continue - } - if plan.IsEmpty() { - fmt.Fprintf(&b, "• %s — already up to date\n", id) - continue - } - fmt.Fprintf(&b, "• %s\n", id) - for _, a := range plan.Actions { - fmt.Fprintf(&b, " - %s\n", a.Description) - } + line := renderOne(ctx, reg, id) + cache.put(id, line) + b.WriteString(line) + } + return b.String() +} + +// renderOne runs Detect+Plan for a single module and returns a rendered +// line. A failing Detect/Plan is shown inline but does not abort. +func renderOne(ctx context.Context, reg *module.Registry, id string) string { + m, ok := reg.Get(id) + if !ok { + return fmt.Sprintf("• %s — (unknown)\n", id) + } + state, err := m.Detect(ctx) + if err != nil { + return fmt.Sprintf("• %s — detect failed: %v\n", id, err) + } + plan, err := m.Plan(ctx, state) + if err != nil { + return fmt.Sprintf("• %s — plan failed: %v\n", id, err) + } + if plan.IsEmpty() { + return fmt.Sprintf("• %s — already up to date\n", id) + } + var b strings.Builder + fmt.Fprintf(&b, "• %s\n", id) + for _, a := range plan.Actions { + fmt.Fprintf(&b, " - %s\n", a.Description) } return b.String() } diff --git a/internal/tui/wizard/wizard_test.go b/internal/tui/wizard/wizard_test.go index f2a886f..9607955 100644 --- a/internal/tui/wizard/wizard_test.go +++ b/internal/tui/wizard/wizard_test.go @@ -31,14 +31,14 @@ func TestSyncSelected(t *testing.T) { func TestRenderPlanWithHello(t *testing.T) { reg := module.NewRegistry() _ = reg.Register(hello.Module{Path: t.TempDir() + "/marker"}) - out := renderPlan(context.Background(), reg, []string{"hello"}) + out := renderPlanCached(context.Background(), reg, []string{"hello"}, newPlanCache()) if !strings.Contains(out, "hello") { t.Fatalf("output missing hello: %q", out) } } func TestRenderPlanEmpty(t *testing.T) { - if got := renderPlan(context.Background(), module.NewRegistry(), nil); got != "No modules selected." { + if got := renderPlanCached(context.Background(), module.NewRegistry(), nil, newPlanCache()); got != "No modules selected." { t.Fatalf("got %q", got) } } @@ -63,6 +63,65 @@ func TestParseIntClamp(t *testing.T) { } } +func TestRenderPlanCachesDetectAndPlan(t *testing.T) { + var detects, plans int + m := countingModule{ + meta: module.Meta{ID: "count", Name: "Count"}, + onDetect: func() { detects++ }, + onPlan: func() { plans++ }, + } + reg := module.NewRegistry() + if err := reg.Register(m); err != nil { + t.Fatal(err) + } + cache := newPlanCache() + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) + if detects != 1 || plans != 1 { + t.Fatalf("want 1 detect and 1 plan, got detects=%d plans=%d", detects, plans) + } +} + +func TestRenderPlanInvalidatesOnSelectionChange(t *testing.T) { + var detects int + m := countingModule{ + meta: module.Meta{ID: "count", Name: "Count"}, + onDetect: func() { detects++ }, + } + reg := module.NewRegistry() + _ = reg.Register(m) + cache := newPlanCache() + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) + _ = renderPlanCached(context.Background(), reg, []string{}, cache) + _ = renderPlanCached(context.Background(), reg, []string{"count"}, cache) + if detects != 2 { + t.Fatalf("deselect+reselect should re-detect; detects=%d (want 2)", detects) + } +} + +type countingModule struct { + meta module.Meta + onDetect func() + onPlan func() +} + +func (c countingModule) Meta() module.Meta { return c.meta } +func (c countingModule) Detect(context.Context) (module.State, error) { + if c.onDetect != nil { + c.onDetect() + } + return nil, nil +} +func (c countingModule) Plan(context.Context, module.State) (module.Plan, error) { + if c.onPlan != nil { + c.onPlan() + } + return module.Plan{}, nil +} +func (c countingModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil } +func (c countingModule) Verify(context.Context) error { return nil } + func TestRunCancelledContextReturnsNoConfirmation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel()