package app import ( "context" "errors" "flag" "fmt" "io" "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 } // 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") } 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) 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 } 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, }, 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 or config show to print normalized effective YAML.") }