package app import ( "context" "errors" "flag" "fmt" "io" "sort" "strings" "gitea.maximumdirect.net/eric/narratio/internal/config" ) type configDiffFlags struct { pipelinePath singletonStringFlag campaignPath singletonStringFlag campaignFilePath singletonStringFlag } type configDiffRequest struct { leftProfile string rightProfile string flags configDiffFlags } type configDiffResolved struct { left *inspectionConfig right *inspectionConfig } type configDiffRecord struct { Kind string Path string Left string Right string } // ConfigDiff compares two explicitly selected profiles through the same // read-only configuration resolution boundary used by config validate, show, // and sources. Differences describe normalized effective values rather than // the layout of root, import, or overlay files. func ConfigDiff(_ context.Context, args []string, out io.Writer) error { request, err := parseConfigDiffRequest(args, out) if err != nil { if errors.Is(err, flag.ErrHelp) { return nil } return err } resolved, err := resolveConfigDiff(request) if err != nil { return fmt.Errorf("config diff: %w", err) } leftValues, err := config.EffectivePipelineValues(resolved.left.Pipeline) if err != nil { return fmt.Errorf("config diff: project left effective configuration: %w", err) } rightValues, err := config.EffectivePipelineValues(resolved.right.Pipeline) if err != nil { return fmt.Errorf("config diff: project right effective configuration: %w", err) } records, err := diffEffectivePipelineValues(leftValues, rightValues) if err != nil { return fmt.Errorf("config diff: %w", err) } leftDigest := config.EffectivePipelineDigest(resolved.left.Pipeline) rightDigest := config.EffectivePipelineDigest(resolved.right.Pipeline) if len(records) == 0 { if leftDigest != rightDigest { return fmt.Errorf("internal error: effective pipeline digests differ without a semantic difference") } _, err := fmt.Fprintln(out, "no differences") return err } for _, record := range records { switch record.Kind { case "added": if _, err := fmt.Fprintf(out, "added\t%s\t%s\n", record.Path, record.Right); err != nil { return err } case "removed": if _, err := fmt.Fprintf(out, "removed\t%s\t%s\n", record.Path, record.Left); err != nil { return err } case "changed": if _, err := fmt.Fprintf(out, "changed\t%s\t%s\t%s\n", record.Path, record.Left, record.Right); err != nil { return err } default: return fmt.Errorf("internal error: unsupported difference kind %q", record.Kind) } } return nil } func parseConfigDiffRequest(args []string, out io.Writer) (configDiffRequest, error) { profiles, flagArgs := splitConfigDiffArguments(args) fs := flag.NewFlagSet("config diff", flag.ContinueOnError) fs.SetOutput(out) var request configDiffRequest request.flags.pipelinePath.name = "config" request.flags.campaignPath.name = "campaign" request.flags.campaignFilePath.name = "campaign-file" fs.Var(&request.flags.pipelinePath, "config", "path to pipeline.yml (optional; defaults searched)") fs.Var(&request.flags.campaignPath, "campaign", "campaign ID") fs.Var(&request.flags.campaignFilePath, "campaign-file", "path to campaign.yml") fs.Usage = func() { fmt.Fprintln(out, "Usage: narratio config diff [--config ] [--campaign | --campaign-file ]") fmt.Fprintln(out) fmt.Fprintln(out, "Compares fully resolved profiles without a session, workspace, manifest, adapters, or external services.") fmt.Fprintln(out) fmt.Fprintln(out, "Flags:") fs.PrintDefaults() } if err := fs.Parse(flagArgs); err != nil { return configDiffRequest{}, fmt.Errorf("config diff: invalid flags: %w", err) } profiles = append(profiles, fs.Args()...) if len(profiles) != 2 || strings.TrimSpace(profiles[0]) == "" || strings.TrimSpace(profiles[1]) == "" { return configDiffRequest{}, fmt.Errorf("config diff: exactly two non-empty profile names are required") } if request.flags.campaignPath.set && request.flags.campaignFilePath.set { return configDiffRequest{}, fmt.Errorf("config diff: --campaign and --campaign-file are mutually exclusive") } request.leftProfile = profiles[0] request.rightProfile = profiles[1] return request, nil } // splitConfigDiffArguments accepts the documented positional-first syntax and // also permits flags before or between profile names. Values belonging to the // supported string flags stay with their flag so they are not mistaken for // profile names. func splitConfigDiffArguments(args []string) (profiles, flagArgs []string) { for index := 0; index < len(args); index++ { argument := args[index] if !strings.HasPrefix(argument, "-") || argument == "-" { profiles = append(profiles, argument) continue } flagArgs = append(flagArgs, argument) name, hasValue := strings.CutPrefix(argument, "--") if !hasValue { continue } name, _, hasInlineValue := strings.Cut(name, "=") if hasInlineValue || !configDiffStringFlag(name) || index+1 >= len(args) { continue } index++ flagArgs = append(flagArgs, args[index]) } return profiles, flagArgs } func configDiffStringFlag(name string) bool { switch name { case "config", "campaign", "campaign-file", "profile": return true default: return false } } func resolveConfigDiff(request configDiffRequest) (*configDiffResolved, error) { pipelinePath, err := resolvePipelineConfigPath(request.flags.pipelinePath.value) if err != nil { return nil, err } leftPipeline, rightPipeline, err := config.LoadPipelineProfilePair(pipelinePath, request.leftProfile, request.rightProfile) if err != nil { return nil, err } needsCampaign := pipelineHasArtifactFamilies(leftPipeline) || pipelineHasArtifactFamilies(rightPipeline) hasCampaignSelection := request.flags.campaignPath.set || request.flags.campaignFilePath.set if !needsCampaign && !hasCampaignSelection { if err := validateInspectionPipeline(pipelinePath, leftPipeline); err != nil { return nil, err } if err := validateInspectionPipeline(pipelinePath, rightPipeline); err != nil { return nil, err } return &configDiffResolved{ left: &inspectionConfig{PipelinePath: pipelinePath, Pipeline: leftPipeline}, right: &inspectionConfig{PipelinePath: pipelinePath, Pipeline: rightPipeline}, }, nil } campaignPath, err := resolveSharedDiffCampaignPath(leftPipeline, rightPipeline, request.flags) if err != nil { if needsCampaign { return nil, fmt.Errorf("pipeline.scriptorium.artifact_families requires one shared campaign: %w", err) } return nil, err } campaign, err := config.LoadCampaign(campaignPath) if err != nil { return nil, err } if request.flags.campaignPath.set && !request.flags.campaignFilePath.set { if got := config.CampaignID(campaign); got != request.flags.campaignPath.value { return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", campaignPath, got, request.flags.campaignPath.value) } } leftLoaded, err := config.LoadPipelineCampaign(pipelinePath, leftPipeline, campaignPath, campaign) if err != nil { return nil, err } rightLoaded, err := config.LoadPipelineCampaignWithParty(pipelinePath, rightPipeline, campaignPath, campaign, leftLoaded.Party) if err != nil { return nil, err } if err := validateInspectionPipeline(leftLoaded.PipelinePath, leftLoaded.Pipeline); err != nil { return nil, err } if err := validateInspectionPipeline(rightLoaded.PipelinePath, rightLoaded.Pipeline); err != nil { return nil, err } if err := validateInspectionCampaign(campaignPath, campaign); err != nil { return nil, err } return &configDiffResolved{ left: &inspectionConfig{ PipelinePath: leftLoaded.PipelinePath, Pipeline: leftLoaded.Pipeline, CampaignPath: leftLoaded.CampaignPath, Campaign: campaign, Party: leftLoaded.Party, }, right: &inspectionConfig{ PipelinePath: rightLoaded.PipelinePath, Pipeline: rightLoaded.Pipeline, CampaignPath: rightLoaded.CampaignPath, Campaign: campaign, Party: rightLoaded.Party, }, }, nil } func resolveSharedDiffCampaignPath(left, right *config.PipelineConfig, flags configDiffFlags) (string, error) { leftPath, leftErr := resolveCampaignConfigPath(left, flags.campaignPath.value, flags.campaignFilePath.value) if leftErr != nil { return "", leftErr } rightPath, rightErr := resolveCampaignConfigPath(right, flags.campaignPath.value, flags.campaignFilePath.value) if rightErr != nil { return "", rightErr } if leftPath != rightPath { return "", fmt.Errorf("profile selections resolve different campaign files; specify --campaign-file to compare one campaign") } return leftPath, nil } func diffEffectivePipelineValues(left, right []config.EffectivePipelineValueRecord) ([]configDiffRecord, error) { leftByPath, err := effectivePipelineValueMap("left", left) if err != nil { return nil, err } rightByPath, err := effectivePipelineValueMap("right", right) if err != nil { return nil, err } paths := make([]string, 0, len(leftByPath)+len(rightByPath)) seen := make(map[string]struct{}, len(leftByPath)+len(rightByPath)) for path := range leftByPath { seen[path] = struct{}{} paths = append(paths, path) } for path := range rightByPath { if _, exists := seen[path]; !exists { paths = append(paths, path) } } sort.Strings(paths) records := make([]configDiffRecord, 0) for _, path := range paths { leftValue, leftExists := leftByPath[path] rightValue, rightExists := rightByPath[path] switch { case !leftExists: records = append(records, configDiffRecord{Kind: "added", Path: path, Right: rightValue}) case !rightExists: records = append(records, configDiffRecord{Kind: "removed", Path: path, Left: leftValue}) case leftValue != rightValue: records = append(records, configDiffRecord{Kind: "changed", Path: path, Left: leftValue, Right: rightValue}) } } return records, nil } func effectivePipelineValueMap(side string, records []config.EffectivePipelineValueRecord) (map[string]string, error) { values := make(map[string]string, len(records)) for _, record := range records { if _, exists := values[record.Path]; exists { return nil, fmt.Errorf("internal error: %s effective configuration has duplicate path %q", side, record.Path) } values[record.Path] = record.Value } return values, nil }