package config import ( "fmt" "os" "sort" "strings" "gopkg.in/yaml.v3" ) // Load reads the YAML config file from disk, decodes it into Config, // and then validates it. // // Important behaviors: // - Uses yaml.Decoder with KnownFields(true) to catch typos like "srouces:". // - Returns a validation error that (usually) contains multiple issues at once. func Load(path string) (*Config, error) { if strings.TrimSpace(path) == "" { path = "config.yml" } raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("config.Load: read %q: %w", path, err) } var cfg Config dec := yaml.NewDecoder(strings.NewReader(string(raw))) dec.KnownFields(true) // strict mode for struct fields if err := dec.Decode(&cfg); err != nil { return nil, fmt.Errorf("config.Load: parse YAML %q: %w", path, err) } // Optional: ensure there isn't a second YAML document accidentally appended. var extra any if err := dec.Decode(&extra); err == nil { return nil, fmt.Errorf("config.Load: %q contains multiple YAML documents; expected exactly one", path) } if err := cfg.Validate(); err != nil { return nil, err } return &cfg, nil } // Validate checks whether the config is internally consistent and safe to run. // // This is intentionally DOMAIN-AGNOSTIC validation: // - required fields are present // - names are unique // - durations are > 0 // - routes reference defined sinks // // We DO NOT enforce domain-specific constraints like "allowed kinds" or // "NWS requires a user-agent". Those belong in the domain module (weatherfeeder). func (c *Config) Validate() error { var m multiError // ---------- sources ---------- if len(c.Sources) == 0 { m.Add(fieldErr("sources", "must contain at least one source")) } else { seen := map[string]bool{} for i, s := range c.Sources { path := fmt.Sprintf("sources[%d]", i) // Name if strings.TrimSpace(s.Name) == "" { m.Add(fieldErr(path+".name", "is required")) } else { if seen[s.Name] { m.Add(fieldErr(path+".name", fmt.Sprintf("duplicate source name %q (source names must be unique)", s.Name))) } seen[s.Name] = true } // Driver if strings.TrimSpace(s.Driver) == "" { m.Add(fieldErr(path+".driver", "is required (e.g. openmeteo_observation, rss_feed, ...)")) } // Every if s.Every.Duration <= 0 { m.Add(fieldErr(path+".every", "must be a positive duration (e.g. 15m, 1m, 30s)")) } // Kind (optional but if present must be non-empty after trimming) if s.Kind != "" && strings.TrimSpace(s.Kind) == "" { m.Add(fieldErr(path+".kind", "cannot be blank (omit it entirely, or provide a non-empty string)")) } // Params can be nil; that's fine. } } // ---------- sinks ---------- sinkNames := map[string]bool{} if len(c.Sinks) == 0 { m.Add(fieldErr("sinks", "must contain at least one sink")) } else { for i, s := range c.Sinks { path := fmt.Sprintf("sinks[%d]", i) if strings.TrimSpace(s.Name) == "" { m.Add(fieldErr(path+".name", "is required")) } else { if sinkNames[s.Name] { m.Add(fieldErr(path+".name", fmt.Sprintf("duplicate sink name %q (sink names must be unique)", s.Name))) } sinkNames[s.Name] = true } if strings.TrimSpace(s.Driver) == "" { m.Add(fieldErr(path+".driver", "is required (stdout|file|postgres|rabbitmq|...)")) } // Params can be nil; that's fine. } } // ---------- routes ---------- // Routes are optional. If provided, validate shape + references. for i, r := range c.Routes { path := fmt.Sprintf("routes[%d]", i) if strings.TrimSpace(r.Sink) == "" { m.Add(fieldErr(path+".sink", "is required")) } else if !sinkNames[r.Sink] { m.Add(fieldErr(path+".sink", fmt.Sprintf("references unknown sink %q (define it under sinks:)", r.Sink))) } if len(r.Kinds) == 0 { // You could relax this later (e.g. empty == "all kinds"), but for now // keeping it strict prevents accidental "route does nothing". m.Add(fieldErr(path+".kinds", "must contain at least one kind")) } else { for j, k := range r.Kinds { kpath := fmt.Sprintf("%s.kinds[%d]", path, j) if strings.TrimSpace(k) == "" { m.Add(fieldErr(kpath, "kind cannot be empty")) } } } } return m.Err() } // ---- error helpers ---- // fieldErr produces consistent "path: message" errors. func fieldErr(path, msg string) error { return fmt.Errorf("%s: %s", path, msg) } // multiError collects many errors and returns them as one error. // This makes config iteration much nicer: you fix several things per run. type multiError struct { errs []error } func (m *multiError) Add(err error) { if err == nil { return } m.errs = append(m.errs, err) } func (m *multiError) Err() error { if len(m.errs) == 0 { return nil } // Sort for stable output (useful in tests and when iterating). sort.Slice(m.errs, func(i, j int) bool { return m.errs[i].Error() < m.errs[j].Error() }) return m } func (m *multiError) Error() string { var b strings.Builder b.WriteString("config validation failed:\n") for _, e := range m.errs { b.WriteString(" - ") b.WriteString(e.Error()) b.WriteString("\n") } return strings.TrimRight(b.String(), "\n") }