summaryrefslogtreecommitdiff
path: root/internal/appconfig
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-17 21:39:01 +0300
committerPaul Buetow <paul@buetow.org>2025-08-17 21:39:01 +0300
commitad99f0a6a65bb1d327feb933d5349fc067c881f2 (patch)
treef8e66b81639e134e4898e000ff34e8782db57fa0 /internal/appconfig
parent454451105ad3522d2ac3d22136eedee4a4d034af (diff)
feat: Support XDG config home
This change implements support for the XDG Base Directory Specification for the configuration file. The configuration file is now read from `$XDG_CONFIG_HOME/hexai/config.json` if the `XDG_CONFIG_HOME` environment variable is set. If it is not set, it falls back to the previous location, `$HOME/.config/hexai/config.json`. This change also includes: - A fix for a bug in the test suite where a test was failing due to an environment variable being set. - Updates to the documentation to reflect the new configuration file location. - A version bump to 0.1.0.
Diffstat (limited to 'internal/appconfig')
-rw-r--r--internal/appconfig/config.go160
1 files changed, 90 insertions, 70 deletions
diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go
index c0f28d2..6b8df4a 100644
--- a/internal/appconfig/config.go
+++ b/internal/appconfig/config.go
@@ -29,75 +29,95 @@ type App struct {
CopilotModel string `json:"copilot_model"`
}
-// Load reads configuration from ~/.config/hexai/config.json and merges with defaults.
+// Load reads configuration from a file and merges with defaults.
+// It respects the XDG Base Directory Specification.
func Load(logger *log.Logger) App {
- cfg := App{
- MaxTokens: 4000,
- ContextMode: "always-full",
- ContextWindowLines: 120,
- MaxContextTokens: 4000,
- LogPreviewLimit: 100,
- NoDiskIO: true,
- }
- home, err := os.UserHomeDir()
- if err != nil {
- return cfg
- }
- path := filepath.Join(home, ".config", "hexai", "config.json")
- f, err := os.Open(path)
- if err != nil {
- return cfg
- }
- defer f.Close()
- dec := json.NewDecoder(f)
- var fileCfg App
- if err := dec.Decode(&fileCfg); err != nil {
- if logger != nil {
- logger.Printf("invalid config file %s: %v", path, err)
- }
- return cfg
- }
- // Merge: file overrides defaults when provided
- if fileCfg.MaxTokens > 0 {
- cfg.MaxTokens = fileCfg.MaxTokens
- }
- if strings.TrimSpace(fileCfg.ContextMode) != "" {
- cfg.ContextMode = fileCfg.ContextMode
- }
- if fileCfg.ContextWindowLines > 0 {
- cfg.ContextWindowLines = fileCfg.ContextWindowLines
- }
- if fileCfg.MaxContextTokens > 0 {
- cfg.MaxContextTokens = fileCfg.MaxContextTokens
- }
- if fileCfg.LogPreviewLimit >= 0 {
- cfg.LogPreviewLimit = fileCfg.LogPreviewLimit
- }
- cfg.NoDiskIO = fileCfg.NoDiskIO
- if len(fileCfg.TriggerCharacters) > 0 {
- cfg.TriggerCharacters = append([]string{}, fileCfg.TriggerCharacters...)
- }
- if strings.TrimSpace(fileCfg.Provider) != "" {
- cfg.Provider = fileCfg.Provider
- }
- // Provider-specific options
- if strings.TrimSpace(fileCfg.OpenAIBaseURL) != "" {
- cfg.OpenAIBaseURL = fileCfg.OpenAIBaseURL
- }
- if strings.TrimSpace(fileCfg.OpenAIModel) != "" {
- cfg.OpenAIModel = fileCfg.OpenAIModel
- }
- if strings.TrimSpace(fileCfg.OllamaBaseURL) != "" {
- cfg.OllamaBaseURL = fileCfg.OllamaBaseURL
- }
- if strings.TrimSpace(fileCfg.OllamaModel) != "" {
- cfg.OllamaModel = fileCfg.OllamaModel
- }
- if strings.TrimSpace(fileCfg.CopilotBaseURL) != "" {
- cfg.CopilotBaseURL = fileCfg.CopilotBaseURL
- }
- if strings.TrimSpace(fileCfg.CopilotModel) != "" {
- cfg.CopilotModel = fileCfg.CopilotModel
- }
- return cfg
+ cfg := App{
+ MaxTokens: 4000,
+ ContextMode: "always-full",
+ ContextWindowLines: 120,
+ MaxContextTokens: 4000,
+ LogPreviewLimit: 100,
+ NoDiskIO: true,
+ }
+
+ if logger == nil {
+ return cfg // Return defaults if no logger is provided (e.g. in tests)
+ }
+
+ var configPath string
+ if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
+ configPath = filepath.Join(xdgConfigHome, "hexai", "config.json")
+ } else {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ if logger != nil {
+ logger.Printf("cannot find user home directory: %v", err)
+ }
+ return cfg // Return defaults if home dir is not found
+ }
+ configPath = filepath.Join(home, ".config", "hexai", "config.json")
+ }
+
+ f, err := os.Open(configPath)
+ if err != nil {
+ if !os.IsNotExist(err) && logger != nil {
+ logger.Printf("cannot open config file %s: %v", configPath, err)
+ }
+ return cfg // Return defaults if file doesn't exist or can't be opened
+ }
+ defer f.Close()
+
+ dec := json.NewDecoder(f)
+ var fileCfg App
+ if err := dec.Decode(&fileCfg); err != nil {
+ if logger != nil {
+ logger.Printf("invalid config file %s: %v", configPath, err)
+ }
+ return cfg // Return defaults on decoding error
+ }
+
+ // Merge: file overrides defaults when provided
+ if fileCfg.MaxTokens > 0 {
+ cfg.MaxTokens = fileCfg.MaxTokens
+ }
+ if strings.TrimSpace(fileCfg.ContextMode) != "" {
+ cfg.ContextMode = fileCfg.ContextMode
+ }
+ if fileCfg.ContextWindowLines > 0 {
+ cfg.ContextWindowLines = fileCfg.ContextWindowLines
+ }
+ if fileCfg.MaxContextTokens > 0 {
+ cfg.MaxContextTokens = fileCfg.MaxContextTokens
+ }
+ if fileCfg.LogPreviewLimit >= 0 {
+ cfg.LogPreviewLimit = fileCfg.LogPreviewLimit
+ }
+ cfg.NoDiskIO = fileCfg.NoDiskIO
+ if len(fileCfg.TriggerCharacters) > 0 {
+ cfg.TriggerCharacters = append([]string{}, fileCfg.TriggerCharacters...)
+ }
+ if strings.TrimSpace(fileCfg.Provider) != "" {
+ cfg.Provider = fileCfg.Provider
+ }
+ // Provider-specific options
+ if strings.TrimSpace(fileCfg.OpenAIBaseURL) != "" {
+ cfg.OpenAIBaseURL = fileCfg.OpenAIBaseURL
+ }
+ if strings.TrimSpace(fileCfg.OpenAIModel) != "" {
+ cfg.OpenAIModel = fileCfg.OpenAIModel
+ }
+ if strings.TrimSpace(fileCfg.OllamaBaseURL) != "" {
+ cfg.OllamaBaseURL = fileCfg.OllamaBaseURL
+ }
+ if strings.TrimSpace(fileCfg.OllamaModel) != "" {
+ cfg.OllamaModel = fileCfg.OllamaModel
+ }
+ if strings.TrimSpace(fileCfg.CopilotBaseURL) != "" {
+ cfg.CopilotBaseURL = fileCfg.CopilotBaseURL
+ }
+ if strings.TrimSpace(fileCfg.CopilotModel) != "" {
+ cfg.CopilotModel = fileCfg.CopilotModel
+ }
+ return cfg
}