package main import ( "os" "path/filepath" "strings" "testing" "time" ) func TestLoadConfigUsesDefaultsWhenOptionalFileIsMissing(t *testing.T) { path := filepath.Join(t.TempDir(), "missing.toml") got, err := loadConfig(path, false) if err != nil { t.Fatal(err) } want := defaultConfig() if got.Theme != want.Theme || got.RefreshInterval.Duration != want.RefreshInterval.Duration || got.Paths.Scroll != want.Paths.Scroll || got.Display.FoldResolved != want.Display.FoldResolved || got.Display.CompactReviews != want.Display.CompactReviews || got.Editing.Mode != "vim" { t.Fatalf("defaults = %#v, want %#v", got, want) } } func TestLoadConfigParsesUserSettings(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") content := ` theme = "light" refresh_interval = "25s" repository = "owner/repo" show_all = true limit = 75 endpoint = "https://github.example.com/api/graphql" mascot = true mascot_expressive = true mascot_animated = true [display] fold_resolved = false thread_list_width_percent = 45 dashboard_mode = "hotkey" compact_reviews = false viewer_label = "you" [paths] scroll = true scroll_interval = "125ms" [threads] status_order = ["resolved", "unresolved", "outdated"] within_status = "timestamp" [cache] enabled = false max_age = "48h" directory = "/tmp/diple-cache" [editing] mode = "standard" [keybindings.navigation] down = ["ctrl+j"] up = ["ctrl+k"] ` if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatal(err) } got, err := loadConfig(path, true) if err != nil { t.Fatal(err) } if err := validateConfig(got); err != nil { t.Fatal(err) } if got.Theme != "light" || got.RefreshInterval.Duration != 25*time.Second || got.Repository != "owner/repo" || !got.ShowAll || got.Limit != 75 || !got.Mascot || !got.MascotExpressive || !got.MascotAnimated || got.Display.FoldResolved || got.Display.ThreadListWidthPercent != 45 || got.Display.DashboardMode != "hotkey" || got.Display.CompactReviews || got.Display.ViewerLabel != "you" || !got.Paths.Scroll || got.Paths.ScrollInterval.Duration != 125*time.Millisecond || strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" || got.Threads.WithinStatus != "timestamp" || got.Cache.Enabled || got.Cache.MaxAge.Duration != 48*time.Hour || got.Cache.Directory != "/tmp/diple-cache" || got.Editing.Mode != "standard" || strings.Join(got.KeyBindings.Navigation.Down, ",") != "ctrl+j" || strings.Join(got.KeyBindings.Navigation.Up, ",") != "ctrl+k" { t.Fatalf("config = %#v", got) } } func TestLoadConfigParsesCustomTheme(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") content := ` theme = "custom" [custom_theme] base = "catppuccin-mocha" mode = "dark" title = "#112233" selection_background = "#223344" author_palette = ["#334455", "#445566"] syntax_theme = "gruvbox" ` if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatal(err) } got, err := loadConfig(path, true) if err != nil { t.Fatal(err) } if err := validateConfig(got); err != nil { t.Fatal(err) } if got.Theme != "custom" || got.CustomTheme.Base != "catppuccin-mocha" || got.CustomTheme.Title != "#112233" || got.CustomTheme.SelectionBackground != "#223344" || len(got.CustomTheme.AuthorPalette) != 2 || got.CustomTheme.SyntaxTheme != "gruvbox" { t.Fatalf("custom theme config = %#v", got.CustomTheme) } } func TestLoadConfigParsesExperimentalAISettings(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") content := ` [ai] enabled = true provider = "codex-cli" model = "gpt-test" command = "/usr/local/bin/codex" timeout = "2m" max_calls = 3 max_request_bytes = 64000 max_run_bytes = 128000 max_file_bytes = 32000 max_context_rounds = 1 max_context_files = 4 store_directory = "/tmp/diple-ai" exclude = ["vendor/", "*.lock"] sensitive_paths = [".env", "*.pem"] [keybindings.views] ai = ["ctrl+a"] ` if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatal(err) } config, err := loadConfig(path, true) if err != nil { t.Fatal(err) } if err := validateConfig(config); err != nil { t.Fatal(err) } if !config.AI.Enabled || config.AI.Model != "gpt-test" || config.AI.MaxCalls != 3 || config.AI.Timeout.Duration != 2*time.Minute || config.AI.MaxContextRounds != 1 || config.AI.MaxContextFiles != 4 || strings.Join(config.AI.SensitivePaths, ",") != ".env,*.pem" || config.AI.StoreDirectory != "/tmp/diple-ai" || strings.Join(config.KeyBindings.Views.AI, ",") != "ctrl+a" { t.Fatalf("AI config = %#v", config.AI) } } func TestValidateConfigRejectsEmptyKeyBinding(t *testing.T) { config := defaultConfig() config.KeyBindings.Navigation.Down = nil err := validateConfig(config) if err == nil || !strings.Contains(err.Error(), "keybindings.navigation.down") { t.Fatalf("empty binding error = %v", err) } } func TestValidateConfigRejectsKeyConflictsInTheSameContext(t *testing.T) { tests := []struct { name string change func(*Config) context string actions []string }{ { name: "thread navigation and reply", change: func(config *Config) { config.KeyBindings.Threads.Reply = []string{"j"} }, context: "review threads", actions: []string{"down", "reply"}, }, { name: "vim motion and cancel", change: func(config *Config) { config.KeyBindings.Input.Cancel = []string{"b"} }, context: "Vim Normal mode", actions: []string{"cancel", "word_backward"}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { config := defaultConfig() test.change(&config) err := validateConfig(config) if err == nil || !strings.Contains(err.Error(), test.context) { t.Fatalf("conflict error = %v", err) } for _, action := range test.actions { if !strings.Contains(err.Error(), action) { t.Fatalf("conflict error does not name %q: %v", action, err) } } }) } } func TestLoadConfigRejectsUnknownSettings(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") if err := os.WriteFile(path, []byte("refesh_interval = \"10s\"\n"), 0o600); err != nil { t.Fatal(err) } _, err := loadConfig(path, true) if err == nil || !strings.Contains(err.Error(), "refesh_interval") { t.Fatalf("unknown setting error = %v", err) } } func TestConfigPathHonorsEnvironmentOverride(t *testing.T) { t.Setenv("DIPLE_CONFIG", "/tmp/custom-diple.toml") t.Setenv("GH_THREADS_CONFIG", "") got, err := configPath() if err != nil { t.Fatal(err) } if got != "/tmp/custom-diple.toml" { t.Fatalf("config path = %q", got) } } func TestConfigPathHonorsLegacyEnvironmentOverride(t *testing.T) { t.Setenv("DIPLE_CONFIG", "") t.Setenv("GH_THREADS_CONFIG", "/tmp/legacy-gh-threads.toml") got, err := configPath() if err != nil { t.Fatal(err) } if got != "/tmp/legacy-gh-threads.toml" { t.Fatalf("legacy config path = %q", got) } } func TestExistingConfigPathFallsBackToDotConfig(t *testing.T) { root := t.TempDir() preferred := filepath.Join(root, "Library", "Application Support", "diple", "config.toml") fallback := filepath.Join(root, ".config", "diple", "config.toml") if err := os.MkdirAll(filepath.Dir(fallback), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(fallback, []byte("theme = \"dark\"\n"), 0o600); err != nil { t.Fatal(err) } if got := existingConfigPath(preferred, fallback); got != fallback { t.Fatalf("config path = %q, want fallback %q", got, fallback) } if err := os.MkdirAll(filepath.Dir(preferred), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(preferred, []byte("theme = \"light\"\n"), 0o600); err != nil { t.Fatal(err) } if got := existingConfigPath(preferred, fallback); got != preferred { t.Fatalf("config path = %q, want preferred %q", got, preferred) } } func TestFirstExistingConfigPathFallsBackToLegacyName(t *testing.T) { root := t.TempDir() current := filepath.Join(root, "diple", "config.toml") legacy := filepath.Join(root, "gh-threads", "config.toml") if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(legacy, []byte("theme = \"dark\"\n"), 0o600); err != nil { t.Fatal(err) } if got := firstExistingOrDefault(current, legacy); got != legacy { t.Fatalf("migration config path = %q, want %q", got, legacy) } } func TestConfigPathHonorsXDGConfigHome(t *testing.T) { t.Setenv("GH_THREADS_CONFIG", "") t.Setenv("DIPLE_CONFIG", "") t.Setenv("XDG_CONFIG_HOME", "/tmp/xdg-config") got, err := configPath() if err != nil { t.Fatal(err) } want := "/tmp/xdg-config/diple/config.toml" if got != want { t.Fatalf("config path = %q, want %q", got, want) } } func TestValidateConfigRejectsUnsafeAnimationRate(t *testing.T) { config := defaultConfig() config.Paths.ScrollInterval.Duration = 10 * time.Millisecond if err := validateConfig(config); err == nil { t.Fatal("unsafe path scrolling interval was accepted") } } func TestValidateConfigRejectsInvalidThreadOrdering(t *testing.T) { config := defaultConfig() config.Threads.StatusOrder = []string{"unresolved", "resolved", "resolved"} if err := validateConfig(config); err == nil { t.Fatal("duplicate thread status was accepted") } config = defaultConfig() config.Threads.WithinStatus = "author" if err := validateConfig(config); err == nil { t.Fatal("unknown within-status ordering was accepted") } } func TestValidateConfigRejectsInvalidDashboardMode(t *testing.T) { config := defaultConfig() config.Display.DashboardMode = "sometimes" if err := validateConfig(config); err == nil { t.Fatal("unknown dashboard mode was accepted") } } func TestValidateConfigRejectsInvalidViewerLabel(t *testing.T) { config := defaultConfig() config.Display.ViewerLabel = "me" if err := validateConfig(config); err == nil { t.Fatal("unknown viewer label was accepted") } } func TestValidateConfigRejectsInvalidEditorMode(t *testing.T) { config := defaultConfig() config.Editing.Mode = "emacs" if err := validateConfig(config); err == nil { t.Fatal("unknown editor mode was accepted") } }