From c710ed81df0c1484402b6e4964f3f20bbff8b7a2 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Tue, 14 Apr 2026 14:19:12 +0200 Subject: [PATCH] feat(doctor): add archduino doctor subcommand --- internal/subcommands/doctor/doctor.go | 104 ++++++++++++++++ internal/subcommands/doctor/doctor_test.go | 135 +++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 internal/subcommands/doctor/doctor.go create mode 100644 internal/subcommands/doctor/doctor_test.go diff --git a/internal/subcommands/doctor/doctor.go b/internal/subcommands/doctor/doctor.go new file mode 100644 index 0000000..1776978 --- /dev/null +++ b/internal/subcommands/doctor/doctor.go @@ -0,0 +1,104 @@ +// Package doctor reports module convergence and pinned-tool version drift. +package doctor + +import ( + "context" + "encoding/json" + "fmt" + "io" + "regexp" + "strings" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +// Run inspects every module in the registry plus pinned tool versions and +// writes a human-readable report to w. Returns 0 when everything is +// converged with no drift; 1 otherwise. Never panics on module errors. +func Run(ctx context.Context, cfg config.Config, reg *module.Registry, runner platform.Runner, w io.Writer) int { + var converged, pending, errs, drift int + + for _, m := range reg.All() { + id := m.Meta().ID + st, err := m.Detect(ctx) + if err != nil { + fmt.Fprintf(w, "[!] %s — detect failed: %v\n", id, err) + errs++ + continue + } + pl, err := m.Plan(ctx, st) + if err != nil { + fmt.Fprintf(w, "[!] %s — plan failed: %v\n", id, err) + errs++ + continue + } + if pl.IsEmpty() { + fmt.Fprintf(w, "[✓] %s\n", id) + converged++ + } else { + fmt.Fprintf(w, "[✗] %s (%d actions pending)\n", id, len(pl.Actions)) + pending++ + } + } + + drift += checkArduinoCLI(ctx, runner, cfg.ArduinoCLI.Version, w) + drift += checkWokwiCLI(ctx, runner, cfg.Renode.WokwiVersion, w) + + fmt.Fprintf(w, "\n%d converged, %d need-install, %d drift, %d errors\n", converged, pending, drift, errs) + + if pending > 0 || drift > 0 || errs > 0 { + return 1 + } + return 0 +} + +type arduinoCLIVersion struct { + VersionString string `json:"VersionString"` +} + +func checkArduinoCLI(ctx context.Context, runner platform.Runner, want string, w io.Writer) int { + if want == "" { + return 0 + } + out, err := runner.Output(ctx, "arduino-cli", "version", "--format", "json") + if err != nil { + return 0 // not installed → silent skip + } + var v arduinoCLIVersion + if err := json.Unmarshal(out, &v); err != nil || v.VersionString == "" { + fmt.Fprintf(w, "[!] arduino-cli — version parse failed\n") + return 1 + } + if v.VersionString != want { + fmt.Fprintf(w, "[!] arduino-cli drift: got %s, want %s\n", v.VersionString, want) + return 1 + } + fmt.Fprintf(w, "[✓] arduino-cli %s\n", v.VersionString) + return 0 +} + +var wokwiVersionRE = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`) + +func checkWokwiCLI(ctx context.Context, runner platform.Runner, want string, w io.Writer) int { + if want == "" { + return 0 + } + out, err := runner.Output(ctx, "wokwi-cli", "--version") + if err != nil { + return 0 + } + m := wokwiVersionRE.FindStringSubmatch(strings.TrimSpace(string(out))) + if len(m) < 2 { + fmt.Fprintf(w, "[!] wokwi-cli — version parse failed\n") + return 1 + } + got := m[1] + if got != want { + fmt.Fprintf(w, "[!] wokwi-cli drift: got %s, want %s\n", got, want) + return 1 + } + fmt.Fprintf(w, "[✓] wokwi-cli %s\n", got) + return 0 +} diff --git a/internal/subcommands/doctor/doctor_test.go b/internal/subcommands/doctor/doctor_test.go new file mode 100644 index 0000000..ad3f5e9 --- /dev/null +++ b/internal/subcommands/doctor/doctor_test.go @@ -0,0 +1,135 @@ +package doctor + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" +) + +// convergedModule returns an empty Plan — always converged. +type convergedModule struct{ id string } + +func (c convergedModule) Meta() module.Meta { return module.Meta{ID: c.id, Name: c.id} } +func (c convergedModule) Detect(context.Context) (module.State, error) { return nil, nil } +func (c convergedModule) Plan(context.Context, module.State) (module.Plan, error) { + return module.Plan{}, nil +} +func (c convergedModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil } +func (c convergedModule) Verify(context.Context) error { return nil } + +type pendingModule struct{ id string } + +func (p pendingModule) Meta() module.Meta { return module.Meta{ID: p.id} } +func (p pendingModule) Detect(context.Context) (module.State, error) { return nil, nil } +func (p pendingModule) Plan(context.Context, module.State) (module.Plan, error) { + return module.Plan{Actions: []module.Action{{Description: "do a thing"}}}, nil +} +func (p pendingModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil } +func (p pendingModule) Verify(context.Context) error { return nil } + +type detectFailModule struct{ id string } + +func (d detectFailModule) Meta() module.Meta { return module.Meta{ID: d.id} } +func (d detectFailModule) Detect(context.Context) (module.State, error) { return nil, errors.New("boom") } +func (d detectFailModule) Plan(context.Context, module.State) (module.Plan, error) { + return module.Plan{}, nil +} +func (d detectFailModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil } +func (d detectFailModule) Verify(context.Context) error { return nil } + +// notFoundRunner simulates "command not found" for version checks. +type notFoundRunner struct{} + +func (notFoundRunner) Run(context.Context, string, ...string) error { return errors.New("not found") } +func (notFoundRunner) Output(context.Context, string, ...string) ([]byte, error) { + return nil, errors.New("not found") +} + +// fixedRunner returns a preset version string for a matching command. +type fixedRunner struct { + arduinoOut []byte + arduinoErr error + wokwiOut []byte + wokwiErr error +} + +func (f fixedRunner) Run(context.Context, string, ...string) error { return nil } +func (f fixedRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + switch name { + case "arduino-cli": + return f.arduinoOut, f.arduinoErr + case "wokwi-cli": + return f.wokwiOut, f.wokwiErr + } + return nil, errors.New("no match") +} + +func TestDoctorAllConvergedExitsZero(t *testing.T) { + reg := module.NewRegistry() + _ = reg.Register(convergedModule{id: "foo"}) + var buf bytes.Buffer + code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf) + if code != 0 { + t.Fatalf("exit %d, want 0; output:\n%s", code, buf.String()) + } + if !strings.Contains(buf.String(), "[✓] foo") { + t.Errorf("missing converged mark: %s", buf.String()) + } +} + +func TestDoctorDivergedExitsOne(t *testing.T) { + reg := module.NewRegistry() + _ = reg.Register(pendingModule{id: "bar"}) + var buf bytes.Buffer + code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf) + if code != 1 { + t.Fatalf("exit %d, want 1", code) + } + if !strings.Contains(buf.String(), "[✗] bar") { + t.Errorf("missing diverged mark: %s", buf.String()) + } +} + +func TestDoctorDetectErrorIsReported(t *testing.T) { + reg := module.NewRegistry() + _ = reg.Register(detectFailModule{id: "baz"}) + var buf bytes.Buffer + code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf) + if code != 1 { + t.Fatalf("exit %d, want 1", code) + } + if !strings.Contains(buf.String(), "[!] baz") { + t.Errorf("missing error mark: %s", buf.String()) + } +} + +func TestDoctorArduinoCLIDriftIsReported(t *testing.T) { + reg := module.NewRegistry() + _ = reg.Register(convergedModule{id: "foo"}) + cfg := config.Config{ArduinoCLI: config.ArduinoCLIConfig{Version: "0.35.3"}} + runner := fixedRunner{arduinoOut: []byte(`{"VersionString":"0.36.0"}`)} + var buf bytes.Buffer + code := Run(context.Background(), cfg, reg, runner, &buf) + if code != 1 { + t.Fatalf("exit %d, want 1 (drift)", code) + } + if !strings.Contains(buf.String(), "drift") { + t.Errorf("missing drift report: %s", buf.String()) + } +} + +func TestDoctorMissingBinariesSkipVersionCheck(t *testing.T) { + reg := module.NewRegistry() + _ = reg.Register(convergedModule{id: "foo"}) + cfg := config.Config{ArduinoCLI: config.ArduinoCLIConfig{Version: "0.35.3"}} + var buf bytes.Buffer + code := Run(context.Background(), cfg, reg, notFoundRunner{}, &buf) + if code != 0 { + t.Fatalf("exit %d, want 0; output:\n%s", code, buf.String()) + } +}