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
1 change: 1 addition & 0 deletions docs/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ title: Changelog
* `[Changed]` Add breathing room between the `USAGE` heading and its usage block in help output.
* `[Refactoring]` Use Go 1.26 `errors.AsType` for type-safe error unwrapping.
* `[Testing]` Add golden-file tests for help and error rendering in `internal/cmd`, replacing bats format checks with snapshot comparisons.
* `[Added]` Add a `theme` user setting with `default`, `ansi`, and `synthwave` themes for lets help and styled error output.

## [0.0.61](https://github.com/lets-cli/lets/releases/tag/v0.0.61)

Expand Down
34 changes: 32 additions & 2 deletions docs/docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ title: Settings

`lets` settings control the behavior of `lets` itself.

Use settings for things like colored output or update notifications. Do not use this file for project commands or runtime env. Project behavior still belongs in `lets.yaml`.
Use settings for things like colored output, theming, or update notifications. Do not use this file for project commands or runtime env. Project behavior still belongs in `lets.yaml`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (typo): Consider expanding the abbreviation "runtime env" to "runtime environment" for clarity.

Spelling this out (e.g., "Do not use this file for project commands or runtime environment.") will make the docs clearer to all readers.

Suggested change
Use settings for things like colored output, theming, or update notifications. Do not use this file for project commands or runtime env. Project behavior still belongs in `lets.yaml`.
Use settings for things like colored output, theming, or update notifications. Do not use this file for project commands or runtime environment. Project behavior still belongs in `lets.yaml`.


## Settings file location

Expand Down Expand Up @@ -48,6 +48,35 @@ Note:
- this affects `lets` output itself
- it does not inject `NO_COLOR` into commands from `lets.yaml`

### `theme`

Choose the theme for `lets` styled help and error output.

Supported values:

- `default`
- `ansi`
- `synthwave`

Example:

```yaml
theme: synthwave
```

Environment override:

- none

Default:

- `theme: default`

Notes:

- this affects `lets` output itself
- project commands still control their own colors

### `upgrade_notify`

Enable or disable background update notifications for interactive sessions.
Expand All @@ -69,7 +98,8 @@ Default:
## Example

```yaml
no_color: true
no_color: false
theme: default
upgrade_notify: false
```

Expand Down
2 changes: 1 addition & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func Main(version string, buildDate string) int {
ctx,
rootCmd,
fang.WithVersion(rootCmd.Version),
fang.WithColorSchemeFunc(theme.DefaultColorScheme),
fang.WithColorSchemeFunc(theme.ColorSchemeByName(appSettings.Theme)),
fang.WithErrorHandler(cmd.ErrorHandler),
fang.WithHelpRenderer(cmd.HelpRenderer),
); err != nil {
Expand Down
22 changes: 20 additions & 2 deletions internal/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,27 @@ import (
"os"

"github.com/fatih/color"
"github.com/lets-cli/lets/internal/theme"
"github.com/lets-cli/lets/internal/util"
"gopkg.in/yaml.v3"
)

type FileSettings struct {
NoColor *bool `yaml:"no_color"`
UpgradeNotify *bool `yaml:"upgrade_notify"`
NoColor *bool `yaml:"no_color"`
Theme *string `yaml:"theme"`
UpgradeNotify *bool `yaml:"upgrade_notify"`
}

type Settings struct {
NoColor bool
Theme string
UpgradeNotify bool
}

func Default() Settings {
return Settings{
NoColor: false,
Theme: theme.DefaultName,
UpgradeNotify: true,
}
}
Expand Down Expand Up @@ -63,12 +67,26 @@ func LoadFile(path string) (Settings, error) {
cfg.NoColor = *fileSettings.NoColor
}

if fileSettings.Theme != nil {
cfg.Theme = *fileSettings.Theme
}

if fileSettings.UpgradeNotify != nil {
cfg.UpgradeNotify = *fileSettings.UpgradeNotify
}

applyEnvOverrides(&cfg)

if !theme.ValidName(cfg.Theme) {
return Settings{}, fmt.Errorf(
Comment on lines +80 to +81

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Avoid hard-coding the list of valid themes in the error message to prevent drift from theme.ValidName.

Right now the message hard-codes DefaultName, ANSIName, and SynthwaveName, which can fall out of sync as themes change. Consider exposing the valid theme names from theme (e.g., via a Names() helper or shared slice) and constructing the error message from that so the validation and error stay aligned.

"invalid theme %q: must be one of %q, %q, %q",
cfg.Theme,
theme.DefaultName,
theme.ANSIName,
theme.SynthwaveName,
)
}

return cfg, nil
}

Expand Down
34 changes: 31 additions & 3 deletions internal/settings/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ func TestLoadFile(t *testing.T) {
if cfg.NoColor {
t.Fatal("expected no_color default to be false")
}
if cfg.Theme != "default" {
t.Fatalf("expected theme default to be %q, got %q", "default", cfg.Theme)
}
if !cfg.UpgradeNotify {
t.Fatal("expected upgrade_notify default to be true")
}
Expand All @@ -49,7 +52,7 @@ func TestLoadFile(t *testing.T) {
unsetEnv(t, "LETS_CHECK_UPDATE")

path := filepath.Join(t.TempDir(), "config.yaml")
err := os.WriteFile(path, []byte("no_color: true\nupgrade_notify: false\n"), 0o644)
err := os.WriteFile(path, []byte("no_color: true\ntheme: synthwave\nupgrade_notify: false\n"), 0o644)
if err != nil {
t.Fatalf("failed to write settings file: %v", err)
}
Expand All @@ -62,14 +65,17 @@ func TestLoadFile(t *testing.T) {
if !cfg.NoColor {
t.Fatal("expected no_color to be true")
}
if cfg.Theme != "synthwave" {
t.Fatalf("expected theme to be %q, got %q", "synthwave", cfg.Theme)
}
if cfg.UpgradeNotify {
t.Fatal("expected upgrade_notify to be false")
}
})

t.Run("env overrides file values", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
err := os.WriteFile(path, []byte("no_color: false\nupgrade_notify: true\n"), 0o644)
err := os.WriteFile(path, []byte("no_color: false\ntheme: ansi\nupgrade_notify: true\n"), 0o644)
if err != nil {
t.Fatalf("failed to write settings file: %v", err)
}
Expand All @@ -85,11 +91,30 @@ func TestLoadFile(t *testing.T) {
if !cfg.NoColor {
t.Fatal("expected NO_COLOR to override settings file")
}
if cfg.Theme != "ansi" {
t.Fatalf("expected theme to remain %q, got %q", "ansi", cfg.Theme)
}
if cfg.UpgradeNotify {
t.Fatal("expected LETS_CHECK_UPDATE to disable notifications")
}
})

t.Run("rejects invalid theme", func(t *testing.T) {
unsetEnv(t, "NO_COLOR")
unsetEnv(t, "LETS_CHECK_UPDATE")

path := filepath.Join(t.TempDir(), "config.yaml")
err := os.WriteFile(path, []byte("theme: vaporwave\n"), 0o644)
if err != nil {
t.Fatalf("failed to write settings file: %v", err)
}

_, err = LoadFile(path)
if err == nil {
t.Fatal("expected error")
}
})

t.Run("rejects unknown fields", func(t *testing.T) {
unsetEnv(t, "NO_COLOR")
unsetEnv(t, "LETS_CHECK_UPDATE")
Expand Down Expand Up @@ -119,7 +144,7 @@ func TestLoad(t *testing.T) {
t.Fatalf("failed to create config dir: %v", err)
}

err = os.WriteFile(configPath, []byte("no_color: true\n"), 0o644)
err = os.WriteFile(configPath, []byte("no_color: true\ntheme: ansi\n"), 0o644)
if err != nil {
t.Fatalf("failed to write settings file: %v", err)
}
Expand All @@ -132,6 +157,9 @@ func TestLoad(t *testing.T) {
if !cfg.NoColor {
t.Fatal("expected loaded no_color to be true")
}
if cfg.Theme != "ansi" {
t.Fatalf("expected loaded theme to be %q, got %q", "ansi", cfg.Theme)
}
}

func TestApply(t *testing.T) {
Expand Down
29 changes: 29 additions & 0 deletions internal/theme/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,35 @@ import (
"github.com/lets-cli/fang"
)

// Supported theme names.
const (
DefaultName = "default"
ANSIName = "ansi"
SynthwaveName = "synthwave"
)

// ValidName reports whether name is a supported theme.
func ValidName(name string) bool {
switch name {
case DefaultName, ANSIName, SynthwaveName:
return true
default:
return false
}
}

// ColorSchemeByName resolves a theme name to a Fang color scheme.
func ColorSchemeByName(name string) fang.ColorSchemeFunc {
switch name {
case ANSIName:
return AnsiColorScheme
case SynthwaveName:
return SynthwaveColorScheme
default:
return DefaultColorScheme
}
}

// DefaultColorScheme is the default colorscheme.
func DefaultColorScheme(c lipgloss.LightDarkFunc) fang.ColorScheme {
baseCyan := charmtone.Turtle
Expand Down
38 changes: 38 additions & 0 deletions internal/theme/theme_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package theme

import (
"testing"

"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/exp/charmtone"
)

func TestValidName(t *testing.T) {
for _, name := range []string{DefaultName, ANSIName, SynthwaveName} {
if !ValidName(name) {
t.Fatalf("expected %q to be valid", name)
}
}

if ValidName("vaporwave") {
t.Fatal("expected unknown theme to be invalid")
}
}

func TestColorSchemeByName(t *testing.T) {
if got := ColorSchemeByName(DefaultName)(lipgloss.LightDark(true)).Title; got != charmtone.Ash {
t.Fatalf("expected default theme title color %v, got %v", charmtone.Ash, got)
}

if got := ColorSchemeByName(ANSIName)(lipgloss.LightDark(true)).ErrorDetails; got != lipgloss.Red {
t.Fatalf("expected ansi theme error details color %v, got %v", lipgloss.Red, got)
}

if got := ColorSchemeByName(SynthwaveName)(lipgloss.LightDark(true)).Title; got != charmtone.Grape {
t.Fatalf("expected synthwave theme title color %v, got %v", charmtone.Grape, got)
}

if got := ColorSchemeByName("unknown")(lipgloss.LightDark(true)).Title; got != charmtone.Ash {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Reduce coupling of the fallback behavior test to specific color values

The last assertion hardcodes charmtone.Ash as the expected Title color for unknown themes, tying the test to the current DefaultColorScheme implementation. If that default color changes, this test will fail even though the fallback still works. Instead, compare the scheme for an unknown name to the scheme for DefaultName, e.g.:

want := ColorSchemeByName(DefaultName)(lipgloss.LightDark(true))
got := ColorSchemeByName("unknown")(lipgloss.LightDark(true))
if got.Title != want.Title { /* ... */ }

Possibly compare multiple fields to assert the fallback behavior without hardcoding specific colors.

Suggested implementation:

func TestColorSchemeByName(t *testing.T) {
	lightDark := lipgloss.LightDark(true)

	defaultScheme := ColorSchemeByName(DefaultName)(lightDark)
	if defaultScheme.Title != charmtone.Ash {
		t.Fatalf("expected default theme title color %v, got %v", charmtone.Ash, defaultScheme.Title)
	}

	unknownScheme := ColorSchemeByName("unknown")(lightDark)
	if unknownScheme.Title != defaultScheme.Title {
		t.Fatalf("expected unknown theme to fall back to default title color %v, got %v", defaultScheme.Title, unknownScheme.Title)
	}
	if unknownScheme.ErrorDetails != defaultScheme.ErrorDetails {
		t.Fatalf("expected unknown theme to fall back to default error details color %v, got %v", defaultScheme.ErrorDetails, unknownScheme.ErrorDetails)
	}
}

If the ColorScheme struct has other important fields that must match for a correct fallback (e.g. Body, Subtitle, etc.), you may want to add analogous comparisons for those fields as well to further strengthen the test without hardcoding specific color values.

t.Fatalf("expected unknown theme to fall back to %v, got %v", charmtone.Ash, got)
}
}
Loading