package config import ( "fmt" "path/filepath" "regexp" "sort" "strings" "gitea.maximumdirect.net/eric/narratio/internal/fileops" ) // LoadPipeline loads pipeline configuration from a YAML file with strict field checking. func LoadPipeline(path string) (*PipelineConfig, error) { return LoadPipelineWithOptions(path, PipelineLoadOptions{}) } // PipelineLoadOptions carries an optional explicit profile selection. Nil, // empty, and the exact name "default" select the base configuration. type PipelineLoadOptions struct { Profile *string } // LoadPipelineWithOptions loads pipeline configuration with strict field // checking and optional named-profile selection. func LoadPipelineWithOptions(path string, opts PipelineLoadOptions) (*PipelineConfig, error) { cfg, err := loadPipelineWithConfigKit(path, opts) if err != nil { return nil, err } return finalizeLoadedPipeline(path, cfg) } // LoadPipelineProfilePair resolves two profiles as independent ConfigKit loads. func LoadPipelineProfilePair(path, leftProfile, rightProfile string) (*PipelineConfig, *PipelineConfig, error) { if leftProfile == rightProfile { return nil, nil, fmt.Errorf("load pipeline profiles: left and right profile selections must differ") } leftOptions := PipelineLoadOptions{Profile: &leftProfile} rightOptions := PipelineLoadOptions{Profile: &rightProfile} left, err := LoadPipelineWithOptions(path, leftOptions) if err != nil { return nil, nil, fmt.Errorf("load left pipeline profile: %w", err) } right, err := LoadPipelineWithOptions(path, rightOptions) if err != nil { return nil, nil, fmt.Errorf("load right pipeline profile: %w", err) } return left, right, nil } func finalizeLoadedPipeline(path string, cfg *PipelineConfig) (*PipelineConfig, error) { captureLogicalNotariusPaths(cfg) if err := resolveNotariusPaths(cfg, path); err != nil { return nil, fmt.Errorf("load pipeline config: %w", err) } finalizeArtifactDependencies(cfg) if err := finalizePipelineResolution(cfg); err != nil { return nil, fmt.Errorf("load pipeline config: %w", err) } retainArtifactFamilyDeclarations(cfg) return cfg, nil } // LoadCampaign loads campaign configuration from a YAML file with strict field checking. func LoadCampaign(path string) (*CampaignConfig, error) { return loadCampaignWithConfigKit(path) } // LoadSession loads session configuration from a YAML file with strict field checking. func LoadSession(path string) (*SessionConfig, error) { return LoadSessionWithOptions(path, SessionLoadOptions{}) } // SessionLoadOptions configures expected session identity checks. type SessionLoadOptions struct { SessionID string PreviousSessionID string Profile *string } // LoadSessionWithOptions loads session configuration from a YAML file with // strict field checking. func LoadSessionWithOptions(path string, opts SessionLoadOptions) (*SessionConfig, error) { sessionBytes, err := fileops.ReadFileBounded(path, maxConfigDocumentBytes) if err != nil { return nil, fmt.Errorf("load session config: session file %q: open: %w", path, err) } return LoadSessionBytesWithOptions(path, sessionBytes, opts) } // LoadSessionBytesWithOptions loads session configuration from YAML bytes with // strict field checking. func LoadSessionBytesWithOptions(label string, data []byte, opts SessionLoadOptions) (*SessionConfig, error) { if err := rejectSessionTemplatePlaceholders(label, string(data)); err != nil { return nil, fmt.Errorf("load session config: %w", err) } cfg, err := loadSessionWithConfigKit(label, data) if err != nil { return nil, err } if strings.TrimSpace(opts.SessionID) != "" && strings.TrimSpace(cfg.SessionID) != "" && strings.TrimSpace(cfg.SessionID) != strings.TrimSpace(opts.SessionID) { return nil, fmt.Errorf( "load session config: session file %q: session_id mismatch: --session-id %q does not match session_id %q", label, strings.TrimSpace(opts.SessionID), strings.TrimSpace(cfg.SessionID), ) } if expectedPreviousSessionID := strings.TrimSpace(opts.PreviousSessionID); expectedPreviousSessionID != "" { actualPreviousSessionID := strings.TrimSpace(cfg.PreviousSessionID) if actualPreviousSessionID == "" { return nil, fmt.Errorf( "load session config: session file %q: previous_session_id is required when --previous-session-id %q is provided", label, expectedPreviousSessionID, ) } if actualPreviousSessionID != expectedPreviousSessionID { return nil, fmt.Errorf( "load session config: session file %q: previous_session_id mismatch: --previous-session-id %q does not match previous_session_id %q", label, expectedPreviousSessionID, actualPreviousSessionID, ) } } return cfg, nil } // Load loads and resolves combined pipeline, campaign, and session configuration. // Passing only a session path is supported for package-internal compatibility; // in that form campaign.yml is expected next to the session file. func Load(pipelinePath string, paths ...string) (*Config, error) { campaignPath, sessionPath, err := campaignSessionPaths(paths...) if err != nil { return nil, err } return LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) } // LoadWithSessionOptions loads and resolves combined pipeline, campaign, and // session configuration with expected session identity checks. func LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) { pipelineCfg, err := LoadPipelineWithOptions(pipelinePath, PipelineLoadOptions{Profile: sessionOpts.Profile}) if err != nil { return nil, err } campaignCfg, err := LoadCampaign(campaignPath) if err != nil { return nil, err } loaded, err := LoadPipelineCampaign(pipelinePath, pipelineCfg, campaignPath, campaignCfg) if err != nil { return nil, err } return LoadSessionWithPipelineCampaignOptions(loaded, sessionPath, sessionOpts) } // LoadedPipelineCampaign retains one already loaded pipeline and campaign for // subsequent local or remote session resolution. It prevents a command from // reloading the root pipeline after campaign selection. type LoadedPipelineCampaign struct { PipelinePath string Pipeline *PipelineConfig CampaignPath string Campaign *CampaignConfig Party ResolvedParty } // LoadPipelineCampaign combines already loaded pipeline and campaign documents // with their campaign-owned party source. Callers that later resolve a session // retain this context rather than independently reimplementing party loading. func LoadPipelineCampaign(pipelinePath string, pipeline *PipelineConfig, campaignPath string, campaign *CampaignConfig) (LoadedPipelineCampaign, error) { if pipeline == nil { return LoadedPipelineCampaign{}, fmt.Errorf("pipeline config is required") } if campaign == nil { return LoadedPipelineCampaign{}, fmt.Errorf("campaign config is required") } party, err := resolveCampaignParty(campaignPath, campaign) if err != nil { return LoadedPipelineCampaign{}, err } return LoadPipelineCampaignWithParty(pipelinePath, pipeline, campaignPath, campaign, party) } // LoadPipelineCampaignWithParty combines an already loaded pipeline and // campaign with one already resolved campaign-owned party. It is useful when // more than one independently resolved pipeline must be expanded against the // exact same party document. func LoadPipelineCampaignWithParty(pipelinePath string, pipeline *PipelineConfig, campaignPath string, campaign *CampaignConfig, party ResolvedParty) (LoadedPipelineCampaign, error) { if pipeline == nil { return LoadedPipelineCampaign{}, fmt.Errorf("pipeline config is required") } if campaign == nil { return LoadedPipelineCampaign{}, fmt.Errorf("campaign config is required") } if party.Canonical == nil { return LoadedPipelineCampaign{}, fmt.Errorf("resolved campaign party is required") } if err := expandPipelineArtifactFamilies(pipeline, party); err != nil { return LoadedPipelineCampaign{}, err } return LoadedPipelineCampaign{ PipelinePath: pipelinePath, Pipeline: pipeline, CampaignPath: campaignPath, Campaign: campaign, Party: party, }, nil } // LoadSessionWithPipelineCampaignOptions loads one local session and combines // it with an already loaded pipeline and campaign. func LoadSessionWithPipelineCampaignOptions(loaded LoadedPipelineCampaign, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) { sessionCfg, err := LoadSessionWithOptions(sessionPath, sessionOpts) if err != nil { return nil, err } return ResolveLoadedPipelineCampaign(loaded, sessionPath, sessionCfg, SessionSource{ Source: "session_config", LocalPath: sessionPath, }) } // ResolveLoadedPipelineCampaign combines an already loaded pipeline and // campaign with optional already loaded session data. A nil session preserves // the resolved pipeline/campaign context for callers that need to locate or // retrieve a session without rereading the root pipeline. func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath string, sessionCfg *SessionConfig, sessionSource SessionSource) (*Config, error) { if loaded.Pipeline == nil { return nil, fmt.Errorf("pipeline config is required") } if loaded.Campaign == nil { return nil, fmt.Errorf("campaign config is required") } if loaded.Party.Canonical == nil { party, err := resolveCampaignParty(loaded.CampaignPath, loaded.Campaign) if err != nil { return nil, err } loaded.Party = party } cfg := &Config{ Pipeline: loaded.Pipeline, Campaign: loaded.Campaign, Session: sessionCfg, PipelinePath: loaded.PipelinePath, CampaignPath: loaded.CampaignPath, SessionPath: sessionPath, SessionSource: sessionSource, Party: loaded.Party, } if sessionCfg == nil { return cfg, nil } stableInputs, err := mergeCampaignSession(loaded.Campaign, sessionCfg, loaded.CampaignPath, sessionPath) if err != nil { return nil, err } if strings.TrimSpace(cfg.SessionSource.Source) == "" { cfg.SessionSource.Source = "session_config" } if strings.TrimSpace(cfg.SessionSource.LocalPath) == "" { cfg.SessionSource.LocalPath = sessionPath } cfg.StableInputs = stableInputs return cfg, nil } // Resolve builds final stage-facing configuration from already loaded // pipeline, campaign, and session documents. func Resolve(pipelinePath string, pipelineCfg *PipelineConfig, campaignPath string, campaignCfg *CampaignConfig, sessionPath string, sessionCfg *SessionConfig, sessionSource SessionSource) (*Config, error) { if sessionCfg == nil { return nil, fmt.Errorf("session config is required") } loaded, err := LoadPipelineCampaign(pipelinePath, pipelineCfg, campaignPath, campaignCfg) if err != nil { return nil, err } return ResolveLoadedPipelineCampaign(loaded, sessionPath, sessionCfg, sessionSource) } func campaignSessionPaths(paths ...string) (campaignPath, sessionPath string, err error) { switch len(paths) { case 1: sessionPath = paths[0] campaignPath = filepath.Join(filepath.Dir(sessionPath), "campaign.yml") case 2: campaignPath = paths[0] sessionPath = paths[1] default: return "", "", fmt.Errorf("load config: expected session path or campaign and session paths") } return campaignPath, sessionPath, nil } func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig, campaignPath, sessionPath string) (ResolvedStableInputs, error) { if campaignCfg == nil { return ResolvedStableInputs{}, fmt.Errorf("campaign config is required") } if sessionCfg == nil { return ResolvedStableInputs{}, fmt.Errorf("session config is required") } if campaignCfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(campaignCfg.Inputs.SpellCatalogFile) == "" { return ResolvedStableInputs{}, fmt.Errorf("campaign.inputs.spell_catalog_file must be non-empty when provided") } if sessionCfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(sessionCfg.Inputs.SpellCatalogFile) == "" { return ResolvedStableInputs{}, fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided") } campaignName := CampaignID(campaignCfg) sessionCampaign := strings.TrimSpace(sessionCfg.Campaign) if sessionCampaign != "" && campaignName != "" && sessionCampaign != campaignName { return ResolvedStableInputs{}, fmt.Errorf( "campaign/session config invalid: session campaign %q does not match campaign config %q", sessionCampaign, campaignName, ) } if sessionCampaign == "" { sessionCfg.Campaign = campaignName } stable := ResolvedStableInputs{ SpeakersFile: selectStableInput( campaignCfg.Inputs.SpeakersFile, sessionCfg.Inputs.SpeakersFile, campaignPath, sessionPath, ), AutocorrectFile: selectStableInput( campaignCfg.Inputs.AutocorrectFile, sessionCfg.Inputs.AutocorrectFile, campaignPath, sessionPath, ), GlossaryFile: selectStableInput( campaignCfg.Inputs.GlossaryFile, sessionCfg.Inputs.GlossaryFile, campaignPath, sessionPath, ), PartyFile: selectStableInput( campaignCfg.Inputs.PartyFile, "", campaignPath, sessionPath, ), SpellCatalogFile: selectStableInput( campaignCfg.Inputs.SpellCatalogFile, sessionCfg.Inputs.SpellCatalogFile, campaignPath, sessionPath, ), } sessionCfg.Inputs.SpeakersFile = stable.SpeakersFile.Path sessionCfg.Inputs.AutocorrectFile = stable.AutocorrectFile.Path sessionCfg.Inputs.GlossaryFile = stable.GlossaryFile.Path sessionCfg.Inputs.SpellCatalogFile = stable.SpellCatalogFile.Path return stable, nil } // CampaignID returns the canonical campaign identity from campaign config. func CampaignID(cfg *CampaignConfig) string { if cfg == nil { return "" } return strings.TrimSpace(cfg.CampaignID) } func selectStableInput(campaignValue, sessionValue, campaignPath, sessionPath string) ResolvedInputFile { if strings.TrimSpace(sessionValue) != "" { return ResolvedInputFile{ Path: sessionValue, ConfigPath: sessionPath, Source: "session_config", } } return ResolvedInputFile{ Path: campaignValue, ConfigPath: campaignPath, Source: "campaign_config", } } var sessionTemplatePlaceholderPattern = regexp.MustCompile(`\{\{[^}]*\}\}`) var sessionTemplateVariablePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`) func rejectSessionTemplatePlaceholders(label, content string) error { placeholders := sessionTemplatePlaceholderPattern.FindAllString(content, -1) if len(placeholders) == 0 { return nil } seen := map[string]struct{}{} vars := make([]string, 0, len(placeholders)) for _, placeholder := range placeholders { name := strings.TrimSpace(placeholder) if match := sessionTemplateVariablePattern.FindStringSubmatch(placeholder); len(match) > 1 { name = match[1] } if _, ok := seen[name]; ok { continue } seen[name] = struct{}{} vars = append(vars, name) } sort.Strings(vars) return fmt.Errorf( "session file %q contains template placeholder(s): %s; session.yml must be concrete; run narratio session init to generate it", label, strings.Join(vars, ", "), ) } func shortName(path, fallback string) string { base := filepath.Base(path) if base == "." || base == string(filepath.Separator) { return fallback } return base } func resolveNotariusPaths(cfg *PipelineConfig, pipelinePath string) error { if cfg == nil || cfg.Notarius == nil || !cfg.Notarius.Enabled { return nil } pipelineAbs, err := filepath.Abs(pipelinePath) if err != nil { return fmt.Errorf("resolve pipeline config path %q: %w", pipelinePath, err) } baseDir := filepath.Dir(pipelineAbs) resolve := func(value string) (string, error) { trimmed := strings.TrimSpace(value) if trimmed == "" { return "", nil } if !filepath.IsAbs(trimmed) { trimmed = filepath.Join(baseDir, trimmed) } return filepath.Abs(trimmed) } resolvedConfigPath, err := resolve(cfg.Notarius.ConfigPath) if err != nil { return fmt.Errorf("resolve pipeline.notarius.config_path: %w", err) } cfg.Notarius.ConfigPath = resolvedConfigPath if strings.TrimSpace(cfg.Notarius.WorkingDirectory) == "" { if resolvedConfigPath != "" { cfg.Notarius.WorkingDirectory = filepath.Dir(resolvedConfigPath) } return nil } workingDirectory, err := resolve(cfg.Notarius.WorkingDirectory) if err != nil { return fmt.Errorf("resolve pipeline.notarius.working_directory: %w", err) } cfg.Notarius.WorkingDirectory = workingDirectory return nil }