package config import ( "fmt" "os" "reflect" "strings" "time" "github.com/pelletier/go-toml/v2" ) type Config struct { Server ServerConfig `toml:"server"` Gitea GiteaConfig `toml:"gitea"` Log LogConfig `toml:"log"` } type ServerConfig struct { ListenAddr string `toml:"listen_addr"` } type GiteaConfig struct { URL string `toml:"url"` Org string `toml:"org"` WebhookSecret string `toml:"webhook_secret"` PollInterval Duration `toml:"poll_interval"` FetchTimeout Duration `toml:"fetch_timeout"` MaxConcurrency int `toml:"max_concurrency"` ExcludePaths ExcludePaths `toml:"exclude_paths"` ExcludeRepos ExcludeRepos `toml:"exclude_repos"` } type ExcludePaths struct { Patterns []string `toml:"patterns"` } type ExcludeRepos struct { Names []string `toml:"names"` } type LogConfig struct { Level string `toml:"level"` } // Duration wraps time.Duration for TOML string parsing. type Duration struct { time.Duration } func (d *Duration) UnmarshalText(text []byte) error { var err error d.Duration, err = time.ParseDuration(string(text)) return err } func (d Duration) MarshalText() ([]byte, error) { return []byte(d.String()), nil } func Load(path string) (*Config, error) { data, err := os.ReadFile(path) // #nosec G304 -- config path is operator-controlled if err != nil { return nil, fmt.Errorf("read config: %w", err) } cfg := &Config{ Server: ServerConfig{ ListenAddr: ":8080", }, Gitea: GiteaConfig{ URL: "https://git.wntrmute.dev", Org: "mc", PollInterval: Duration{15 * time.Minute}, FetchTimeout: Duration{30 * time.Second}, MaxConcurrency: 4, ExcludePaths: ExcludePaths{ Patterns: []string{"vendor/", ".claude/", "node_modules/", ".junie/"}, }, }, Log: LogConfig{ Level: "info", }, } if err := toml.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } applyEnvOverrides(cfg) if err := cfg.validate(); err != nil { return nil, fmt.Errorf("validate config: %w", err) } return cfg, nil } func (c *Config) validate() error { if c.Server.ListenAddr == "" { return fmt.Errorf("server.listen_addr is required") } if c.Gitea.URL == "" { return fmt.Errorf("gitea.url is required") } if c.Gitea.Org == "" { return fmt.Errorf("gitea.org is required") } if c.Gitea.MaxConcurrency < 1 { return fmt.Errorf("gitea.max_concurrency must be >= 1") } return nil } // applyEnvOverrides checks for MCDOC_* environment variables and applies // them to the config. Also checks $PORT for MCP agent port assignment. func applyEnvOverrides(cfg *Config) { if port := os.Getenv("PORT"); port != "" { cfg.Server.ListenAddr = ":" + port } applyEnvToStruct("MCDOC", reflect.ValueOf(cfg).Elem()) } func applyEnvToStruct(prefix string, v reflect.Value) { t := v.Type() for i := range t.NumField() { field := t.Field(i) fv := v.Field(i) tag := field.Tag.Get("toml") if tag == "" || tag == "-" { continue } envKey := prefix + "_" + strings.ToUpper(tag) if fv.Kind() == reflect.Struct && field.Type != reflect.TypeOf(Duration{}) { applyEnvToStruct(envKey, fv) continue } envVal := os.Getenv(envKey) if envVal == "" { continue } switch fv.Kind() { case reflect.String: fv.SetString(envVal) case reflect.Int: var n int if _, err := fmt.Sscanf(envVal, "%d", &n); err == nil { fv.SetInt(int64(n)) } } if field.Type == reflect.TypeOf(Duration{}) { if d, err := time.ParseDuration(envVal); err == nil { fv.Set(reflect.ValueOf(Duration{d})) } } } }