// Package config resolves and validates gitflow settings from CLI flags, // environment variables, and an optional YAML configuration file. // // Precedence (highest first): flags, environment variables, config file, // built-in defaults. package config import ( "errors" "fmt" "os" "strings" "time" "github.com/spf13/pflag" "github.com/spf13/viper" "gopkg.in/yaml.v3" "gitea.oblak.solutions/dimitar/gitFlow/internal/rules" ) // AIConfig holds AI agent settings. type AIConfig struct { Enabled bool `yaml:"enabled"` Provider string `yaml:"provider"` // openai, ollama, or anthropic Model string `yaml:"model"` // model name; empty lets the provider choose APIKeyEnv string `yaml:"api_key_env"` // env var holding the API key BaseURL string `yaml:"base_url"` // provider endpoint override Execute bool `yaml:"execute"` // run confirmed AI-suggested commands (experimental) } // Config is the fully resolved runtime configuration. type Config struct { Dir string `yaml:"dir"` Interval time.Duration `yaml:"interval"` Format string `yaml:"format"` Color string `yaml:"color"` Theme string `yaml:"theme"` Notify bool `yaml:"notify"` Exclude []string `yaml:"exclude,omitempty"` MaxDepth int `yaml:"max_depth"` Workers int `yaml:"workers"` Rules []rules.Rule `yaml:"rules,omitempty"` AI AIConfig `yaml:"ai"` ConfigFile string `yaml:"-"` // path of the loaded config file, if any } // flagKeys maps CLI flag names to their viper keys. var flagKeys = []struct{ flag, key string }{ {"dir", "dir"}, {"interval", "interval"}, {"format", "format"}, {"color", "color"}, {"exclude", "exclude"}, {"max-depth", "max_depth"}, {"workers", "workers"}, {"notify", "notify"}, {"theme", "theme"}, {"ai", "ai.enabled"}, {"ai-provider", "ai.provider"}, {"ai-model", "ai.model"}, {"ai-execute", "ai.execute"}, } // RegisterFlags defines every gitflow flag on f. Call Load with the same // FlagSet to resolve the effective configuration. func RegisterFlags(f *pflag.FlagSet) { f.StringP("dir", "d", ".", "parent directory to scan") f.DurationP("interval", "i", 0, "rescan interval (e.g. 30s, 5m); 0 runs once") f.StringP("format", "f", "table", "output format: table, json, or compact") f.String("color", "auto", "color output: auto, always, or never") f.StringSlice("exclude", nil, "glob patterns of directories to skip (repeatable)") f.Int("max-depth", 0, "maximum directory depth to scan (0 = unlimited)") f.Int("workers", 8, "number of concurrent git scans") f.Bool("notify", false, "send desktop notifications when watch sees changes") f.String("theme", "dark", "color theme: dark or light") f.Bool("ai", false, "enable AI suggestions") f.String("ai-provider", "openai", "AI provider: openai, ollama, or anthropic") f.String("ai-model", "gpt-4o", "AI model name") f.Bool("ai-execute", false, "run confirmed AI-suggested commands (experimental)") } // NewFlagSet returns a FlagSet with every gitflow flag registered. func NewFlagSet() *pflag.FlagSet { fs := pflag.NewFlagSet("gitflow", pflag.ContinueOnError) RegisterFlags(fs) return fs } // Load resolves the effective configuration from flags, environment, and // config file, then validates it. func Load(flags *pflag.FlagSet) (*Config, error) { v := viper.New() applyDefaults(v) v.SetEnvPrefix("GITFLOW") v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) v.AutomaticEnv() if err := readConfigFile(v); err != nil { return nil, err } bindFlags(v, flags) cfg := &Config{ Dir: v.GetString("dir"), Interval: v.GetDuration("interval"), Format: v.GetString("format"), Color: v.GetString("color"), Theme: v.GetString("theme"), Notify: v.GetBool("notify"), Exclude: v.GetStringSlice("exclude"), MaxDepth: v.GetInt("max_depth"), Workers: v.GetInt("workers"), ConfigFile: v.ConfigFileUsed(), AI: AIConfig{ Enabled: v.GetBool("ai.enabled"), Provider: v.GetString("ai.provider"), Model: v.GetString("ai.model"), APIKeyEnv: v.GetString("ai.api_key_env"), BaseURL: v.GetString("ai.base_url"), Execute: v.GetBool("ai.execute"), }, } if err := cfg.Validate(); err != nil { return nil, err } // Rules are not exposed as flags; read them from the file/env only. var rulesList []rules.Rule if err := v.UnmarshalKey("rules", &rulesList); err != nil { return nil, fmt.Errorf("config: rules: %w", err) } cfg.Rules = rulesList for _, r := range cfg.Rules { if err := r.Validate(); err != nil { return nil, err } } return cfg, nil } func applyDefaults(v *viper.Viper) { v.SetDefault("dir", ".") v.SetDefault("interval", 0) v.SetDefault("format", "table") v.SetDefault("color", "auto") v.SetDefault("exclude", []string{}) v.SetDefault("max_depth", 0) v.SetDefault("workers", 8) v.SetDefault("notify", false) v.SetDefault("theme", "dark") v.SetDefault("ai.enabled", false) v.SetDefault("ai.provider", "openai") v.SetDefault("ai.model", "gpt-4o") v.SetDefault("ai.api_key_env", "OPENAI_API_KEY") v.SetDefault("ai.base_url", "") v.SetDefault("ai.execute", false) } // readConfigFile loads ~/.gitflow.yaml (or $GITFLOW_CONFIG when set). A // missing config file is not an error; a malformed one is. func readConfigFile(v *viper.Viper) error { if path := os.Getenv("GITFLOW_CONFIG"); path != "" { v.SetConfigFile(path) } else { v.SetConfigName(".gitflow") v.SetConfigType("yaml") v.AddConfigPath("$HOME") v.AddConfigPath(".") } if err := v.ReadInConfig(); err != nil { var notFound viper.ConfigFileNotFoundError if errors.As(err, ¬Found) { return nil } return fmt.Errorf("config: %w", err) } return nil } func bindFlags(v *viper.Viper, flags *pflag.FlagSet) { for _, fk := range flagKeys { if fl := flags.Lookup(fk.flag); fl != nil { _ = v.BindPFlag(fk.key, fl) } } } // Validate rejects configuration that cannot be used. func (c *Config) Validate() error { if c.Dir == "" { return errors.New("config: dir must not be empty") } switch c.Format { case "table", "json", "compact": default: return fmt.Errorf("config: unsupported format %q (want table, json, or compact)", c.Format) } switch c.Color { case "auto", "always", "never": default: return fmt.Errorf("config: unsupported color mode %q (want auto, always, or never)", c.Color) } switch c.Theme { case "dark", "light": default: return fmt.Errorf("config: unsupported theme %q (want dark or light)", c.Theme) } if c.Interval < 0 { return errors.New("config: interval must not be negative") } if c.MaxDepth < 0 { return errors.New("config: max-depth must not be negative") } if c.Workers < 1 { return errors.New("config: workers must be at least 1") } if c.AI.Enabled { switch c.AI.Provider { case "openai", "ollama", "anthropic": default: return fmt.Errorf("config: unsupported ai provider %q (want openai, ollama, or anthropic)", c.AI.Provider) } } return nil } // Dump renders the effective configuration as human-readable YAML, with the // interval shown as a duration string. func (c *Config) Dump() (string, error) { v := map[string]any{ "config_file": c.ConfigFile, "dir": c.Dir, "interval": c.Interval.String(), "format": c.Format, "color": c.Color, "exclude": c.Exclude, "max_depth": c.MaxDepth, "workers": c.Workers, "notify": c.Notify, "theme": c.Theme, "rules": c.Rules, "ai": map[string]any{ "enabled": c.AI.Enabled, "provider": c.AI.Provider, "model": c.AI.Model, "api_key_env": c.AI.APIKeyEnv, "base_url": c.AI.BaseURL, "execute": c.AI.Execute, }, } out, err := yaml.Marshal(v) if err != nil { return "", err } return string(out), nil }