From 7277392b138c4a847f418994d1a861d39690ed4a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 20:14:46 +0000 Subject: [PATCH 1/5] feat(config): add local config override and env var support - Add layered config loading: .tusk/local-config.yaml merges on top of .tusk/config.yaml for user-specific local overrides - Add TUSK_CONFIG_OVERRIDE env var to specify an explicit override file - Add env var overrides for recording fields: - TUSK_RECORDING_SAMPLING_MODE - TUSK_RECORDING_EXPORT_SPANS - TUSK_ENABLE_ENV_VAR_RECORDING - Document precedence order and new override options - Add comprehensive tests for all new functionality Addresses feature request for overriding recording.sampling.mode, recording.export_spans, and recording.enable_env_var_recording without duplicating the full base config. Co-Authored-By: Sohil Kshirsagar --- docs/drift/configuration.md | 37 +++- internal/config/config.go | 54 +++++- internal/config/config_test.go | 315 +++++++++++++++++++++++++++++++++ 3 files changed, 403 insertions(+), 3 deletions(-) diff --git a/docs/drift/configuration.md b/docs/drift/configuration.md index 9e7dc8f..ffd956d 100644 --- a/docs/drift/configuration.md +++ b/docs/drift/configuration.md @@ -2,14 +2,47 @@ This document lists all configuration options, defaults, environment overrides, and guidance. See [`docs/architecture.md`](architecture.md) for the end‑to‑end flow. -Where the CLI reads config from: +Where the CLI reads config from (highest precedence first): 1. CLI flags (e.g., `--concurrency`, `--results-dir`, `--enable-service-logs`). See `--help` for each command for more details. 2. Environment variables (prefix `TUSK_`) -3. Config file (auto-discovered): `.tusk/config.yaml`, `.tusk/config.yml`, `tusk.yaml`, `tusk.yml`, or `~/.tusk/config.yaml` +3. Explicit override file via `TUSK_CONFIG_OVERRIDE` env var (path to a YAML file) +4. Local override file: `.tusk/local-config.yaml` or `.tusk/local-config.yml` (same directory as base config) +5. Base config file (auto-discovered): `.tusk/config.yaml`, `.tusk/config.yml`, `tusk.yaml`, `tusk.yml`, or `~/.tusk/config.yaml` **✨ Run `tusk drift setup` in your service root directory to start an agent automatically create a config file based on your service.** +## Local Config Overrides + +For local development, you can create a `.tusk/local-config.yaml` (or `.tusk/local-config.yml`) file alongside your base `.tusk/config.yaml`. This file is merged on top of the base config, so you only need to specify the fields you want to override. + +This is useful for local-only settings like disabling span export or changing the sampling mode without modifying the shared config file. **We recommend adding `.tusk/local-config.yaml` to your `.gitignore`.** + +Example `.tusk/local-config.yaml`: + +```yaml +recording: + sampling: + mode: fixed + base_rate: 1.0 + export_spans: false + enable_env_var_recording: false +``` + +You can also set `TUSK_CONFIG_OVERRIDE` to an explicit file path to load as an override (takes precedence over `local-config.yaml` but is still overridden by env vars). + +### Recording Environment Variable Overrides + +These env vars override the corresponding config keys regardless of what's in the config files: + +| Env var | Config key | +|---------|-----------| +| `TUSK_RECORDING_SAMPLING_MODE` | `recording.sampling.mode` | +| `TUSK_RECORDING_SAMPLING_RATE` | `recording.sampling.base_rate` (+ legacy `recording.sampling_rate`) | +| `TUSK_RECORDING_SAMPLING_LOG_TRANSITIONS` | `recording.sampling.log_transitions` | +| `TUSK_RECORDING_EXPORT_SPANS` | `recording.export_spans` | +| `TUSK_ENABLE_ENV_VAR_RECORDING` | `recording.enable_env_var_recording` | + ## Service diff --git a/internal/config/config.go b/internal/config/config.go index 70d4880..9e0e22e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -131,8 +131,14 @@ type CoverageConfig struct { StripPathPrefix string `koanf:"strip_path_prefix"` } -// Load loads the config file and applies environment overrides. +// Load loads the config file, applies local overrides, and applies environment overrides. // This function is idempotent - calling it multiple times will only load once. +// +// Config precedence (highest wins): +// 1. Environment variables (TUSK_*) +// 2. Override file from TUSK_CONFIG_OVERRIDE env var +// 3. Local override file (.tusk/local-config.yaml or .tusk/local-config.yml) +// 4. Base config file (.tusk/config.yaml) func Load(configFile string) error { loadMutex.Lock() defer loadMutex.Unlock() @@ -153,11 +159,33 @@ func Load(configFile string) error { return fmt.Errorf("error loading config file: %w", err) } log.Debug("Config file loaded", "file", configFile) + + // Load local override file (same directory as base config) + if localOverride := findLocalOverrideFile(configFile); localOverride != "" { + if err := k.Load(file.Provider(localOverride), yaml.Parser()); err != nil { + log.ServiceLog(fmt.Sprintf("Failed to load local override file: %s. Error: %s", localOverride, err)) + return fmt.Errorf("error loading local override file: %w", err) + } + log.Debug("Local override file loaded", "file", localOverride) + } } else { configFileFound = false log.Debug("No config file found, using defaults and environment variables") } + // Load explicit override file from TUSK_CONFIG_OVERRIDE env var + if overridePath := os.Getenv("TUSK_CONFIG_OVERRIDE"); overridePath != "" { + if _, err := os.Stat(overridePath); err == nil { // #nosec G703 -- path from trusted env var + if err := k.Load(file.Provider(overridePath), yaml.Parser()); err != nil { + return fmt.Errorf("error loading TUSK_CONFIG_OVERRIDE file %s: %w", overridePath, err) + } + log.Debug("TUSK_CONFIG_OVERRIDE file loaded", "file", overridePath) + configFileFound = true + } else { + return fmt.Errorf("TUSK_CONFIG_OVERRIDE file not found: %s", overridePath) + } + } + // Support environment variable overrides for specific config keys envOverrides := map[string]string{ "TUSK_TRACES_DIR": "traces.dir", @@ -167,6 +195,9 @@ func Load(configFile string) error { "TUSK_RESULTS_DIR": "results.dir", "TUSK_RECORDING_SAMPLING_RATE": "recording.sampling_rate", "TUSK_RECORDING_SAMPLING_LOG_TRANSITIONS": "recording.sampling.log_transitions", + "TUSK_RECORDING_SAMPLING_MODE": "recording.sampling.mode", + "TUSK_RECORDING_EXPORT_SPANS": "recording.export_spans", + "TUSK_ENABLE_ENV_VAR_RECORDING": "recording.enable_env_var_recording", } for envKey, configKey := range envOverrides { @@ -594,6 +625,27 @@ func findConfigFile() string { return "" } +// findLocalOverrideFile looks for a local override config file in the same directory +// as the base config file. It checks for local-config.yaml and local-config.yml. +// This file is intended for user-specific local overrides (e.g., recording settings +// for local development) and should typically be added to .gitignore. +func findLocalOverrideFile(baseConfigPath string) string { + dir := filepath.Dir(baseConfigPath) + + localPaths := []string{ + filepath.Join(dir, "local-config.yaml"), + filepath.Join(dir, "local-config.yml"), + } + + for _, p := range localPaths { + if _, err := os.Stat(p); err == nil { + return p + } + } + + return "" +} + // Invalidate clears all cached config state, forcing a reload on next Get(). // Used when updating the config file and for testing. func Invalidate() { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d1d4d5d..2d58fc9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -99,6 +99,321 @@ recording: assert.False(t, *cfg.Recording.Sampling.LogTransitions) } +func TestLocalOverrideFileMergesOnTopOfBaseConfig(t *testing.T) { + defer Invalidate() + + tmpDir := t.TempDir() + tuskDir := filepath.Join(tmpDir, ".tusk") + require.NoError(t, os.MkdirAll(tuskDir, 0o750)) + + // Base config + baseConfig := filepath.Join(tuskDir, "config.yaml") + require.NoError(t, os.WriteFile(baseConfig, []byte(` +service: + name: my-service + port: 8080 + start: + command: npm start +recording: + sampling: + mode: adaptive + base_rate: 0.25 + export_spans: true + enable_env_var_recording: true +`), 0o600)) + + // Local override - only overrides recording settings + localConfig := filepath.Join(tuskDir, "local-config.yaml") + require.NoError(t, os.WriteFile(localConfig, []byte(` +recording: + sampling: + mode: fixed + base_rate: 1.0 + export_spans: false + enable_env_var_recording: false +`), 0o600)) + + require.NoError(t, Load(baseConfig)) + + cfg, err := Get() + require.NoError(t, err) + + // Recording settings should be overridden by local config + assert.Equal(t, "fixed", cfg.Recording.Sampling.Mode) + require.NotNil(t, cfg.Recording.Sampling.BaseRate) + assert.Equal(t, 1.0, *cfg.Recording.Sampling.BaseRate) + require.NotNil(t, cfg.Recording.ExportSpans) + assert.False(t, *cfg.Recording.ExportSpans) + require.NotNil(t, cfg.Recording.EnableEnvVarRecording) + assert.False(t, *cfg.Recording.EnableEnvVarRecording) + + // Service settings from base config should be preserved + assert.Equal(t, "my-service", cfg.Service.Name) + assert.Equal(t, 8080, cfg.Service.Port) + assert.Equal(t, "npm start", cfg.Service.Start.Command) +} + +func TestLocalOverrideFileYmlExtension(t *testing.T) { + defer Invalidate() + + tmpDir := t.TempDir() + tuskDir := filepath.Join(tmpDir, ".tusk") + require.NoError(t, os.MkdirAll(tuskDir, 0o750)) + + baseConfig := filepath.Join(tuskDir, "config.yaml") + require.NoError(t, os.WriteFile(baseConfig, []byte(` +service: + name: base-service + port: 3000 + start: + command: npm start +`), 0o600)) + + // Use .yml extension for local override + localConfig := filepath.Join(tuskDir, "local-config.yml") + require.NoError(t, os.WriteFile(localConfig, []byte(` +service: + name: local-service +`), 0o600)) + + require.NoError(t, Load(baseConfig)) + + cfg, err := Get() + require.NoError(t, err) + assert.Equal(t, "local-service", cfg.Service.Name) + assert.Equal(t, 3000, cfg.Service.Port) +} + +func TestTuskConfigOverrideEnvVar(t *testing.T) { + defer Invalidate() + + tmpDir := t.TempDir() + tuskDir := filepath.Join(tmpDir, ".tusk") + require.NoError(t, os.MkdirAll(tuskDir, 0o750)) + + baseConfig := filepath.Join(tuskDir, "config.yaml") + require.NoError(t, os.WriteFile(baseConfig, []byte(` +service: + name: base-service + port: 3000 + start: + command: npm start +recording: + sampling: + mode: adaptive +`), 0o600)) + + // External override file (not in .tusk/ directory) + overrideFile := filepath.Join(tmpDir, "my-override.yaml") + require.NoError(t, os.WriteFile(overrideFile, []byte(` +recording: + sampling: + mode: fixed + base_rate: 1.0 + export_spans: false +`), 0o600)) + + t.Setenv("TUSK_CONFIG_OVERRIDE", overrideFile) + + require.NoError(t, Load(baseConfig)) + + cfg, err := Get() + require.NoError(t, err) + + // Override file takes precedence over base config + assert.Equal(t, "fixed", cfg.Recording.Sampling.Mode) + require.NotNil(t, cfg.Recording.Sampling.BaseRate) + assert.Equal(t, 1.0, *cfg.Recording.Sampling.BaseRate) + require.NotNil(t, cfg.Recording.ExportSpans) + assert.False(t, *cfg.Recording.ExportSpans) + + // Base config values preserved for non-overridden fields + assert.Equal(t, "base-service", cfg.Service.Name) +} + +func TestTuskConfigOverrideEnvVarFileNotFound(t *testing.T) { + defer Invalidate() + + t.Setenv("TUSK_CONFIG_OVERRIDE", "/nonexistent/override.yaml") + + err := Load("") + require.Error(t, err) + assert.Contains(t, err.Error(), "TUSK_CONFIG_OVERRIDE file not found") +} + +func TestTuskConfigOverrideBeatsLocalOverride(t *testing.T) { + defer Invalidate() + + tmpDir := t.TempDir() + tuskDir := filepath.Join(tmpDir, ".tusk") + require.NoError(t, os.MkdirAll(tuskDir, 0o750)) + + baseConfig := filepath.Join(tuskDir, "config.yaml") + require.NoError(t, os.WriteFile(baseConfig, []byte(` +service: + name: base + port: 3000 + start: + command: npm start +recording: + sampling: + mode: adaptive +`), 0o600)) + + // Local override sets mode to fixed + localConfig := filepath.Join(tuskDir, "local-config.yaml") + require.NoError(t, os.WriteFile(localConfig, []byte(` +recording: + sampling: + mode: fixed +`), 0o600)) + + // TUSK_CONFIG_OVERRIDE re-sets it back to adaptive with different rate + overrideFile := filepath.Join(tmpDir, "env-override.yaml") + require.NoError(t, os.WriteFile(overrideFile, []byte(` +recording: + sampling: + mode: adaptive + base_rate: 0.5 +`), 0o600)) + + t.Setenv("TUSK_CONFIG_OVERRIDE", overrideFile) + + require.NoError(t, Load(baseConfig)) + + cfg, err := Get() + require.NoError(t, err) + + // TUSK_CONFIG_OVERRIDE wins over local-config.yaml + assert.Equal(t, "adaptive", cfg.Recording.Sampling.Mode) + require.NotNil(t, cfg.Recording.Sampling.BaseRate) + assert.Equal(t, 0.5, *cfg.Recording.Sampling.BaseRate) +} + +func TestRecordingSamplingModeEnvOverride(t *testing.T) { + defer Invalidate() + + t.Setenv("TUSK_RECORDING_SAMPLING_MODE", "fixed") + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + require.NoError(t, os.WriteFile(configPath, []byte(` +recording: + sampling: + mode: adaptive + base_rate: 0.25 +`), 0o600)) + + require.NoError(t, Load(configPath)) + + cfg, err := Get() + require.NoError(t, err) + assert.Equal(t, "fixed", cfg.Recording.Sampling.Mode) +} + +func TestRecordingExportSpansEnvOverride(t *testing.T) { + defer Invalidate() + + t.Setenv("TUSK_RECORDING_EXPORT_SPANS", "false") + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + require.NoError(t, os.WriteFile(configPath, []byte(` +recording: + export_spans: true +`), 0o600)) + + require.NoError(t, Load(configPath)) + + cfg, err := Get() + require.NoError(t, err) + require.NotNil(t, cfg.Recording.ExportSpans) + assert.False(t, *cfg.Recording.ExportSpans) +} + +func TestEnableEnvVarRecordingEnvOverride(t *testing.T) { + defer Invalidate() + + t.Setenv("TUSK_ENABLE_ENV_VAR_RECORDING", "false") + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + require.NoError(t, os.WriteFile(configPath, []byte(` +recording: + enable_env_var_recording: true +`), 0o600)) + + require.NoError(t, Load(configPath)) + + cfg, err := Get() + require.NoError(t, err) + require.NotNil(t, cfg.Recording.EnableEnvVarRecording) + assert.False(t, *cfg.Recording.EnableEnvVarRecording) +} + +func TestEnvVarOverrideBeatsAllConfigFiles(t *testing.T) { + defer Invalidate() + + tmpDir := t.TempDir() + tuskDir := filepath.Join(tmpDir, ".tusk") + require.NoError(t, os.MkdirAll(tuskDir, 0o750)) + + baseConfig := filepath.Join(tuskDir, "config.yaml") + require.NoError(t, os.WriteFile(baseConfig, []byte(` +service: + name: test + port: 3000 + start: + command: npm start +recording: + sampling: + mode: adaptive + export_spans: true +`), 0o600)) + + localConfig := filepath.Join(tuskDir, "local-config.yaml") + require.NoError(t, os.WriteFile(localConfig, []byte(` +recording: + sampling: + mode: fixed +`), 0o600)) + + // Env vars win over everything + t.Setenv("TUSK_RECORDING_SAMPLING_MODE", "adaptive") + t.Setenv("TUSK_RECORDING_EXPORT_SPANS", "false") + + require.NoError(t, Load(baseConfig)) + + cfg, err := Get() + require.NoError(t, err) + assert.Equal(t, "adaptive", cfg.Recording.Sampling.Mode) + require.NotNil(t, cfg.Recording.ExportSpans) + assert.False(t, *cfg.Recording.ExportSpans) +} + +func TestFindLocalOverrideFile(t *testing.T) { + tmpDir := t.TempDir() + tuskDir := filepath.Join(tmpDir, ".tusk") + require.NoError(t, os.MkdirAll(tuskDir, 0o750)) + + baseConfig := filepath.Join(tuskDir, "config.yaml") + require.NoError(t, os.WriteFile(baseConfig, []byte("service:\n name: test"), 0o600)) + + // No local override file exists + assert.Equal(t, "", findLocalOverrideFile(baseConfig)) + + // Create local-config.yaml + localYaml := filepath.Join(tuskDir, "local-config.yaml") + require.NoError(t, os.WriteFile(localYaml, []byte("service:\n name: local"), 0o600)) + assert.Equal(t, localYaml, findLocalOverrideFile(baseConfig)) + + // Remove .yaml and create .yml + require.NoError(t, os.Remove(localYaml)) + localYml := filepath.Join(tuskDir, "local-config.yml") + require.NoError(t, os.WriteFile(localYml, []byte("service:\n name: local"), 0o600)) + assert.Equal(t, localYml, findLocalOverrideFile(baseConfig)) +} + func TestValidateRejectsInvalidRecordingSamplingMode(t *testing.T) { cfg := &Config{ Service: ServiceConfig{ From dc4bfd07c50966375f576f35a58cbad122dde1d3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 02:58:31 +0000 Subject: [PATCH 2/5] refactor(config): replace auto-discovery with explicit --config-override flag - Remove automatic local-config.yaml discovery from Load() - Remove findLocalOverrideFile helper - Add --config-override persistent flag to drift commands - Load() now accepts optional override file path as variadic arg - Flag takes precedence over TUSK_CONFIG_OVERRIDE env var - Update all tests to use explicit override paths - Update internal docs to reflect new flag-based approach Co-Authored-By: Sohil Kshirsagar --- cmd/drift.go | 2 + cmd/list.go | 4 +- cmd/root.go | 7 ++- cmd/run.go | 2 +- docs/drift/configuration.md | 28 ++++++--- internal/config/config.go | 57 ++++++------------- internal/config/config_test.go | 101 +++++++++++++++++++-------------- 7 files changed, 103 insertions(+), 98 deletions(-) diff --git a/cmd/drift.go b/cmd/drift.go index 32ca160..85afe4e 100644 --- a/cmd/drift.go +++ b/cmd/drift.go @@ -8,6 +8,7 @@ import ( ) const configFlagUsage = "config file (default is .tusk/config.yaml)" +const configOverrideFlagUsage = "config override file (merges on top of the base config)" //go:embed short_docs/drift/drift_overview.md var driftOverviewContent string @@ -21,6 +22,7 @@ var driftCmd = &cobra.Command{ func init() { rootCmd.AddCommand(driftCmd) driftCmd.PersistentFlags().StringVar(&cfgFile, "config", "", configFlagUsage) + driftCmd.PersistentFlags().StringVar(&cfgOverrideFile, "config-override", "", configOverrideFlagUsage) } func bindLegacyDriftAliasConfigFlag(cmd *cobra.Command) { diff --git a/cmd/list.go b/cmd/list.go index d54c0fd..d9ea228 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -69,7 +69,7 @@ func bindListFlags(cmd *cobra.Command) { func listTests(cmd *cobra.Command, args []string) error { setupSignalHandling() - _ = config.Load(cfgFile) + _ = config.Load(cfgFile, cfgOverrideFile) cfg, getConfigErr := config.Get() executor := runner.NewExecutor() @@ -124,7 +124,7 @@ func listTests(cmd *cobra.Command, args []string) error { } tests = runner.ConvertTraceTestsToRunnerTests(all) } else { - _ = config.Load("") + _ = config.Load("", cfgOverrideFile) cfg, getConfigErr := config.Get() selected := traceDir diff --git a/cmd/root.go b/cmd/root.go index 7ce4166..4e825ba 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,9 +19,10 @@ import ( ) var ( - cfgFile string - debug bool - showVersion bool + cfgFile string + cfgOverrideFile string + debug bool + showVersion bool // Cleanup infrastructure cleanupFuncs []func() diff --git a/cmd/run.go b/cmd/run.go index 4f724ca..4493280 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -157,7 +157,7 @@ func runTests(cmd *cobra.Command, args []string) error { executor := runner.NewExecutor() executor.SetDebug(debug) - _ = config.Load(cfgFile) + _ = config.Load(cfgFile, cfgOverrideFile) cfg, getConfigErr := config.Get() if getConfigErr == nil && cfg.TestExecution.Concurrency > 0 { executor.SetConcurrency(cfg.TestExecution.Concurrency) diff --git a/docs/drift/configuration.md b/docs/drift/configuration.md index ffd956d..45115f2 100644 --- a/docs/drift/configuration.md +++ b/docs/drift/configuration.md @@ -6,19 +6,31 @@ Where the CLI reads config from (highest precedence first): 1. CLI flags (e.g., `--concurrency`, `--results-dir`, `--enable-service-logs`). See `--help` for each command for more details. 2. Environment variables (prefix `TUSK_`) -3. Explicit override file via `TUSK_CONFIG_OVERRIDE` env var (path to a YAML file) -4. Local override file: `.tusk/local-config.yaml` or `.tusk/local-config.yml` (same directory as base config) -5. Base config file (auto-discovered): `.tusk/config.yaml`, `.tusk/config.yml`, `tusk.yaml`, `tusk.yml`, or `~/.tusk/config.yaml` +3. Config override file via `--config-override` flag or `TUSK_CONFIG_OVERRIDE` env var (flag takes precedence) +4. Base config file (auto-discovered): `.tusk/config.yaml`, `.tusk/config.yml`, `tusk.yaml`, `tusk.yml`, or `~/.tusk/config.yaml` **✨ Run `tusk drift setup` in your service root directory to start an agent automatically create a config file based on your service.** -## Local Config Overrides +## Config Overrides -For local development, you can create a `.tusk/local-config.yaml` (or `.tusk/local-config.yml`) file alongside your base `.tusk/config.yaml`. This file is merged on top of the base config, so you only need to specify the fields you want to override. +You can provide a config override file that is merged on top of the base config. Only the fields you specify in the override file are changed; everything else is preserved from the base config. -This is useful for local-only settings like disabling span export or changing the sampling mode without modifying the shared config file. **We recommend adding `.tusk/local-config.yaml` to your `.gitignore`.** +This is useful for local-only settings like disabling span export or changing the sampling mode without modifying the shared config file. -Example `.tusk/local-config.yaml`: +**Using the `--config-override` flag:** +```bash +tusk drift run --config-override .tusk/local-config.yaml +tusk drift list --config-override .tusk/local-config.yaml +``` + +**Using the `TUSK_CONFIG_OVERRIDE` env var:** +```bash +TUSK_CONFIG_OVERRIDE=.tusk/local-config.yaml tusk drift run +``` + +The `--config-override` flag takes precedence over the `TUSK_CONFIG_OVERRIDE` env var. Both are overridden by individual env vars (e.g., `TUSK_RECORDING_SAMPLING_MODE`). + +Example override file: ```yaml recording: @@ -29,8 +41,6 @@ recording: enable_env_var_recording: false ``` -You can also set `TUSK_CONFIG_OVERRIDE` to an explicit file path to load as an override (takes precedence over `local-config.yaml` but is still overridden by env vars). - ### Recording Environment Variable Overrides These env vars override the corresponding config keys regardless of what's in the config files: diff --git a/internal/config/config.go b/internal/config/config.go index 9e0e22e..76d06dc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -131,15 +131,14 @@ type CoverageConfig struct { StripPathPrefix string `koanf:"strip_path_prefix"` } -// Load loads the config file, applies local overrides, and applies environment overrides. +// Load loads the config file, applies overrides, and applies environment overrides. // This function is idempotent - calling it multiple times will only load once. // // Config precedence (highest wins): // 1. Environment variables (TUSK_*) -// 2. Override file from TUSK_CONFIG_OVERRIDE env var -// 3. Local override file (.tusk/local-config.yaml or .tusk/local-config.yml) -// 4. Base config file (.tusk/config.yaml) -func Load(configFile string) error { +// 2. Override file (from --config-override flag or TUSK_CONFIG_OVERRIDE env var) +// 3. Base config file (.tusk/config.yaml) +func Load(configFile string, overrideFiles ...string) error { loadMutex.Lock() defer loadMutex.Unlock() @@ -159,30 +158,28 @@ func Load(configFile string) error { return fmt.Errorf("error loading config file: %w", err) } log.Debug("Config file loaded", "file", configFile) - - // Load local override file (same directory as base config) - if localOverride := findLocalOverrideFile(configFile); localOverride != "" { - if err := k.Load(file.Provider(localOverride), yaml.Parser()); err != nil { - log.ServiceLog(fmt.Sprintf("Failed to load local override file: %s. Error: %s", localOverride, err)) - return fmt.Errorf("error loading local override file: %w", err) - } - log.Debug("Local override file loaded", "file", localOverride) - } } else { configFileFound = false log.Debug("No config file found, using defaults and environment variables") } - // Load explicit override file from TUSK_CONFIG_OVERRIDE env var - if overridePath := os.Getenv("TUSK_CONFIG_OVERRIDE"); overridePath != "" { - if _, err := os.Stat(overridePath); err == nil { // #nosec G703 -- path from trusted env var + // Determine override file: --config-override flag takes precedence over TUSK_CONFIG_OVERRIDE env var + var overridePath string + if len(overrideFiles) > 0 && overrideFiles[0] != "" { + overridePath = overrideFiles[0] + } else if envOverridePath := os.Getenv("TUSK_CONFIG_OVERRIDE"); envOverridePath != "" { + overridePath = envOverridePath + } + + if overridePath != "" { + if _, err := os.Stat(overridePath); err == nil { // #nosec G703 -- path from trusted flag/env var if err := k.Load(file.Provider(overridePath), yaml.Parser()); err != nil { - return fmt.Errorf("error loading TUSK_CONFIG_OVERRIDE file %s: %w", overridePath, err) + return fmt.Errorf("error loading config override file %s: %w", overridePath, err) } - log.Debug("TUSK_CONFIG_OVERRIDE file loaded", "file", overridePath) + log.Debug("Config override file loaded", "file", overridePath) configFileFound = true } else { - return fmt.Errorf("TUSK_CONFIG_OVERRIDE file not found: %s", overridePath) + return fmt.Errorf("config override file not found: %s", overridePath) } } @@ -625,26 +622,6 @@ func findConfigFile() string { return "" } -// findLocalOverrideFile looks for a local override config file in the same directory -// as the base config file. It checks for local-config.yaml and local-config.yml. -// This file is intended for user-specific local overrides (e.g., recording settings -// for local development) and should typically be added to .gitignore. -func findLocalOverrideFile(baseConfigPath string) string { - dir := filepath.Dir(baseConfigPath) - - localPaths := []string{ - filepath.Join(dir, "local-config.yaml"), - filepath.Join(dir, "local-config.yml"), - } - - for _, p := range localPaths { - if _, err := os.Stat(p); err == nil { - return p - } - } - - return "" -} // Invalidate clears all cached config state, forcing a reload on next Get(). // Used when updating the config file and for testing. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2d58fc9..67cfa3d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -99,7 +99,7 @@ recording: assert.False(t, *cfg.Recording.Sampling.LogTransitions) } -func TestLocalOverrideFileMergesOnTopOfBaseConfig(t *testing.T) { +func TestConfigOverrideFileMergesOnTopOfBaseConfig(t *testing.T) { defer Invalidate() tmpDir := t.TempDir() @@ -122,9 +122,9 @@ recording: enable_env_var_recording: true `), 0o600)) - // Local override - only overrides recording settings - localConfig := filepath.Join(tuskDir, "local-config.yaml") - require.NoError(t, os.WriteFile(localConfig, []byte(` + // Override file - only overrides recording settings + overrideFile := filepath.Join(tmpDir, "override.yaml") + require.NoError(t, os.WriteFile(overrideFile, []byte(` recording: sampling: mode: fixed @@ -133,12 +133,12 @@ recording: enable_env_var_recording: false `), 0o600)) - require.NoError(t, Load(baseConfig)) + require.NoError(t, Load(baseConfig, overrideFile)) cfg, err := Get() require.NoError(t, err) - // Recording settings should be overridden by local config + // Recording settings should be overridden assert.Equal(t, "fixed", cfg.Recording.Sampling.Mode) require.NotNil(t, cfg.Recording.Sampling.BaseRate) assert.Equal(t, 1.0, *cfg.Recording.Sampling.BaseRate) @@ -153,7 +153,7 @@ recording: assert.Equal(t, "npm start", cfg.Service.Start.Command) } -func TestLocalOverrideFileYmlExtension(t *testing.T) { +func TestConfigOverrideFilePartialMerge(t *testing.T) { defer Invalidate() tmpDir := t.TempDir() @@ -169,18 +169,18 @@ service: command: npm start `), 0o600)) - // Use .yml extension for local override - localConfig := filepath.Join(tuskDir, "local-config.yml") - require.NoError(t, os.WriteFile(localConfig, []byte(` + // Override only changes one field + overrideFile := filepath.Join(tmpDir, "override.yml") + require.NoError(t, os.WriteFile(overrideFile, []byte(` service: - name: local-service + name: override-service `), 0o600)) - require.NoError(t, Load(baseConfig)) + require.NoError(t, Load(baseConfig, overrideFile)) cfg, err := Get() require.NoError(t, err) - assert.Equal(t, "local-service", cfg.Service.Name) + assert.Equal(t, "override-service", cfg.Service.Name) assert.Equal(t, 3000, cfg.Service.Port) } @@ -231,6 +231,14 @@ recording: assert.Equal(t, "base-service", cfg.Service.Name) } +func TestConfigOverrideFileNotFound(t *testing.T) { + defer Invalidate() + + err := Load("", "/nonexistent/override.yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "config override file not found") +} + func TestTuskConfigOverrideEnvVarFileNotFound(t *testing.T) { defer Invalidate() @@ -238,10 +246,10 @@ func TestTuskConfigOverrideEnvVarFileNotFound(t *testing.T) { err := Load("") require.Error(t, err) - assert.Contains(t, err.Error(), "TUSK_CONFIG_OVERRIDE file not found") + assert.Contains(t, err.Error(), "config override file not found") } -func TestTuskConfigOverrideBeatsLocalOverride(t *testing.T) { +func TestConfigOverrideFlagBeatsEnvVar(t *testing.T) { defer Invalidate() tmpDir := t.TempDir() @@ -260,31 +268,31 @@ recording: mode: adaptive `), 0o600)) - // Local override sets mode to fixed - localConfig := filepath.Join(tuskDir, "local-config.yaml") - require.NoError(t, os.WriteFile(localConfig, []byte(` + // TUSK_CONFIG_OVERRIDE env var sets mode to fixed + envOverrideFile := filepath.Join(tmpDir, "env-override.yaml") + require.NoError(t, os.WriteFile(envOverrideFile, []byte(` recording: sampling: mode: fixed `), 0o600)) - // TUSK_CONFIG_OVERRIDE re-sets it back to adaptive with different rate - overrideFile := filepath.Join(tmpDir, "env-override.yaml") - require.NoError(t, os.WriteFile(overrideFile, []byte(` + // --config-override flag sets mode back to adaptive with different rate + flagOverrideFile := filepath.Join(tmpDir, "flag-override.yaml") + require.NoError(t, os.WriteFile(flagOverrideFile, []byte(` recording: sampling: mode: adaptive base_rate: 0.5 `), 0o600)) - t.Setenv("TUSK_CONFIG_OVERRIDE", overrideFile) + t.Setenv("TUSK_CONFIG_OVERRIDE", envOverrideFile) - require.NoError(t, Load(baseConfig)) + // Flag override (passed as second arg) beats TUSK_CONFIG_OVERRIDE env var + require.NoError(t, Load(baseConfig, flagOverrideFile)) cfg, err := Get() require.NoError(t, err) - // TUSK_CONFIG_OVERRIDE wins over local-config.yaml assert.Equal(t, "adaptive", cfg.Recording.Sampling.Mode) require.NotNil(t, cfg.Recording.Sampling.BaseRate) assert.Equal(t, 0.5, *cfg.Recording.Sampling.BaseRate) @@ -371,8 +379,8 @@ recording: export_spans: true `), 0o600)) - localConfig := filepath.Join(tuskDir, "local-config.yaml") - require.NoError(t, os.WriteFile(localConfig, []byte(` + overrideFile := filepath.Join(tmpDir, "override.yaml") + require.NoError(t, os.WriteFile(overrideFile, []byte(` recording: sampling: mode: fixed @@ -382,7 +390,7 @@ recording: t.Setenv("TUSK_RECORDING_SAMPLING_MODE", "adaptive") t.Setenv("TUSK_RECORDING_EXPORT_SPANS", "false") - require.NoError(t, Load(baseConfig)) + require.NoError(t, Load(baseConfig, overrideFile)) cfg, err := Get() require.NoError(t, err) @@ -391,27 +399,34 @@ recording: assert.False(t, *cfg.Recording.ExportSpans) } -func TestFindLocalOverrideFile(t *testing.T) { +func TestConfigOverrideViaEnvVar(t *testing.T) { + defer Invalidate() + tmpDir := t.TempDir() - tuskDir := filepath.Join(tmpDir, ".tusk") - require.NoError(t, os.MkdirAll(tuskDir, 0o750)) + baseConfig := filepath.Join(tmpDir, "config.yaml") + require.NoError(t, os.WriteFile(baseConfig, []byte(` +service: + name: base + port: 3000 + start: + command: npm start +`), 0o600)) - baseConfig := filepath.Join(tuskDir, "config.yaml") - require.NoError(t, os.WriteFile(baseConfig, []byte("service:\n name: test"), 0o600)) + overrideFile := filepath.Join(tmpDir, "override.yaml") + require.NoError(t, os.WriteFile(overrideFile, []byte(` +service: + name: overridden +`), 0o600)) - // No local override file exists - assert.Equal(t, "", findLocalOverrideFile(baseConfig)) + t.Setenv("TUSK_CONFIG_OVERRIDE", overrideFile) - // Create local-config.yaml - localYaml := filepath.Join(tuskDir, "local-config.yaml") - require.NoError(t, os.WriteFile(localYaml, []byte("service:\n name: local"), 0o600)) - assert.Equal(t, localYaml, findLocalOverrideFile(baseConfig)) + // No explicit override arg — falls back to env var + require.NoError(t, Load(baseConfig)) - // Remove .yaml and create .yml - require.NoError(t, os.Remove(localYaml)) - localYml := filepath.Join(tuskDir, "local-config.yml") - require.NoError(t, os.WriteFile(localYml, []byte("service:\n name: local"), 0o600)) - assert.Equal(t, localYml, findLocalOverrideFile(baseConfig)) + cfg, err := Get() + require.NoError(t, err) + assert.Equal(t, "overridden", cfg.Service.Name) + assert.Equal(t, 3000, cfg.Service.Port) } func TestValidateRejectsInvalidRecordingSamplingMode(t *testing.T) { From c4378e11d74ab1d2c67ea00ffd3cc3961772046e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 03:00:27 +0000 Subject: [PATCH 3/5] fix: gofumpt formatting for const block and extra blank line Co-Authored-By: Sohil Kshirsagar --- cmd/drift.go | 6 ++++-- internal/config/config.go | 1 - 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/drift.go b/cmd/drift.go index 85afe4e..3dfa4e8 100644 --- a/cmd/drift.go +++ b/cmd/drift.go @@ -7,8 +7,10 @@ import ( "github.com/spf13/cobra" ) -const configFlagUsage = "config file (default is .tusk/config.yaml)" -const configOverrideFlagUsage = "config override file (merges on top of the base config)" +const ( + configFlagUsage = "config file (default is .tusk/config.yaml)" + configOverrideFlagUsage = "config override file (merges on top of the base config)" +) //go:embed short_docs/drift/drift_overview.md var driftOverviewContent string diff --git a/internal/config/config.go b/internal/config/config.go index 76d06dc..e8c8325 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -622,7 +622,6 @@ func findConfigFile() string { return "" } - // Invalidate clears all cached config state, forcing a reload on next Get(). // Used when updating the config file and for testing. func Invalidate() { From 1639724f6ed5ff70e37bc3f266830b7c2f8384e5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 04:49:49 +0000 Subject: [PATCH 4/5] fix: address code review issues with config override - Fix validation interference: TUSK_CONFIG_OVERRIDE env var is now only checked when the caller explicitly passes the overrideFiles arg. This prevents ValidateConfigFile from failing due to an unrelated env var. - Fix silent override failure: config.Load errors are now surfaced (not discarded) when --config-override or TUSK_CONFIG_OVERRIDE is specified. A missing override file will now properly error instead of silently falling back to base config only. - Fix override flag on query commands: drift query subcommands now call config.Load(cfgFile, cfgOverrideFile) before api.SetupCloud, ensuring --config-override is respected. Co-Authored-By: Sohil Kshirsagar --- cmd/drift_query.go | 3 +++ cmd/drift_query_services.go | 4 ++++ cmd/list.go | 12 ++++++++++-- cmd/run.go | 6 +++++- internal/config/config.go | 12 ++++++++---- internal/config/config_test.go | 30 ++++++++++++++++++++++++++---- 6 files changed, 56 insertions(+), 11 deletions(-) diff --git a/cmd/drift_query.go b/cmd/drift_query.go index cedd24d..87da74d 100644 --- a/cmd/drift_query.go +++ b/cmd/drift_query.go @@ -28,6 +28,9 @@ func init() { // setupDriftQueryCloud sets up the API client and resolves the service ID. func setupDriftQueryCloud(serviceIDFlag string) (*api.TuskClient, api.AuthOptions, string, error) { + // Ensure config is loaded with override support before Get() is called by SetupCloud + _ = config.Load(cfgFile, cfgOverrideFile) + client, authOptions, cfg, err := api.SetupCloud(context.Background(), false) if err != nil { return nil, api.AuthOptions{}, "", err diff --git a/cmd/drift_query_services.go b/cmd/drift_query_services.go index 744180e..a4a25a1 100644 --- a/cmd/drift_query_services.go +++ b/cmd/drift_query_services.go @@ -4,6 +4,7 @@ import ( "context" "github.com/Use-Tusk/tusk-cli/internal/api" + "github.com/Use-Tusk/tusk-cli/internal/config" "github.com/spf13/cobra" ) @@ -12,6 +13,9 @@ var driftQueryServicesCmd = &cobra.Command{ Short: "List available Tusk Drift Cloud services", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { + // Ensure config is loaded with override support before Get() is called by SetupCloud + _ = config.Load(cfgFile, cfgOverrideFile) + client, authOptions, _, err := api.SetupCloud(context.Background(), false) if err != nil { return formatApiError(err) diff --git a/cmd/list.go b/cmd/list.go index d9ea228..2cc0d1e 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -69,7 +69,11 @@ func bindListFlags(cmd *cobra.Command) { func listTests(cmd *cobra.Command, args []string) error { setupSignalHandling() - _ = config.Load(cfgFile, cfgOverrideFile) + if err := config.Load(cfgFile, cfgOverrideFile); err != nil { + if cfgOverrideFile != "" || os.Getenv("TUSK_CONFIG_OVERRIDE") != "" { + return fmt.Errorf("failed to load config: %w", err) + } + } cfg, getConfigErr := config.Get() executor := runner.NewExecutor() @@ -124,7 +128,11 @@ func listTests(cmd *cobra.Command, args []string) error { } tests = runner.ConvertTraceTestsToRunnerTests(all) } else { - _ = config.Load("", cfgOverrideFile) + if err := config.Load("", cfgOverrideFile); err != nil { + if cfgOverrideFile != "" || os.Getenv("TUSK_CONFIG_OVERRIDE") != "" { + return fmt.Errorf("failed to load config: %w", err) + } + } cfg, getConfigErr := config.Get() selected := traceDir diff --git a/cmd/run.go b/cmd/run.go index 4493280..39227b6 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -157,7 +157,11 @@ func runTests(cmd *cobra.Command, args []string) error { executor := runner.NewExecutor() executor.SetDebug(debug) - _ = config.Load(cfgFile, cfgOverrideFile) + if err := config.Load(cfgFile, cfgOverrideFile); err != nil { + if cfgOverrideFile != "" || os.Getenv("TUSK_CONFIG_OVERRIDE") != "" { + return fmt.Errorf("failed to load config: %w", err) + } + } cfg, getConfigErr := config.Get() if getConfigErr == nil && cfg.TestExecution.Concurrency > 0 { executor.SetConcurrency(cfg.TestExecution.Concurrency) diff --git a/internal/config/config.go b/internal/config/config.go index e8c8325..fc5e7df 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -163,12 +163,16 @@ func Load(configFile string, overrideFiles ...string) error { log.Debug("No config file found, using defaults and environment variables") } - // Determine override file: --config-override flag takes precedence over TUSK_CONFIG_OVERRIDE env var + // Determine override file: --config-override flag takes precedence over TUSK_CONFIG_OVERRIDE env var. + // The env var fallback is only checked when the caller explicitly passes the overrideFiles arg + // (even if empty). Callers like ValidateConfigFile that pass no variadic arg won't trigger env + // var lookup, preventing TUSK_CONFIG_OVERRIDE from interfering with validation. var overridePath string - if len(overrideFiles) > 0 && overrideFiles[0] != "" { + if len(overrideFiles) > 0 { overridePath = overrideFiles[0] - } else if envOverridePath := os.Getenv("TUSK_CONFIG_OVERRIDE"); envOverridePath != "" { - overridePath = envOverridePath + if overridePath == "" { + overridePath = os.Getenv("TUSK_CONFIG_OVERRIDE") + } } if overridePath != "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 67cfa3d..308129f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -215,7 +215,8 @@ recording: t.Setenv("TUSK_CONFIG_OVERRIDE", overrideFile) - require.NoError(t, Load(baseConfig)) + // Pass explicit empty override to opt into env var fallback (simulates CLI invocation) + require.NoError(t, Load(baseConfig, "")) cfg, err := Get() require.NoError(t, err) @@ -244,7 +245,8 @@ func TestTuskConfigOverrideEnvVarFileNotFound(t *testing.T) { t.Setenv("TUSK_CONFIG_OVERRIDE", "/nonexistent/override.yaml") - err := Load("") + // Pass explicit empty override to opt into env var fallback (simulates CLI invocation) + err := Load("", "") require.Error(t, err) assert.Contains(t, err.Error(), "config override file not found") } @@ -420,8 +422,8 @@ service: t.Setenv("TUSK_CONFIG_OVERRIDE", overrideFile) - // No explicit override arg — falls back to env var - require.NoError(t, Load(baseConfig)) + // Pass explicit empty override to opt into env var fallback (simulates --config-override not set) + require.NoError(t, Load(baseConfig, "")) cfg, err := Get() require.NoError(t, err) @@ -429,6 +431,26 @@ service: assert.Equal(t, 3000, cfg.Service.Port) } +func TestValidateConfigFileIgnoresTuskConfigOverrideEnvVar(t *testing.T) { + defer Invalidate() + + tmpDir := t.TempDir() + baseConfig := filepath.Join(tmpDir, "config.yaml") + require.NoError(t, os.WriteFile(baseConfig, []byte(` +service: + name: test + port: 3000 + start: + command: npm start +`), 0o600)) + + // Set TUSK_CONFIG_OVERRIDE to a nonexistent file — ValidateConfigFile should NOT fail + t.Setenv("TUSK_CONFIG_OVERRIDE", "/nonexistent/override.yaml") + + result := ValidateConfigFile(baseConfig) + assert.True(t, result.Valid, "ValidateConfigFile should not be affected by TUSK_CONFIG_OVERRIDE env var") +} + func TestValidateRejectsInvalidRecordingSamplingMode(t *testing.T) { cfg := &Config{ Service: ServiceConfig{ From 6a61489b541319f0061bfd1c38984693083945fa Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 20:26:47 +0000 Subject: [PATCH 5/5] fix: surface config load errors in drift query commands Match error handling in drift run/list: return error when config.Load fails and --config-override or TUSK_CONFIG_OVERRIDE was specified. Co-Authored-By: Sohil Kshirsagar --- cmd/drift_query.go | 6 +++++- cmd/drift_query_services.go | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/cmd/drift_query.go b/cmd/drift_query.go index 87da74d..5395504 100644 --- a/cmd/drift_query.go +++ b/cmd/drift_query.go @@ -29,7 +29,11 @@ func init() { // setupDriftQueryCloud sets up the API client and resolves the service ID. func setupDriftQueryCloud(serviceIDFlag string) (*api.TuskClient, api.AuthOptions, string, error) { // Ensure config is loaded with override support before Get() is called by SetupCloud - _ = config.Load(cfgFile, cfgOverrideFile) + if err := config.Load(cfgFile, cfgOverrideFile); err != nil { + if cfgOverrideFile != "" || os.Getenv("TUSK_CONFIG_OVERRIDE") != "" { + return nil, api.AuthOptions{}, "", fmt.Errorf("failed to load config: %w", err) + } + } client, authOptions, cfg, err := api.SetupCloud(context.Background(), false) if err != nil { diff --git a/cmd/drift_query_services.go b/cmd/drift_query_services.go index a4a25a1..ec09064 100644 --- a/cmd/drift_query_services.go +++ b/cmd/drift_query_services.go @@ -2,6 +2,8 @@ package cmd import ( "context" + "fmt" + "os" "github.com/Use-Tusk/tusk-cli/internal/api" "github.com/Use-Tusk/tusk-cli/internal/config" @@ -14,7 +16,11 @@ var driftQueryServicesCmd = &cobra.Command{ SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { // Ensure config is loaded with override support before Get() is called by SetupCloud - _ = config.Load(cfgFile, cfgOverrideFile) + if err := config.Load(cfgFile, cfgOverrideFile); err != nil { + if cfgOverrideFile != "" || os.Getenv("TUSK_CONFIG_OVERRIDE") != "" { + return fmt.Errorf("failed to load config: %w", err) + } + } client, authOptions, _, err := api.SetupCloud(context.Background(), false) if err != nil {