diff --git a/docs/config.md b/docs/config.md index 8127e37..1684825 100644 --- a/docs/config.md +++ b/docs/config.md @@ -53,7 +53,11 @@ All omitted fields use their built-in defaults. | `format` | `json` | Required and must be `json`. | Timezone values may be IANA names, configured aliases such as `Chicago` and -`Stl`, US timezone abbreviations, or UTC offsets such as `-5` and `+09:30`. +`Stl`, US timezone abbreviations, or signed UTC offsets such as `-5`, `+0930`, +and `+09:30`. Numeric offsets require a sign, one or two hour digits, and an +optional two-digit minute component with or without a colon. Hours must be +from `00` through `23`, minutes from `00` through `59`, so the largest accepted +offset magnitude is `23:59`. ### `location` @@ -161,9 +165,10 @@ output selection, and failure handling. `missing_source.default` defaults to `warn` and accepts `error`, `warn`, or `none`. `missing_source.sources` optionally overrides that policy by source. -Hourly forecast data is required for generated reports. Supported optional -source keys are `observations`, `current`, `narrative`, `alerts`, `discussion`, -`weather_story`, and `spc_convective_outlooks`. +Hourly forecast data is required for generated reports and cannot have a +source-specific policy. Supported optional source keys are `observations`, +`current`, `narrative`, `alerts`, `discussion`, `weather_story`, and +`spc_convective_outlooks`; any other key is rejected. ### `promptkit` @@ -218,8 +223,9 @@ derivation. Every item needs `name`, `start`, and `end`; start and end use `reports` optionally overrides a report's ordered deterministic modules and Distributor path templates. Omit a report entry to retain its defaults. -Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`; hyphens -and underscores are equivalent. +Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`. Keys are +trimmed, case-folded to lowercase, and normalize hyphens to underscores before +lookup. Each report entry can contain: diff --git a/internal/adapters/weatherapi/client.go b/internal/adapters/weatherapi/client.go index d3f7edb..8286641 100644 --- a/internal/adapters/weatherapi/client.go +++ b/internal/adapters/weatherapi/client.go @@ -23,7 +23,7 @@ import ( const ( convectiveOutlooksEndpoint = "/outlooks/convective" - sourceSPCConvectiveOutlooks = "spc_convective_outlooks" + sourceSPCConvectiveOutlooks = config.MissingSourceSPCConvectiveOutlooks defaultWarmupEndpoint = "/conditions/current" defaultWarmupAttempts = 3 @@ -168,7 +168,7 @@ type fetchedSource struct { func (b *bundleBuilder) fetchObservation(ctx context.Context) error { var observation weatherdata.Observation fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{ - name: "observations", + name: config.MissingSourceObservations, endpoint: "/observations", query: queryOptions{precision: true}, missingMessage: "observation data is missing", @@ -186,7 +186,7 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error { func (b *bundleBuilder) fetchCurrent(ctx context.Context) error { var current weatherdata.Current fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{ - name: "current", + name: config.MissingSourceCurrent, endpoint: "/conditions/current", query: queryOptions{precision: true}, missingMessage: "current conditions data is missing", @@ -227,7 +227,7 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error { func (b *bundleBuilder) fetchNarrative(ctx context.Context) error { var narrative weatherdata.ForecastRun fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{ - name: "narrative", + name: config.MissingSourceNarrative, endpoint: "/forecast/narrative", query: queryOptions{precision: true, timezone: true}, missingMessage: "narrative forecast data is missing", @@ -244,7 +244,7 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error { } func (b *bundleBuilder) fetchAlerts(ctx context.Context) error { - raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{allowNull: true}) + raw, source, err := b.client.fetch(ctx, config.MissingSourceAlerts, "/alerts/active", queryOptions{allowNull: true}) if err != nil { return err } @@ -258,7 +258,7 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error { } var alerts weatherdata.AlertRun if err := decodeSource(raw, &alerts); err != nil { - return b.handleMalformed(&source, err, sourceRequest{name: "alerts"}) + return b.handleMalformed(&source, err, sourceRequest{name: config.MissingSourceAlerts}) } alerts.Raw = append(json.RawMessage(nil), raw...) if alerts.AsOf != nil { @@ -272,7 +272,7 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error { func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error { var discussion weatherdata.Discussion fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{ - name: "discussion", + name: config.MissingSourceDiscussion, endpoint: "/discussion", query: queryOptions{timezone: true}, missingMessage: "forecast discussion data is missing", @@ -291,7 +291,7 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error { func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error { var story weatherdata.WeatherStory fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{ - name: "weather_story", + name: config.MissingSourceWeatherStory, endpoint: "/weatherstories/latest", query: queryOptions{omitUnits: true}, missingMessage: "NWS weather story data is missing", diff --git a/internal/config/config.go b/internal/config/config.go index 6c4fced..192ad07 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,33 @@ const ( NotifyFailureError NotifyFailurePolicy = "error" ) +const ( + MissingSourceObservations = "observations" + MissingSourceCurrent = "current" + MissingSourceNarrative = "narrative" + MissingSourceAlerts = "alerts" + MissingSourceDiscussion = "discussion" + MissingSourceWeatherStory = "weather_story" + MissingSourceSPCConvectiveOutlooks = "spc_convective_outlooks" +) + +var supportedMissingSources = map[string]struct{}{ + MissingSourceObservations: {}, + MissingSourceCurrent: {}, + MissingSourceNarrative: {}, + MissingSourceAlerts: {}, + MissingSourceDiscussion: {}, + MissingSourceWeatherStory: {}, + MissingSourceSPCConvectiveOutlooks: {}, +} + +// IsSupportedMissingSource reports whether source accepts a missing-source +// policy override. Required sources, including hourly, are not configurable. +func IsSupportedMissingSource(source string) bool { + _, ok := supportedMissingSources[source] + return ok +} + type Config struct { WeatherAPI WeatherAPIConfig `yaml:"weather_api"` Location LocationConfig `yaml:"location"` @@ -246,7 +273,11 @@ func (c *ReportDistributorConfig) UnmarshalYAML(value *yaml.Node) error { } func (c ReportDistributorConfig) PathTemplatesSet() bool { - return c.pathTemplatesSet + return c.pathTemplatesSet || c.PathTemplates != nil +} + +func (c ReportConfig) deterministicModulesConfigured() bool { + return c.deterministicModulesSet || c.DeterministicModules != nil } func (m *ModuleConfigItem) UnmarshalYAML(value *yaml.Node) error { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9160d8b..a4acfb6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1066,6 +1066,44 @@ func TestInvalidConfigProducesActionableError(t *testing.T) { } } +func TestMissingSourcePoliciesOnlyAllowSupportedOptionalSources(t *testing.T) { + for _, source := range []string{ + MissingSourceObservations, + MissingSourceCurrent, + MissingSourceNarrative, + MissingSourceAlerts, + MissingSourceDiscussion, + MissingSourceWeatherStory, + MissingSourceSPCConvectiveOutlooks, + } { + t.Run("supported_"+source, func(t *testing.T) { + cfg := Defaults() + cfg.MissingSource.Sources = map[string]MissingSourcePolicy{source: MissingSourceNone} + if err := Validate(cfg); err != nil { + t.Fatalf("Validate() error = %v", err) + } + loaded, err := LoadFile(writeConfig(t, "missing_source:\n sources:\n "+source+": none\n")) + if err != nil { + t.Fatalf("LoadFile() error = %v", err) + } + if loaded.MissingSource.Sources[source] != MissingSourceNone { + t.Fatalf("loaded source policy = %q, want none", loaded.MissingSource.Sources[source]) + } + }) + } + + for _, source := range []string{"alert", "unknown", "hourly", ""} { + t.Run("unsupported_"+source, func(t *testing.T) { + cfg := Defaults() + cfg.MissingSource.Sources = map[string]MissingSourcePolicy{source: MissingSourceWarn} + err := Validate(cfg) + if err == nil || !strings.Contains(err.Error(), "missing_source.sources") { + t.Fatalf("Validate() error = %v, want unsupported source error", err) + } + }) + } +} + func TestLoadAppliesOverrides(t *testing.T) { cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30"}) if err != nil { diff --git a/internal/config/overrides_external_test.go b/internal/config/overrides_external_test.go new file mode 100644 index 0000000..8441be6 --- /dev/null +++ b/internal/config/overrides_external_test.go @@ -0,0 +1,75 @@ +package config_test + +import ( + "strings" + "testing" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/config" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" +) + +func TestConstructedReportOverridesUseExportedFields(t *testing.T) { + cfg := config.Defaults() + cfg.Reports = map[string]config.ReportConfig{ + "daily": { + DeterministicModules: []config.ModuleConfigItem{ + {ID: module.Metadata}, + {ID: module.AreaForecastDiscussion}, + }, + Distributor: config.ReportDistributorConfig{ + PathTemplates: []string{"daily/{valid_start_date}/index.md"}, + }, + }, + } + + if err := config.Validate(cfg); err != nil { + t.Fatalf("Validate() error = %v", err) + } + modules, err := cfg.ReportModuleOverrides() + if err != nil { + t.Fatalf("ReportModuleOverrides() error = %v", err) + } + if got := modules[report.Daily]; len(got) != 2 || got[0].ID != module.Metadata || got[1].ID != module.AreaForecastDiscussion { + t.Fatalf("module override = %#v, want exported daily modules", got) + } + paths, err := cfg.ReportDistributorPathOverrides() + if err != nil { + t.Fatalf("ReportDistributorPathOverrides() error = %v", err) + } + if got := paths[report.Daily]; len(got) != 1 || got[0] != "daily/{valid_start_date}/index.md" { + t.Fatalf("path override = %#v, want exported daily path", got) + } +} + +func TestConstructedExplicitEmptyReportOverridesAreRejected(t *testing.T) { + tests := []struct { + name string + report config.ReportConfig + wantErr string + }{ + { + name: "modules", + report: config.ReportConfig{DeterministicModules: []config.ModuleConfigItem{}}, + wantErr: "reports.daily.deterministic_modules must contain at least one entry", + }, + { + name: "paths", + report: config.ReportConfig{Distributor: config.ReportDistributorConfig{ + PathTemplates: []string{}, + }}, + wantErr: "reports.daily.distributor.path_templates must contain at least one entry", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := config.Defaults() + cfg.Reports = map[string]config.ReportConfig{"daily": tt.report} + err := config.Validate(cfg) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Validate() error = %v, want %q", err, tt.wantErr) + } + }) + } +} diff --git a/internal/config/reports.go b/internal/config/reports.go index bac707a..c2de24d 100644 --- a/internal/config/reports.go +++ b/internal/config/reports.go @@ -72,9 +72,12 @@ func traverseReportModules(cfg *Config, opts reportModuleTraversalOptions) (map[ if _, err := reportRegistry.Lookup(reportID); err != nil { return nil, fmt.Errorf("reports.%s: %w", key, err) } - if !reportCfg.deterministicModulesSet { + if !reportCfg.deterministicModulesConfigured() { continue } + if len(reportCfg.DeterministicModules) == 0 { + return nil, fmt.Errorf("reports.%s.deterministic_modules must contain at least one entry", key) + } items, normalized, err := moduleItemsFromConfig(moduleRegistry, key, reportCfg.DeterministicModules, opts.normalizeOptions) if err != nil { return nil, err @@ -110,7 +113,7 @@ func traverseReportDistributorPathOverrides(cfg Config) (map[report.ID][]string, if _, err := reportRegistry.Lookup(reportID); err != nil { return nil, fmt.Errorf("reports.%s: %w", key, err) } - if !reportCfg.Distributor.pathTemplatesSet { + if !reportCfg.Distributor.PathTemplatesSet() { continue } if err := validateReportDistributorPathTemplates(key, reportID, reportCfg.Distributor.PathTemplates); err != nil { diff --git a/internal/config/validate.go b/internal/config/validate.go index 2364ffb..244f635 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -53,6 +53,9 @@ func Validate(cfg Config) error { if strings.TrimSpace(source) == "" { return fmt.Errorf("missing_source.sources contains an empty source name") } + if !IsSupportedMissingSource(source) { + return fmt.Errorf("missing_source.sources.%s is not a supported optional source", source) + } if err := validatePolicy("missing_source.sources."+source, policy); err != nil { return err }