diff --git a/internal/config/config.go b/internal/config/config.go index 34d9ec1..be5177b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -113,10 +113,16 @@ type RecentChangeConfig struct { } type ReportConfig struct { - DeterministicModules []ModuleConfigItem `yaml:"deterministic_modules"` + DeterministicModules []ModuleConfigItem `yaml:"deterministic_modules"` + Distributor ReportDistributorConfig `yaml:"distributor"` deterministicModulesSet bool } +type ReportDistributorConfig struct { + PathTemplates []string `yaml:"path_templates"` + pathTemplatesSet bool +} + type ModuleConfigItem struct { ID module.ID `yaml:"id"` Options any `yaml:"options,omitempty"` @@ -135,6 +141,10 @@ func (c *ReportConfig) UnmarshalYAML(value *yaml.Node) error { return err } c.deterministicModulesSet = true + case "distributor": + if err := node.Decode(&c.Distributor); err != nil { + return err + } default: return fmt.Errorf("unknown report entry field %q", key) } @@ -142,6 +152,30 @@ func (c *ReportConfig) UnmarshalYAML(value *yaml.Node) error { return nil } +func (c *ReportDistributorConfig) UnmarshalYAML(value *yaml.Node) error { + if value.Kind != yaml.MappingNode { + return fmt.Errorf("report distributor entry must be a mapping") + } + for i := 0; i < len(value.Content); i += 2 { + key := value.Content[i].Value + node := value.Content[i+1] + switch key { + case "path_templates": + if err := node.Decode(&c.PathTemplates); err != nil { + return err + } + c.pathTemplatesSet = true + default: + return fmt.Errorf("unknown report distributor field %q", key) + } + } + return nil +} + +func (c ReportDistributorConfig) PathTemplatesSet() bool { + return c.pathTemplatesSet +} + func (m *ModuleConfigItem) UnmarshalYAML(value *yaml.Node) error { switch value.Kind { case yaml.ScalarNode: diff --git a/internal/config/config_test.go b/internal/config/config_test.go index be49208..cf934d3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -10,6 +10,7 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gopkg.in/yaml.v3" ) func TestDefaults(t *testing.T) { @@ -313,6 +314,102 @@ reports: } } +func TestLoadReportDistributorPathOverrides(t *testing.T) { + path := writeConfig(t, ` +reports: + daily: + distributor: + path_templates: + - "daily/{valid_start_date}/{run_id}.md" + - "daily/{valid_start_date}/index.md" + today: + deterministic_modules: + - metadata +`) + + cfg, err := LoadFile(path) + if err != nil { + t.Fatalf("LoadFile() error = %v", err) + } + daily := cfg.Reports["daily"].Distributor + if !daily.PathTemplatesSet() { + t.Fatal("daily distributor path_templates set = false, want true") + } + want := []string{ + "daily/{valid_start_date}/{run_id}.md", + "daily/{valid_start_date}/index.md", + } + if !reflect.DeepEqual(daily.PathTemplates, want) { + t.Fatalf("daily path templates = %#v, want %#v", daily.PathTemplates, want) + } + if cfg.Reports["today"].Distributor.PathTemplatesSet() { + t.Fatal("today distributor path_templates set = true, want false") + } + + overrides, err := cfg.ReportDistributorPathOverrides() + if err != nil { + t.Fatalf("ReportDistributorPathOverrides() error = %v", err) + } + if !reflect.DeepEqual(overrides[report.Daily], want) { + t.Fatalf("daily distributor override = %#v, want %#v", overrides[report.Daily], want) + } + if _, ok := overrides[report.Today]; ok { + t.Fatalf("today distributor override = %#v, want omitted override absent", overrides[report.Today]) + } +} + +func TestReportDistributorPathTemplatesSetTracksExplicitEmptyList(t *testing.T) { + var cfg Config + if err := yaml.Unmarshal([]byte(` +reports: + daily: + distributor: + path_templates: [] + today: + distributor: {} +`), &cfg); err != nil { + t.Fatalf("yaml.Unmarshal() error = %v", err) + } + if !cfg.Reports["daily"].Distributor.PathTemplatesSet() { + t.Fatal("daily distributor path_templates set = false, want true") + } + if len(cfg.Reports["daily"].Distributor.PathTemplates) != 0 { + t.Fatalf("daily path templates = %#v, want empty explicit list", cfg.Reports["daily"].Distributor.PathTemplates) + } + if cfg.Reports["today"].Distributor.PathTemplatesSet() { + t.Fatal("today distributor path_templates set = true, want false") + } +} + +func TestLoadReportDistributorPathOverrideAliases(t *testing.T) { + path := writeConfig(t, ` +reports: + three-day-outlook: + distributor: + path_templates: + - "three-day/{valid_start_date}/index.md" + weekend_outlook: + distributor: + path_templates: + - "weekend/{valid_start_date}/index.md" +`) + + cfg, err := LoadFile(path) + if err != nil { + t.Fatalf("LoadFile() error = %v", err) + } + overrides, err := cfg.ReportDistributorPathOverrides() + if err != nil { + t.Fatalf("ReportDistributorPathOverrides() error = %v", err) + } + if !reflect.DeepEqual(overrides[report.ThreeDay], []string{"three-day/{valid_start_date}/index.md"}) { + t.Fatalf("three-day distributor override = %#v, want alias override", overrides[report.ThreeDay]) + } + if !reflect.DeepEqual(overrides[report.Weekend], []string{"weekend/{valid_start_date}/index.md"}) { + t.Fatalf("weekend distributor override = %#v, want alias override", overrides[report.Weekend]) + } +} + func TestValidateReportModuleKeysWithoutMutatingOptions(t *testing.T) { cfg := Defaults() rawOptions := map[string]any{ @@ -565,6 +662,168 @@ reports: } } +func TestReportDistributorPathOverrideValidation(t *testing.T) { + tests := []struct { + name string + yaml string + wantErr string + }{ + { + name: "UnknownReportField", + yaml: ` +reports: + daily: + distributor_paths: + - latest.md +`, + wantErr: `unknown report entry field "distributor_paths"`, + }, + { + name: "UnknownDistributorField", + yaml: ` +reports: + daily: + distributor: + paths: + - latest.md +`, + wantErr: `unknown report distributor field "paths"`, + }, + { + name: "DuplicateReportAlias", + yaml: ` +reports: + three-day: + distributor: + path_templates: + - "three-day/{valid_start_date}/index.md" + three_day: + distributor: + path_templates: + - "three-day/latest.md" +`, + wantErr: "duplicates report override", + }, + { + name: "UnknownTemplateVariable", + yaml: ` +reports: + daily: + distributor: + path_templates: + - "{unknown}.md" +`, + wantErr: `reports.daily.distributor.path_templates[0] contains unknown template variable "unknown"`, + }, + { + name: "AbsolutePath", + yaml: ` +reports: + daily: + distributor: + path_templates: + - "/daily.md" +`, + wantErr: "reports.daily.distributor.path_templates[0] must render a relative path", + }, + { + name: "ParentSegment", + yaml: ` +reports: + daily: + distributor: + path_templates: + - "daily/../index.md" +`, + wantErr: "reports.daily.distributor.path_templates[0] must not render . or .. path segments", + }, + { + name: "Manifest", + yaml: ` +reports: + daily: + distributor: + path_templates: + - "daily/manifest.json" +`, + wantErr: `reports.daily.distributor.path_templates[0] must not render reserved path segment "manifest.json"`, + }, + { + name: "DuplicateRenderedPath", + yaml: ` +reports: + daily: + distributor: + path_templates: + - "daily/index.md" + - "daily/index.md" +`, + wantErr: `reports.daily.distributor.path_templates renders duplicate path "daily/index.md"`, + }, + { + name: "EmptyOverrideList", + yaml: ` +reports: + daily: + distributor: + path_templates: [] +`, + wantErr: "reports.daily.distributor.path_templates must contain at least one entry", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := LoadFile(writeConfig(t, tt.yaml)) + if err == nil { + t.Fatal("LoadFile() error = nil, want validation error") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr) + } + }) + } +} + +func TestReportDistributorPathOverridesConsistentForLoadedAndConstructedConfig(t *testing.T) { + yaml := ` +reports: + daily: + distributor: + path_templates: + - "daily/{valid_start_date}/index.md" +` + reports := map[string]ReportConfig{ + "daily": { + Distributor: ReportDistributorConfig{ + PathTemplates: []string{"daily/{valid_start_date}/index.md"}, + pathTemplatesSet: true, + }, + }, + } + + cfg, err := LoadFile(writeConfig(t, yaml)) + if err != nil { + t.Fatalf("LoadFile() error = %v", err) + } + loaded, err := cfg.ReportDistributorPathOverrides() + if err != nil { + t.Fatalf("loaded ReportDistributorPathOverrides() error = %v", err) + } + cfg = Defaults() + cfg.Reports = reports + if err := Validate(cfg); err != nil { + t.Fatalf("Validate() error = %v", err) + } + constructed, err := cfg.ReportDistributorPathOverrides() + if err != nil { + t.Fatalf("constructed ReportDistributorPathOverrides() error = %v", err) + } + if !reflect.DeepEqual(loaded, constructed) { + t.Fatalf("loaded overrides = %#v, constructed = %#v", loaded, constructed) + } +} + func TestReportModuleValidationConsistentForLoadedAndConstructedConfig(t *testing.T) { tests := []struct { name string diff --git a/internal/config/reports.go b/internal/config/reports.go index 84ce37f..305d1b3 100644 --- a/internal/config/reports.go +++ b/internal/config/reports.go @@ -17,6 +17,10 @@ func (cfg Config) ReportModuleOverrides() (map[report.ID][]module.ConfigItem, er }) } +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{} @@ -35,6 +39,11 @@ func validateReportModules(cfg Config) error { return err } +func validateReportDistributorPathOverrides(cfg Config) error { + _, err := traverseReportDistributorPathOverrides(cfg) + return err +} + type reportModuleTraversalOptions struct { normalizeOptions bool updateConfig bool @@ -82,6 +91,76 @@ func traverseReportModules(cfg *Config, opts reportModuleTraversalOptions) (map[ 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, reportCfg.Distributor.PathTemplates); err != nil { + return nil, err + } + overrides[reportID] = append([]string(nil), reportCfg.Distributor.PathTemplates...) + } + return overrides, nil +} + +func validateReportDistributorPathTemplates(reportKey string, templates []string) error { + name := fmt.Sprintf("reports.%s.distributor.path_templates", reportKey) + if len(templates) == 0 { + return fmt.Errorf("%s must contain at least one entry", name) + } + values := sampleDistributorTemplateValues() + seen := map[string]struct{}{} + for i, template := range templates { + itemName := fmt.Sprintf("%s[%d]", name, i) + rendered, err := renderDistributorTemplate(itemName, template, values, distributorTemplateVariables) + if err != nil { + return err + } + if err := ValidateDistributorReportPath(itemName, rendered); err != nil { + return err + } + if _, ok := seen[rendered]; ok { + return fmt.Errorf("%s renders duplicate path %q", name, rendered) + } + seen[rendered] = struct{}{} + } + return nil +} + +func sampleDistributorTemplateValues() DistributorTemplateValues { + 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", + } +} + 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...) diff --git a/internal/config/validate.go b/internal/config/validate.go index a38b516..72de1a7 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -12,6 +12,9 @@ func Validate(cfg Config) error { if err := validateReportModules(cfg); err != nil { return err } + if err := validateReportDistributorPathOverrides(cfg); err != nil { + return err + } if cfg.WeatherAPI.BaseURL != "" { parsed, err := url.Parse(cfg.WeatherAPI.BaseURL) if err != nil || parsed.Scheme == "" || parsed.Host == "" { @@ -124,19 +127,7 @@ func validateDistributorNotify(cfg DistributorNotifyConfig) error { if len(cfg.ReportPathTemplates) == 0 { return fmt.Errorf("notify.distributor.report_path_templates must contain at least one entry when enabled") } - values := 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", - } + values := sampleDistributorTemplateValues() bundleID, err := RenderDistributorBundleID(cfg.BundleIDTemplate, values) if err != nil { return err