Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ grut is primarily a TUI, but it also exposes subcommands for scripting and setup
| `grut [path]` | Launch the TUI in the current or given directory |
| `grut version` | Print the version of grut |
| `grut update` | Update grut to the latest release |
| `grut doctor` | Check environment health (`--json` for reports, `--check` for quiet gates) |
| `grut doctor` | Check environment health (`--json` for reports, `--check` to fail on required-check failures) |
| `grut status` | Print a summary of the working tree status |
| `grut config` | Inspect configuration (`check`, `get <key>`, `defaults`) |
| `grut theme` | Inspect themes (`theme list`) |
Expand Down
17 changes: 9 additions & 8 deletions cmd/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,17 +86,18 @@ func newDoctorCmdWithDeps(deps doctorCommandDeps) *cobra.Command {
SilenceUsage: true,
RunE: func(cmd *cobra.Command, _ []string) error {
report := runDoctor(cmd.Context(), deps)
check, _ := cmd.Flags().GetBool("check")
var err error
if asJSON {
err = writeDoctorJSON(cmd.OutOrStdout(), report)
} else if !check {
if err := writeDoctorJSON(cmd.OutOrStdout(), report); err != nil {
return err
}
} else {
writeDoctorText(cmd.OutOrStdout(), report)
}
if err != nil {
return err
}
if !report.OK {
// --check turns the report into an exit-code gate, matching
// "status --check" and "clean --check". Without it, doctor is a
// diagnostic that always succeeds so it stays usable mid-pipeline.
check, _ := cmd.Flags().GetBool("check")
if check && !report.OK {
return errDoctorRequiredChecksFailed
}
return nil
Expand Down
20 changes: 13 additions & 7 deletions cmd/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func TestDoctorInvalidConfigFailsRequiredCheck(t *testing.T) {

out, err := runDoctorTest(t, deps, []string{"--json"})

require.Error(t, err)
require.NoError(t, err)
var report doctorReport
require.NoError(t, json.Unmarshal([]byte(out), &report))
assert.False(t, report.OK)
Expand Down Expand Up @@ -78,7 +78,7 @@ func TestDoctorMissingGitHubAuthWarnsWithoutFailing(t *testing.T) {
assert.Contains(t, out, "All required checks passed.")
}

func TestDoctorRequiredFailureExitsNonZero(t *testing.T) {
func TestDoctorRequiredFailureReportsFailure(t *testing.T) {
deps := doctorTestDeps(&config.Config{
Theme: config.ThemeConfig{Name: "default"},
AI: config.AIConfig{Enabled: false, Provider: "none"},
Expand All @@ -92,23 +92,25 @@ func TestDoctorRequiredFailureExitsNonZero(t *testing.T) {

out, err := runDoctorTest(t, deps, nil)

require.Error(t, err)
require.NoError(t, err)
assert.Contains(t, out, "Git")
assert.Contains(t, out, "FAIL")
assert.Contains(t, out, "One or more required checks failed.")
}

func TestDoctorCheckPassingRequiredChecksPrintsNothing(t *testing.T) {
// --check is an exit-code gate, matching "status --check" and "clean --check".
// The report still prints; only the exit code changes.
func TestDoctorCheckPassingRequiredChecksPrintsReport(t *testing.T) {
out, err := runDoctorTest(t, doctorTestDeps(&config.Config{
Theme: config.ThemeConfig{Name: "default"},
AI: config.AIConfig{Enabled: false, Provider: "none"},
}), []string{"--check"})

require.NoError(t, err)
assert.Empty(t, out)
assert.Contains(t, out, "All required checks passed.")
}

func TestDoctorCheckRequiredFailureExitsNonZeroAndPrintsNothing(t *testing.T) {
func TestDoctorCheckRequiredFailureExitsNonZeroAndStillPrints(t *testing.T) {
deps := doctorTestDeps(&config.Config{
Theme: config.ThemeConfig{Name: "default"},
AI: config.AIConfig{Enabled: false, Provider: "none"},
Expand All @@ -123,9 +125,13 @@ func TestDoctorCheckRequiredFailureExitsNonZeroAndPrintsNothing(t *testing.T) {
out, err := runDoctorTest(t, deps, []string{"--check"})

require.ErrorIs(t, err, errDoctorRequiredChecksFailed)
assert.Empty(t, out)
assert.Contains(t, out, "One or more required checks failed.")
}

// Without --check, doctor is a diagnostic: it reports failures but exits 0 so
// it stays usable mid-pipeline. Covered by
// TestDoctorRequiredFailureReportsFailure above.

func TestDoctorCheckJSONStillPrintsJSON(t *testing.T) {
out, err := runDoctorTest(t, doctorTestDeps(&config.Config{
Theme: config.ThemeConfig{Name: "default"},
Expand Down
17 changes: 14 additions & 3 deletions cmd/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"encoding/json"
"fmt"
"slices"
"strings"

"github.com/jongio/grut/internal/theme"
Expand All @@ -17,7 +18,7 @@ func newThemeCmd() *cobra.Command {
Short: "Inspect grut themes",
}
themeCmd.AddCommand(newThemeListCmd(theme.ListThemes))
themeCmd.AddCommand(newThemeShowCmd(theme.Load))
themeCmd.AddCommand(newThemeShowCmd(theme.Load, theme.ListThemes))
return themeCmd
}

Expand Down Expand Up @@ -70,14 +71,24 @@ type themeShowReport struct {
Colors theme.Colors `json:"colors"`
}

func newThemeShowCmd(load themeLoadFunc) *cobra.Command {
func newThemeShowCmd(load themeLoadFunc, list themeListFunc) *cobra.Command {
var asJSON bool
showCmd := &cobra.Command{
Use: "show <name>",
Short: "Show resolved theme details",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
resolved, err := load(args[0])
// theme.Load falls back to the default theme for an unknown name so
// a stale config value can't stop the TUI from launching. That is
// wrong for "show", where silently reporting the default theme
// hides the typo, so reject unknown names up front.
name := args[0]
available := list()
if !slices.Contains(available, name) {
return fmt.Errorf("unknown theme %q (available: %s)",
name, strings.Join(available, ", "))
}
resolved, err := load(name)
if err != nil {
return err
}
Expand Down
27 changes: 25 additions & 2 deletions cmd/theme_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ func TestThemeListCommandFilterNoMatchesJSON(t *testing.T) {
func TestThemeShowCommandPrintsText(t *testing.T) {
cmd := newThemeShowCmd(func(string) (*theme.Theme, error) {
return &theme.Theme{Name: "default", Variant: "dark", Mode: theme.ModeColor, Colors: theme.Colors{Background: "#000000"}}, nil
})
}, func() []string { return []string{"default", "gruvbox"} })
cmd.SetArgs([]string{"default"})
var out bytes.Buffer
cmd.SetOut(&out)
Expand All @@ -117,7 +117,7 @@ func TestThemeShowCommandPrintsText(t *testing.T) {
func TestThemeShowCommandPrintsJSON(t *testing.T) {
cmd := newThemeShowCmd(func(string) (*theme.Theme, error) {
return &theme.Theme{Name: "gruvbox", Variant: "dark", Mode: theme.ModeColor, Colors: theme.Colors{Background: "#282828"}}, nil
})
}, func() []string { return []string{"default", "gruvbox"} })
cmd.SetArgs([]string{"gruvbox", "--json"})
var out bytes.Buffer
cmd.SetOut(&out)
Expand All @@ -133,6 +133,29 @@ func TestThemeShowCommandPrintsJSON(t *testing.T) {
assert.Equal(t, "#282828", report.Colors.Background)
}

// theme.Load falls back to the default theme for an unknown name, which would
// make "theme show typo" print the default theme and exit 0. Assert the
// command rejects the name before ever calling load.
func TestThemeShowCommandUnknownThemeErrors(t *testing.T) {
loadCalled := false
cmd := newThemeShowCmd(func(string) (*theme.Theme, error) {
loadCalled = true
return &theme.Theme{Name: "default", Variant: "dark", Mode: theme.ModeColor}, nil
}, func() []string { return []string{"default", "gruvbox"} })
cmd.SetArgs([]string{"grubvox"})
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SilenceUsage = true

err := cmd.Execute()

require.Error(t, err)
assert.Contains(t, err.Error(), `unknown theme "grubvox"`)
assert.Contains(t, err.Error(), "gruvbox")
assert.False(t, loadCalled, "load should not run for an unknown theme")
}

func TestRootRegistersThemeListCommand(t *testing.T) {
root, cleanup := newRootCommand()
defer cleanup()
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Config: C:\Users\me\AppData\Roaming\grut\config.toml
Data: C:\Users\me\AppData\Local\grut
```

Run `grut doctor` to validate the active config, selected theme, terminal compatibility, Git/GitHub auth, AI provider readiness, and config/data directory writability. Use `grut doctor --json` to attach a stable machine-readable report to bug reports or CI logs. Use `grut doctor --check` for quiet shell gates that only need an exit code.
Run `grut doctor` to validate the active config, selected theme, terminal compatibility, Git/GitHub auth, AI provider readiness, and config/data directory writability. Use `grut doctor --json` to attach a stable machine-readable report to bug reports or CI logs. Use `grut doctor --check` for shell gates: it prints the same report but exits non-zero when a required check fails, matching `grut status --check` and `grut clean --check`.

---

Expand Down
Loading