package config import ( "bytes" "fmt" "reflect" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gopkg.in/yaml.v3" ) func (cfg Config) ReportModuleOverrides() (map[report.ID][]module.ConfigItem, error) { return traverseReportModules(&cfg, reportModuleTraversalOptions{ normalizeOptions: true, }) } func (cfg Config) ReportDistributorPathOverrides() (map[report.ID][]string, error) { return traverseReportDistributorPathOverrides(cfg) } func normalizeReportModules(cfg *Config) error { if cfg.Reports == nil { cfg.Reports = map[string]ReportConfig{} } _, err := traverseReportModules(cfg, reportModuleTraversalOptions{ normalizeOptions: true, updateConfig: true, }) return err } func validateReportModules(cfg Config) error { _, err := traverseReportModules(&cfg, reportModuleTraversalOptions{ normalizeOptions: true, }) return err } func validateReportDistributorPathOverrides(cfg Config) error { _, err := traverseReportDistributorPathOverrides(cfg) return err } type reportModuleTraversalOptions struct { normalizeOptions bool updateConfig bool } func traverseReportModules(cfg *Config, opts reportModuleTraversalOptions) (map[report.ID][]module.ConfigItem, error) { overrides := map[report.ID][]module.ConfigItem{} if cfg.Reports == nil { return overrides, nil } moduleRegistry, err := briefing.DefaultModuleRegistry() if err != nil { return nil, fmt.Errorf("initialize module registry: %w", err) } reportRegistry := report.DefaultRegistry() seenReports := map[report.ID]string{} for key, reportCfg := range cfg.Reports { reportID, err := report.IDForConfigKey(key) if err != nil { return nil, fmt.Errorf("reports.%s: %w", key, err) } if previous, ok := seenReports[reportID]; ok { return nil, fmt.Errorf("reports.%s duplicates report override %q", key, previous) } seenReports[reportID] = key if _, err := reportRegistry.Lookup(reportID); err != nil { return nil, fmt.Errorf("reports.%s: %w", key, err) } if !reportCfg.deterministicModulesSet { continue } items, normalized, err := moduleItemsFromConfig(moduleRegistry, key, reportCfg.DeterministicModules, opts.normalizeOptions) if err != nil { return nil, err } if err := moduleRegistry.ValidateComposition(reportID, items); err != nil { return nil, fmt.Errorf("reports.%s.deterministic_modules: %w", key, err) } overrides[reportID] = items if opts.updateConfig { reportCfg.DeterministicModules = normalized cfg.Reports[key] = reportCfg } } return overrides, nil } func traverseReportDistributorPathOverrides(cfg Config) (map[report.ID][]string, error) { overrides := map[report.ID][]string{} if cfg.Reports == nil { return overrides, nil } reportRegistry := report.DefaultRegistry() seenReports := map[report.ID]string{} for key, reportCfg := range cfg.Reports { reportID, err := report.IDForConfigKey(key) if err != nil { return nil, fmt.Errorf("reports.%s: %w", key, err) } if previous, ok := seenReports[reportID]; ok { return nil, fmt.Errorf("reports.%s duplicates report override %q", key, previous) } seenReports[reportID] = key if _, err := reportRegistry.Lookup(reportID); err != nil { return nil, fmt.Errorf("reports.%s: %w", key, err) } if !reportCfg.Distributor.pathTemplatesSet { continue } if err := validateReportDistributorPathTemplates(key, reportID, reportCfg.Distributor.PathTemplates); err != nil { return nil, err } overrides[reportID] = append([]string(nil), reportCfg.Distributor.PathTemplates...) } return overrides, nil } func validateReportDistributorPathTemplates(reportKey string, reportID report.ID, templates []string) error { name := fmt.Sprintf("reports.%s.distributor.path_templates", reportKey) _, err := RenderDistributorReportPaths(name, templates, sampleDistributorTemplateValuesForReport(reportID)) return err } func sampleDistributorTemplateValues() DistributorTemplateValues { return sampleDistributorTemplateValuesForReport(report.Storm) } func sampleDistributorTemplateValuesForReport(reportID report.ID) DistributorTemplateValues { stormID := "" if reportID == report.Storm { stormID = "2026-05-29T0000-2026-05-30T0000" } return DistributorTemplateValues{ LocationID: "location", ReportID: "report", RunID: "run", ArtifactGroup: "artifact", BatchOutputName: "report.md", ValidStartDate: "2026-05-29", ValidEndDate: "2026-05-30", ValidStartTime: "0000", ValidEndTime: "0000", ValidStartStamp: "2026-05-29T0000", ValidEndStamp: "2026-05-30T0000", StormID: stormID, } } func moduleItemsFromConfig(registry briefing.ModuleRegistry, reportKey string, items []ModuleConfigItem, normalizeOptions bool) ([]module.ConfigItem, []ModuleConfigItem, error) { out := make([]module.ConfigItem, 0, len(items)) normalizedItems := append([]ModuleConfigItem(nil), items...) for i, item := range items { options := item.Options if normalizeOptions { var err error options, err = normalizeModuleOptions(registry, item.ID, item.Options) if err != nil { return nil, nil, fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", reportKey, i, err) } normalizedItems[i].Options = options } out = append(out, module.ConfigItem{ID: item.ID, Options: options}) } return out, normalizedItems, nil } func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw any) (any, error) { if raw == nil { return nil, nil } definition, err := registry.Lookup(id) if err != nil { return nil, err } if definition.DefaultOptions == nil { return nil, fmt.Errorf("module %q does not accept options", id) } if err := definition.ValidateOptions(raw); err == nil { return raw, nil } optionType := reflect.TypeOf(definition.DefaultOptions) normalized, err := decodeKnownOptions(raw, optionType) if err != nil { return nil, fmt.Errorf("module %q options are invalid: %w", id, err) } return normalized, nil } func decodeKnownOptions(raw any, optionType reflect.Type) (any, error) { data, err := yaml.Marshal(raw) if err != nil { return nil, err } target := reflect.New(optionType) decoder := yaml.NewDecoder(bytes.NewReader(data)) decoder.KnownFields(true) if err := decoder.Decode(target.Interface()); err != nil { return nil, err } return target.Elem().Interface(), nil }