feat(doctor): add archduino doctor subcommand
This commit is contained in:
104
internal/subcommands/doctor/doctor.go
Normal file
104
internal/subcommands/doctor/doctor.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
135
internal/subcommands/doctor/doctor_test.go
Normal file
135
internal/subcommands/doctor/doctor_test.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user