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:
84
internal/cmd/doctor.go
Normal file
84
internal/cmd/doctor.go
Normal 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
118
internal/doctor/doctor.go
Normal 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
|
||||
}
|
||||
360
internal/doctor/doctor_test.go
Normal file
360
internal/doctor/doctor_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
9
internal/doctor/errors.go
Normal file
9
internal/doctor/errors.go
Normal 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
192
internal/doctor/types.go
Normal 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, ", "))
|
||||
}
|
||||
Reference in New Issue
Block a user