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{}