diff options
Diffstat (limited to 'internal/appconfig/config_features_test.go')
| -rw-r--r-- | internal/appconfig/config_features_test.go | 588 |
1 files changed, 588 insertions, 0 deletions
diff --git a/internal/appconfig/config_features_test.go b/internal/appconfig/config_features_test.go new file mode 100644 index 0000000..b3c12e9 --- /dev/null +++ b/internal/appconfig/config_features_test.go @@ -0,0 +1,588 @@ +// Summary: Tests for ignore config, tmux-edit config, and low-level parsing helpers +// (temperature, model entries, surface entries, resolved model). +package appconfig + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestIgnoreConfig_Defaults(t *testing.T) { + clearHexaiEnv(t) + cfg := Load(nil) + if cfg.IgnoreGitignore == nil || !*cfg.IgnoreGitignore { + t.Error("expected IgnoreGitignore default true") + } + if cfg.IgnoreLSPNotify == nil || !*cfg.IgnoreLSPNotify { + t.Error("expected IgnoreLSPNotify default true") + } + if len(cfg.IgnoreExtraPatterns) != 0 { + t.Errorf("expected empty IgnoreExtraPatterns, got %v", cfg.IgnoreExtraPatterns) + } +} + +func TestIgnoreConfig_FromFile(t *testing.T) { + clearHexaiEnv(t) + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + writeFile(t, cfgPath, ` +[ignore] +gitignore = false +extra_patterns = ["*.min.js", "dist/**"] +lsp_notify_ignored = false +`) + cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: dir}) + if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore { + t.Error("expected IgnoreGitignore false from file") + } + if cfg.IgnoreLSPNotify == nil || *cfg.IgnoreLSPNotify { + t.Error("expected IgnoreLSPNotify false from file") + } + want := []string{"*.min.js", "dist/**"} + if !reflect.DeepEqual(cfg.IgnoreExtraPatterns, want) { + t.Errorf("IgnoreExtraPatterns = %v, want %v", cfg.IgnoreExtraPatterns, want) + } +} + +func TestIgnoreConfig_EnvOverrides(t *testing.T) { + clearHexaiEnv(t) + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + writeFile(t, cfgPath, ` +[ignore] +gitignore = true +lsp_notify_ignored = true +`) + withEnv(t, "HEXAI_IGNORE_GITIGNORE", "false") + withEnv(t, "HEXAI_IGNORE_LSP_NOTIFY", "0") + withEnv(t, "HEXAI_IGNORE_EXTRA_PATTERNS", "*.bak,*.tmp") + cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: dir}) + if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore { + t.Error("expected IgnoreGitignore false from env override") + } + if cfg.IgnoreLSPNotify == nil || *cfg.IgnoreLSPNotify { + t.Error("expected IgnoreLSPNotify false from env override") + } + want := []string{"*.bak", "*.tmp"} + if !reflect.DeepEqual(cfg.IgnoreExtraPatterns, want) { + t.Errorf("IgnoreExtraPatterns = %v, want %v", cfg.IgnoreExtraPatterns, want) + } +} + +func TestIgnoreConfig_ProjectOverride(t *testing.T) { + clearHexaiEnv(t) + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + writeFile(t, cfgPath, ` +[ignore] +gitignore = true +`) + // Set up a fake git repo with project override + projectDir := t.TempDir() + if err := os.Mkdir(filepath.Join(projectDir, ".git"), 0o755); err != nil { + t.Fatalf("mkdir .git: %v", err) + } + projectCfg := filepath.Join(projectDir, ProjectConfigFilename) + writeFile(t, projectCfg, ` +[ignore] +gitignore = false +extra_patterns = ["build/**"] +`) + cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: projectDir}) + if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore { + t.Error("expected project override to set IgnoreGitignore false") + } + want := []string{"build/**"} + if !reflect.DeepEqual(cfg.IgnoreExtraPatterns, want) { + t.Errorf("IgnoreExtraPatterns = %v, want %v", cfg.IgnoreExtraPatterns, want) + } +} + +func TestIgnoreConfig_DisableGitignore(t *testing.T) { + clearHexaiEnv(t) + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + writeFile(t, cfgPath, ` +[ignore] +gitignore = false +`) + cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: dir}) + if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore { + t.Error("expected IgnoreGitignore false") + } + // LSP notify should still be true (default, not overridden) + if cfg.IgnoreLSPNotify == nil || !*cfg.IgnoreLSPNotify { + t.Error("expected IgnoreLSPNotify to remain true (default)") + } +} + +func TestTmuxEditConfig_FromFile(t *testing.T) { + clearHexaiEnv(t) + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + writeFile(t, cfgPath, ` +[tmux_edit] +popup_width = "90%" +popup_height = "85%" +default_agent = "claude" + +[[tmux_edit.agents]] +name = "claude" +display_name = "Claude Code" +detect_pattern = "(?i)(claude|anthropic)" +prompt_pattern = '(?s)>\s*(.+?)$' +clear_first = true +clear_keys = "C-u" +newline_keys = "S-Enter" +submit_keys = "Enter" + +[[tmux_edit.agents]] +name = "cursor" +display_name = "Cursor" +detect_pattern = "(?i)cursor" +prompt_pattern = '(?s)│\s*(.+?)$' +strip_patterns = ["INSERT", "Add a follow-up"] +clear_first = true +clear_keys = "C-u" +newline_keys = "S-Enter" +submit_keys = "Enter" +`) + cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: dir}) + if cfg.TmuxEditPopupWidth != "90%" { + t.Errorf("PopupWidth = %q, want 90%%", cfg.TmuxEditPopupWidth) + } + if cfg.TmuxEditPopupHeight != "85%" { + t.Errorf("PopupHeight = %q, want 85%%", cfg.TmuxEditPopupHeight) + } + if cfg.TmuxEditDefaultAgent != "claude" { + t.Errorf("DefaultAgent = %q, want claude", cfg.TmuxEditDefaultAgent) + } + if len(cfg.TmuxEditAgents) != 2 { + t.Fatalf("got %d agents, want 2", len(cfg.TmuxEditAgents)) + } + a := cfg.TmuxEditAgents[0] + if a.Name != "claude" || a.DisplayName != "Claude Code" { + t.Errorf("agent[0] = %q/%q, want claude/Claude Code", a.Name, a.DisplayName) + } + if a.ClearFirst == nil || !*a.ClearFirst { + t.Error("expected ClearFirst = true for claude agent") + } + b := cfg.TmuxEditAgents[1] + if b.Name != "cursor" { + t.Errorf("agent[1].Name = %q, want cursor", b.Name) + } + if len(b.StripPatterns) != 2 { + t.Errorf("agent[1].StripPatterns = %v, want 2 entries", b.StripPatterns) + } +} + +func TestTmuxEditConfig_Merge(t *testing.T) { + clearHexaiEnv(t) + a := newDefaultConfig() + b := App{ + TmuxEditPopupWidth: "70%", + TmuxEditDefaultAgent: "amp", + TmuxEditAgents: []TmuxEditAgentCfg{ + {Name: "amp", DisplayName: "Amp"}, + }, + } + a.mergeWith(&b) + if a.TmuxEditPopupWidth != "70%" { + t.Errorf("PopupWidth = %q, want 70%%", a.TmuxEditPopupWidth) + } + if a.TmuxEditDefaultAgent != "amp" { + t.Errorf("DefaultAgent = %q, want amp", a.TmuxEditDefaultAgent) + } + if len(a.TmuxEditAgents) != 1 || a.TmuxEditAgents[0].Name != "amp" { + t.Errorf("Agents = %v, want single amp", a.TmuxEditAgents) + } +} + +func TestTmuxEditConfig_SkipsEmptyName(t *testing.T) { + clearHexaiEnv(t) + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + writeFile(t, cfgPath, ` +[tmux_edit] +[[tmux_edit.agents]] +name = "" +display_name = "Empty" +`) + cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: dir}) + if len(cfg.TmuxEditAgents) != 0 { + t.Errorf("got %d agents, want 0 (empty name should be skipped)", len(cfg.TmuxEditAgents)) + } +} + +// --- Config Parsing Tests --- + +func TestParseTemperatureValue(t *testing.T) { + tests := []struct { + name string + input any + wantValue *float64 + wantOK bool + }{ + {"float64 zero", float64(0.0), floatPtr(0.0), true}, + {"float64 half", float64(0.5), floatPtr(0.5), true}, + {"float64 one", float64(1.0), floatPtr(1.0), true}, + {"float64 two", float64(2.0), floatPtr(2.0), true}, + {"int64 zero", int64(0), floatPtr(0.0), true}, + {"int64 one", int64(1), floatPtr(1.0), true}, + {"int64 two", int64(2), floatPtr(2.0), true}, + {"string zero", "0", floatPtr(0.0), true}, + {"string one", "1", floatPtr(1.0), true}, + {"string two", "2", floatPtr(2.0), true}, + {"string float", "0.75", floatPtr(0.75), true}, + {"string empty", "", nil, true}, + {"string whitespace", " ", nil, true}, + {"string invalid", "invalid", nil, false}, + {"string negative", "-0.5", floatPtr(-0.5), true}, + {"string very small", "0.0001", floatPtr(0.0001), true}, + {"string high precision", "1.123456789", floatPtr(1.123456789), true}, + {"nil value", nil, nil, false}, + {"bool value", true, nil, false}, + {"map value", map[string]any{}, nil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := parseTemperatureValue(tt.input, "test", newLogger()) + if ok != tt.wantOK { + t.Errorf("parseTemperatureValue() ok = %v, want %v", ok, tt.wantOK) + } + if !ok { + return + } + if (got == nil) != (tt.wantValue == nil) { + t.Errorf("parseTemperatureValue() = %v, want %v", got, tt.wantValue) + return + } + if got != nil && tt.wantValue != nil && *got != *tt.wantValue { + t.Errorf("parseTemperatureValue() = %v, want %v", *got, *tt.wantValue) + } + }) + } +} + +func TestDecodeModelEntry(t *testing.T) { + tests := []struct { + name string + input any + wantCfg *SurfaceConfig + wantOK bool + }{ + { + name: "simple string model", + input: "gpt-4", + wantCfg: &SurfaceConfig{Model: "gpt-4"}, + wantOK: true, + }, + { + name: "empty string", + input: "", + wantCfg: nil, + wantOK: false, + }, + { + name: "whitespace string", + input: " ", + wantCfg: nil, + wantOK: false, + }, + { + name: "object with all fields", + input: map[string]any{ + "model": "claude-3", + "provider": "anthropic", + "temperature": float64(0.7), + }, + wantCfg: &SurfaceConfig{ + Model: "claude-3", + Provider: "anthropic", + Temperature: floatPtr(0.7), + }, + wantOK: true, + }, + { + name: "object with model only", + input: map[string]any{ + "model": "gpt-4o", + }, + wantCfg: &SurfaceConfig{Model: "gpt-4o"}, + wantOK: true, + }, + { + name: "object with provider only", + input: map[string]any{ + "provider": "openai", + }, + wantCfg: &SurfaceConfig{Provider: "openai"}, + wantOK: true, + }, + { + name: "object with temperature only", + input: map[string]any{ + "temperature": float64(0.5), + }, + wantCfg: &SurfaceConfig{Temperature: floatPtr(0.5)}, + wantOK: true, + }, + { + name: "object with empty fields", + input: map[string]any{ + "model": "", + "provider": "", + }, + wantCfg: nil, + wantOK: false, + }, + { + name: "object with invalid model type", + input: map[string]any{ + "model": 123, + }, + wantCfg: nil, + wantOK: false, + }, + { + name: "object with invalid provider type", + input: map[string]any{ + "provider": 456, + }, + wantCfg: nil, + wantOK: false, + }, + { + name: "object with invalid temperature", + input: map[string]any{ + "model": "gpt-4", + "temperature": "not a number", + }, + wantCfg: nil, + wantOK: false, + }, + { + name: "nil input", + input: nil, + wantCfg: nil, + wantOK: false, + }, + { + name: "invalid type (int)", + input: 123, + wantCfg: nil, + wantOK: false, + }, + { + name: "invalid type (slice)", + input: []string{"gpt-4"}, + wantCfg: nil, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := decodeModelEntry(tt.input, "test", newLogger()) + if ok != tt.wantOK { + t.Errorf("decodeModelEntry() ok = %v, want %v", ok, tt.wantOK) + } + if !ok { + return + } + if (got == nil) != (tt.wantCfg == nil) { + t.Errorf("decodeModelEntry() = %v, want %v", got, tt.wantCfg) + return + } + if got == nil { + return + } + if got.Model != tt.wantCfg.Model { + t.Errorf("Model = %q, want %q", got.Model, tt.wantCfg.Model) + } + if got.Provider != tt.wantCfg.Provider { + t.Errorf("Provider = %q, want %q", got.Provider, tt.wantCfg.Provider) + } + if (got.Temperature == nil) != (tt.wantCfg.Temperature == nil) { + t.Errorf("Temperature nil mismatch: got %v, want %v", got.Temperature, tt.wantCfg.Temperature) + } else if got.Temperature != nil && *got.Temperature != *tt.wantCfg.Temperature { + t.Errorf("Temperature = %v, want %v", *got.Temperature, *tt.wantCfg.Temperature) + } + }) + } +} + +func TestResolvedModel(t *testing.T) { + tests := []struct { + name string + section sectionOpenAI + want string + }{ + { + name: "explicit model no presets", + section: sectionOpenAI{Model: "gpt-4"}, + want: "gpt-4", + }, + { + name: "empty model", + section: sectionOpenAI{Model: ""}, + want: "", + }, + { + name: "whitespace model", + section: sectionOpenAI{Model: " "}, + want: "", + }, + { + name: "preset match exact case", + section: sectionOpenAI{ + Model: "fast", + Presets: map[string]string{"fast": "gpt-3.5-turbo"}, + }, + want: "gpt-3.5-turbo", + }, + { + name: "preset match case insensitive", + section: sectionOpenAI{ + Model: "FAST", + Presets: map[string]string{"fast": "gpt-3.5-turbo"}, + }, + want: "gpt-3.5-turbo", + }, + { + name: "no preset match returns original", + section: sectionOpenAI{ + Model: "custom-model", + Presets: map[string]string{"fast": "gpt-3.5-turbo"}, + }, + want: "custom-model", + }, + { + name: "preset empty value returns original", + section: sectionOpenAI{ + Model: "fast", + Presets: map[string]string{"fast": ""}, + }, + want: "fast", + }, + { + name: "preset whitespace value returns original", + section: sectionOpenAI{ + Model: "fast", + Presets: map[string]string{"fast": " "}, + }, + want: "fast", + }, + { + name: "multiple presets uses correct one", + section: sectionOpenAI{ + Model: "smart", + Presets: map[string]string{ + "fast": "gpt-3.5-turbo", + "smart": "gpt-4", + "mini": "gpt-3.5-mini", + }, + }, + want: "gpt-4", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.section.resolvedModel() + if got != tt.want { + t.Errorf("resolvedModel() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestParseSurfaceEntries(t *testing.T) { + tests := []struct { + name string + input any + wantLen int + wantOK bool + }{ + { + name: "nil input", + input: nil, + wantLen: 0, + wantOK: false, + }, + { + name: "single string", + input: "gpt-4", + wantLen: 1, + wantOK: true, + }, + { + name: "single map", + input: map[string]any{ + "model": "claude-3", + "provider": "anthropic", + }, + wantLen: 1, + wantOK: true, + }, + { + name: "array of strings", + input: []any{ + "gpt-4", + "claude-3", + }, + wantLen: 2, + wantOK: true, + }, + { + name: "array of maps", + input: []any{ + map[string]any{"model": "gpt-4", "provider": "openai"}, + map[string]any{"model": "claude-3", "provider": "anthropic"}, + }, + wantLen: 2, + wantOK: true, + }, + { + name: "array with invalid entries", + input: []any{ + "gpt-4", + 123, + "claude-3", + }, + wantLen: 2, + wantOK: true, + }, + { + name: "array with all invalid entries", + input: []any{ + 123, + true, + nil, + }, + wantLen: 0, + wantOK: false, + }, + { + name: "empty array", + input: []any{}, + wantLen: 0, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := parseSurfaceEntries(tt.input, "test", newLogger()) + if ok != tt.wantOK { + t.Errorf("parseSurfaceEntries() ok = %v, want %v", ok, tt.wantOK) + } + if len(got) != tt.wantLen { + t.Errorf("parseSurfaceEntries() len = %d, want %d", len(got), tt.wantLen) + } + }) + } +} |
