// 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"`
RemoteUserHeader string `mapstructure:"remote_user_header"`
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)
}