// Package config loads and validates YAML configuration for raven. package config import ( "fmt" "os" "regexp" "time" "raven/internal/filter" "raven/internal/imap" "raven/internal/intake" "raven/internal/llm" "raven/internal/smtp" "raven/internal/tool" "gopkg.in/yaml.v3" ) // Config holds all application configuration sections. type Config struct { // Concurrency is the number of concurrent answer workers. // Defaults to 1. Concurrency int `yaml:"concurrency"` // ShutdownTimeout is the maximum time to wait for graceful shutdown. // Defaults to "30s". ShutdownTimeout string `yaml:"shutdown_timeout"` // shutdownTimeout is the parsed duration, set during Load. shutdownTimeout time.Duration `yaml:"-"` // Filters configures server-side IMAP search criteria. Filters filter.Filters `yaml:"filters"` // IMAP configures the mail server connection for reading messages. IMAP imap.Config `yaml:"imap"` // Intake configures the message retrieval strategy (IDLE or poll). Intake intake.Config `yaml:"intake"` // LLM configures the language model client. LLM llm.Config `yaml:"llm"` // SMTP configures the mail server connection for sending responses. // Credentials default to IMAP values if not specified. SMTP smtp.SMTP `yaml:"smtp"` // Tools defines external commands available to the LLM. Tools []tool.Tool `yaml:"tools"` } // ApplyDefaults sets default values for optional configuration fields. // Called automatically by Load before validation. func (cfg *Config) ApplyDefaults() { if cfg.Concurrency == 0 { cfg.Concurrency = 1 } if cfg.ShutdownTimeout == "" { cfg.ShutdownTimeout = "30s" } // Intake if cfg.Intake.Mode == "" { cfg.Intake.Mode = intake.ModePoll } if cfg.Intake.PollInterval == "" { cfg.Intake.PollInterval = "30s" } // SMTP defaults to IMAP credentials. if cfg.SMTP.User == "" { cfg.SMTP.User = cfg.IMAP.User } if cfg.SMTP.Password == "" { cfg.SMTP.Password = cfg.IMAP.Password } if cfg.SMTP.From == "" { cfg.SMTP.From = cfg.IMAP.User } } // GetShutdownTimeout returns the parsed shutdown timeout duration. func (c *Config) GetShutdownTimeout() time.Duration { return c.shutdownTimeout } // Validate checks that all required fields are present and valid. // Delegates to each subsection's Validate method. func (c *Config) Validate() error { if err := c.IMAP.Validate(); err != nil { return fmt.Errorf("invalid imap config: %v", err) } if err := c.Intake.Validate(); err != nil { return fmt.Errorf("invalid intake config: %v", err) } if err := c.LLM.Validate(); err != nil { return fmt.Errorf("invalid llm config: %v", err) } if err := c.SMTP.Validate(); err != nil { return fmt.Errorf("invalid smtp config: %v", err) } if _, err := time.ParseDuration(c.ShutdownTimeout); err != nil { return fmt.Errorf( "invalid shutdown_timeout %q: %w", c.ShutdownTimeout, err, ) } for i, t := range c.Tools { if err := t.Validate(); err != nil { return fmt.Errorf("tools[%d] invalid: %v", i, err) } } return nil } // Load reads a YAML configuration file, expands environment variables, // applies defaults, and validates the result. Returns an error if the // file cannot be read, parsed, or contains invalid configuration. func Load(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read file: %w", err) } // Expand environment variables. // Unset variables are replaced with empty strings. re := regexp.MustCompile(`\$\{([^}]+)\}`) expanded := re.ReplaceAllStringFunc( string(data), func(match string) string { // Extract variable name from ${VAR}. v := match[2 : len(match)-1] return os.Getenv(v) }, ) var cfg Config if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } cfg.ApplyDefaults() if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("invalid config: %v", err) } d, err := time.ParseDuration(cfg.ShutdownTimeout) if err != nil { return nil, fmt.Errorf( "parse shutdown timeout: %v", err, ) } cfg.shutdownTimeout = d return &cfg, nil }