diff --git a/internal/app/app.go b/internal/app/app.go index 65d3e60..a7976a8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -934,7 +934,7 @@ func buildNotificationRequest(cfg config.Config, resolved report.Resolved, repor if err != nil { return NotificationRequest{}, err } - bundlePaths, err := config.RenderDistributorReportPaths(cfg.Notify.Distributor.ReportPathTemplates, values) + bundlePaths, err := config.RenderDistributorReportPaths("notify.distributor.report_path_templates", cfg.Notify.Distributor.ReportPathTemplates, values) if err != nil { return NotificationRequest{}, err } @@ -964,6 +964,9 @@ func distributorTemplateValuesForReport(cfg config.Config, resolved report.Resol if err := addDistributorValidPeriodValues(&values, resolved.ValidPeriod, cfg.WeatherAPI.Timezone); err != nil { return config.DistributorTemplateValues{}, err } + if resolved.Definition.ID == report.Storm { + values.StormID = values.ValidStartStamp + "-" + values.ValidEndStamp + } return values, nil } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 80a7574..49a9aae 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -2214,6 +2214,55 @@ func TestResolveGenerateStorm(t *testing.T) { } } +func TestDistributorTemplateValuesDeriveStormID(t *testing.T) { + cfg := config.Defaults() + cfg.WeatherAPI.Timezone = "America/Chicago" + now := mustParse("2026-05-29T12:00:00-05:00") + start := mustParse("2026-05-29T18:00:00-05:00") + end := mustParse("2026-05-30T06:00:00-05:00") + + resolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportStorm, + StormStart: start, + StormEnd: end, + }, now) + if err != nil { + t.Fatalf("ResolveGenerate() error = %v", err) + } + values, err := distributorTemplateValuesForReport(cfg, resolved, "run", "") + if err != nil { + t.Fatalf("distributorTemplateValuesForReport() error = %v", err) + } + if values.StormID != "2026-05-29T1800-2026-05-30T0600" { + t.Fatalf("StormID = %q, want storm valid-period stamp", values.StormID) + } +} + +func TestDistributorTemplateValuesLeaveStormIDEmptyForOtherReports(t *testing.T) { + cfg := config.Defaults() + cfg.WeatherAPI.Timezone = "America/Chicago" + location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone) + if err != nil { + t.Fatalf("load location: %v", err) + } + resolved, err := report.Resolve(report.Today, report.ResolveRequest{ + Now: mustParse("2026-05-29T12:00:00-05:00"), + Location: location, + }) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + + values, err := distributorTemplateValuesForReport(cfg, resolved, "run", "") + if err != nil { + t.Fatalf("distributorTemplateValuesForReport() error = %v", err) + } + if values.StormID != "" { + t.Fatalf("StormID = %q, want empty for %s", values.StormID, resolved.Definition.ID) + } +} + func TestBatchRunIDUsesUTCStartAndBatchName(t *testing.T) { tests := []struct { name string diff --git a/internal/app/batch_notification.go b/internal/app/batch_notification.go index 9182972..cdec654 100644 --- a/internal/app/batch_notification.go +++ b/internal/app/batch_notification.go @@ -161,7 +161,7 @@ func buildBatchNotificationRequest(cfg config.Config, batch BatchKind, runID str if err != nil { return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q source path %q: %w", item.ReportID, item.RunID, item.ReportPath, err) } - bundlePaths, err := config.RenderDistributorReportPaths(cfg.Notify.Distributor.ReportPathTemplates, values) + bundlePaths, err := config.RenderDistributorReportPaths("notify.distributor.report_path_templates", cfg.Notify.Distributor.ReportPathTemplates, values) if err != nil { return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q source path %q: %w", item.ReportID, item.RunID, item.ReportPath, err) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cf934d3..631c84e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1317,35 +1317,38 @@ func TestDistributorTemplateRendering(t *testing.T) { ValidEndTime: "0600", ValidStartStamp: "2026-06-07T1800", ValidEndStamp: "2026-06-08T0600", + StormID: "2026-06-07T1800-2026-06-08T0600", BundleID: "weatherreporter.home.daily", } - bundleID, err := RenderDistributorBundleID("weatherreporter.{location_id}.{report_id}", values) + bundleID, err := RenderDistributorBundleID("weatherreporter.{location_id}.{report_id}.{storm_id}", values) if err != nil { t.Fatalf("RenderDistributorBundleID() error = %v", err) } - if bundleID != "weatherreporter.home.daily" { + if bundleID != "weatherreporter.home.daily.2026-06-07T1800-2026-06-08T0600" { t.Fatalf("bundleID = %q, want rendered value", bundleID) } + values.BundleID = bundleID - pipelineID, err := RenderDistributorPipelineID("weatherreporter.{artifact_group}.{bundle_id}", values) + pipelineID, err := RenderDistributorPipelineID("weatherreporter.{artifact_group}.{storm_id}.{bundle_id}", values) if err != nil { t.Fatalf("RenderDistributorPipelineID() error = %v", err) } - if pipelineID != "weatherreporter.daily.weatherreporter.home.daily" { + if pipelineID != "weatherreporter.daily.2026-06-07T1800-2026-06-08T0600.weatherreporter.home.daily.2026-06-07T1800-2026-06-08T0600" { t.Fatalf("pipelineID = %q, want rendered pipeline ID", pipelineID) } - idempotencyKey, err := RenderDistributorIdempotencyKey("{bundle_id}.{run_id}", values) + idempotencyKey, err := RenderDistributorIdempotencyKey("{bundle_id}.{storm_id}.{run_id}", values) if err != nil { t.Fatalf("RenderDistributorIdempotencyKey() error = %v", err) } - if idempotencyKey != "weatherreporter.home.daily.20260607T120000Z" { + if idempotencyKey != "weatherreporter.home.daily.2026-06-07T1800-2026-06-08T0600.2026-06-07T1800-2026-06-08T0600.20260607T120000Z" { t.Fatalf("idempotencyKey = %q, want rendered run key", idempotencyKey) } - reportPaths, err := RenderDistributorReportPaths([]string{ + reportPaths, err := RenderDistributorReportPaths("reports.daily.distributor.path_templates", []string{ "{valid_start_date}/{artifact_group}/{valid_start_stamp}-{valid_end_stamp}-{run_id}.md", + "storm/{storm_id}/index.md", "{valid_start_date}/{artifact_group}/latest.md", }, values) if err != nil { @@ -1353,6 +1356,7 @@ func TestDistributorTemplateRendering(t *testing.T) { } wantPaths := []string{ "2026-06-07/daily/2026-06-07T1800-2026-06-08T0600-20260607T120000Z.md", + "storm/2026-06-07T1800-2026-06-08T0600/index.md", "2026-06-07/daily/latest.md", } if strings.Join(reportPaths, "\n") != strings.Join(wantPaths, "\n") { @@ -1360,6 +1364,50 @@ func TestDistributorTemplateRendering(t *testing.T) { } } +func TestDistributorReportPathRenderingUsesCallerName(t *testing.T) { + values := DistributorTemplateValues{ + BatchOutputName: "report.md", + } + tests := []struct { + name string + templates []string + wantErr string + }{ + { + name: "UnknownVariable", + templates: []string{"{unknown}.md"}, + wantErr: `report.daily.distributor_path_templates[0] contains unknown template variable "unknown"`, + }, + { + name: "InvalidPath", + templates: []string{"/{batch_output_name}"}, + wantErr: "report.daily.distributor_path_templates[0] must render a relative path", + }, + { + name: "DuplicatePath", + templates: []string{"latest.md", "latest.md"}, + wantErr: `report.daily.distributor_path_templates renders duplicate path "latest.md"`, + }, + { + name: "Empty", + templates: nil, + wantErr: "report.daily.distributor_path_templates must contain at least one entry", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := RenderDistributorReportPaths("report.daily.distributor_path_templates", tt.templates, values) + if err == nil { + t.Fatal("RenderDistributorReportPaths() error = nil, want error") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr) + } + }) + } +} + func TestDistributorBatchTemplateRendering(t *testing.T) { values := DistributorBatchTemplateValues{ LocationID: "home", @@ -1492,12 +1540,15 @@ func TestDistributorReportPathRenderingRejectsInvalidValues(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := RenderDistributorReportPaths([]string{"{batch_output_name}"}, DistributorTemplateValues{ + _, err := RenderDistributorReportPaths("report.daily.distributor_path_templates", []string{"{batch_output_name}"}, DistributorTemplateValues{ BatchOutputName: tt.batchOutputName, }) if err == nil { t.Fatal("RenderDistributorReportPaths() error = nil, want error") } + if !strings.Contains(err.Error(), "report.daily.distributor_path_templates[0]") { + t.Fatalf("error = %q, want caller path name", err.Error()) + } }) } } diff --git a/internal/config/notify_templates.go b/internal/config/notify_templates.go index 759234f..4c37eef 100644 --- a/internal/config/notify_templates.go +++ b/internal/config/notify_templates.go @@ -18,6 +18,7 @@ type DistributorTemplateValues struct { ValidEndTime string ValidStartStamp string ValidEndStamp string + StormID string BundleID string } @@ -41,6 +42,7 @@ var distributorTemplateVariables = map[string]struct{}{ "valid_end_time": {}, "valid_start_stamp": {}, "valid_end_stamp": {}, + "storm_id": {}, } var distributorIdempotencyTemplateVariables = map[string]struct{}{ @@ -55,6 +57,7 @@ var distributorIdempotencyTemplateVariables = map[string]struct{}{ "valid_end_time": {}, "valid_start_stamp": {}, "valid_end_stamp": {}, + "storm_id": {}, "bundle_id": {}, } @@ -129,23 +132,23 @@ func RenderDistributorBatchIdempotencyKey(template string, values DistributorBat return rendered, nil } -func RenderDistributorReportPaths(templates []string, values DistributorTemplateValues) ([]string, error) { +func RenderDistributorReportPaths(name string, templates []string, values DistributorTemplateValues) ([]string, error) { if len(templates) == 0 { - return nil, fmt.Errorf("notify.distributor.report_path_templates must contain at least one entry") + return nil, fmt.Errorf("%s must contain at least one entry", name) } paths := make([]string, 0, len(templates)) seen := make(map[string]struct{}, len(templates)) for i, template := range templates { - name := fmt.Sprintf("notify.distributor.report_path_templates[%d]", i) - rendered, err := renderDistributorTemplate(name, template, values, distributorTemplateVariables) + itemName := fmt.Sprintf("%s[%d]", name, i) + rendered, err := renderDistributorTemplate(itemName, template, values, distributorTemplateVariables) if err != nil { return nil, err } - if err := ValidateDistributorReportPath(name, rendered); err != nil { + if err := ValidateDistributorReportPath(itemName, rendered); err != nil { return nil, err } if _, ok := seen[rendered]; ok { - return nil, fmt.Errorf("notify.distributor.report_path_templates renders duplicate path %q", rendered) + return nil, fmt.Errorf("%s renders duplicate path %q", name, rendered) } seen[rendered] = struct{}{} paths = append(paths, rendered) @@ -243,6 +246,8 @@ func distributorTemplateValue(variable string, values DistributorTemplateValues) return values.ValidStartStamp case "valid_end_stamp": return values.ValidEndStamp + case "storm_id": + return values.StormID case "bundle_id": return values.BundleID default: diff --git a/internal/config/reports.go b/internal/config/reports.go index 305d1b3..60bf66c 100644 --- a/internal/config/reports.go +++ b/internal/config/reports.go @@ -123,26 +123,8 @@ func traverseReportDistributorPathOverrides(cfg Config) (map[report.ID][]string, 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 + _, err := RenderDistributorReportPaths(name, templates, sampleDistributorTemplateValues()) + return err } func sampleDistributorTemplateValues() DistributorTemplateValues { @@ -158,6 +140,7 @@ func sampleDistributorTemplateValues() DistributorTemplateValues { ValidEndTime: "0000", ValidStartStamp: "2026-05-29T0000", ValidEndStamp: "2026-05-30T0000", + StormID: "2026-05-29T0000-2026-05-30T0000", } } diff --git a/internal/config/validate.go b/internal/config/validate.go index 72de1a7..de80589 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -136,7 +136,7 @@ func validateDistributorNotify(cfg DistributorNotifyConfig) error { if _, err := RenderDistributorPipelineID(cfg.PipelineIDTemplate, values); err != nil { return err } - if _, err := RenderDistributorReportPaths(cfg.ReportPathTemplates, values); err != nil { + if _, err := RenderDistributorReportPaths("notify.distributor.report_path_templates", cfg.ReportPathTemplates, values); err != nil { return err } if err := validateDistributorBatchNotify(cfg.Batch); err != nil {