package config import ( "fmt" "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" ) func validateCampaign(cfg *CampaignConfig) error { if cfg == nil { return fmt.Errorf("campaign config is required") } if CampaignID(cfg) == "" { return fmt.Errorf("campaign.campaign_id is required") } if err := pathsafe.ValidateOpaqueSegment(cfg.CampaignID); err != nil { return fmt.Errorf("campaign.campaign_id: %w", err) } if strings.TrimSpace(cfg.Inputs.SpeakersFile) == "" { return fmt.Errorf("campaign.inputs.speakers_file is required") } if strings.TrimSpace(cfg.Inputs.AutocorrectFile) == "" { return fmt.Errorf("campaign.inputs.autocorrect_file is required") } if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" { return fmt.Errorf("campaign.inputs.glossary_file is required") } if strings.TrimSpace(cfg.Inputs.PartyFile) == "" { return fmt.Errorf("campaign.inputs.party_file is required") } if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" { return fmt.Errorf("campaign.inputs.spell_catalog_file must be non-empty when provided") } return nil } func validateSpool(cfg SpoolConfig) error { return nil } func validateCache(cfg CacheConfig) error { if cfg.S3Audio != nil && *cfg.S3Audio && strings.TrimSpace(cfg.Root) == "" { return fmt.Errorf("pipeline.cache.root is required when pipeline.cache.s3_audio is true") } return nil } func validateSession(cfg *SessionConfig) error { if err := validateSessionIdentifier("session.session_id", cfg.SessionID, true); err != nil { return err } if err := validateSessionIdentifier("session.previous_session_id", cfg.PreviousSessionID, false); err != nil { return err } if strings.TrimSpace(cfg.PreviousSessionID) != "" && strings.TrimSpace(cfg.PreviousSessionID) == strings.TrimSpace(cfg.SessionID) { return fmt.Errorf("session.previous_session_id must not equal session.session_id") } if strings.TrimSpace(cfg.Campaign) == "" { return fmt.Errorf("session.campaign is required") } if err := pathsafe.ValidateOpaqueSegment(cfg.Campaign); err != nil { return fmt.Errorf("session.campaign: %w", err) } if strings.TrimSpace(cfg.Inputs.SpeakersFile) == "" { return fmt.Errorf("session.inputs.speakers_file is required") } if strings.TrimSpace(cfg.Inputs.AutocorrectFile) == "" { return fmt.Errorf("session.inputs.autocorrect_file is required") } if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" { return fmt.Errorf("session.inputs.glossary_file is required") } if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" { return fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided") } hasAudioDir := strings.TrimSpace(cfg.Inputs.AudioDir) != "" hasAudioFiles := len(cfg.Inputs.AudioFiles) > 0 hasAudioS3 := cfg.Inputs.AudioS3 != nil if hasAudioS3 { if strings.TrimSpace(cfg.Inputs.AudioS3.Prefix) == "" { return fmt.Errorf("session.inputs.audio_s3.prefix is required when session.inputs.audio_s3 is configured") } if err := validateRelativeSafePath("session.inputs.audio_s3.prefix", cfg.Inputs.AudioS3.Prefix); err != nil { return err } } if hasAudioS3 && (hasAudioDir || hasAudioFiles) { return fmt.Errorf("session.inputs.audio_dir/audio_files and session.inputs.audio_s3 are mutually exclusive") } if !hasAudioDir && !hasAudioFiles && !hasAudioS3 { return fmt.Errorf("session.inputs requires audio_dir, at least one audio_files entry, or audio_s3") } return nil } func validateResolvedPartyInputs(cfg *Config) error { if cfg == nil || cfg.Session == nil { return fmt.Errorf("session config is required") } if cfg.Party.Canonical == nil { return fmt.Errorf("canonical party data is required") } if cfg.Party.Source.Source != "campaign_config" { return fmt.Errorf("canonical party must be sourced by campaign_config") } return nil } func validateSessionIdentifier(fieldName, value string, required bool) error { if strings.TrimSpace(value) == "" { if required { return fmt.Errorf("%s is required", fieldName) } return nil } if err := pathsafe.ValidateOpaqueSegment(value); err != nil { return fmt.Errorf("%s: %w", fieldName, err) } return nil } func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig, stableInputs ResolvedStableInputs) error { if pipeline == nil || session == nil { return nil } if pipeline.Notarius != nil && pipeline.Notarius.Enabled { for _, selector := range sortedStringKeys(pipeline.Notarius.References) { source := pipeline.Notarius.References[selector] if source == artifactpolicy.SourceInputSpellCatalog && strings.TrimSpace(stableInputs.SpellCatalogFile.Path) == "" { return fmt.Errorf("pipeline.notarius.references.%s requires campaign.inputs.spell_catalog_file or session.inputs.spell_catalog_file", selector) } } } audioS3Enabled := session.Inputs.AudioS3 != nil publishUploadEnabled := publishUploadConfiguredForS3(pipeline) if audioS3Enabled && !strings.EqualFold(strings.TrimSpace(pipeline.Storage.Backend), StorageBackendS3) { return fmt.Errorf("pipeline.storage.backend must be s3 when session.inputs.audio_s3 is configured") } if !audioS3Enabled && !publishUploadEnabled { return nil } if pipeline.Storage.S3 == nil || strings.TrimSpace(pipeline.Storage.S3.Bucket) == "" { return fmt.Errorf("pipeline.storage.s3.bucket is required when S3 session audio or publish upload is enabled") } return nil }