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"` Display DisplayConfig `toml:"display"` Paths PathConfig `toml:"paths"` } type DisplayConfig struct { FoldResolved bool `toml:"fold_resolved"` ThreadListWidthPercent int `toml:"thread_list_width_percent"` } type PathConfig struct { Scroll bool `toml:"scroll"` ScrollInterval configDuration `toml:"scroll_interval"` } func defaultConfig() Config { return Config{ Theme: "dark", RefreshInterval: configDuration{10 * time.Second}, Limit: 50, Endpoint: "https://api.github.com/graphql", Display: DisplayConfig{ FoldResolved: true, ThreadListWidthPercent: 33, }, Paths: PathConfig{ Scroll: false, ScrollInterval: configDuration{350 * time.Millisecond}, }, } } func configPath() (string, error) { if path := os.Getenv("GH_THREADS_CONFIG"); path != "" { return path, nil } if base := os.Getenv("XDG_CONFIG_HOME"); base != "" { return 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, "gh-threads", "config.toml") home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("find home directory: %w", err) } fallback := filepath.Join(home, ".config", "gh-threads", "config.toml") return existingConfigPath(preferred, fallback), nil } func existingConfigPath(preferred, fallback string) string { if _, err := os.Stat(preferred); err == nil || !errors.Is(err, os.ErrNotExist) { return preferred } if _, err := os.Stat(fallback); err == nil { return fallback } 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 { switch config.Theme { case "dark", "light": default: return fmt.Errorf("theme must be dark or light") } if config.RefreshInterval.Duration < 2*time.Second { return fmt.Errorf("refresh_interval must be at least 2s") } if config.Limit < 1 || config.Limit > 100 { return fmt.Errorf("limit must be between 1 and 100") } 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") } if config.ShowAll && config.Repository == "" { return fmt.Errorf("show_all requires repository") } return nil }