package app import ( "context" "errors" "flag" "fmt" "io" "sort" "strings" "gitea.maximumdirect.net/eric/narratio/internal/config" ) type inspectionFlags struct { pipelinePath string campaignPath string campaignFilePath string profile singletonStringFlag } type inspectionConfig struct { PipelinePath string Pipeline *config.PipelineConfig CampaignPath string Campaign *config.CampaignConfig Party config.ResolvedParty } // Config dispatches read-only pipeline configuration commands. func Config(ctx context.Context, args []string, out io.Writer) error { if len(args) == 0 { return fmt.Errorf("config: expected subcommand: validate|show|sources|diff") } if args[0] == "--help" || args[0] == "-h" { writeConfigCommandUsage(out) return nil } switch args[0] { case "validate": return ConfigValidate(ctx, args[1:], out) case "show": return ConfigShow(ctx, args[1:], out) case "sources": return ConfigSources(ctx, args[1:], out) case "diff": return ConfigDiff(ctx, args[1:], out) default: return fmt.Errorf("config: unknown subcommand %q", args[0]) } } // ConfigValidate validates one fully resolved effective pipeline without // loading session state or constructing runtime collaborators. func ConfigValidate(_ context.Context, args []string, out io.Writer) error { flags, err := parseInspectionFlags("config validate", args, out) if err != nil { if errors.Is(err, flag.ErrHelp) { return nil } return err } resolved, err := resolveInspectionConfig(flags) if err != nil { return fmt.Errorf("config validate: %w", err) } _, err = fmt.Fprintf(out, "Configuration valid: root=%s; profile=%s; digest=%s\n", inspectionRootPath(resolved), inspectionProfile(resolved.Pipeline), config.EffectivePipelineDigest(resolved.Pipeline)) return err } // ConfigShow validates and renders one fully resolved effective pipeline // without loading session state or constructing runtime collaborators. func ConfigShow(_ context.Context, args []string, out io.Writer) error { flags, err := parseInspectionFlags("config show", args, out) if err != nil { if errors.Is(err, flag.ErrHelp) { return nil } return err } resolved, err := resolveInspectionConfig(flags) if err != nil { return fmt.Errorf("config show: %w", err) } data, err := config.MarshalEffectivePipeline(resolved.Pipeline) if err != nil { return fmt.Errorf("config show: %w", err) } _, err = out.Write(data) return err } // ConfigSources validates and reports deterministic effective configuration // ownership without loading session state or runtime collaborators. func ConfigSources(_ context.Context, args []string, out io.Writer) error { flags, err := parseInspectionFlags("config sources", args, out) if err != nil { if errors.Is(err, flag.ErrHelp) { return nil } return err } resolved, err := resolveInspectionConfig(flags) if err != nil { return fmt.Errorf("config sources: %w", err) } records, err := config.EffectivePipelineSources(resolved.Pipeline, resolved.Party) if err != nil { return fmt.Errorf("config sources: %w", err) } records = append(records, config.EffectiveCampaignSources(resolved.CampaignPath, resolved.Campaign, resolved.Party)...) sortInspectionSourceRecords(records) if err := writeInspectionSourceHeader(out, resolved); err != nil { return err } for _, record := range records { if _, err := fmt.Fprintf(out, "%s\t%s\t%s\n", record.Path, record.Role, record.Source); err != nil { return err } } return nil } func parseInspectionFlags(command string, args []string, out io.Writer) (inspectionFlags, error) { fs := flag.NewFlagSet(command, flag.ContinueOnError) fs.SetOutput(out) var flags inspectionFlags fs.StringVar(&flags.pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&flags.campaignPath, "campaign", "", "campaign ID") fs.StringVar(&flags.campaignFilePath, "campaign-file", "", "path to campaign.yml") flags.profile.name = "profile" fs.Var(&flags.profile, "profile", "named pipeline profile") fs.Usage = func() { fmt.Fprintf(out, "Usage: narratio %s [--config ] [--campaign | --campaign-file ] [--profile ]\n\n", command) fmt.Fprintln(out, "Resolves configuration without a session, workspace, manifest, adapters, or external services.") fmt.Fprintln(out) fmt.Fprintln(out, "Flags:") fs.PrintDefaults() } if err := fs.Parse(args); err != nil { return inspectionFlags{}, fmt.Errorf("%s: invalid flags: %w", command, err) } if fs.NArg() != 0 { return inspectionFlags{}, fmt.Errorf("%s: unexpected positional arguments", command) } if strings.TrimSpace(flags.campaignPath) != "" && strings.TrimSpace(flags.campaignFilePath) != "" { return inspectionFlags{}, fmt.Errorf("%s: --campaign and --campaign-file are mutually exclusive", command) } return flags, nil } func resolveInspectionConfig(flags inspectionFlags) (*inspectionConfig, error) { pipelinePath, pipeline, err := loadPipelineConfig(flags.pipelinePath, config.PipelineLoadOptions{Profile: flags.profile.pointer()}) if err != nil { return nil, err } needsCampaign := pipelineHasArtifactFamilies(pipeline) hasCampaignSelection := strings.TrimSpace(flags.campaignPath) != "" || strings.TrimSpace(flags.campaignFilePath) != "" if !needsCampaign && !hasCampaignSelection { if err := validateInspectionPipeline(pipelinePath, pipeline); err != nil { return nil, err } return &inspectionConfig{PipelinePath: pipelinePath, Pipeline: pipeline}, nil } campaignPath, err := resolveCampaignConfigPath(pipeline, flags.campaignPath, flags.campaignFilePath) if err != nil { if needsCampaign { return nil, fmt.Errorf("pipeline.scriptorium.artifact_families requires a campaign: %w", err) } return nil, err } campaign, err := config.LoadCampaign(campaignPath) if err != nil { return nil, err } if selectedID := strings.TrimSpace(flags.campaignPath); selectedID != "" && strings.TrimSpace(flags.campaignFilePath) == "" { if got := config.CampaignID(campaign); got != selectedID { return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", campaignPath, got, selectedID) } } loaded, err := config.LoadPipelineCampaign(pipelinePath, pipeline, campaignPath, campaign) if err != nil { return nil, err } if err := validateInspectionPipeline(loaded.PipelinePath, loaded.Pipeline); err != nil { return nil, err } if err := validateInspectionCampaign(loaded.CampaignPath, loaded.Campaign); err != nil { return nil, err } return &inspectionConfig{ PipelinePath: loaded.PipelinePath, Pipeline: loaded.Pipeline, CampaignPath: loaded.CampaignPath, Campaign: loaded.Campaign, Party: loaded.Party, }, nil } func pipelineHasArtifactFamilies(pipeline *config.PipelineConfig) bool { return pipeline != nil && pipeline.Scriptorium != nil && len(pipeline.Scriptorium.ArtifactFamilies) != 0 } func validateInspectionPipeline(path string, pipeline *config.PipelineConfig) error { if err := config.ValidatePipelineConfig(pipeline); err != nil { return fmt.Errorf("pipeline config %q invalid: %w", path, err) } return nil } func validateInspectionCampaign(path string, campaign *config.CampaignConfig) error { if err := config.ValidateCampaignConfig(campaign); err != nil { return fmt.Errorf("campaign config %q invalid: %w", path, err) } return nil } func inspectionRootPath(resolved *inspectionConfig) string { if resolved == nil { return "" } if root := config.EffectivePipelineRootPath(resolved.Pipeline); root != "" { return root } return resolved.PipelinePath } func inspectionProfile(pipeline *config.PipelineConfig) string { if profile, ok := config.SelectedPipelineProfile(pipeline); ok { return profile.Name } return "none" } func writeConfigCommandUsage(out io.Writer) { fmt.Fprintln(out, "Usage: narratio config ") fmt.Fprintln(out) fmt.Fprintln(out, "Use config validate to check an effective pipeline, config show to print normalized YAML, config sources to report ownership, or config diff to compare two profiles.") } func writeInspectionSourceHeader(out io.Writer, resolved *inspectionConfig) error { if _, err := fmt.Fprintf(out, "root: %s\n", inspectionRootPath(resolved)); err != nil { return err } imports := config.EffectivePipelineImports(resolved.Pipeline) if len(imports) == 0 { if _, err := fmt.Fprintln(out, "imports: none"); err != nil { return err } } for _, imported := range imports { if _, err := fmt.Fprintf(out, "import: %s\n", config.NormalizedConfigurationPath(imported)); err != nil { return err } } if profile, ok := config.SelectedPipelineProfile(resolved.Pipeline); ok { if _, err := fmt.Fprintf(out, "profile: name=%s selection=%s overlay=%s\n", profile.Name, profile.Source, config.NormalizedConfigurationPath(profile.OverlayPath)); err != nil { return err } } else if _, err := fmt.Fprintln(out, "profile: none"); err != nil { return err } if resolved.Campaign == nil { if _, err := fmt.Fprintln(out, "campaign: none"); err != nil { return err } if _, err := fmt.Fprintln(out, "party: none"); err != nil { return err } } else { if _, err := fmt.Fprintf(out, "campaign: id=%s source=%s\n", config.CampaignID(resolved.Campaign), config.NormalizedConfigurationPath(resolved.CampaignPath)); err != nil { return err } if _, err := fmt.Fprintf(out, "party: mode=%s source=%s\n", resolved.Party.Mode, config.NormalizedConfigurationPath(resolved.Party.Source.Path)); err != nil { return err } } if _, err := fmt.Fprintf(out, "digest: %s\n", config.EffectivePipelineDigest(resolved.Pipeline)); err != nil { return err } _, err := fmt.Fprintln(out, "path\trole\tsource") return err } func sortInspectionSourceRecords(records []config.EffectivePipelineSourceRecord) { sort.Slice(records, func(left, right int) bool { if records[left].Path != records[right].Path { return records[left].Path < records[right].Path } if records[left].Role != records[right].Role { return records[left].Role < records[right].Role } return records[left].Source < records[right].Source }) }