Skip to content

Add lets user settings for color and update notifications#330

Merged
kindermax merged 3 commits intomasterfrom
codex/plan-lets-system-config-support
Apr 2, 2026
Merged

Add lets user settings for color and update notifications#330
kindermax merged 3 commits intomasterfrom
codex/plan-lets-system-config-support

Conversation

@kindermax
Copy link
Copy Markdown
Collaborator

@kindermax kindermax commented Apr 2, 2026

Summary

  • add internal/settings to load ~/.config/lets/config.yaml with no_color and upgrade_notify
  • apply settings during CLI startup so lets can disable colored output before logging is initialized
  • make update notification checks honor resolved settings while keeping env opt-outs higher priority
  • extract shared ~/.config/lets path helpers and reuse them for updater state
  • document the new settings support in the changelog

Testing

  • go test ./...

Summary by Sourcery

Introduce configurable user settings for CLI behavior and centralize lets user config paths.

New Features:

  • Add user settings loaded from ~/.config/lets/config.yaml to control color output and upgrade notifications.
  • Apply user settings during CLI startup so color behavior is configured before logging and command execution.
  • Respect user-configured upgrade notification preferences while keeping environment variable overrides higher priority.

Enhancements:

  • Centralize ~/.config/lets directory and file path helpers in a shared util package and reuse them for updater state paths.

Documentation:

  • Document the new user settings and their precedence in the changelog.

Tests:

  • Add tests for loading and applying user settings, including env and file precedence and error handling for unknown fields.
  • Add tests for the lets user config directory/file helpers and update CLI tests to cover the new upgrade notification behavior.

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai bot commented Apr 2, 2026

Reviewer's Guide

Introduce a user settings system loaded from ~/.config/lets/config.yaml to control color output and upgrade notifications, ensure these settings are applied before logging and respected by update checks, add reusable helpers for lets user config paths, and update tests and docs accordingly.

Sequence diagram for CLI startup applying settings and logging

sequenceDiagram
  actor User
  participant CLI_Main as CLI_Main
  participant SettingsPkg as settings
  participant ColorLib as color
  participant Logging as logging

  User->>CLI_Main: Main(version, buildDate)
  CLI_Main->>SettingsPkg: Load()
  alt settings load error
    SettingsPkg-->>CLI_Main: error
    CLI_Main->>User: print "lets: settings error" to stderr
    CLI_Main-->>User: exit code 1
  else settings loaded
    SettingsPkg-->>CLI_Main: Settings
    CLI_Main->>SettingsPkg: Settings.Apply()
    SettingsPkg->>ColorLib: set NoColor based on Settings.NoColor
    ColorLib-->>SettingsPkg: NoColor set
    CLI_Main->>Logging: InitLogging(stdout, stderr)
    Logging-->>CLI_Main: logging configured
    CLI_Main-->>User: proceed with command execution
  end
Loading

Sequence diagram for update check honoring settings and env vars

sequenceDiagram
  actor User
  participant CLI_Main as CLI_Main
  participant SettingsPkg as settings
  participant CLI_Update as cli_update

  User->>CLI_Main: run lets command
  CLI_Main->>SettingsPkg: Load()
  SettingsPkg-->>CLI_Main: Settings
  CLI_Main->>CLI_Update: maybeStartUpdateCheck(ctx, version, command, Settings)
  CLI_Update->>CLI_Update: interactive = isInteractiveStderr()
  CLI_Update->>CLI_Update: call shouldCheckForUpdate(command.Name(), interactive, Settings)
  alt non interactive or Settings.UpgradeNotify is false or CI env set
    CLI_Update-->>CLI_Main: no update check (nil channel)
  else
    CLI_Update-->>CLI_Main: start background update check
  end
Loading

Updated class diagram for settings and user config path helpers

classDiagram

  class FileSettings {
    +bool NoColor
    +bool UpgradeNotify
  }

  class Settings {
    +bool NoColor
    +bool UpgradeNotify
    +Apply()
  }

  class settings_pkg {
    +Default() Settings
    +Load() Settings
    +LoadFile(path string) Settings
  }

  class util_pkg {
    +LetsUserDir() string
    +LetsUserFile(name string) string
  }

  class UpdateNotifier {
    +writeState(state notifierState) error
    +letsStatePath() string
  }

  class cli_pkg {
    +Main(version string, buildDate string) int
    +maybeStartUpdateCheck(ctx context.Context, version string, command *cobra.Command, appSettings Settings) (<-chan updateCheckResult, context.CancelFunc)
    +shouldCheckForUpdate(commandName string, interactive bool, appSettings Settings) bool
  }

  class color_pkg {
    +NoColor bool
  }

  FileSettings <.. Settings : decoded_into
  settings_pkg --> FileSettings : uses
  settings_pkg --> Settings : constructs
  settings_pkg --> util_pkg : uses
  Settings --> color_pkg : Apply() sets NoColor
  cli_pkg --> settings_pkg : loads_settings
  cli_pkg --> Settings : passes_to_update_check
  cli_pkg --> util_pkg : indirect_via_settings
  UpdateNotifier --> util_pkg : uses
Loading

File-Level Changes

Change Details Files
Add a settings subsystem that loads config from ~/.config/lets/config.yaml, merges it with environment overrides, and applies it to global color behavior.
  • Introduce Settings and FileSettings types with defaults for color and upgrade notifications.
  • Implement Load and LoadFile to read YAML settings, enforce known fields, and fall back to defaults when the file is missing.
  • Apply environment variables NO_COLOR and LETS_CHECK_UPDATE as highest-priority overrides.
  • Provide an Apply method that updates fatih/color global flags based on resolved settings.
  • Add unit tests covering default behavior, file loading, env overrides, unknown fields, and Apply behavior.
internal/settings/settings.go
internal/settings/settings_test.go
Wire settings into CLI startup so color is configured before logging and upgrade checks honor user settings.
  • Load settings at the start of Main, failing fast with an error message on load errors.
  • Apply settings prior to initializing logging so color output is correctly configured.
  • Thread Settings into maybeStartUpdateCheck and shouldCheckForUpdate to gate background update checks based on UpgradeNotify.
  • Adjust update-check logic to skip when notifications are disabled in settings while still honoring CI and interactivity constraints.
  • Update CLI tests to use default settings and add coverage for disabling notifications via settings.
internal/cli/cli.go
internal/cli/cli_test.go
Extract reusable helpers for lets user config directory and reuse them in the upgrade notifier state path.
  • Add LetsUserDir and LetsUserFile helpers that derive ~/.config/lets paths from the user home directory.
  • Update the upgrade notifier to use LetsUserFile for state.yaml instead of inlining path construction logic.
  • Add tests to verify the derived directory and file paths respect HOME and match the expected .config/lets layout.
internal/util/lets_user_dir.go
internal/util/lets_user_dir_test.go
internal/upgrade/notifier.go
Document the new user settings behavior in the changelog.
  • Describe the new ~/.config/lets/config.yaml support and the precedence of environment variables.
  • Associate the entry with the appropriate release section.
docs/docs/changelog.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

Copy link
Copy Markdown

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 4 issues, and left some high level feedback:

  • The settings loader always uses ~/.config/lets/config.yaml via LetsUserFile, but the CLI already supports LETS_CONFIG_DIR for configuration; consider honoring that same override for settings so config and settings paths stay consistent.
  • In internal/util/LetsUserDir, you manually construct ~/.config/lets from UserHomeDir; using os.UserConfigDir would be more portable across platforms and better expresses the intent of locating the user config directory.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The settings loader always uses `~/.config/lets/config.yaml` via `LetsUserFile`, but the CLI already supports `LETS_CONFIG_DIR` for configuration; consider honoring that same override for settings so config and settings paths stay consistent.
- In `internal/util/LetsUserDir`, you manually construct `~/.config/lets` from `UserHomeDir`; using `os.UserConfigDir` would be more portable across platforms and better expresses the intent of locating the user config directory.

## Individual Comments

### Comment 1
<location path="internal/settings/settings.go" line_range="54-56" />
<code_context>
+	defer file.Close()
+
+	var fileSettings FileSettings
+	decoder := yaml.NewDecoder(file)
+	decoder.KnownFields(true)
+	if err := decoder.Decode(&fileSettings); err != nil {
+		return Settings{}, fmt.Errorf("failed to decode settings file: %w", err)
+	}
</code_context>
<issue_to_address>
**issue:** Treat an empty settings file (EOF) as "no settings" instead of a hard error.

With `decoder.Decode` + `KnownFields(true)`, an empty `config.yaml` returns `io.EOF`, which we currently wrap and treat as fatal, exiting the CLI. Please special-case `io.EOF` (e.g. via `errors.Is(err, io.EOF)`) and treat it like a missing config (defaults + env overrides) instead of an error.
</issue_to_address>

### Comment 2
<location path="internal/util/lets_user_dir.go" line_range="9-15" />
<code_context>
+	"path/filepath"
+)
+
+func LetsUserDir() (string, error) {
+	homeDir, err := os.UserHomeDir()
+	if err != nil {
+		return "", fmt.Errorf("failed to get home dir: %w", err)
+	}
+
+	return filepath.Join(homeDir, ".config", "lets"), nil
+}
+
</code_context>
<issue_to_address>
**suggestion:** Consider using os.UserConfigDir for better cross-platform config placement.

This helper hardcodes `~/.config/lets`, which is Unix-specific. Since this is now the central helper for user config paths, using `os.UserConfigDir()` and then `filepath.Join(dir, "lets")` would better follow platform conventions (especially on Windows) and avoid surprising config locations.

Suggested implementation:

```golang
import (
	"fmt"
	"os"
	"path/filepath"
)

```

```golang
func LetsUserDir() (string, error) {
	configDir, err := os.UserConfigDir()
	if err != nil {
		return "", fmt.Errorf("failed to get user config dir: %w", err)
	}

	return filepath.Join(configDir, "lets"), nil

```
</issue_to_address>

### Comment 3
<location path="internal/settings/settings_test.go" line_range="29-30" />
<code_context>
+func TestLoadFile(t *testing.T) {
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test for malformed/invalid YAML in the settings file

There’s no test for syntactically invalid YAML (e.g. `no_color: [`). In this case, `decoder.Decode` should fail and be wrapped with `"failed to decode settings file"`. Please add a subtest that writes malformed YAML and asserts that `LoadFile` returns an error and does not fall back to defaults.

```suggestion
func TestLoadFile(t *testing.T) {
	t.Run("returns error on malformed YAML", func(t *testing.T) {
		unsetEnv(t, "NO_COLOR")
		unsetEnv(t, "LETS_CHECK_UPDATE")

		dir := t.TempDir()
		path := filepath.Join(dir, "settings.yaml")

		if err := os.WriteFile(path, []byte("no_color: ["), 0o600); err != nil {
			t.Fatalf("failed to write malformed settings file: %v", err)
		}

		cfg, err := LoadFile(path)
		if err == nil {
			t.Fatalf("LoadFile() error = nil, want non-nil error for malformed YAML")
		}

		var zero Config
		if cfg != zero {
			t.Fatalf("LoadFile() cfg = %#v, want zero-value config on error", cfg)
		}
	})

	t.Run("uses defaults when file is missing", func(t *testing.T) {
```
</issue_to_address>

### Comment 4
<location path="internal/settings/settings_test.go" line_range="110-119" />
<code_context>
+	})
+}
+
+func TestLoad(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("HOME", tmpDir)
+	unsetEnv(t, "NO_COLOR")
+	unsetEnv(t, "LETS_CHECK_UPDATE")
+
+	configPath := filepath.Join(tmpDir, ".config", "lets", "config.yaml")
+	err := os.MkdirAll(filepath.Dir(configPath), 0o755)
+	if err != nil {
+		t.Fatalf("failed to create config dir: %v", err)
+	}
+
+	err = os.WriteFile(configPath, []byte("no_color: true\n"), 0o644)
+	if err != nil {
+		t.Fatalf("failed to write settings file: %v", err)
+	}
+
+	cfg, err := Load()
+	if err != nil {
+		t.Fatalf("Load() error = %v", err)
+	}
+
+	if !cfg.NoColor {
+		t.Fatal("expected loaded no_color to be true")
+	}
</code_context>
<issue_to_address>
**suggestion (testing):** Add coverage for Load() when the config file is missing

Currently this only tests the case where `~/.config/lets/config.yaml` exists. Please add a subtest where no config file is created under the temp HOME and `Load()` is expected to succeed and return the default settings (respecting any env overrides). This will exercise the missing-config defaulting behavior end-to-end via `Load`, not just `LoadFile`.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +54 to +56
decoder := yaml.NewDecoder(file)
decoder.KnownFields(true)
if err := decoder.Decode(&fileSettings); err != nil {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue: Treat an empty settings file (EOF) as "no settings" instead of a hard error.

With decoder.Decode + KnownFields(true), an empty config.yaml returns io.EOF, which we currently wrap and treat as fatal, exiting the CLI. Please special-case io.EOF (e.g. via errors.Is(err, io.EOF)) and treat it like a missing config (defaults + env overrides) instead of an error.

Comment on lines +9 to +15
func LetsUserDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home dir: %w", err)
}

return filepath.Join(homeDir, ".config", "lets"), nil
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: Consider using os.UserConfigDir for better cross-platform config placement.

This helper hardcodes ~/.config/lets, which is Unix-specific. Since this is now the central helper for user config paths, using os.UserConfigDir() and then filepath.Join(dir, "lets") would better follow platform conventions (especially on Windows) and avoid surprising config locations.

Suggested implementation:

import (
	"fmt"
	"os"
	"path/filepath"
)
func LetsUserDir() (string, error) {
	configDir, err := os.UserConfigDir()
	if err != nil {
		return "", fmt.Errorf("failed to get user config dir: %w", err)
	}

	return filepath.Join(configDir, "lets"), nil

Comment on lines +29 to +30
func TestLoadFile(t *testing.T) {
t.Run("uses defaults when file is missing", func(t *testing.T) {
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): Add a test for malformed/invalid YAML in the settings file

There’s no test for syntactically invalid YAML (e.g. no_color: [). In this case, decoder.Decode should fail and be wrapped with "failed to decode settings file". Please add a subtest that writes malformed YAML and asserts that LoadFile returns an error and does not fall back to defaults.

Suggested change
func TestLoadFile(t *testing.T) {
t.Run("uses defaults when file is missing", func(t *testing.T) {
func TestLoadFile(t *testing.T) {
t.Run("returns error on malformed YAML", func(t *testing.T) {
unsetEnv(t, "NO_COLOR")
unsetEnv(t, "LETS_CHECK_UPDATE")
dir := t.TempDir()
path := filepath.Join(dir, "settings.yaml")
if err := os.WriteFile(path, []byte("no_color: ["), 0o600); err != nil {
t.Fatalf("failed to write malformed settings file: %v", err)
}
cfg, err := LoadFile(path)
if err == nil {
t.Fatalf("LoadFile() error = nil, want non-nil error for malformed YAML")
}
var zero Config
if cfg != zero {
t.Fatalf("LoadFile() cfg = %#v, want zero-value config on error", cfg)
}
})
t.Run("uses defaults when file is missing", func(t *testing.T) {

Comment on lines +110 to +119
func TestLoad(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)
unsetEnv(t, "NO_COLOR")
unsetEnv(t, "LETS_CHECK_UPDATE")

configPath := filepath.Join(tmpDir, ".config", "lets", "config.yaml")
err := os.MkdirAll(filepath.Dir(configPath), 0o755)
if err != nil {
t.Fatalf("failed to create config dir: %v", err)
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): Add coverage for Load() when the config file is missing

Currently this only tests the case where ~/.config/lets/config.yaml exists. Please add a subtest where no config file is created under the temp HOME and Load() is expected to succeed and return the default settings (respecting any env overrides). This will exercise the missing-config defaulting behavior end-to-end via Load, not just LoadFile.

@kindermax kindermax merged commit 5dc94bb into master Apr 2, 2026
5 checks passed
@kindermax kindermax deleted the codex/plan-lets-system-config-support branch April 2, 2026 18:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant