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 err := validateReportDistributorPathOverrides(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 err := validatePromptkit(cfg.Promptkit); err != nil { return err } 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 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 } 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 } 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 := validateDistributorBatchNotify(cfg.Batch); err != nil { return err } 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) } }