// Package config loads and validates the lethe collector configuration from a // YAML file plus LETHE_COLLECTOR_* environment overrides. // // Validation runs in fail-fast mode: any unknown YAML key, missing required // field, or out-of-range value rejects the load with a culpa-wrapped error. // The only field without a default is host — the operator must choose a stable // identity for this machine. package config import ( "errors" "io/fs" "os" "path/filepath" "strings" "time" "github.com/go-playground/validator/v10" "github.com/go-viper/mapstructure/v2" "github.com/spf13/viper" "go.bigb.es/auxilia/culpa" ) // Config is the root collector configuration. type Config struct { Host string `mapstructure:"host" validate:"required"` ServerURL string `mapstructure:"server_url" validate:"required,url"` StateDir string `mapstructure:"state_dir"` HTTP HTTPConfig `mapstructure:"http"` Outbox OutboxConfig `mapstructure:"outbox"` Sources []SourceConfig `mapstructure:"sources" validate:"required,min=1,dive"` Log LogConfig `mapstructure:"log"` } // HTTPConfig tunes the outbound POST client. type HTTPConfig struct { Timeout time.Duration `mapstructure:"timeout" validate:"gt=0"` RetryMax int `mapstructure:"retry_max" validate:"gte=0"` } // OutboxConfig caps the local safety-net buffer. type OutboxConfig struct { MaxBytes int64 `mapstructure:"max_bytes" validate:"gt=0"` } // SourceConfig describes one tool's source root and polling behaviour. type SourceConfig struct { Tool string `mapstructure:"tool" validate:"required"` Path string `mapstructure:"path" validate:"required"` PollInterval time.Duration `mapstructure:"poll_interval" validate:"gt=0"` BatchMaxLines int `mapstructure:"batch_max_lines" validate:"gt=0"` BatchMaxBytes int64 `mapstructure:"batch_max_bytes" validate:"gt=0"` } // LogConfig selects log level and formatter. type LogConfig struct { Level string `mapstructure:"level" validate:"required,oneof=debug info warn error"` Format string `mapstructure:"format" validate:"required,oneof=tint json human"` } // Load reads YAML from path, applies env overrides, fills documented defaults, // expands ~ in paths, and validates. Errors carry a culpa code. func Load(path string) (*Config, error) { v := viper.New() v.SetConfigFile(path) v.SetEnvPrefix("LETHE_COLLECTOR") v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) v.AutomaticEnv() registerDefaults(v) if err := v.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); ok { return nil, culpa.WithCode(culpa.Wrap(err, "config file not found"), "CONFIG_NOT_FOUND") } if isFileMissing(err) { return nil, culpa.WithCode(culpa.Wrap(err, "config file not found"), "CONFIG_NOT_FOUND") } return nil, culpa.WithCode(culpa.Wrap(err, "parse config"), "CONFIG_PARSE") } var cfg Config if err := v.UnmarshalExact( &cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToSliceHookFunc(","), )), func(c *mapstructure.DecoderConfig) { c.ErrorUnused = true }, ); err != nil { return nil, culpa.WithCode(culpa.Wrap(err, "decode config"), "CONFIG_PARSE") } applySourceDefaults(&cfg) expandTildes(&cfg) val := validator.New(validator.WithRequiredStructEnabled()) if err := val.Struct(&cfg); err != nil { return nil, culpa.WithCode(culpa.Wrap(err, "validate config"), "CONFIG_VALIDATE") } return &cfg, nil } // MustLoad calls Load and panics on error. func MustLoad(path string) *Config { cfg, err := Load(path) if err != nil { panic(err) } return cfg } func registerDefaults(v *viper.Viper) { home, _ := os.UserHomeDir() v.SetDefault("state_dir", filepath.Join(home, ".local/state/lethe")) v.SetDefault("http.timeout", 30*time.Second) v.SetDefault("http.retry_max", 5) v.SetDefault("outbox.max_bytes", int64(104857600)) // 100 MiB v.SetDefault("log.level", "info") v.SetDefault("log.format", "human") } func applySourceDefaults(cfg *Config) { for i := range cfg.Sources { if cfg.Sources[i].PollInterval == 0 { cfg.Sources[i].PollInterval = 30 * time.Second } if cfg.Sources[i].BatchMaxLines == 0 { cfg.Sources[i].BatchMaxLines = 500 } if cfg.Sources[i].BatchMaxBytes == 0 { cfg.Sources[i].BatchMaxBytes = 8388608 // 8 MiB } } } func expandTildes(cfg *Config) { cfg.StateDir = expandTilde(cfg.StateDir) for i := range cfg.Sources { cfg.Sources[i].Path = expandTilde(cfg.Sources[i].Path) } } func expandTilde(path string) string { if path == "~" || strings.HasPrefix(path, "~/") { home, err := os.UserHomeDir() if err != nil { return path } if path == "~" { return home } return filepath.Join(home, path[2:]) } return path } func isFileMissing(err error) bool { var pe *fs.PathError if errors.As(err, &pe) { return errors.Is(pe.Err, fs.ErrNotExist) } return errors.Is(err, fs.ErrNotExist) }