Files
diple/config.go

224 lines
6.3 KiB
Go

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"`
Threads ThreadConfig `toml:"threads"`
Cache CacheConfig `toml:"cache"`
Editing EditingConfig `toml:"editing"`
KeyBindings KeyBindings `toml:"keybindings"`
}
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"`
}
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",
Display: DisplayConfig{
FoldResolved: true,
ThreadListWidthPercent: 33,
DashboardMode: "hotkey",
CompactReviews: true,
},
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"},
KeyBindings: defaultKeyBindings(),
}
}
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", "no-color", "high-contrast":
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 > 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")
}
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
}
return nil
}
func defaultCacheDir() (string, error) {
base, err := os.UserCacheDir()
if err != nil {
return "", fmt.Errorf("find user cache directory: %w", err)
}
return 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
}