package main import ( "errors" "fmt" "os" "path/filepath" "strings" "time" "github.com/BurntSushi/toml" ) type configDuration struct { time.Duration } func (d *configDuration) UnmarshalText(text []byte) error { value, err := time.ParseDuration(string(text)) if err != nil { return err } d.Duration = value return nil } type Config struct { Theme string `toml:"theme"` RefreshInterval configDuration `toml:"refresh_interval"` Repository string `toml:"repository"` ShowAll bool `toml:"show_all"` Limit int `toml:"limit"` Endpoint string `toml:"endpoint"` Mascot bool `toml:"mascot"` MascotExpressive bool `toml:"mascot_expressive"` MascotAnimated bool `toml:"mascot_animated"` Display DisplayConfig `toml:"display"` Paths PathConfig `toml:"paths"` Threads ThreadConfig `toml:"threads"` Cache CacheConfig `toml:"cache"` Editing EditingConfig `toml:"editing"` CustomTheme CustomThemeConfig `toml:"custom_theme"` KeyBindings KeyBindings `toml:"keybindings"` AI AIConfig `toml:"ai"` } type CustomThemeConfig struct { Base string `toml:"base"` Mode string `toml:"mode"` Title string `toml:"title"` Dim string `toml:"dim"` Text string `toml:"text"` ActiveForeground string `toml:"active_foreground"` ActiveBackground string `toml:"active_background"` Success string `toml:"success"` Warning string `toml:"warning"` Error string `toml:"error"` EditorForeground string `toml:"editor_foreground"` EditorBackground string `toml:"editor_background"` PaneInactive string `toml:"pane_inactive"` PaneActive string `toml:"pane_active"` Quote string `toml:"quote"` SelectionBackground string `toml:"selection_background"` SuggestionRemoveBackground string `toml:"suggestion_remove_background"` SuggestionAddBackground string `toml:"suggestion_add_background"` ChangedRemoveBackground string `toml:"changed_remove_background"` ChangedAddBackground string `toml:"changed_add_background"` AuthorPalette []string `toml:"author_palette"` SyntaxTheme string `toml:"syntax_theme"` } type DisplayConfig struct { FoldResolved bool `toml:"fold_resolved"` ThreadListWidthPercent int `toml:"thread_list_width_percent"` DashboardMode string `toml:"dashboard_mode"` CompactReviews bool `toml:"compact_reviews"` ViewerLabel string `toml:"viewer_label"` } type PathConfig struct { Scroll bool `toml:"scroll"` ScrollInterval configDuration `toml:"scroll_interval"` } type ThreadConfig struct { StatusOrder []string `toml:"status_order"` WithinStatus string `toml:"within_status"` } type CacheConfig struct { Enabled bool `toml:"enabled"` MaxAge configDuration `toml:"max_age"` Directory string `toml:"directory"` MaxEntries int `toml:"max_entries"` } type EditingConfig struct { Mode string `toml:"mode"` } func defaultConfig() Config { return Config{ Theme: "dark", RefreshInterval: configDuration{10 * time.Second}, Limit: 50, Endpoint: "https://api.github.com/graphql", Mascot: false, MascotExpressive: false, MascotAnimated: false, Display: DisplayConfig{ FoldResolved: true, ThreadListWidthPercent: 33, DashboardMode: "hotkey", CompactReviews: true, ViewerLabel: "login", }, Paths: PathConfig{ Scroll: false, ScrollInterval: configDuration{350 * time.Millisecond}, }, Threads: ThreadConfig{ StatusOrder: []string{"unresolved", "outdated", "resolved"}, WithinStatus: "file", }, Cache: CacheConfig{ Enabled: true, MaxAge: configDuration{7 * 24 * time.Hour}, MaxEntries: 200, }, Editing: EditingConfig{Mode: "vim"}, AI: defaultAIConfig(), KeyBindings: defaultKeyBindings(), } } func configPath() (string, error) { if path := os.Getenv("DIPLE_CONFIG"); path != "" { return path, nil } // Preserve the old override during the rename so existing scripts do not // silently start with a fresh configuration. if path := os.Getenv("GH_THREADS_CONFIG"); path != "" { return path, nil } if base := os.Getenv("XDG_CONFIG_HOME"); base != "" { return firstExistingOrDefault( filepath.Join(base, "diple", "config.toml"), filepath.Join(base, "gh-threads", "config.toml"), ), nil } base, err := os.UserConfigDir() if err != nil { return "", fmt.Errorf("find user config directory: %w", err) } preferred := filepath.Join(base, "diple", "config.toml") home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("find home directory: %w", err) } dotConfig := filepath.Join(home, ".config", "diple", "config.toml") legacyPreferred := filepath.Join(base, "gh-threads", "config.toml") legacyDotConfig := filepath.Join(home, ".config", "gh-threads", "config.toml") return firstExistingOrDefault( preferred, dotConfig, legacyPreferred, legacyDotConfig, ), nil } func existingConfigPath(preferred, fallback string) string { return firstExistingOrDefault(preferred, fallback) } func firstExistingOrDefault(preferred string, alternatives ...string) string { for _, candidate := range append([]string{preferred}, alternatives...) { if _, err := os.Stat(candidate); err == nil || !errors.Is(err, os.ErrNotExist) { return candidate } } return preferred } func loadConfig(path string, required bool) (Config, error) { config := defaultConfig() metadata, err := toml.DecodeFile(path, &config) if err != nil { if !required && errors.Is(err, os.ErrNotExist) { return config, nil } return Config{}, fmt.Errorf("read config %s: %w", path, err) } if undecoded := metadata.Undecoded(); len(undecoded) > 0 { keys := make([]string, len(undecoded)) for i, key := range undecoded { keys[i] = key.String() } return Config{}, fmt.Errorf("config %s contains unknown settings: %s", path, strings.Join(keys, ", ")) } return config, nil } func validateConfig(config Config) error { if _, err := resolveThemePalette(config.Theme, config.CustomTheme); err != nil { return err } if config.RefreshInterval.Duration < 2*time.Second { return fmt.Errorf("refresh_interval must be at least 2s") } if config.Limit < 1 || config.Limit > 1000 { return fmt.Errorf("limit must be between 1 and 1000") } if config.Paths.ScrollInterval.Duration < 50*time.Millisecond { return fmt.Errorf("paths.scroll_interval must be at least 50ms") } if config.Display.ThreadListWidthPercent < 20 || config.Display.ThreadListWidthPercent > 60 { return fmt.Errorf("display.thread_list_width_percent must be between 20 and 60") } switch config.Display.DashboardMode { case "intermediate", "hotkey": default: return fmt.Errorf("display.dashboard_mode must be intermediate or hotkey") } switch config.Display.ViewerLabel { case "login", "you": default: return fmt.Errorf("display.viewer_label must be login or you") } if err := validateThreadStatusOrder(config.Threads.StatusOrder); err != nil { return err } switch config.Threads.WithinStatus { case "file", "timestamp": default: return fmt.Errorf("threads.within_status must be file or timestamp") } if config.ShowAll && config.Repository == "" { return fmt.Errorf("show_all requires repository") } if config.Cache.MaxAge.Duration < 0 { return fmt.Errorf("cache.max_age must not be negative") } if config.Cache.MaxEntries < 10 || config.Cache.MaxEntries > 10000 { return fmt.Errorf("cache.max_entries must be between 10 and 10000") } switch config.Editing.Mode { case "standard", "vim": default: return fmt.Errorf("editing.mode must be standard or vim") } if err := validateKeyBindings(config.KeyBindings); err != nil { return err } if err := validateAIConfig(config.AI); err != nil { return err } return nil } func defaultCacheDir() (string, error) { base, err := os.UserCacheDir() if err != nil { return "", fmt.Errorf("find user cache directory: %w", err) } return firstExistingOrDefault( filepath.Join(base, "diple"), filepath.Join(base, "gh-threads"), ), nil } func validateThreadStatusOrder(order []string) error { if len(order) != 3 { return fmt.Errorf("threads.status_order must contain unresolved, outdated, and resolved exactly once") } seen := map[string]bool{} for _, status := range order { switch status { case "unresolved", "outdated", "resolved": if seen[status] { return fmt.Errorf("threads.status_order contains %q more than once", status) } seen[status] = true default: return fmt.Errorf("threads.status_order contains unknown status %q", status) } } return nil }