From 40639309b1936f98b6c0d99b984f50e7b23fbe31 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 9 Jun 2026 20:51:22 +0000 Subject: [PATCH] Add configurable report module composition --- docs/config.md | 35 ++++++ examples/config.yml | 2 + internal/app/app.go | 20 +++- internal/app/app_test.go | 40 +++++++ internal/briefing/base_modules.go | 45 ++++++-- internal/briefing/base_modules_test.go | 20 ++++ internal/config/config.go | 95 ++++++++++++++-- internal/config/config_test.go | 144 +++++++++++++++++++++++++ internal/config/defaults.go | 1 + internal/config/load.go | 7 ++ internal/config/reports.go | 125 +++++++++++++++++++++ internal/config/validate.go | 3 + internal/module/module.go | 4 +- internal/report/period_test.go | 41 +++++++ internal/report/registry.go | 23 ++++ 15 files changed, 586 insertions(+), 19 deletions(-) create mode 100644 internal/config/reports.go diff --git a/docs/config.md b/docs/config.md index 493b321..bc7ffbe 100644 --- a/docs/config.md +++ b/docs/config.md @@ -174,6 +174,41 @@ midday, afternoon, and evening. Recent Changes are added to prompt input when a prior comparable briefing snapshot exists and a threshold is crossed. +### `reports` + +`reports` optionally overrides the ordered deterministic modules declared by +built-in report definitions. Omit a report entry to use its built-in module +order. + +Supported report keys are `daily`, `tomorrow`, `three_day`, `weekend`, and +`storm`. Canonical report IDs such as `daily_today` and `daily_tomorrow` are +also accepted. + +Each report entry supports: + +- `deterministic_modules`: ordered module list. Entries may be string module + IDs or objects with `id` and optional `options`. + +Example: + +```yaml +reports: + daily: + deterministic_modules: + - metadata + - current_conditions + - id: area_forecast_discussion + options: + sections: + - short_term +``` + +Unknown reports, unknown modules, duplicate modules, incompatible report/module +combinations, duplicate stanza names, and invalid options fail config loading. +`area_forecast_discussion.options.sections` may contain `product`, +`key_messages`, `short_term`, and `long_term`. Empty or omitted `sections` +includes all available AFD sections. + ## Secrets Configuration files should not contain raw secrets. Use `secrets.directory` to diff --git a/examples/config.yml b/examples/config.yml index 5fcfd8e..18ed659 100644 --- a/examples/config.yml +++ b/examples/config.yml @@ -66,3 +66,5 @@ recent_change: precip_probability_points: 20 wind_gust_miles_per_hour: 10 precip_timing_shift_minutes: 120 + +reports: {} diff --git a/internal/app/app.go b/internal/app/app.go index 4ef45d7..6d718ff 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -343,7 +343,11 @@ func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error if err != nil { return report.Resolved{}, err } - return report.DefaultRegistry().Resolve(id, report.ResolveRequest{ + registry, err := reportRegistry(req.Config) + if err != nil { + return report.Resolved{}, err + } + return registry.Resolve(id, report.ResolveRequest{ Now: now, Location: location, Date: req.Date, @@ -361,12 +365,24 @@ func ResolveBatch(req BatchRequest, now time.Time) ([]report.Resolved, error) { if err != nil { return nil, err } - return report.DefaultRegistry().BatchReports(batch, report.ResolveRequest{ + registry, err := reportRegistry(req.Config) + if err != nil { + return nil, err + } + return registry.BatchReports(batch, report.ResolveRequest{ Now: now, Location: location, }) } +func reportRegistry(cfg config.Config) (report.Registry, error) { + registry, err := report.DefaultRegistry().WithModuleOverrides(cfg.ReportModuleOverrides()) + if err != nil { + return report.Registry{}, err + } + return registry, nil +} + func reportIDForCommand(kind ReportKind) (report.ID, error) { switch kind { case ReportDaily: diff --git a/internal/app/app_test.go b/internal/app/app_test.go index c6fa42a..865ef04 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -16,6 +16,7 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/state" ) @@ -1154,6 +1155,45 @@ func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) { } } +func TestResolveGenerateUsesConfiguredReportModules(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yml") + if err := os.WriteFile(path, []byte(` +reports: + tomorrow: + deterministic_modules: + - metadata + - alert_digest + - tomorrow_planning +`), 0o600); err != nil { + t.Fatalf("write config fixture: %v", err) + } + cfg, err := config.LoadFile(path) + if err != nil { + t.Fatalf("LoadFile() error = %v", err) + } + now := mustParse("2026-05-29T18:00:00-05:00") + + resolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportTomorrow, + }, now) + if err != nil { + t.Fatalf("ResolveGenerate() error = %v", err) + } + want := []module.ID{module.Metadata, module.AlertDigest, module.TomorrowPlanning} + if got := resolved.Definition.ModuleIDs(); strings.Join(moduleIDsForTest(got), ",") != strings.Join(moduleIDsForTest(want), ",") { + t.Fatalf("ModuleIDs() = %#v, want %#v", got, want) + } +} + +func moduleIDsForTest(ids []module.ID) []string { + out := make([]string, 0, len(ids)) + for _, id := range ids { + out = append(out, string(id)) + } + return out +} + func dailyBundleServer(t *testing.T) *httptest.Server { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/briefing/base_modules.go b/internal/briefing/base_modules.go index 99faa4c..76dc955 100644 --- a/internal/briefing/base_modules.go +++ b/internal/briefing/base_modules.go @@ -1,6 +1,7 @@ package briefing import ( + "fmt" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/facts" @@ -134,19 +135,30 @@ func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) { return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil } -func buildAreaForecastDiscussionModule(ctx ModuleContext, _ any) (*module.Output, error) { +func buildAreaForecastDiscussionModule(ctx ModuleContext, options any) (*module.Output, error) { discussion := ctx.Collected.Discussion if discussion == nil { return nil, nil } - value := AreaForecastDiscussionModule{ - Product: discussion.Product, - KeyMessages: append([]string(nil), discussion.KeyMessages...), + opts, ok := options.(module.AreaForecastDiscussionOptions) + if !ok { + return nil, fmt.Errorf("area forecast discussion options have type %T", options) } - if discussion.ShortTerm != nil { + sections, err := areaForecastDiscussionSections(opts) + if err != nil { + return nil, err + } + value := AreaForecastDiscussionModule{} + if sections["product"] { + value.Product = discussion.Product + } + if sections["key_messages"] { + value.KeyMessages = append([]string(nil), discussion.KeyMessages...) + } + if sections["short_term"] && discussion.ShortTerm != nil { value.ShortTerm = discussion.ShortTerm.Text } - if discussion.LongTerm != nil { + if sections["long_term"] && discussion.LongTerm != nil { value.LongTerm = discussion.LongTerm.Text } if value.Product == "" && len(value.KeyMessages) == 0 && value.ShortTerm == "" && value.LongTerm == "" { @@ -155,6 +167,27 @@ func buildAreaForecastDiscussionModule(ctx ModuleContext, _ any) (*module.Output return &module.Output{ID: module.AreaForecastDiscussion, StanzaName: "area_forecast_discussion", Value: value}, nil } +func areaForecastDiscussionSections(options module.AreaForecastDiscussionOptions) (map[string]bool, error) { + if len(options.Sections) == 0 { + return map[string]bool{ + "product": true, + "key_messages": true, + "short_term": true, + "long_term": true, + }, nil + } + sections := map[string]bool{} + for _, section := range options.Sections { + switch section { + case "product", "key_messages", "short_term", "long_term": + sections[section] = true + default: + return nil, fmt.Errorf("area forecast discussion section %q is not supported", section) + } + } + return sections, nil +} + func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) { story := ctx.Collected.WeatherStory if story == nil { diff --git a/internal/briefing/base_modules_test.go b/internal/briefing/base_modules_test.go index 9031991..94f6b55 100644 --- a/internal/briefing/base_modules_test.go +++ b/internal/briefing/base_modules_test.go @@ -174,6 +174,26 @@ func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) { } } +func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := testModuleContext() + + output, err := registry.BuildModule(ctx, module.ConfigItem{ + ID: module.AreaForecastDiscussion, + Options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}, + }) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + afd := moduleValue[AreaForecastDiscussionModule](t, output) + if afd.ShortTerm != "Showers increase this afternoon." { + t.Fatalf("ShortTerm = %q, want selected short term section", afd.ShortTerm) + } + if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.LongTerm != "" { + t.Fatalf("AFD = %#v, want only short_term section", afd) + } +} + func testModuleContext() ModuleContext { generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00") definition := report.DefaultRegistry().MustLookup(report.DailyToday) diff --git a/internal/config/config.go b/internal/config/config.go index 21b9775..207d151 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,7 +2,13 @@ // precedence, and validation. package config -import "time" +import ( + "fmt" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gopkg.in/yaml.v3" +) type MissingSourcePolicy string type NotifyFailurePolicy string @@ -16,15 +22,16 @@ const ( ) type Config struct { - WeatherAPI WeatherAPIConfig `yaml:"weather_api"` - Location LocationConfig `yaml:"location"` - Secrets SecretsConfig `yaml:"secrets"` - Notify NotifyConfig `yaml:"notify"` - MissingSource MissingSourceConfig `yaml:"missing_source"` - Scriptorium ScriptoriumConfig `yaml:"scriptorium"` - Workspace WorkspaceConfig `yaml:"workspace"` - Dayparts []DaypartConfig `yaml:"dayparts"` - RecentChange RecentChangeConfig `yaml:"recent_change"` + WeatherAPI WeatherAPIConfig `yaml:"weather_api"` + Location LocationConfig `yaml:"location"` + Secrets SecretsConfig `yaml:"secrets"` + Notify NotifyConfig `yaml:"notify"` + MissingSource MissingSourceConfig `yaml:"missing_source"` + Scriptorium ScriptoriumConfig `yaml:"scriptorium"` + Workspace WorkspaceConfig `yaml:"workspace"` + Dayparts []DaypartConfig `yaml:"dayparts"` + RecentChange RecentChangeConfig `yaml:"recent_change"` + Reports map[string]ReportConfig `yaml:"reports"` } type WeatherAPIConfig struct { @@ -96,3 +103,71 @@ type RecentChangeConfig struct { WindGustMilesPerHour int `yaml:"wind_gust_miles_per_hour"` PrecipTimingShiftMinutes int `yaml:"precip_timing_shift_minutes"` } + +type ReportConfig struct { + DeterministicModules []ModuleConfigItem `yaml:"deterministic_modules"` + deterministicModulesSet bool +} + +type ModuleConfigItem struct { + ID module.ID `yaml:"id"` + Options any `yaml:"options,omitempty"` +} + +func (c *ReportConfig) UnmarshalYAML(value *yaml.Node) error { + if value.Kind != yaml.MappingNode { + return fmt.Errorf("report 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 "deterministic_modules": + if err := node.Decode(&c.DeterministicModules); err != nil { + return err + } + c.deterministicModulesSet = true + default: + return fmt.Errorf("unknown report entry field %q", key) + } + } + return nil +} + +func (m *ModuleConfigItem) UnmarshalYAML(value *yaml.Node) error { + switch value.Kind { + case yaml.ScalarNode: + if value.Value == "" { + return fmt.Errorf("module id is required") + } + m.ID = module.ID(value.Value) + return nil + case yaml.MappingNode: + var sawID bool + for i := 0; i < len(value.Content); i += 2 { + key := value.Content[i].Value + node := value.Content[i+1] + switch key { + case "id": + if err := node.Decode(&m.ID); err != nil { + return err + } + sawID = true + case "options": + var options any + if err := node.Decode(&options); err != nil { + return err + } + m.Options = options + default: + return fmt.Errorf("unknown module entry field %q", key) + } + } + if !sawID || m.ID == "" { + return fmt.Errorf("module id is required") + } + return nil + default: + return fmt.Errorf("module entry must be a string or mapping") + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index fedc487..5cc5fd4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -6,6 +6,9 @@ import ( "strings" "testing" "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" ) func TestDefaults(t *testing.T) { @@ -116,6 +119,138 @@ func TestLoadMinimalExampleConfig(t *testing.T) { } } +func TestLoadReportModuleOverrides(t *testing.T) { + path := writeConfig(t, ` +reports: + daily: + deterministic_modules: + - metadata + - current_conditions + - id: area_forecast_discussion + options: + sections: + - short_term +`) + + cfg, err := LoadFile(path) + if err != nil { + t.Fatalf("LoadFile() error = %v", err) + } + overrides := cfg.ReportModuleOverrides() + items := overrides[report.DailyToday] + if len(items) != 3 { + t.Fatalf("daily override length = %d, want 3", len(items)) + } + if items[0].ID != module.Metadata || items[1].ID != module.CurrentConditions || items[2].ID != module.AreaForecastDiscussion { + t.Fatalf("daily override = %#v, want configured module order", items) + } + options, ok := items[2].Options.(module.AreaForecastDiscussionOptions) + if !ok { + t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", items[2].Options) + } + if strings.Join(options.Sections, ",") != "short_term" { + t.Fatalf("AFD sections = %#v, want short_term", options.Sections) + } +} + +func TestReportModuleOverrideValidation(t *testing.T) { + tests := []struct { + name string + yaml string + wantErr string + }{ + { + name: "UnknownReport", + yaml: ` +reports: + moon: + deterministic_modules: + - metadata +`, + wantErr: "reports.moon", + }, + { + name: "UnknownModule", + yaml: ` +reports: + daily: + deterministic_modules: + - missing_module +`, + wantErr: `unknown module "missing_module"`, + }, + { + name: "DuplicateModule", + yaml: ` +reports: + daily: + deterministic_modules: + - metadata + - metadata +`, + wantErr: `duplicate module "metadata"`, + }, + { + name: "IncompatibleModule", + yaml: ` +reports: + daily: + deterministic_modules: + - storm_window_summary +`, + wantErr: `not compatible with report "daily_today"`, + }, + { + name: "InvalidOptions", + yaml: ` +reports: + daily: + deterministic_modules: + - id: metadata + options: + sections: + - short_term +`, + wantErr: "options are invalid", + }, + { + name: "DuplicateReportAlias", + yaml: ` +reports: + daily: + deterministic_modules: + - metadata + daily_today: + deterministic_modules: + - current_conditions +`, + wantErr: "duplicates report override", + }, + { + name: "UnknownReportField", + yaml: ` +reports: + daily: + modules: + - metadata +`, + wantErr: `unknown report entry field "modules"`, + }, + } + + 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 TestExplicitMissingConfigReturnsError(t *testing.T) { _, err := LoadFile(filepath.Join(t.TempDir(), "missing.yml")) if err == nil { @@ -126,6 +261,15 @@ func TestExplicitMissingConfigReturnsError(t *testing.T) { } } +func writeConfig(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yml") + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write config fixture: %v", err) + } + return path +} + func TestInvalidConfigProducesActionableError(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yml") diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 76cf471..3bd74ee 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -65,5 +65,6 @@ func Defaults() Config { WindGustMilesPerHour: 10, PrecipTimingShiftMinutes: 120, }, + Reports: map[string]ReportConfig{}, } } diff --git a/internal/config/load.go b/internal/config/load.go index cd8b190..fa4216d 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -35,6 +35,10 @@ func Load(opts LoadOptions) (Config, error) { cfg.WeatherAPI.Timezone = opts.Timezone } + if err := normalizeReportModules(&cfg); err != nil { + return Config{}, err + } + if err := loadSecrets(cfg.Secrets); err != nil { return Config{}, err } @@ -61,5 +65,8 @@ func mergeFile(cfg *Config, path string) error { if cfg.MissingSource.Sources == nil { cfg.MissingSource.Sources = map[string]MissingSourcePolicy{} } + if cfg.Reports == nil { + cfg.Reports = map[string]ReportConfig{} + } return nil } diff --git a/internal/config/reports.go b/internal/config/reports.go new file mode 100644 index 0000000..d3a15b7 --- /dev/null +++ b/internal/config/reports.go @@ -0,0 +1,125 @@ +package config + +import ( + "bytes" + "fmt" + "reflect" + "strings" + + "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 { + overrides := map[report.ID][]module.ConfigItem{} + for key, reportCfg := range cfg.Reports { + if !reportCfg.deterministicModulesSet { + continue + } + id, err := reportIDForConfigKey(key) + if err != nil { + continue + } + items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules)) + for _, item := range reportCfg.DeterministicModules { + items = append(items, module.ConfigItem{ID: item.ID, Options: item.Options}) + } + overrides[id] = items + } + return overrides +} + +func normalizeReportModules(cfg *Config) error { + if cfg.Reports == nil { + cfg.Reports = map[string]ReportConfig{} + } + moduleRegistry, err := briefing.DefaultModuleRegistry() + if err != nil { + return fmt.Errorf("initialize module registry: %w", err) + } + reportRegistry := report.DefaultRegistry() + seenReports := map[report.ID]string{} + for key, reportCfg := range cfg.Reports { + reportID, err := reportIDForConfigKey(key) + if err != nil { + return err + } + if previous, ok := seenReports[reportID]; ok { + return fmt.Errorf("reports.%s duplicates report override %q", key, previous) + } + seenReports[reportID] = key + if _, err := reportRegistry.Lookup(reportID); err != nil { + return fmt.Errorf("reports.%s: %w", key, err) + } + if !reportCfg.deterministicModulesSet { + continue + } + items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules)) + for i, rawItem := range reportCfg.DeterministicModules { + options, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, rawItem.Options) + if err != nil { + return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err) + } + reportCfg.DeterministicModules[i].Options = options + items = append(items, module.ConfigItem{ID: rawItem.ID, Options: options}) + } + if err := moduleRegistry.ValidateComposition(reportID, items); err != nil { + return fmt.Errorf("reports.%s.deterministic_modules: %w", key, err) + } + cfg.Reports[key] = reportCfg + } + return 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) + } + 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 +} + +func reportIDForConfigKey(key string) (report.ID, error) { + normalized := strings.ReplaceAll(strings.TrimSpace(strings.ToLower(key)), "-", "_") + switch normalized { + case "daily", "daily_today": + return report.DailyToday, nil + case "tomorrow", "daily_tomorrow": + return report.DailyTomorrow, nil + case "three_day", "three_day_outlook": + return report.ThreeDay, nil + case "weekend", "weekend_outlook": + return report.Weekend, nil + case "storm", "storm_report": + return report.Storm, nil + default: + return "", fmt.Errorf("reports.%s is not a known report", key) + } +} diff --git a/internal/config/validate.go b/internal/config/validate.go index fcf3447..4964329 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -9,6 +9,9 @@ import ( ) func Validate(cfg Config) error { + if err := normalizeReportModules(&cfg); err != nil { + return err + } if cfg.WeatherAPI.BaseURL != "" { parsed, err := url.Parse(cfg.WeatherAPI.BaseURL) if err != nil || parsed.Scheme == "" || parsed.Host == "" { diff --git a/internal/module/module.go b/internal/module/module.go index e5a3138..91abb61 100644 --- a/internal/module/module.go +++ b/internal/module/module.go @@ -137,7 +137,9 @@ type DerivedDaypartSummariesOptions struct{} type HourlyTableOptions struct{} type PrecipTimingOptions struct{} type AlertDigestOptions struct{} -type AreaForecastDiscussionOptions struct{} +type AreaForecastDiscussionOptions struct { + Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"` +} type WeatherStoryOptions struct{} type ForecastDeltaOptions struct{} type OutdoorWindowsOptions struct{} diff --git a/internal/report/period_test.go b/internal/report/period_test.go index aa98f9f..d2a988a 100644 --- a/internal/report/period_test.go +++ b/internal/report/period_test.go @@ -345,6 +345,47 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) { } } +func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) { + base := DefaultRegistry() + overridden, err := base.WithModuleOverrides(map[ID][]module.ConfigItem{ + DailyToday: { + {ID: module.Metadata}, + {ID: module.AlertDigest}, + }, + }) + if err != nil { + t.Fatalf("WithModuleOverrides() error = %v", err) + } + + definition, err := overridden.Lookup(DailyToday) + if err != nil { + t.Fatalf("Lookup(overridden) error = %v", err) + } + if !reflect.DeepEqual(definition.ModuleIDs(), []module.ID{module.Metadata, module.AlertDigest}) { + t.Fatalf("overridden ModuleIDs() = %#v, want metadata and alert digest", definition.ModuleIDs()) + } + + defaultDefinition, err := base.Lookup(DailyToday) + if err != nil { + t.Fatalf("Lookup(default) error = %v", err) + } + if len(defaultDefinition.ModuleIDs()) <= len(definition.ModuleIDs()) { + t.Fatalf("default ModuleIDs() = %#v, want original defaults unchanged", defaultDefinition.ModuleIDs()) + } +} + +func TestRegistryRejectsModuleOverrideForUnknownReport(t *testing.T) { + _, err := DefaultRegistry().WithModuleOverrides(map[ID][]module.ConfigItem{ + ID("unknown"): {{ID: module.Metadata}}, + }) + if err == nil { + t.Fatal("WithModuleOverrides() error = nil, want unknown report") + } + if !strings.Contains(err.Error(), `unknown report "unknown"`) { + t.Fatalf("error = %q, want unknown report context", err.Error()) + } +} + func TestResolvedMetadata(t *testing.T) { location := mustLoadLocation(t) resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location}) diff --git a/internal/report/registry.go b/internal/report/registry.go index 386e428..d6ee443 100644 --- a/internal/report/registry.go +++ b/internal/report/registry.go @@ -84,6 +84,23 @@ func DefaultRegistry() Registry { return registry } +func (r Registry) WithModuleOverrides(overrides map[ID][]module.ConfigItem) (Registry, error) { + next := Registry{definitions: map[ID]Definition{}} + for id, definition := range r.definitions { + definition.Modules = append([]module.ConfigItem(nil), definition.Modules...) + next.definitions[id] = definition + } + for id, items := range overrides { + definition, ok := next.definitions[id] + if !ok { + return Registry{}, fmt.Errorf("unknown report %q", id) + } + definition.Modules = cloneModuleItems(items) + next.definitions[id] = definition + } + return next, nil +} + func dailyTodayModules() []module.ConfigItem { return moduleItems( module.Metadata, @@ -154,6 +171,12 @@ func moduleItems(ids ...module.ID) []module.ConfigItem { return items } +func cloneModuleItems(items []module.ConfigItem) []module.ConfigItem { + cloned := make([]module.ConfigItem, len(items)) + copy(cloned, items) + return cloned +} + func (r Registry) Lookup(id ID) (Definition, error) { definition, ok := r.definitions[id] if !ok {