package briefing import ( "fmt" "reflect" "strings" "gitea.maximumdirect.net/eric/weatherreporter/internal/facts" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" ) type ModuleContext struct { Resolved report.Resolved Collected facts.CollectedFacts Derived facts.DerivedFacts Units string Timezone string Location *LocationContext } type ModuleBuilder func(ModuleContext, any) (*module.Output, error) type ModuleDefinition struct { ID module.ID StanzaName string DefaultOptions any RequiredCollected []module.FactRequirement RequiredDerived []module.FactRequirement SupportedReports []report.ID MissingData module.MissingDataBehavior AllowDuplicate bool Builder ModuleBuilder } type ModuleRegistry struct { definitions map[module.ID]ModuleDefinition } func DefaultModuleRegistry() (ModuleRegistry, error) { return NewModuleRegistry(defaultModuleDefinitions()) } func MustDefaultModuleRegistry() ModuleRegistry { registry, err := DefaultModuleRegistry() if err != nil { panic(err) } return registry } func NewModuleRegistry(definitions []ModuleDefinition) (ModuleRegistry, error) { registry := ModuleRegistry{definitions: map[module.ID]ModuleDefinition{}} seenStanzas := map[string]module.ID{} for i, definition := range definitions { if definition.ID == "" { return ModuleRegistry{}, fmt.Errorf("module definition[%d].id is required", i) } if definition.StanzaName == "" { return ModuleRegistry{}, fmt.Errorf("module %q stanza name is required", definition.ID) } if _, ok := registry.definitions[definition.ID]; ok { return ModuleRegistry{}, fmt.Errorf("duplicate module definition %q", definition.ID) } if definition.Builder == nil { return ModuleRegistry{}, fmt.Errorf("module %q has no builder", definition.ID) } if definition.MissingData == module.MissingDataWarn { return ModuleRegistry{}, fmt.Errorf("module %q uses unsupported missing data behavior %q", definition.ID, definition.MissingData) } if existingID, ok := seenStanzas[definition.StanzaName]; ok { return ModuleRegistry{}, fmt.Errorf("duplicate stanza name %q for modules %q and %q", definition.StanzaName, existingID, definition.ID) } seenStanzas[definition.StanzaName] = definition.ID registry.definitions[definition.ID] = definition } return registry, nil } func (r ModuleRegistry) Lookup(id module.ID) (ModuleDefinition, error) { definition, ok := r.definitions[id] if !ok { return ModuleDefinition{}, fmt.Errorf("unknown module %q", id) } return definition, nil } func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (*module.Output, error) { definition, err := r.Lookup(item.ID) if err != nil { return nil, err } if !definition.SupportsReport(ctx.Resolved.Definition.ID) { return nil, fmt.Errorf("module %q is not compatible with report %q", item.ID, ctx.Resolved.Definition.ID) } if err := definition.ValidateOptions(item.Options); err != nil { return nil, err } if definition.Builder == nil { return nil, fmt.Errorf("module %q has no builder", item.ID) } missing := missingRequirements(definition, ctx) if len(missing) > 0 { switch definition.MissingData { case module.MissingDataOmit: return nil, nil case module.MissingDataError: return nil, fmt.Errorf("module %q missing required facts: %s", item.ID, strings.Join(missing, ", ")) case module.MissingDataEmpty: case module.MissingDataWarn: return nil, fmt.Errorf("module %q uses unsupported missing data behavior %q", item.ID, definition.MissingData) default: return nil, fmt.Errorf("module %q has unknown missing data behavior %q", item.ID, definition.MissingData) } } options := item.Options if options == nil { options = definition.DefaultOptions } output, err := definition.Builder(ctx, options) if err != nil { return nil, err } if output == nil { return nil, nil } if output.ID != definition.ID { return nil, fmt.Errorf("module %q produced output id %q", definition.ID, output.ID) } if output.StanzaName != definition.StanzaName { return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName) } return output, nil } func missingRequirements(definition ModuleDefinition, ctx ModuleContext) []string { var missing []string for _, requirement := range definition.RequiredCollected { if !collectedFactAvailable(requirement, ctx) { missing = append(missing, string(requirement)) } } for _, requirement := range definition.RequiredDerived { if !derivedFactAvailable(requirement, ctx) { missing = append(missing, string(requirement)) } } return missing } func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool { switch requirement { case module.CollectedCurrentConditions: return ctx.Collected.Current != nil case module.CollectedAlerts: return ctx.Collected.Alerts != nil case module.CollectedDiscussion: return ctx.Collected.Discussion != nil case module.CollectedWeatherStory: return ctx.Collected.WeatherStory != nil case module.CollectedSourceMetadata: return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0 default: return false } } func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool { switch requirement { case module.RequiresDerivedHourlyPeriods: return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0 case module.RequiresDerivedNarrativePeriods: return len(ctx.Derived.ValidPeriodNarrativePeriods) > 0 case module.RequiresDerivedAlertOverlaps: return true case module.RequiresDerivedDailySummaries: return len(ctx.Derived.DailySummaries) > 0 case module.RequiresDerivedDaypartSummaries: return len(ctx.Derived.DaypartSummaries) > 0 case module.RequiresDerivedPrecipTiming: return true default: return false } } func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error { seenModules := map[module.ID]struct{}{} seenStanzas := map[string]module.ID{} for i, item := range items { definition, err := r.Lookup(item.ID) if err != nil { return fmt.Errorf("modules[%d]: %w", i, err) } if _, ok := seenModules[item.ID]; ok && !definition.AllowDuplicate { return fmt.Errorf("modules[%d]: duplicate module %q", i, item.ID) } seenModules[item.ID] = struct{}{} if existingID, ok := seenStanzas[definition.StanzaName]; ok { return fmt.Errorf("modules[%d]: duplicate stanza name %q for modules %q and %q", i, definition.StanzaName, existingID, item.ID) } seenStanzas[definition.StanzaName] = item.ID if !definition.SupportsReport(reportID) { return fmt.Errorf("modules[%d]: module %q is not compatible with report %q", i, item.ID, reportID) } if err := definition.ValidateOptions(item.Options); err != nil { return fmt.Errorf("modules[%d]: %w", i, err) } } return nil } func (d ModuleDefinition) SupportsReport(id report.ID) bool { if len(d.SupportedReports) == 0 { return true } for _, supported := range d.SupportedReports { if supported == id { return true } } return false } func (d ModuleDefinition) ValidateOptions(options any) error { if options == nil { return nil } if d.DefaultOptions == nil { return fmt.Errorf("module %q does not accept options", d.ID) } want := reflect.TypeOf(d.DefaultOptions) got := reflect.TypeOf(options) if got == want { return nil } if got.Kind() == reflect.Pointer && got.Elem() == want { return nil } return fmt.Errorf("module %q options have type %s, want %s", d.ID, got, want) } func defaultModuleDefinitions() []ModuleDefinition { allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend, report.Storm} daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend} return []ModuleDefinition{ { ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, RequiredCollected: []module.FactRequirement{module.CollectedSourceMetadata}, SupportedReports: allReports, MissingData: module.MissingDataEmpty, Builder: buildMetadataModule, }, { ID: module.CurrentConditions, StanzaName: "current_conditions", DefaultOptions: module.CurrentConditionsOptions{}, RequiredCollected: []module.FactRequirement{module.CollectedCurrentConditions}, SupportedReports: allReports, MissingData: module.MissingDataOmit, Builder: buildCurrentConditionsModule, }, { ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", DefaultOptions: module.DerivedDailySummaryOptions{}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming}, SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow}, MissingData: module.MissingDataError, Builder: buildDerivedDailySummaryModule, }, { ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", DefaultOptions: module.DerivedDaypartSummariesOptions{}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries}, SupportedReports: daypartReports, MissingData: module.MissingDataError, Builder: buildDerivedDaypartSummariesModule, }, { ID: module.PrecipTiming, StanzaName: "precip_timing", DefaultOptions: module.PrecipTimingOptions{}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries}, SupportedReports: allReports, MissingData: module.MissingDataEmpty, Builder: buildPrecipTimingModule, }, { ID: module.AlertDigest, StanzaName: "alert_digest", DefaultOptions: module.AlertDigestOptions{}, RequiredCollected: []module.FactRequirement{module.CollectedAlerts}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedAlertOverlaps}, SupportedReports: allReports, MissingData: module.MissingDataEmpty, Builder: buildAlertDigestModule, }, { ID: module.AreaForecastDiscussion, StanzaName: "area_forecast_discussion", DefaultOptions: module.AreaForecastDiscussionOptions{}, RequiredCollected: []module.FactRequirement{module.CollectedDiscussion}, SupportedReports: allReports, MissingData: module.MissingDataOmit, Builder: buildAreaForecastDiscussionModule, }, { ID: module.WeatherStory, StanzaName: "weather_story", DefaultOptions: module.WeatherStoryOptions{}, RequiredCollected: []module.FactRequirement{module.CollectedWeatherStory}, SupportedReports: allReports, MissingData: module.MissingDataOmit, Builder: buildWeatherStoryModule, }, { ID: module.OutdoorWindows, StanzaName: "outdoor_windows", DefaultOptions: module.OutdoorWindowsOptions{}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries}, SupportedReports: daypartReports, MissingData: module.MissingDataEmpty, Builder: buildOutdoorWindowsModule, }, { ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", DefaultOptions: module.TomorrowPlanningOptions{}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries}, SupportedReports: []report.ID{report.DailyTomorrow}, MissingData: module.MissingDataEmpty, Builder: buildTomorrowPlanningModule, }, } }