summaryrefslogtreecommitdiff
path: root/internal/appconfig
diff options
context:
space:
mode:
Diffstat (limited to 'internal/appconfig')
-rw-r--r--internal/appconfig/config.go413
-rw-r--r--internal/appconfig/config_test.go294
2 files changed, 393 insertions, 314 deletions
diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go
index d19ea18..92fdf19 100644
--- a/internal/appconfig/config.go
+++ b/internal/appconfig/config.go
@@ -2,14 +2,14 @@
package appconfig
import (
- "encoding/json"
- "fmt"
- "log"
- "os"
- "path/filepath"
- "slices"
- "strconv"
- "strings"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "slices"
+ "strconv"
+ "strings"
)
// App holds user-configurable settings read from ~/.config/hexai/config.json.
@@ -20,25 +20,25 @@ type App struct {
MaxContextTokens int `json:"max_context_tokens"`
LogPreviewLimit int `json:"log_preview_limit"`
// Single knob for LSP requests; if set, overrides hardcoded temps in LSP.
- CodingTemperature *float64 `json:"coding_temperature"`
- // Minimum identifier characters required for manual (TriggerKind=1) invoke
- // to proceed without structural triggers. 0 means always allow.
- ManualInvokeMinPrefix int `json:"manual_invoke_min_prefix"`
+ CodingTemperature *float64 `json:"coding_temperature"`
+ // Minimum identifier characters required for manual (TriggerKind=1) invoke
+ // to proceed without structural triggers. 0 means always allow.
+ ManualInvokeMinPrefix int `json:"manual_invoke_min_prefix"`
- // Completion debounce in milliseconds. When > 0, the server waits until
- // there has been no text change for at least this duration before sending
- // an LLM completion request.
- CompletionDebounceMs int `json:"completion_debounce_ms"`
- // Completion throttle in milliseconds. When > 0, caps the minimum spacing
- // between LLM requests (both chat and code-completer paths).
- CompletionThrottleMs int `json:"completion_throttle_ms"`
+ // Completion debounce in milliseconds. When > 0, the server waits until
+ // there has been no text change for at least this duration before sending
+ // an LLM completion request.
+ CompletionDebounceMs int `json:"completion_debounce_ms"`
+ // Completion throttle in milliseconds. When > 0, caps the minimum spacing
+ // between LLM requests (both chat and code-completer paths).
+ CompletionThrottleMs int `json:"completion_throttle_ms"`
TriggerCharacters []string `json:"trigger_characters"`
Provider string `json:"provider"`
// Inline prompt trigger characters (default: >text> and >>text>)
- InlineOpen string `json:"inline_open"`
- InlineClose string `json:"inline_close"`
+ InlineOpen string `json:"inline_open"`
+ InlineClose string `json:"inline_close"`
// In-editor chat triggers (default: suffix ">" after one of [?, !, :, ;])
ChatSuffix string `json:"chat_suffix"`
ChatPrefixes []string `json:"chat_prefixes"`
@@ -64,51 +64,51 @@ func newDefaultConfig() App {
// Users can override per provider in config.json (including 0.0).
t := 0.2
return App{
- MaxTokens: 4000,
- ContextMode: "always-full",
- ContextWindowLines: 120,
- MaxContextTokens: 4000,
- LogPreviewLimit: 100,
- CodingTemperature: &t,
- OpenAITemperature: &t,
- OllamaTemperature: &t,
- CopilotTemperature: &t,
- ManualInvokeMinPrefix: 0,
- CompletionDebounceMs: 200,
- CompletionThrottleMs: 0,
- // Inline/chat trigger defaults
- InlineOpen: ">",
- InlineClose: ">",
- ChatSuffix: ">",
- ChatPrefixes: []string{"?", "!", ":", ";"},
- }
+ MaxTokens: 4000,
+ ContextMode: "always-full",
+ ContextWindowLines: 120,
+ MaxContextTokens: 4000,
+ LogPreviewLimit: 100,
+ CodingTemperature: &t,
+ OpenAITemperature: &t,
+ OllamaTemperature: &t,
+ CopilotTemperature: &t,
+ ManualInvokeMinPrefix: 0,
+ CompletionDebounceMs: 200,
+ CompletionThrottleMs: 0,
+ // Inline/chat trigger defaults
+ InlineOpen: ">",
+ InlineClose: ">",
+ ChatSuffix: ">",
+ ChatPrefixes: []string{"?", "!", ":", ";"},
+ }
}
// Load reads configuration from a file and merges with defaults.
// It respects the XDG Base Directory Specification.
func Load(logger *log.Logger) App {
- cfg := newDefaultConfig()
- if logger == nil {
- return cfg // Return defaults if no logger is provided (e.g. in tests)
- }
+ cfg := newDefaultConfig()
+ if logger == nil {
+ return cfg // Return defaults if no logger is provided (e.g. in tests)
+ }
- configPath, err := getConfigPath()
- if err != nil {
- logger.Printf("%v", err)
- // Even if config path cannot be resolved, still allow env overrides below.
- } else {
- if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil {
- cfg.mergeWith(fileCfg)
- }
- // When the config file is missing or invalid, we keep defaults and still
- // apply any environment overrides below.
- }
+ configPath, err := getConfigPath()
+ if err != nil {
+ logger.Printf("%v", err)
+ // Even if config path cannot be resolved, still allow env overrides below.
+ } else {
+ if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil {
+ cfg.mergeWith(fileCfg)
+ }
+ // When the config file is missing or invalid, we keep defaults and still
+ // apply any environment overrides below.
+ }
- // Environment overrides (take precedence over file)
- if envCfg := loadFromEnv(logger); envCfg != nil {
- cfg.mergeWith(envCfg)
- }
- return cfg
+ // Environment overrides (take precedence over file)
+ if envCfg := loadFromEnv(logger); envCfg != nil {
+ cfg.mergeWith(envCfg)
+ }
+ return cfg
}
// Private helpers
@@ -134,8 +134,8 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) {
}
func (a *App) mergeWith(other *App) {
- a.mergeBasics(other)
- a.mergeProviderFields(other)
+ a.mergeBasics(other)
+ a.mergeProviderFields(other)
}
// mergeBasics merges general (non-provider) fields.
@@ -155,32 +155,36 @@ func (a *App) mergeBasics(other *App) {
if other.LogPreviewLimit >= 0 {
a.LogPreviewLimit = other.LogPreviewLimit
}
- if other.CodingTemperature != nil { // allow explicit 0.0
- a.CodingTemperature = other.CodingTemperature
- }
- if other.ManualInvokeMinPrefix >= 0 {
- a.ManualInvokeMinPrefix = other.ManualInvokeMinPrefix
- }
- if other.CompletionDebounceMs > 0 { a.CompletionDebounceMs = other.CompletionDebounceMs }
- if other.CompletionThrottleMs > 0 { a.CompletionThrottleMs = other.CompletionThrottleMs }
- if len(other.TriggerCharacters) > 0 {
- a.TriggerCharacters = slices.Clone(other.TriggerCharacters)
- }
- if s := strings.TrimSpace(other.InlineOpen); s != "" {
- a.InlineOpen = s
- }
- if s := strings.TrimSpace(other.InlineClose); s != "" {
- a.InlineClose = s
- }
- if s := strings.TrimSpace(other.ChatSuffix); s != "" {
- a.ChatSuffix = s
- }
- if len(other.ChatPrefixes) > 0 {
- a.ChatPrefixes = slices.Clone(other.ChatPrefixes)
- }
- if s := strings.TrimSpace(other.Provider); s != "" {
- a.Provider = s
- }
+ if other.CodingTemperature != nil { // allow explicit 0.0
+ a.CodingTemperature = other.CodingTemperature
+ }
+ if other.ManualInvokeMinPrefix >= 0 {
+ a.ManualInvokeMinPrefix = other.ManualInvokeMinPrefix
+ }
+ if other.CompletionDebounceMs > 0 {
+ a.CompletionDebounceMs = other.CompletionDebounceMs
+ }
+ if other.CompletionThrottleMs > 0 {
+ a.CompletionThrottleMs = other.CompletionThrottleMs
+ }
+ if len(other.TriggerCharacters) > 0 {
+ a.TriggerCharacters = slices.Clone(other.TriggerCharacters)
+ }
+ if s := strings.TrimSpace(other.InlineOpen); s != "" {
+ a.InlineOpen = s
+ }
+ if s := strings.TrimSpace(other.InlineClose); s != "" {
+ a.InlineClose = s
+ }
+ if s := strings.TrimSpace(other.ChatSuffix); s != "" {
+ a.ChatSuffix = s
+ }
+ if len(other.ChatPrefixes) > 0 {
+ a.ChatPrefixes = slices.Clone(other.ChatPrefixes)
+ }
+ if s := strings.TrimSpace(other.Provider); s != "" {
+ a.Provider = s
+ }
}
// mergeProviderFields merges per-provider configuration.
@@ -225,7 +229,7 @@ func getConfigPath() (string, error) {
}
configPath = filepath.Join(home, ".config", "hexai", "config.json")
}
- return configPath, nil
+ return configPath, nil
}
// --- Environment overrides ---
@@ -233,98 +237,155 @@ func getConfigPath() (string, error) {
// loadFromEnv constructs an App containing only fields set via HEXAI_* env vars.
// These values should take precedence over file config when merged.
func loadFromEnv(logger *log.Logger) *App {
- var out App
- var any bool
+ var out App
+ var any bool
- // helpers
- getenv := func(k string) string { return strings.TrimSpace(os.Getenv(k)) }
- parseInt := func(k string) (int, bool) {
- v := getenv(k)
- if v == "" { return 0, false }
- n, err := strconv.Atoi(v)
- if err != nil { if logger != nil { logger.Printf("invalid %s: %v", k, err) } ; return 0, false }
- return n, true
- }
- parseFloatPtr := func(k string) (*float64, bool) {
- v := getenv(k)
- if v == "" { return nil, false }
- f, err := strconv.ParseFloat(v, 64)
- if err != nil {
- if logger != nil { logger.Printf("invalid %s: %v", k, err) }
- return nil, false
- }
- return &f, true
- }
+ // helpers
+ getenv := func(k string) string { return strings.TrimSpace(os.Getenv(k)) }
+ parseInt := func(k string) (int, bool) {
+ v := getenv(k)
+ if v == "" {
+ return 0, false
+ }
+ n, err := strconv.Atoi(v)
+ if err != nil {
+ if logger != nil {
+ logger.Printf("invalid %s: %v", k, err)
+ }
+ return 0, false
+ }
+ return n, true
+ }
+ parseFloatPtr := func(k string) (*float64, bool) {
+ v := getenv(k)
+ if v == "" {
+ return nil, false
+ }
+ f, err := strconv.ParseFloat(v, 64)
+ if err != nil {
+ if logger != nil {
+ logger.Printf("invalid %s: %v", k, err)
+ }
+ return nil, false
+ }
+ return &f, true
+ }
- if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok {
- out.MaxTokens = n; any = true
- }
- if s := getenv("HEXAI_CONTEXT_MODE"); s != "" {
- out.ContextMode = s; any = true
- }
- if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok {
- out.ContextWindowLines = n; any = true
- }
- if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok {
- out.MaxContextTokens = n; any = true
- }
- if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok {
- out.LogPreviewLimit = n; any = true
- }
- if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok {
- out.ManualInvokeMinPrefix = n; any = true
- }
- if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok {
- out.CompletionDebounceMs = n; any = true
- }
- if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok {
- out.CompletionThrottleMs = n; any = true
- }
- if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok {
- out.CodingTemperature = f; any = true
- }
- if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" {
- parts := strings.Split(s, ",")
- out.TriggerCharacters = nil
- for _, p := range parts {
- if t := strings.TrimSpace(p); t != "" {
- out.TriggerCharacters = append(out.TriggerCharacters, t)
- }
- }
- any = true
- }
- if s := getenv("HEXAI_INLINE_OPEN"); s != "" { out.InlineOpen = s; any = true }
- if s := getenv("HEXAI_INLINE_CLOSE"); s != "" { out.InlineClose = s; any = true }
- if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" { out.ChatSuffix = s; any = true }
- if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" {
- parts := strings.Split(s, ",")
- out.ChatPrefixes = nil
- for _, p := range parts {
- if t := strings.TrimSpace(p); t != "" {
- out.ChatPrefixes = append(out.ChatPrefixes, t)
- }
- }
- any = true
- }
- if s := getenv("HEXAI_PROVIDER"); s != "" {
- out.Provider = s; any = true
- }
+ if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok {
+ out.MaxTokens = n
+ any = true
+ }
+ if s := getenv("HEXAI_CONTEXT_MODE"); s != "" {
+ out.ContextMode = s
+ any = true
+ }
+ if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok {
+ out.ContextWindowLines = n
+ any = true
+ }
+ if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok {
+ out.MaxContextTokens = n
+ any = true
+ }
+ if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok {
+ out.LogPreviewLimit = n
+ any = true
+ }
+ if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok {
+ out.ManualInvokeMinPrefix = n
+ any = true
+ }
+ if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok {
+ out.CompletionDebounceMs = n
+ any = true
+ }
+ if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok {
+ out.CompletionThrottleMs = n
+ any = true
+ }
+ if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok {
+ out.CodingTemperature = f
+ any = true
+ }
+ if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" {
+ parts := strings.Split(s, ",")
+ out.TriggerCharacters = nil
+ for _, p := range parts {
+ if t := strings.TrimSpace(p); t != "" {
+ out.TriggerCharacters = append(out.TriggerCharacters, t)
+ }
+ }
+ any = true
+ }
+ if s := getenv("HEXAI_INLINE_OPEN"); s != "" {
+ out.InlineOpen = s
+ any = true
+ }
+ if s := getenv("HEXAI_INLINE_CLOSE"); s != "" {
+ out.InlineClose = s
+ any = true
+ }
+ if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" {
+ out.ChatSuffix = s
+ any = true
+ }
+ if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" {
+ parts := strings.Split(s, ",")
+ out.ChatPrefixes = nil
+ for _, p := range parts {
+ if t := strings.TrimSpace(p); t != "" {
+ out.ChatPrefixes = append(out.ChatPrefixes, t)
+ }
+ }
+ any = true
+ }
+ if s := getenv("HEXAI_PROVIDER"); s != "" {
+ out.Provider = s
+ any = true
+ }
- // Provider-specific
- if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" { out.OpenAIBaseURL = s; any = true }
- if s := getenv("HEXAI_OPENAI_MODEL"); s != "" { out.OpenAIModel = s; any = true }
- if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok { out.OpenAITemperature = f; any = true }
+ // Provider-specific
+ if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" {
+ out.OpenAIBaseURL = s
+ any = true
+ }
+ if s := getenv("HEXAI_OPENAI_MODEL"); s != "" {
+ out.OpenAIModel = s
+ any = true
+ }
+ if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok {
+ out.OpenAITemperature = f
+ any = true
+ }
- if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" { out.OllamaBaseURL = s; any = true }
- if s := getenv("HEXAI_OLLAMA_MODEL"); s != "" { out.OllamaModel = s; any = true }
- if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok { out.OllamaTemperature = f; any = true }
+ if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" {
+ out.OllamaBaseURL = s
+ any = true
+ }
+ if s := getenv("HEXAI_OLLAMA_MODEL"); s != "" {
+ out.OllamaModel = s
+ any = true
+ }
+ if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok {
+ out.OllamaTemperature = f
+ any = true
+ }
- if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" { out.CopilotBaseURL = s; any = true }
- if s := getenv("HEXAI_COPILOT_MODEL"); s != "" { out.CopilotModel = s; any = true }
- if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok { out.CopilotTemperature = f; any = true }
+ if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" {
+ out.CopilotBaseURL = s
+ any = true
+ }
+ if s := getenv("HEXAI_COPILOT_MODEL"); s != "" {
+ out.CopilotModel = s
+ any = true
+ }
+ if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok {
+ out.CopilotTemperature = f
+ any = true
+ }
- if !any {
- return nil
- }
- return &out
+ if !any {
+ return nil
+ }
+ return &out
}
diff --git a/internal/appconfig/config_test.go b/internal/appconfig/config_test.go
index 30898a6..f2e3f7a 100644
--- a/internal/appconfig/config_test.go
+++ b/internal/appconfig/config_test.go
@@ -1,167 +1,185 @@
package appconfig
import (
- "encoding/json"
- "io"
- "log"
- "os"
- "path/filepath"
- "reflect"
- "strings"
- "testing"
+ "encoding/json"
+ "io"
+ "log"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
)
func newLogger() *log.Logger { return log.New(io.Discard, "", 0) }
func writeJSON(t *testing.T, path string, v any) {
- t.Helper()
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatalf("mkdir: %v", err)
- }
- f, err := os.Create(path)
- if err != nil { t.Fatalf("create: %v", err) }
- defer f.Close()
- enc := json.NewEncoder(f)
- if err := enc.Encode(v); err != nil {
- t.Fatalf("encode json: %v", err)
- }
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ defer f.Close()
+ enc := json.NewEncoder(f)
+ if err := enc.Encode(v); err != nil {
+ t.Fatalf("encode json: %v", err)
+ }
}
-func withEnv(t *testing.T, k, v string) { t.Helper(); old := os.Getenv(k); _ = os.Setenv(k, v); t.Cleanup(func(){ _ = os.Setenv(k, old) }) }
+func withEnv(t *testing.T, k, v string) {
+ t.Helper()
+ old := os.Getenv(k)
+ _ = os.Setenv(k, v)
+ t.Cleanup(func() { _ = os.Setenv(k, old) })
+}
func TestLoad_Defaults_NoLogger(t *testing.T) {
- cfg := Load(nil)
- if cfg.MaxTokens == 0 || cfg.ContextMode == "" || cfg.ContextWindowLines == 0 || cfg.MaxContextTokens == 0 {
- t.Fatalf("expected defaults populated, got %+v", cfg)
- }
- if cfg.CodingTemperature == nil { t.Fatalf("expected default CodingTemperature") }
+ cfg := Load(nil)
+ if cfg.MaxTokens == 0 || cfg.ContextMode == "" || cfg.ContextWindowLines == 0 || cfg.MaxContextTokens == 0 {
+ t.Fatalf("expected defaults populated, got %+v", cfg)
+ }
+ if cfg.CodingTemperature == nil {
+ t.Fatalf("expected default CodingTemperature")
+ }
}
func TestLoad_Defaults_WithLogger_NoFile_NoEnv(t *testing.T) {
- t.Setenv("XDG_CONFIG_HOME", t.TempDir())
- logger := newLogger()
- cfg := Load(logger)
- def := newDefaultConfig()
- if cfg.MaxTokens != def.MaxTokens || cfg.ContextMode != def.ContextMode || cfg.ContextWindowLines != def.ContextWindowLines {
- t.Fatalf("expected defaults; got %+v want %+v", cfg, def)
- }
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ logger := newLogger()
+ cfg := Load(logger)
+ def := newDefaultConfig()
+ if cfg.MaxTokens != def.MaxTokens || cfg.ContextMode != def.ContextMode || cfg.ContextWindowLines != def.ContextWindowLines {
+ t.Fatalf("expected defaults; got %+v want %+v", cfg, def)
+ }
}
func TestLoad_FileMerge_And_EnvOverride(t *testing.T) {
- dir := t.TempDir()
- t.Setenv("XDG_CONFIG_HOME", dir)
- cfgPath := filepath.Join(dir, "hexai", "config.json")
- temp0 := 0.0
- fileCfg := App{
- MaxTokens: 123,
- ContextMode: "file-on-new-func",
- ContextWindowLines: 50,
- MaxContextTokens: 999,
- LogPreviewLimit: 0,
- CodingTemperature: &temp0,
- ManualInvokeMinPrefix: 2,
- CompletionDebounceMs: 150,
- CompletionThrottleMs: 300,
- TriggerCharacters: []string{".", ":"},
- Provider: "openai",
- OpenAIBaseURL: "https://api.example",
- OpenAIModel: "gpt-x",
- OpenAITemperature: &temp0,
- OllamaBaseURL: "http://ollama",
- OllamaModel: "llama",
- OllamaTemperature: &temp0,
- CopilotBaseURL: "http://copilot",
- CopilotModel: "ghost",
- CopilotTemperature: &temp0,
- }
- writeJSON(t, cfgPath, fileCfg)
+ dir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", dir)
+ cfgPath := filepath.Join(dir, "hexai", "config.json")
+ temp0 := 0.0
+ fileCfg := App{
+ MaxTokens: 123,
+ ContextMode: "file-on-new-func",
+ ContextWindowLines: 50,
+ MaxContextTokens: 999,
+ LogPreviewLimit: 0,
+ CodingTemperature: &temp0,
+ ManualInvokeMinPrefix: 2,
+ CompletionDebounceMs: 150,
+ CompletionThrottleMs: 300,
+ TriggerCharacters: []string{".", ":"},
+ Provider: "openai",
+ OpenAIBaseURL: "https://api.example",
+ OpenAIModel: "gpt-x",
+ OpenAITemperature: &temp0,
+ OllamaBaseURL: "http://ollama",
+ OllamaModel: "llama",
+ OllamaTemperature: &temp0,
+ CopilotBaseURL: "http://copilot",
+ CopilotModel: "ghost",
+ CopilotTemperature: &temp0,
+ }
+ writeJSON(t, cfgPath, fileCfg)
- // Env overrides take precedence
- withEnv(t, "HEXAI_MAX_TOKENS", "321")
- withEnv(t, "HEXAI_CONTEXT_MODE", "always-full")
- withEnv(t, "HEXAI_CONTEXT_WINDOW_LINES", "77")
- withEnv(t, "HEXAI_MAX_CONTEXT_TOKENS", "888")
- withEnv(t, "HEXAI_LOG_PREVIEW_LIMIT", "7")
- withEnv(t, "HEXAI_CODING_TEMPERATURE", "0.7")
- withEnv(t, "HEXAI_MANUAL_INVOKE_MIN_PREFIX", "5")
- withEnv(t, "HEXAI_COMPLETION_DEBOUNCE_MS", "333")
- withEnv(t, "HEXAI_COMPLETION_THROTTLE_MS", "444")
- withEnv(t, "HEXAI_TRIGGER_CHARACTERS", "., / ,_")
- withEnv(t, "HEXAI_PROVIDER", "ollama")
- withEnv(t, "HEXAI_OPENAI_BASE_URL", "https://override")
- withEnv(t, "HEXAI_OPENAI_MODEL", "gpt-override")
- withEnv(t, "HEXAI_OPENAI_TEMPERATURE", "0.4")
- withEnv(t, "HEXAI_OLLAMA_BASE_URL", "http://ollama-override")
- withEnv(t, "HEXAI_OLLAMA_MODEL", "mistral")
- withEnv(t, "HEXAI_OLLAMA_TEMPERATURE", "0.6")
- withEnv(t, "HEXAI_COPILOT_BASE_URL", "http://copilot-override")
- withEnv(t, "HEXAI_COPILOT_MODEL", "ghost-override")
- withEnv(t, "HEXAI_COPILOT_TEMPERATURE", "0.3")
+ // Env overrides take precedence
+ withEnv(t, "HEXAI_MAX_TOKENS", "321")
+ withEnv(t, "HEXAI_CONTEXT_MODE", "always-full")
+ withEnv(t, "HEXAI_CONTEXT_WINDOW_LINES", "77")
+ withEnv(t, "HEXAI_MAX_CONTEXT_TOKENS", "888")
+ withEnv(t, "HEXAI_LOG_PREVIEW_LIMIT", "7")
+ withEnv(t, "HEXAI_CODING_TEMPERATURE", "0.7")
+ withEnv(t, "HEXAI_MANUAL_INVOKE_MIN_PREFIX", "5")
+ withEnv(t, "HEXAI_COMPLETION_DEBOUNCE_MS", "333")
+ withEnv(t, "HEXAI_COMPLETION_THROTTLE_MS", "444")
+ withEnv(t, "HEXAI_TRIGGER_CHARACTERS", "., / ,_")
+ withEnv(t, "HEXAI_PROVIDER", "ollama")
+ withEnv(t, "HEXAI_OPENAI_BASE_URL", "https://override")
+ withEnv(t, "HEXAI_OPENAI_MODEL", "gpt-override")
+ withEnv(t, "HEXAI_OPENAI_TEMPERATURE", "0.4")
+ withEnv(t, "HEXAI_OLLAMA_BASE_URL", "http://ollama-override")
+ withEnv(t, "HEXAI_OLLAMA_MODEL", "mistral")
+ withEnv(t, "HEXAI_OLLAMA_TEMPERATURE", "0.6")
+ withEnv(t, "HEXAI_COPILOT_BASE_URL", "http://copilot-override")
+ withEnv(t, "HEXAI_COPILOT_MODEL", "ghost-override")
+ withEnv(t, "HEXAI_COPILOT_TEMPERATURE", "0.3")
- logger := newLogger()
- cfg := Load(logger)
+ logger := newLogger()
+ cfg := Load(logger)
- // Check overrides
- if cfg.MaxTokens != 321 || cfg.ContextMode != "always-full" || cfg.ContextWindowLines != 77 || cfg.MaxContextTokens != 888 {
- t.Fatalf("env overrides (basic) not applied: %+v", cfg)
- }
- if cfg.LogPreviewLimit != 7 || cfg.ManualInvokeMinPrefix != 5 || cfg.CompletionDebounceMs != 333 || cfg.CompletionThrottleMs != 444 {
- t.Fatalf("env overrides (ints) not applied: %+v", cfg)
- }
- if cfg.CodingTemperature == nil || *cfg.CodingTemperature != 0.7 {
- t.Fatalf("env override (CodingTemperature) not applied: %+v", cfg.CodingTemperature)
- }
- if want := []string{".", "/", "_"}; !reflect.DeepEqual(cfg.TriggerCharacters, want) {
- t.Fatalf("env override (TriggerCharacters), got %v want %v", cfg.TriggerCharacters, want)
- }
- if cfg.Provider != "ollama" {
- t.Fatalf("provider override failed: %q", cfg.Provider)
- }
- // Provider-specific
- if cfg.OpenAIBaseURL != "https://override" || cfg.OpenAIModel != "gpt-override" || cfg.OpenAITemperature == nil || *cfg.OpenAITemperature != 0.4 {
- t.Fatalf("openai overrides not applied: %+v", cfg)
- }
- if cfg.OllamaBaseURL != "http://ollama-override" || cfg.OllamaModel != "mistral" || cfg.OllamaTemperature == nil || *cfg.OllamaTemperature != 0.6 {
- t.Fatalf("ollama overrides not applied: %+v", cfg)
- }
- if cfg.CopilotBaseURL != "http://copilot-override" || cfg.CopilotModel != "ghost-override" || cfg.CopilotTemperature == nil || *cfg.CopilotTemperature != 0.3 {
- t.Fatalf("copilot overrides not applied: %+v", cfg)
- }
+ // Check overrides
+ if cfg.MaxTokens != 321 || cfg.ContextMode != "always-full" || cfg.ContextWindowLines != 77 || cfg.MaxContextTokens != 888 {
+ t.Fatalf("env overrides (basic) not applied: %+v", cfg)
+ }
+ if cfg.LogPreviewLimit != 7 || cfg.ManualInvokeMinPrefix != 5 || cfg.CompletionDebounceMs != 333 || cfg.CompletionThrottleMs != 444 {
+ t.Fatalf("env overrides (ints) not applied: %+v", cfg)
+ }
+ if cfg.CodingTemperature == nil || *cfg.CodingTemperature != 0.7 {
+ t.Fatalf("env override (CodingTemperature) not applied: %+v", cfg.CodingTemperature)
+ }
+ if want := []string{".", "/", "_"}; !reflect.DeepEqual(cfg.TriggerCharacters, want) {
+ t.Fatalf("env override (TriggerCharacters), got %v want %v", cfg.TriggerCharacters, want)
+ }
+ if cfg.Provider != "ollama" {
+ t.Fatalf("provider override failed: %q", cfg.Provider)
+ }
+ // Provider-specific
+ if cfg.OpenAIBaseURL != "https://override" || cfg.OpenAIModel != "gpt-override" || cfg.OpenAITemperature == nil || *cfg.OpenAITemperature != 0.4 {
+ t.Fatalf("openai overrides not applied: %+v", cfg)
+ }
+ if cfg.OllamaBaseURL != "http://ollama-override" || cfg.OllamaModel != "mistral" || cfg.OllamaTemperature == nil || *cfg.OllamaTemperature != 0.6 {
+ t.Fatalf("ollama overrides not applied: %+v", cfg)
+ }
+ if cfg.CopilotBaseURL != "http://copilot-override" || cfg.CopilotModel != "ghost-override" || cfg.CopilotTemperature == nil || *cfg.CopilotTemperature != 0.3 {
+ t.Fatalf("copilot overrides not applied: %+v", cfg)
+ }
- // Ensure file values would have applied absent env
- // Spot-check: reset env and reload
- for _, k := range []string{
- "HEXAI_MAX_TOKENS","HEXAI_CONTEXT_MODE","HEXAI_CONTEXT_WINDOW_LINES","HEXAI_MAX_CONTEXT_TOKENS","HEXAI_LOG_PREVIEW_LIMIT","HEXAI_CODING_TEMPERATURE","HEXAI_MANUAL_INVOKE_MIN_PREFIX","HEXAI_COMPLETION_DEBOUNCE_MS","HEXAI_COMPLETION_THROTTLE_MS","HEXAI_TRIGGER_CHARACTERS","HEXAI_PROVIDER","HEXAI_OPENAI_BASE_URL","HEXAI_OPENAI_MODEL","HEXAI_OPENAI_TEMPERATURE","HEXAI_OLLAMA_BASE_URL","HEXAI_OLLAMA_MODEL","HEXAI_OLLAMA_TEMPERATURE","HEXAI_COPILOT_BASE_URL","HEXAI_COPILOT_MODEL","HEXAI_COPILOT_TEMPERATURE",
- } { t.Setenv(k, "") }
- cfg2 := Load(logger)
- if cfg2.MaxTokens != 123 || cfg2.ContextMode != "file-on-new-func" || cfg2.ContextWindowLines != 50 || cfg2.MaxContextTokens != 999 || cfg2.LogPreviewLimit != 0 {
- t.Fatalf("file merge not applied: %+v", cfg2)
- }
- if cfg2.CodingTemperature == nil || *cfg2.CodingTemperature != 0.0 {
- t.Fatalf("file merge (CodingTemperature) not applied: %+v", cfg2.CodingTemperature)
- }
- if cfg2.OpenAIBaseURL != "https://api.example" || cfg2.OpenAIModel != "gpt-x" || cfg2.OpenAITemperature == nil || *cfg2.OpenAITemperature != 0.0 {
- t.Fatalf("file merge (openai) not applied: %+v", cfg2)
- }
+ // Ensure file values would have applied absent env
+ // Spot-check: reset env and reload
+ for _, k := range []string{
+ "HEXAI_MAX_TOKENS", "HEXAI_CONTEXT_MODE", "HEXAI_CONTEXT_WINDOW_LINES", "HEXAI_MAX_CONTEXT_TOKENS", "HEXAI_LOG_PREVIEW_LIMIT", "HEXAI_CODING_TEMPERATURE", "HEXAI_MANUAL_INVOKE_MIN_PREFIX", "HEXAI_COMPLETION_DEBOUNCE_MS", "HEXAI_COMPLETION_THROTTLE_MS", "HEXAI_TRIGGER_CHARACTERS", "HEXAI_PROVIDER", "HEXAI_OPENAI_BASE_URL", "HEXAI_OPENAI_MODEL", "HEXAI_OPENAI_TEMPERATURE", "HEXAI_OLLAMA_BASE_URL", "HEXAI_OLLAMA_MODEL", "HEXAI_OLLAMA_TEMPERATURE", "HEXAI_COPILOT_BASE_URL", "HEXAI_COPILOT_MODEL", "HEXAI_COPILOT_TEMPERATURE",
+ } {
+ t.Setenv(k, "")
+ }
+ cfg2 := Load(logger)
+ if cfg2.MaxTokens != 123 || cfg2.ContextMode != "file-on-new-func" || cfg2.ContextWindowLines != 50 || cfg2.MaxContextTokens != 999 || cfg2.LogPreviewLimit != 0 {
+ t.Fatalf("file merge not applied: %+v", cfg2)
+ }
+ if cfg2.CodingTemperature == nil || *cfg2.CodingTemperature != 0.0 {
+ t.Fatalf("file merge (CodingTemperature) not applied: %+v", cfg2.CodingTemperature)
+ }
+ if cfg2.OpenAIBaseURL != "https://api.example" || cfg2.OpenAIModel != "gpt-x" || cfg2.OpenAITemperature == nil || *cfg2.OpenAITemperature != 0.0 {
+ t.Fatalf("file merge (openai) not applied: %+v", cfg2)
+ }
}
func TestGetConfigPath_XDG(t *testing.T) {
- dir := t.TempDir()
- t.Setenv("XDG_CONFIG_HOME", dir)
- path, err := getConfigPath()
- if err != nil { t.Fatalf("getConfigPath: %v", err) }
- if !strings.HasPrefix(path, filepath.Join(dir, "hexai")) || !strings.HasSuffix(path, "config.json") {
- t.Fatalf("unexpected path: %s", path)
- }
+ dir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", dir)
+ path, err := getConfigPath()
+ if err != nil {
+ t.Fatalf("getConfigPath: %v", err)
+ }
+ if !strings.HasPrefix(path, filepath.Join(dir, "hexai")) || !strings.HasSuffix(path, "config.json") {
+ t.Fatalf("unexpected path: %s", path)
+ }
}
func TestLoadFromFile_InvalidJSON(t *testing.T) {
- dir := t.TempDir()
- t.Setenv("XDG_CONFIG_HOME", dir)
- cfgPath := filepath.Join(dir, "hexai", "config.json")
- if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { t.Fatal(err) }
- if err := os.WriteFile(cfgPath, []byte("{ invalid"), 0o644); err != nil { t.Fatal(err) }
- _, err := loadFromFile(cfgPath, newLogger())
- if err == nil { t.Fatalf("expected error for invalid JSON") }
+ dir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", dir)
+ cfgPath := filepath.Join(dir, "hexai", "config.json")
+ if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(cfgPath, []byte("{ invalid"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ _, err := loadFromFile(cfgPath, newLogger())
+ if err == nil {
+ t.Fatalf("expected error for invalid JSON")
+ }
}
-