package config import ( "fmt" "net/url" "strings" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) func Validate(cfg Config) error { if err := validateReportModules(cfg); err != nil { return err } if err := validateReportDistributorPathOverrides(cfg); err != nil { return err } if cfg.Output.Directory != "" && strings.TrimSpace(cfg.Output.Directory) == "" { return fmt.Errorf("output.directory must not be blank when configured") } 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 !strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https") { return fmt.Errorf("weather_api.base_url must use http or https") } } 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 !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 } } if err := validateDistributorNotify(cfg.Notify.Distributor); err != nil { return err } if err := validatePromptkit(cfg.Promptkit); err != nil { return err } if len(cfg.Dayparts) == 0 { return fmt.Errorf("dayparts must contain at least one entry") } daypartNames := make(map[string]int, len(cfg.Dayparts)) for i, daypart := range cfg.Dayparts { if strings.TrimSpace(daypart.Name) == "" { return fmt.Errorf("dayparts[%d].name is required", i) } key := forecast.CanonicalDaypartKey(daypart.Name) if previous, exists := daypartNames[key]; exists { return fmt.Errorf("dayparts[%d].name %q conflicts with dayparts[%d].name after canonicalization", i, daypart.Name, previous) } daypartNames[key] = 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 validatePromptkit(cfg PromptkitConfig) error { if cfg.ProfileFile != "" && cfg.ProfileDir != "" { return fmt.Errorf("promptkit.profile_file and promptkit.profile_dir cannot both be configured") } if cfg.Timeout <= 0 { return fmt.Errorf("promptkit.timeout must be greater than zero") } if cfg.Local.Endpoint != "" { parsed, err := url.Parse(cfg.Local.Endpoint) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return fmt.Errorf("promptkit.local.endpoint must be an absolute URL when configured") } } if cfg.Local.ConcurrencyLimit < 0 { return fmt.Errorf("promptkit.local.concurrency_limit must be zero or greater") } return nil } func validateDistributorNotify(cfg DistributorNotifyConfig) error { if !cfg.Enabled { return nil } if err := ValidateDistributorEndpoint(cfg.Endpoint); err != nil { return fmt.Errorf("notify.distributor.endpoint %w", err) } 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 } values := sampleDistributorTemplateValues() 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 := RenderDistributorIdempotencyKey(cfg.IdempotencyKeyTemplate, values); err != nil { return err } if err := validateDistributorBatchNotify(cfg.Batch); err != nil { return err } return nil } // ValidateDistributorEndpoint verifies the endpoint grammar accepted by the // pinned Distributor upload client. func ValidateDistributorEndpoint(endpoint string) error { parsed, err := url.Parse(endpoint) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return fmt.Errorf("must be an absolute URL") } if parsed.Scheme != "http" && parsed.Scheme != "https" { return fmt.Errorf("must use http or https") } if parsed.User != nil { return fmt.Errorf("must not include userinfo") } if parsed.RawQuery != "" || parsed.Fragment != "" { return fmt.Errorf("must not include query or fragment") } return nil } func validateDistributorBatchNotify(cfg DistributorBatchNotifyConfig) error { if !cfg.Enabled { return nil } if cfg.PipelineIDTemplate == "" { return fmt.Errorf("notify.distributor.batch.pipeline_id_template is required when enabled") } if err := validateDistributorBatchTemplate("notify.distributor.batch.pipeline_id_template", cfg.PipelineIDTemplate, distributorBatchPipelineTemplateVariables); err != nil { return err } if cfg.BundleIDTemplate == "" { return fmt.Errorf("notify.distributor.batch.bundle_id_template is required when enabled") } if err := validateDistributorBatchTemplate("notify.distributor.batch.bundle_id_template", cfg.BundleIDTemplate, distributorBatchTemplateVariables); err != nil { return err } if cfg.IdempotencyKeyTemplate == "" { return fmt.Errorf("notify.distributor.batch.idempotency_key_template is required when enabled") } if err := validateDistributorBatchTemplate("notify.distributor.batch.idempotency_key_template", cfg.IdempotencyKeyTemplate, distributorBatchIdempotencyTemplateVariables); err != nil { return err } values := DistributorBatchTemplateValues{ LocationID: "location", Batch: "morning", BatchRunID: "20260529T100000.000000000Z_morning", BatchStartedDate: "2026-05-29", } bundleID, err := RenderDistributorBatchBundleID(cfg.BundleIDTemplate, values) if err != nil { return err } values.BundleID = bundleID if _, err := RenderDistributorBatchPipelineID(cfg.PipelineIDTemplate, values); err != nil { return err } if _, err := RenderDistributorBatchIdempotencyKey(cfg.IdempotencyKeyTemplate, 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) } }