package config import ( "fmt" "net/url" "strings" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) func Validate(cfg Config) error { if err := validateReportModules(cfg); err != nil { return err } if cfg.WeatherAPI.BaseURL != "" { parsed, err := url.Parse(cfg.WeatherAPI.BaseURL) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return fmt.Errorf("weather_api.base_url must be an absolute URL") } } if cfg.WeatherAPI.Timeout <= 0 { return fmt.Errorf("weather_api.timeout must be greater than zero") } if cfg.WeatherAPI.Precision < 0 { return fmt.Errorf("weather_api.precision must be zero or greater") } if cfg.WeatherAPI.Units == "" { return fmt.Errorf("weather_api.units is required") } if cfg.WeatherAPI.Timezone == "" { return fmt.Errorf("weather_api.timezone is required") } if _, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone); err != nil { return fmt.Errorf("weather_api.timezone %q is invalid: %w", cfg.WeatherAPI.Timezone, err) } if cfg.WeatherAPI.Format == "" { return fmt.Errorf("weather_api.format is required") } if cfg.WeatherAPI.Format != "json" { return fmt.Errorf("weather_api.format must be json") } if err := validatePolicy("missing_source.default", cfg.MissingSource.Default); err != nil { return err } for source, policy := range cfg.MissingSource.Sources { if strings.TrimSpace(source) == "" { return fmt.Errorf("missing_source.sources contains an empty source name") } if err := validatePolicy("missing_source.sources."+source, policy); err != nil { return err } } if err := validateDistributorNotify(cfg.Notify.Distributor); err != nil { return err } if cfg.Scriptorium.Binary == "" { return fmt.Errorf("scriptorium.binary is required") } if cfg.Scriptorium.Timeout <= 0 { return fmt.Errorf("scriptorium.timeout must be greater than zero") } if cfg.Workspace.Root == "" { return fmt.Errorf("workspace.root is required") } if len(cfg.Dayparts) == 0 { return fmt.Errorf("dayparts must contain at least one entry") } for i, daypart := range cfg.Dayparts { if strings.TrimSpace(daypart.Name) == "" { return fmt.Errorf("dayparts[%d].name is required", i) } if _, err := timeutil.ParseClock(daypart.Start); err != nil { return fmt.Errorf("dayparts[%d].start is invalid: %w", i, err) } if _, err := timeutil.ParseClock(daypart.End); err != nil { return fmt.Errorf("dayparts[%d].end is invalid: %w", i, err) } } return nil } func validateDistributorNotify(cfg DistributorNotifyConfig) error { if !cfg.Enabled { return nil } parsed, err := url.Parse(cfg.Endpoint) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return fmt.Errorf("notify.distributor.endpoint must be an absolute URL when enabled") } if cfg.TokenEnv == "" { return fmt.Errorf("notify.distributor.token_env is required when enabled") } if !secretNamePattern.MatchString(cfg.TokenEnv) { return fmt.Errorf("notify.distributor.token_env must be a valid environment variable name") } if cfg.Timeout <= 0 { return fmt.Errorf("notify.distributor.timeout must be greater than zero when enabled") } if cfg.FailurePolicy != NotifyFailureError { return fmt.Errorf("notify.distributor.failure_policy must be error when enabled") } if cfg.PipelineIDTemplate == "" { return fmt.Errorf("notify.distributor.pipeline_id_template is required when enabled") } if err := validateDistributorTemplate("notify.distributor.pipeline_id_template", cfg.PipelineIDTemplate, distributorPipelineTemplateVariables); err != nil { return err } if cfg.BundleIDTemplate == "" { return fmt.Errorf("notify.distributor.bundle_id_template is required when enabled") } if err := validateDistributorTemplate("notify.distributor.bundle_id_template", cfg.BundleIDTemplate, distributorTemplateVariables); err != nil { return err } if cfg.IdempotencyKeyTemplate == "" { return fmt.Errorf("notify.distributor.idempotency_key_template is required when enabled") } if err := validateDistributorTemplate("notify.distributor.idempotency_key_template", cfg.IdempotencyKeyTemplate, distributorIdempotencyTemplateVariables); err != nil { return err } if len(cfg.ReportPathTemplates) == 0 { return fmt.Errorf("notify.distributor.report_path_templates must contain at least one entry when enabled") } values := DistributorTemplateValues{ LocationID: "location", ReportID: "report", RunID: "run", ArtifactGroup: "artifact", BatchOutputName: "report.md", ValidStartDate: "2026-05-29", ValidEndDate: "2026-05-30", ValidStartTime: "0000", ValidEndTime: "0000", ValidStartStamp: "2026-05-29T0000", ValidEndStamp: "2026-05-30T0000", } bundleID, err := RenderDistributorBundleID(cfg.BundleIDTemplate, values) if err != nil { return err } values.BundleID = bundleID if _, err := RenderDistributorPipelineID(cfg.PipelineIDTemplate, values); err != nil { return err } if _, err := RenderDistributorReportPaths(cfg.ReportPathTemplates, values); err != nil { return err } return nil } func validatePolicy(name string, policy MissingSourcePolicy) error { switch policy { case MissingSourceError, MissingSourceWarn, MissingSourceNone: return nil default: return fmt.Errorf("%s must be one of error, warn, or none", name) } }