summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-08 10:09:42 +0200
committerPaul Buetow <paul@buetow.org>2026-02-08 10:09:42 +0200
commitbd698b257a548d835fbc2675ff5be5e1a69ff229 (patch)
treef622fc46969db9fa332bbc5d3d0d4210d4baebb7
parent04c42582018f0295935701215490236e789cf5e3 (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.md3
-rw-r--r--Magefile.go2
-rw-r--r--config.toml.example6
-rw-r--r--docs/configuration.md11
-rw-r--r--internal/appconfig/config.go63
-rw-r--r--internal/appconfig/config_test.go184
6 files changed, 263 insertions, 6 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 598b0f8..4009b7f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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)
+ }
+}