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") } 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) 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, or config sources to report ownership.") } 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 }) }