package app import ( "fmt" "path/filepath" "strings" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" ) func resolveCampaignConfigPath(pipelineCfg *config.PipelineConfig, campaignIDFlag, campaignFileFlag string) (string, error) { campaignID := strings.TrimSpace(campaignIDFlag) campaignFile := strings.TrimSpace(campaignFileFlag) if campaignID != "" && campaignFile != "" { return "", fmt.Errorf("--campaign and --campaign-file are mutually exclusive") } if campaignFile != "" { return filepath.Clean(campaignFile), nil } if campaignID == "" && pipelineCfg != nil { campaignID = strings.TrimSpace(pipelineCfg.Campaigns.DefaultCampaignID) } if campaignID == "" { return "", fmt.Errorf("no campaign selected; pass --campaign or set pipeline.campaigns.default_campaign_id") } if err := validateCampaignIDToken(campaignID); err != nil { return "", err } if pipelineCfg == nil || strings.TrimSpace(pipelineCfg.Campaigns.Root) == "" { return "", fmt.Errorf("pipeline.campaigns.root is required to select campaign %q", campaignID) } return filepath.Clean(filepath.Join(pipelineCfg.Campaigns.Root, campaignID, "campaign.yml")), nil } func validateCampaignIDToken(campaignID string) error { if err := pathsafe.ValidateOpaqueSegment(campaignID); err != nil { return fmt.Errorf("campaign id %q must be a single path segment and opaque identifier: %w", campaignID, err) } return nil }