Add configurable report module composition
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -66,3 +66,5 @@ recent_change:
|
||||
precip_probability_points: 20
|
||||
wind_gust_miles_per_hour: 10
|
||||
precip_timing_shift_minutes: 120
|
||||
|
||||
reports: {}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -65,5 +65,6 @@ func Defaults() Config {
|
||||
WindGustMilesPerHour: 10,
|
||||
PrecipTimingShiftMinutes: 120,
|
||||
},
|
||||
Reports: map[string]ReportConfig{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
125
internal/config/reports.go
Normal file
125
internal/config/reports.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user