feat(doctor): add health check framework (gt-f9x.4)

Add doctor package with:
- Check interface for implementing health checks
- CheckContext for passing context to checks
- CheckResult and CheckStatus types
- Report with summary and pretty printing
- Doctor runner with Run() and Fix() methods
- BaseCheck and FixableCheck for easy check implementation
- CLI command: gt doctor [--fix] [--verbose] [--rig <name>]

Built-in checks will be added in:
- gt-f9x.5: Town-level checks (config, state, mail, rigs)
- gt-f9x.6: Rig-level checks (refinery, clones, gitignore)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-17 15:48:13 -08:00
parent f011e9bc80
commit d0f3ad9140
6 changed files with 956 additions and 193 deletions

84
internal/cmd/doctor.go Normal file
View File

@@ -0,0 +1,84 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/doctor"
"github.com/steveyegge/gastown/internal/workspace"
)
var (
doctorFix bool
doctorVerbose bool
doctorRig string
)
var doctorCmd = &cobra.Command{
Use: "doctor",
Short: "Run health checks on the workspace",
Long: `Run diagnostic checks on the Gas Town workspace.
Doctor checks for common configuration issues, missing files,
and other problems that could affect workspace operation.
Use --fix to attempt automatic fixes for issues that support it.
Use --rig to check a specific rig instead of the entire workspace.`,
RunE: runDoctor,
}
func init() {
doctorCmd.Flags().BoolVar(&doctorFix, "fix", false, "Attempt to automatically fix issues")
doctorCmd.Flags().BoolVarP(&doctorVerbose, "verbose", "v", false, "Show detailed output")
doctorCmd.Flags().StringVar(&doctorRig, "rig", "", "Check specific rig only")
rootCmd.AddCommand(doctorCmd)
}
func runDoctor(cmd *cobra.Command, args []string) error {
// Find town root
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Create check context
ctx := &doctor.CheckContext{
TownRoot: townRoot,
RigName: doctorRig,
Verbose: doctorVerbose,
}
// Create doctor and register checks
d := doctor.NewDoctor()
// Register built-in checks
// Note: Town-level checks are registered in gt-f9x.5
// Rig-level checks are registered in gt-f9x.6
// For now, we just have the framework ready
// If no checks registered, inform user
if len(d.Checks()) == 0 {
fmt.Println("No health checks registered yet.")
fmt.Println("Town-level and rig-level checks will be added in future updates.")
return nil
}
// Run checks
var report *doctor.Report
if doctorFix {
report = d.Fix(ctx)
} else {
report = d.Run(ctx)
}
// Print report
report.Print(os.Stdout, doctorVerbose)
// Exit with error code if there are errors
if report.HasErrors() {
return fmt.Errorf("doctor found %d error(s)", report.Summary.Errors)
}
return nil
}

118
internal/doctor/doctor.go Normal file
View File

@@ -0,0 +1,118 @@
package doctor
// Doctor manages and executes health checks.
type Doctor struct {
checks []Check
}
// NewDoctor creates a new Doctor with no registered checks.
func NewDoctor() *Doctor {
return &Doctor{
checks: make([]Check, 0),
}
}
// Register adds a check to the doctor's check list.
func (d *Doctor) Register(check Check) {
d.checks = append(d.checks, check)
}
// RegisterAll adds multiple checks to the doctor's check list.
func (d *Doctor) RegisterAll(checks ...Check) {
d.checks = append(d.checks, checks...)
}
// Checks returns the list of registered checks.
func (d *Doctor) Checks() []Check {
return d.checks
}
// Run executes all registered checks and returns a report.
func (d *Doctor) Run(ctx *CheckContext) *Report {
report := NewReport()
for _, check := range d.checks {
result := check.Run(ctx)
// Ensure check name is populated
if result.Name == "" {
result.Name = check.Name()
}
report.Add(result)
}
return report
}
// Fix runs all checks with auto-fix enabled where possible.
// It first runs the check, then if it fails and can be fixed, attempts the fix.
func (d *Doctor) Fix(ctx *CheckContext) *Report {
report := NewReport()
for _, check := range d.checks {
result := check.Run(ctx)
if result.Name == "" {
result.Name = check.Name()
}
// Attempt fix if check failed and is fixable
if result.Status != StatusOK && check.CanFix() {
err := check.Fix(ctx)
if err == nil {
// Re-run check to verify fix worked
result = check.Run(ctx)
if result.Name == "" {
result.Name = check.Name()
}
// Update message to indicate fix was applied
if result.Status == StatusOK {
result.Message = result.Message + " (fixed)"
}
} else {
// Fix failed, add error to details
result.Details = append(result.Details, "Fix failed: "+err.Error())
}
}
report.Add(result)
}
return report
}
// BaseCheck provides a base implementation for checks that don't support auto-fix.
// Embed this in custom checks to get default CanFix() and Fix() implementations.
type BaseCheck struct {
CheckName string
CheckDescription string
}
// Name returns the check name.
func (b *BaseCheck) Name() string {
return b.CheckName
}
// Description returns the check description.
func (b *BaseCheck) Description() string {
return b.CheckDescription
}
// CanFix returns false by default.
func (b *BaseCheck) CanFix() bool {
return false
}
// Fix returns an error indicating this check cannot be auto-fixed.
func (b *BaseCheck) Fix(ctx *CheckContext) error {
return ErrCannotFix
}
// FixableCheck provides a base implementation for checks that support auto-fix.
// Embed this and override CanFix() to return true, and implement Fix().
type FixableCheck struct {
BaseCheck
}
// CanFix returns true for fixable checks.
func (f *FixableCheck) CanFix() bool {
return true
}

View File

@@ -0,0 +1,360 @@
package doctor
import (
"bytes"
"testing"
)
// mockCheck is a test check that can be configured to return any status.
type mockCheck struct {
BaseCheck
status CheckStatus
fixable bool
fixError error
fixCount int
}
func newMockCheck(name string, status CheckStatus) *mockCheck {
return &mockCheck{
BaseCheck: BaseCheck{
CheckName: name,
CheckDescription: "Test check: " + name,
},
status: status,
}
}
func (m *mockCheck) Run(ctx *CheckContext) *CheckResult {
return &CheckResult{
Name: m.CheckName,
Status: m.status,
Message: "mock result",
}
}
func (m *mockCheck) CanFix() bool {
return m.fixable
}
func (m *mockCheck) Fix(ctx *CheckContext) error {
m.fixCount++
if m.fixError != nil {
return m.fixError
}
// Simulate successful fix by changing status
m.status = StatusOK
return nil
}
func TestCheckStatus_String(t *testing.T) {
tests := []struct {
status CheckStatus
want string
}{
{StatusOK, "OK"},
{StatusWarning, "Warning"},
{StatusError, "Error"},
{CheckStatus(99), "Unknown"},
}
for _, tt := range tests {
got := tt.status.String()
if got != tt.want {
t.Errorf("CheckStatus(%d).String() = %q, want %q", tt.status, got, tt.want)
}
}
}
func TestCheckContext_RigPath(t *testing.T) {
tests := []struct {
name string
ctx CheckContext
wantPath string
}{
{
name: "empty rig name",
ctx: CheckContext{TownRoot: "/town"},
wantPath: "",
},
{
name: "with rig name",
ctx: CheckContext{TownRoot: "/town", RigName: "myrig"},
wantPath: "/town/myrig",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.ctx.RigPath()
if got != tt.wantPath {
t.Errorf("RigPath() = %q, want %q", got, tt.wantPath)
}
})
}
}
func TestNewReport(t *testing.T) {
r := NewReport()
if r.Timestamp.IsZero() {
t.Error("NewReport() should set Timestamp")
}
if len(r.Checks) != 0 {
t.Error("NewReport() should have empty Checks slice")
}
if r.Summary.Total != 0 {
t.Error("NewReport() should have zero Total")
}
}
func TestReport_Add(t *testing.T) {
r := NewReport()
// Add an OK result
r.Add(&CheckResult{Name: "test1", Status: StatusOK})
if r.Summary.Total != 1 || r.Summary.OK != 1 {
t.Errorf("After adding OK: Total=%d, OK=%d", r.Summary.Total, r.Summary.OK)
}
// Add a warning
r.Add(&CheckResult{Name: "test2", Status: StatusWarning})
if r.Summary.Total != 2 || r.Summary.Warnings != 1 {
t.Errorf("After adding Warning: Total=%d, Warnings=%d", r.Summary.Total, r.Summary.Warnings)
}
// Add an error
r.Add(&CheckResult{Name: "test3", Status: StatusError})
if r.Summary.Total != 3 || r.Summary.Errors != 1 {
t.Errorf("After adding Error: Total=%d, Errors=%d", r.Summary.Total, r.Summary.Errors)
}
}
func TestReport_HasErrors(t *testing.T) {
r := NewReport()
if r.HasErrors() {
t.Error("Empty report should not have errors")
}
r.Add(&CheckResult{Status: StatusOK})
if r.HasErrors() {
t.Error("Report with only OK should not have errors")
}
r.Add(&CheckResult{Status: StatusWarning})
if r.HasErrors() {
t.Error("Report with only OK/Warning should not have errors")
}
r.Add(&CheckResult{Status: StatusError})
if !r.HasErrors() {
t.Error("Report with Error should have errors")
}
}
func TestReport_HasWarnings(t *testing.T) {
r := NewReport()
if r.HasWarnings() {
t.Error("Empty report should not have warnings")
}
r.Add(&CheckResult{Status: StatusOK})
if r.HasWarnings() {
t.Error("Report with only OK should not have warnings")
}
r.Add(&CheckResult{Status: StatusWarning})
if !r.HasWarnings() {
t.Error("Report with Warning should have warnings")
}
}
func TestReport_IsHealthy(t *testing.T) {
tests := []struct {
name string
results []CheckStatus
want bool
}{
{"empty", nil, true},
{"all OK", []CheckStatus{StatusOK, StatusOK}, true},
{"has warning", []CheckStatus{StatusOK, StatusWarning}, false},
{"has error", []CheckStatus{StatusOK, StatusError}, false},
{"mixed", []CheckStatus{StatusOK, StatusWarning, StatusError}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewReport()
for _, status := range tt.results {
r.Add(&CheckResult{Status: status})
}
if got := r.IsHealthy(); got != tt.want {
t.Errorf("IsHealthy() = %v, want %v", got, tt.want)
}
})
}
}
func TestReport_Print(t *testing.T) {
r := NewReport()
r.Add(&CheckResult{
Name: "TestCheck",
Status: StatusOK,
Message: "All good",
})
r.Add(&CheckResult{
Name: "WarningCheck",
Status: StatusWarning,
Message: "Minor issue",
FixHint: "Run fix command",
})
var buf bytes.Buffer
r.Print(&buf, false)
output := buf.String()
if output == "" {
t.Error("Print() should produce output")
}
// Basic checks that key elements are present
if !bytes.Contains(buf.Bytes(), []byte("TestCheck")) {
t.Error("Output should contain check name")
}
if !bytes.Contains(buf.Bytes(), []byte("2 checks")) {
t.Error("Output should contain summary")
}
}
func TestNewDoctor(t *testing.T) {
d := NewDoctor()
if d == nil {
t.Fatal("NewDoctor() returned nil")
}
if len(d.Checks()) != 0 {
t.Error("NewDoctor() should have no checks registered")
}
}
func TestDoctor_Register(t *testing.T) {
d := NewDoctor()
check1 := newMockCheck("check1", StatusOK)
check2 := newMockCheck("check2", StatusOK)
d.Register(check1)
if len(d.Checks()) != 1 {
t.Error("Register() should add one check")
}
d.Register(check2)
if len(d.Checks()) != 2 {
t.Error("Register() should add another check")
}
}
func TestDoctor_RegisterAll(t *testing.T) {
d := NewDoctor()
check1 := newMockCheck("check1", StatusOK)
check2 := newMockCheck("check2", StatusOK)
check3 := newMockCheck("check3", StatusOK)
d.RegisterAll(check1, check2, check3)
if len(d.Checks()) != 3 {
t.Errorf("RegisterAll() should add 3 checks, got %d", len(d.Checks()))
}
}
func TestDoctor_Run(t *testing.T) {
d := NewDoctor()
d.Register(newMockCheck("ok", StatusOK))
d.Register(newMockCheck("warn", StatusWarning))
d.Register(newMockCheck("error", StatusError))
ctx := &CheckContext{TownRoot: "/test"}
report := d.Run(ctx)
if report.Summary.Total != 3 {
t.Errorf("Run() Total = %d, want 3", report.Summary.Total)
}
if report.Summary.OK != 1 {
t.Errorf("Run() OK = %d, want 1", report.Summary.OK)
}
if report.Summary.Warnings != 1 {
t.Errorf("Run() Warnings = %d, want 1", report.Summary.Warnings)
}
if report.Summary.Errors != 1 {
t.Errorf("Run() Errors = %d, want 1", report.Summary.Errors)
}
}
func TestDoctor_Fix(t *testing.T) {
d := NewDoctor()
okCheck := newMockCheck("ok", StatusOK)
d.Register(okCheck)
fixableCheck := newMockCheck("fixable", StatusError)
fixableCheck.fixable = true
d.Register(fixableCheck)
unfixableCheck := newMockCheck("unfixable", StatusError)
unfixableCheck.fixable = false
d.Register(unfixableCheck)
ctx := &CheckContext{TownRoot: "/test"}
report := d.Fix(ctx)
// OK check should remain OK
if report.Checks[0].Status != StatusOK {
t.Error("OK check should remain OK")
}
// Fixable check should be fixed
if fixableCheck.fixCount != 1 {
t.Error("Fixable check should have Fix() called once")
}
if report.Checks[1].Status != StatusOK {
t.Error("Fixable check should be OK after fix")
}
// Unfixable check should remain error
if unfixableCheck.fixCount != 0 {
t.Error("Unfixable check should not have Fix() called")
}
if report.Checks[2].Status != StatusError {
t.Error("Unfixable check should remain Error")
}
}
func TestBaseCheck(t *testing.T) {
b := &BaseCheck{
CheckName: "test",
CheckDescription: "Test description",
}
if b.Name() != "test" {
t.Errorf("Name() = %q, want %q", b.Name(), "test")
}
if b.Description() != "Test description" {
t.Errorf("Description() = %q, want %q", b.Description(), "Test description")
}
if b.CanFix() {
t.Error("BaseCheck.CanFix() should return false")
}
if err := b.Fix(nil); err != ErrCannotFix {
t.Errorf("BaseCheck.Fix() should return ErrCannotFix, got %v", err)
}
}
func TestFixableCheck(t *testing.T) {
f := &FixableCheck{
BaseCheck: BaseCheck{
CheckName: "fixable",
CheckDescription: "Fixable check",
},
}
if !f.CanFix() {
t.Error("FixableCheck.CanFix() should return true")
}
}

View File

@@ -0,0 +1,9 @@
package doctor
import "errors"
// Common errors
var (
// ErrCannotFix is returned when a check does not support auto-fix.
ErrCannotFix = errors.New("check does not support auto-fix")
)

192
internal/doctor/types.go Normal file
View File

@@ -0,0 +1,192 @@
// Package doctor provides a framework for running health checks on Gas Town workspaces.
package doctor
import (
"fmt"
"io"
"strings"
"time"
"github.com/steveyegge/gastown/internal/style"
)
// CheckStatus represents the result status of a health check.
type CheckStatus int
const (
// StatusOK indicates the check passed.
StatusOK CheckStatus = iota
// StatusWarning indicates a non-critical issue.
StatusWarning
// StatusError indicates a critical problem.
StatusError
)
// String returns a human-readable status.
func (s CheckStatus) String() string {
switch s {
case StatusOK:
return "OK"
case StatusWarning:
return "Warning"
case StatusError:
return "Error"
default:
return "Unknown"
}
}
// CheckContext provides context for running checks.
type CheckContext struct {
TownRoot string // Root directory of the Gas Town workspace
RigName string // Rig name (empty for town-level checks)
Verbose bool // Enable verbose output
}
// RigPath returns the full path to the rig directory.
// Returns empty string if RigName is not set.
func (ctx *CheckContext) RigPath() string {
if ctx.RigName == "" {
return ""
}
return ctx.TownRoot + "/" + ctx.RigName
}
// CheckResult represents the outcome of a health check.
type CheckResult struct {
Name string // Check name
Status CheckStatus // Result status
Message string // Primary result message
Details []string // Additional information
FixHint string // Suggestion if not auto-fixable
}
// Check defines the interface for a health check.
type Check interface {
// Name returns the check identifier.
Name() string
// Description returns a human-readable description.
Description() string
// Run executes the check and returns a result.
Run(ctx *CheckContext) *CheckResult
// Fix attempts to automatically fix the issue.
// Should only be called if CanFix() returns true.
Fix(ctx *CheckContext) error
// CanFix returns true if this check can automatically fix issues.
CanFix() bool
}
// ReportSummary summarizes the results of all checks.
type ReportSummary struct {
Total int
OK int
Warnings int
Errors int
}
// Report contains all check results and a summary.
type Report struct {
Timestamp time.Time
Checks []*CheckResult
Summary ReportSummary
}
// NewReport creates an empty report with the current timestamp.
func NewReport() *Report {
return &Report{
Timestamp: time.Now(),
Checks: make([]*CheckResult, 0),
}
}
// Add adds a check result to the report and updates the summary.
func (r *Report) Add(result *CheckResult) {
r.Checks = append(r.Checks, result)
r.Summary.Total++
switch result.Status {
case StatusOK:
r.Summary.OK++
case StatusWarning:
r.Summary.Warnings++
case StatusError:
r.Summary.Errors++
}
}
// HasErrors returns true if any check reported an error.
func (r *Report) HasErrors() bool {
return r.Summary.Errors > 0
}
// HasWarnings returns true if any check reported a warning.
func (r *Report) HasWarnings() bool {
return r.Summary.Warnings > 0
}
// IsHealthy returns true if all checks passed without errors or warnings.
func (r *Report) IsHealthy() bool {
return r.Summary.Errors == 0 && r.Summary.Warnings == 0
}
// Print outputs the report to the given writer.
func (r *Report) Print(w io.Writer, verbose bool) {
// Print individual check results
for _, check := range r.Checks {
r.printCheck(w, check, verbose)
}
// Print summary
fmt.Fprintln(w)
r.printSummary(w)
}
// printCheck outputs a single check result.
func (r *Report) printCheck(w io.Writer, check *CheckResult, verbose bool) {
var prefix string
switch check.Status {
case StatusOK:
prefix = style.SuccessPrefix
case StatusWarning:
prefix = style.WarningPrefix
case StatusError:
prefix = style.ErrorPrefix
}
fmt.Fprintf(w, "%s %s: %s\n", prefix, check.Name, check.Message)
// Print details in verbose mode or for non-OK results
if len(check.Details) > 0 && (verbose || check.Status != StatusOK) {
for _, detail := range check.Details {
fmt.Fprintf(w, " %s\n", detail)
}
}
// Print fix hint for errors/warnings
if check.FixHint != "" && check.Status != StatusOK {
fmt.Fprintf(w, " %s %s\n", style.ArrowPrefix, check.FixHint)
}
}
// printSummary outputs the summary line.
func (r *Report) printSummary(w io.Writer) {
parts := []string{
fmt.Sprintf("%d checks", r.Summary.Total),
}
if r.Summary.OK > 0 {
parts = append(parts, style.Success.Render(fmt.Sprintf("%d passed", r.Summary.OK)))
}
if r.Summary.Warnings > 0 {
parts = append(parts, style.Warning.Render(fmt.Sprintf("%d warnings", r.Summary.Warnings)))
}
if r.Summary.Errors > 0 {
parts = append(parts, style.Error.Render(fmt.Sprintf("%d errors", r.Summary.Errors)))
}
fmt.Fprintln(w, strings.Join(parts, ", "))
}