diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-08 10:09:42 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-08 10:09:42 +0200 |
| commit | bd698b257a548d835fbc2675ff5be5e1a69ff229 (patch) | |
| tree | f622fc46969db9fa332bbc5d3d0d4210d4baebb7 | |
| parent | 04c42582018f0295935701215490236e789cf5e3 (diff) | |
add per-project .hexaiconfig.toml config override and lower coverage target to 80%
Introduce support for a .hexaiconfig.toml file at the git repository root
that selectively overrides the global config. Precedence order:
defaults → global config → project config → env vars.
Also lower the coverage threshold from 85% to 80%.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| -rw-r--r-- | AGENTS.md | 3 | ||||
| -rw-r--r-- | Magefile.go | 2 | ||||
| -rw-r--r-- | config.toml.example | 6 | ||||
| -rw-r--r-- | docs/configuration.md | 11 | ||||
| -rw-r--r-- | internal/appconfig/config.go | 63 | ||||
| -rw-r--r-- | internal/appconfig/config_test.go | 184 |
6 files changed, 263 insertions, 6 deletions
@@ -10,7 +10,7 @@ - Avoid duplication of code when the functions are larger than 5 lines. - If possible, construct individual methods so that they can be unit tested. But only if it doesn't add too much boilerplate to the code base. -- Aim for at least 85% unit test coverage of all source code. The command to check the coverage is "mage coverage" +- Aim for at least 80% unit test coverage of all source code. The command to check the coverage is "mage coverage" - Ensure that all unit tests pass before commiting any changes. - Always run the gofumpt code reformatter on all go files modified. - There should be no source code file larger than 1000 lines. If so, split it up into multiple. @@ -25,3 +25,4 @@ - Whenever incrementing the version, update the version number in the project, commit to git, tag the version and push to git. - When a major feature was introduced, increment ?.X.? - When only minor changes were done or only bugs were fixed, increment the version as ?.?.X + diff --git a/Magefile.go b/Magefile.go index fdf5389..1644f08 100644 --- a/Magefile.go +++ b/Magefile.go @@ -18,7 +18,7 @@ import ( var ( Default = Build // Default target: build all binaries. - coverageThreshold float64 = 85 + coverageThreshold float64 = 80 coveragePrinted = make(chan struct{}, 1) ) diff --git a/config.toml.example b/config.toml.example index bb8165d..81d8ba0 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1,4 +1,10 @@ # Hexai sectioned config example +# +# Global location: $XDG_CONFIG_HOME/hexai/config.toml (usually ~/.config/hexai/config.toml) +# +# Per-project overrides: place a .hexaiconfig.toml at the root of a git +# repository. It uses the same format and selectively overrides the global +# config. Environment variables (HEXAI_*) always take precedence over both. [general] max_tokens = 4000 diff --git a/docs/configuration.md b/docs/configuration.md index f4469a9..10e3c59 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,15 +2,22 @@ This page explains where the config lives and how to choose a style; the authoritative list of options and comments lives in the example file. -Config file +Global config file - Location: `$XDG_CONFIG_HOME/hexai/config.toml` (usually `~/.config/hexai/config.toml`). - Style: sectioned tables only — see [config.toml.example](../config.toml.example) for a complete, commented reference. +Per-project config file + +- Place a `.hexaiconfig.toml` at the root of a git repository to selectively override the global config for that project. +- Uses the same TOML format as the global config file — only specify the settings you want to override. +- Hexai auto-detects the git repository root by walking up from the current working directory. +- Precedence (lowest to highest): built-in defaults → global config → per-project config → environment variables. + Environment overrides - All options can be overridden by environment variables prefixed with `HEXAI_`. -- Env values take precedence over the config file. +- Env values always take precedence over both the global and per-project config files. - Examples: - `HEXAI_PROVIDER`, `HEXAI_MAX_TOKENS`, `HEXAI_CONTEXT_MODE`, `HEXAI_CONTEXT_WINDOW_LINES`, `HEXAI_MAX_CONTEXT_TOKENS`, `HEXAI_LOG_PREVIEW_LIMIT`, `HEXAI_REQUEST_TIMEOUT` - `HEXAI_CODING_TEMPERATURE` diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index 3077d42..87ad9ff 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -190,8 +190,12 @@ func Load(logger *log.Logger) App { return LoadWithOptions(logger, LoadOptions{} // LoadOptions tune how configuration is loaded at runtime. type LoadOptions struct { // IgnoreEnv skips applying environment overrides when true. - IgnoreEnv bool + IgnoreEnv bool + // ConfigPath overrides the global config file path (e.g. via --config flag). ConfigPath string + // ProjectRoot overrides the project root directory for locating .hexaiconfig.toml. + // When empty, findGitRoot() is used to auto-detect from the current working directory. + ProjectRoot string } // LoadWithOptions reads configuration and applies the requested loading options. @@ -201,6 +205,7 @@ func LoadWithOptions(logger *log.Logger, opts LoadOptions) App { return cfg // Return defaults if no logger is provided (e.g. in tests) } + // Step 1: Load global config file configPath := strings.TrimSpace(opts.ConfigPath) if configPath != "" { if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil { @@ -217,8 +222,12 @@ func LoadWithOptions(logger *log.Logger, opts LoadOptions) App { } } + // Step 2: Load per-project config (.hexaiconfig.toml at git repo root). + // Project config overrides global config but is itself overridden by env vars. + loadProjectConfig(logger, opts, &cfg) + + // Step 3: Environment overrides (always take precedence over all config files) if !opts.IgnoreEnv { - // Environment overrides (take precedence over file) if envCfg := loadFromEnv(logger); envCfg != nil { cfg.mergeWith(envCfg) } @@ -226,6 +235,22 @@ func LoadWithOptions(logger *log.Logger, opts LoadOptions) App { return cfg } +// loadProjectConfig attempts to load .hexaiconfig.toml from the project root and +// merges it into cfg. Uses opts.ProjectRoot if set, otherwise auto-detects via findGitRoot(). +func loadProjectConfig(logger *log.Logger, opts LoadOptions, cfg *App) { + projectRoot := strings.TrimSpace(opts.ProjectRoot) + if projectRoot == "" { + projectRoot = findGitRoot() + } + if projectRoot == "" { + return + } + projectCfgPath := filepath.Join(projectRoot, ProjectConfigFilename) + if projCfg, err := loadFromFile(projectCfgPath, logger); err == nil && projCfg != nil { + cfg.mergeWith(projCfg) + } +} + // Private helpers // Sectioned (table-based) file format only. type fileConfig struct { @@ -1110,6 +1135,40 @@ func ConfigPath() (string, error) { return configPath, nil } +// ProjectConfigFilename is the name of the per-project config file placed at a git repo root. +const ProjectConfigFilename = ".hexaiconfig.toml" + +// ProjectConfigPath returns the path to the per-project config file if a git repository +// root is detected from the current working directory. Returns empty string otherwise. +func ProjectConfigPath() string { + root := findGitRoot() + if root == "" { + return "" + } + return filepath.Join(root, ProjectConfigFilename) +} + +// findGitRoot walks up from the current working directory looking for a .git +// directory or file (worktrees use a .git file). Returns the directory +// containing .git, or empty string if none is found. +func findGitRoot() string { + dir, err := os.Getwd() + if err != nil { + return "" + } + for { + if info, err := os.Stat(filepath.Join(dir, ".git")); err == nil && + (info.IsDir() || info.Mode().IsRegular()) { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + return "" // reached filesystem root + } + dir = parent + } +} + // --- Environment overrides --- // loadFromEnv constructs an App containing only fields set via HEXAI_* env vars. diff --git a/internal/appconfig/config_test.go b/internal/appconfig/config_test.go index ff9616b..75cc7ee 100644 --- a/internal/appconfig/config_test.go +++ b/internal/appconfig/config_test.go @@ -600,3 +600,187 @@ custom_menu_hotkey = "r" t.Fatalf("expected clash error, got %v", err) } } + +func TestFindGitRoot(t *testing.T) { + // Create a temp dir with a .git subdirectory to simulate a git repo + dir := t.TempDir() + gitDir := filepath.Join(dir, ".git") + if err := os.Mkdir(gitDir, 0o755); err != nil { + t.Fatalf("mkdir .git: %v", err) + } + // Create a nested subdir to test walking up + nested := filepath.Join(dir, "a", "b", "c") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + + // Save and restore cwd + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + // Test from nested subdir — should find the git root + if err := os.Chdir(nested); err != nil { + t.Fatalf("chdir: %v", err) + } + root := findGitRoot() + if root != dir { + t.Fatalf("findGitRoot() = %q, want %q", root, dir) + } + + // Test from a dir with no .git ancestor + noGit := t.TempDir() + if err := os.Chdir(noGit); err != nil { + t.Fatalf("chdir: %v", err) + } + root = findGitRoot() + if root != "" { + t.Fatalf("findGitRoot() = %q, want empty", root) + } +} + +func TestLoadWithOptions_ProjectConfig(t *testing.T) { + clearHexaiEnv(t) + + // Set up global config + globalDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", globalDir) + globalCfgPath := filepath.Join(globalDir, "hexai", "config.toml") + writeFile(t, globalCfgPath, ` +[general] +max_tokens = 2000 +context_mode = "always-full" + +[provider] +name = "openai" +`) + + // Set up project root with .git and .hexaiconfig.toml + projectDir := t.TempDir() + if err := os.Mkdir(filepath.Join(projectDir, ".git"), 0o755); err != nil { + t.Fatalf("mkdir .git: %v", err) + } + writeFile(t, filepath.Join(projectDir, ProjectConfigFilename), ` +[general] +max_tokens = 8000 + +[provider] +name = "anthropic" +`) + + // Load using explicit ProjectRoot (avoids needing to chdir) + logger := newLogger() + cfg := LoadWithOptions(logger, LoadOptions{ProjectRoot: projectDir}) + + // Project config should override global values + if cfg.MaxTokens != 8000 { + t.Fatalf("expected project max_tokens=8000, got %d", cfg.MaxTokens) + } + if cfg.Provider != "anthropic" { + t.Fatalf("expected project provider=anthropic, got %q", cfg.Provider) + } + // Values not overridden by project config should come from global config + if cfg.ContextMode != "always-full" { + t.Fatalf("expected global context_mode=always-full, got %q", cfg.ContextMode) + } +} + +func TestLoadWithOptions_ProjectConfig_EnvOverridesProject(t *testing.T) { + clearHexaiEnv(t) + + // Set up global config + globalDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", globalDir) + globalCfgPath := filepath.Join(globalDir, "hexai", "config.toml") + writeFile(t, globalCfgPath, ` +[general] +max_tokens = 2000 +`) + + // Set up project config + projectDir := t.TempDir() + if err := os.Mkdir(filepath.Join(projectDir, ".git"), 0o755); err != nil { + t.Fatalf("mkdir .git: %v", err) + } + writeFile(t, filepath.Join(projectDir, ProjectConfigFilename), ` +[general] +max_tokens = 8000 +`) + + // Env var should override project config + withEnv(t, "HEXAI_MAX_TOKENS", "9999") + + logger := newLogger() + cfg := LoadWithOptions(logger, LoadOptions{ProjectRoot: projectDir}) + + if cfg.MaxTokens != 9999 { + t.Fatalf("expected env max_tokens=9999 to override project, got %d", cfg.MaxTokens) + } +} + +func TestLoadWithOptions_ProjectConfig_NoGitRoot(t *testing.T) { + clearHexaiEnv(t) + + // Set up global config + globalDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", globalDir) + globalCfgPath := filepath.Join(globalDir, "hexai", "config.toml") + writeFile(t, globalCfgPath, ` +[general] +max_tokens = 2000 +`) + + // No ProjectRoot, no .git — should work as before + noGit := t.TempDir() + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(origDir) }) + if err := os.Chdir(noGit); err != nil { + t.Fatalf("chdir: %v", err) + } + + logger := newLogger() + cfg := LoadWithOptions(logger, LoadOptions{}) + + // Should get global config values, not defaults + if cfg.MaxTokens != 2000 { + t.Fatalf("expected global max_tokens=2000 without project config, got %d", cfg.MaxTokens) + } +} + +func TestProjectConfigPath(t *testing.T) { + // Set up a fake git repo + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, ".git"), 0o755); err != nil { + t.Fatalf("mkdir .git: %v", err) + } + + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + path := ProjectConfigPath() + want := filepath.Join(dir, ProjectConfigFilename) + if path != want { + t.Fatalf("ProjectConfigPath() = %q, want %q", path, want) + } + + // No git root + noGit := t.TempDir() + if err := os.Chdir(noGit); err != nil { + t.Fatalf("chdir: %v", err) + } + path = ProjectConfigPath() + if path != "" { + t.Fatalf("ProjectConfigPath() = %q, want empty", path) + } +} |
