diff --git a/docs/user/reference/cli/azldev_component.md b/docs/user/reference/cli/azldev_component.md index 525c63a7..b50e012a 100644 --- a/docs/user/reference/cli/azldev_component.md +++ b/docs/user/reference/cli/azldev_component.md @@ -16,7 +16,8 @@ components defined in the project configuration. ### Options ``` - -h, --help help for component + -h, --help help for component + --spec-editor editor Select the RPM spec editor (legacy or experimental) (default legacy) ``` ### Options inherited from parent commands diff --git a/docs/user/reference/cli/azldev_component_add.md b/docs/user/reference/cli/azldev_component_add.md index bce63e5e..d37c5ba4 100644 --- a/docs/user/reference/cli/azldev_component_add.md +++ b/docs/user/reference/cli/azldev_component_add.md @@ -45,6 +45,7 @@ azldev component add [flags] --permissive-config do not fail on unknown fields in TOML config files -C, --project string path to Azure Linux project -q, --quiet only enable minimal output + --spec-editor editor Select the RPM spec editor (legacy or experimental) (default legacy) -v, --verbose enable verbose output ``` diff --git a/docs/user/reference/cli/azldev_component_build.md b/docs/user/reference/cli/azldev_component_build.md index 2086f683..9eca6655 100644 --- a/docs/user/reference/cli/azldev_component_build.md +++ b/docs/user/reference/cli/azldev_component_build.md @@ -76,6 +76,7 @@ azldev component build [flags] --permissive-config do not fail on unknown fields in TOML config files -C, --project string path to Azure Linux project -q, --quiet only enable minimal output + --spec-editor editor Select the RPM spec editor (legacy or experimental) (default legacy) -v, --verbose enable verbose output ``` diff --git a/docs/user/reference/cli/azldev_component_changed.md b/docs/user/reference/cli/azldev_component_changed.md index ce88bd3f..6f47c132 100644 --- a/docs/user/reference/cli/azldev_component_changed.md +++ b/docs/user/reference/cli/azldev_component_changed.md @@ -72,6 +72,7 @@ azldev component changed [flags] --permissive-config do not fail on unknown fields in TOML config files -C, --project string path to Azure Linux project -q, --quiet only enable minimal output + --spec-editor editor Select the RPM spec editor (legacy or experimental) (default legacy) -v, --verbose enable verbose output ``` diff --git a/docs/user/reference/cli/azldev_component_diff-sources.md b/docs/user/reference/cli/azldev_component_diff-sources.md index afd471ac..6ed7b6ea 100644 --- a/docs/user/reference/cli/azldev_component_diff-sources.md +++ b/docs/user/reference/cli/azldev_component_diff-sources.md @@ -39,6 +39,7 @@ azldev component diff-sources [flags] --permissive-config do not fail on unknown fields in TOML config files -C, --project string path to Azure Linux project -q, --quiet only enable minimal output + --spec-editor editor Select the RPM spec editor (legacy or experimental) (default legacy) -v, --verbose enable verbose output ``` diff --git a/docs/user/reference/cli/azldev_component_history.md b/docs/user/reference/cli/azldev_component_history.md index 00c58cd5..01c50c93 100644 --- a/docs/user/reference/cli/azldev_component_history.md +++ b/docs/user/reference/cli/azldev_component_history.md @@ -65,6 +65,7 @@ azldev component history [flags] --permissive-config do not fail on unknown fields in TOML config files -C, --project string path to Azure Linux project -q, --quiet only enable minimal output + --spec-editor editor Select the RPM spec editor (legacy or experimental) (default legacy) -v, --verbose enable verbose output ``` diff --git a/docs/user/reference/cli/azldev_component_list.md b/docs/user/reference/cli/azldev_component_list.md index 2900def0..3bff150e 100644 --- a/docs/user/reference/cli/azldev_component_list.md +++ b/docs/user/reference/cli/azldev_component_list.md @@ -54,6 +54,7 @@ azldev component list [flags] --permissive-config do not fail on unknown fields in TOML config files -C, --project string path to Azure Linux project -q, --quiet only enable minimal output + --spec-editor editor Select the RPM spec editor (legacy or experimental) (default legacy) -v, --verbose enable verbose output ``` diff --git a/docs/user/reference/cli/azldev_component_prepare-sources.md b/docs/user/reference/cli/azldev_component_prepare-sources.md index 590286e7..b0a1385c 100644 --- a/docs/user/reference/cli/azldev_component_prepare-sources.md +++ b/docs/user/reference/cli/azldev_component_prepare-sources.md @@ -59,6 +59,7 @@ azldev component prepare-sources [flags] --permissive-config do not fail on unknown fields in TOML config files -C, --project string path to Azure Linux project -q, --quiet only enable minimal output + --spec-editor editor Select the RPM spec editor (legacy or experimental) (default legacy) -v, --verbose enable verbose output ``` diff --git a/docs/user/reference/cli/azldev_component_query.md b/docs/user/reference/cli/azldev_component_query.md index 688fdc35..8c8ffa0c 100644 --- a/docs/user/reference/cli/azldev_component_query.md +++ b/docs/user/reference/cli/azldev_component_query.md @@ -57,6 +57,7 @@ azldev component query [flags] --permissive-config do not fail on unknown fields in TOML config files -C, --project string path to Azure Linux project -q, --quiet only enable minimal output + --spec-editor editor Select the RPM spec editor (legacy or experimental) (default legacy) -v, --verbose enable verbose output ``` diff --git a/docs/user/reference/cli/azldev_component_render.md b/docs/user/reference/cli/azldev_component_render.md index 1ded12de..3a3d8ad7 100644 --- a/docs/user/reference/cli/azldev_component_render.md +++ b/docs/user/reference/cli/azldev_component_render.md @@ -79,6 +79,7 @@ azldev component render [flags] --permissive-config do not fail on unknown fields in TOML config files -C, --project string path to Azure Linux project -q, --quiet only enable minimal output + --spec-editor editor Select the RPM spec editor (legacy or experimental) (default legacy) -v, --verbose enable verbose output ``` diff --git a/docs/user/reference/cli/azldev_component_update.md b/docs/user/reference/cli/azldev_component_update.md index 91144579..4e9d6fe0 100644 --- a/docs/user/reference/cli/azldev_component_update.md +++ b/docs/user/reference/cli/azldev_component_update.md @@ -77,6 +77,7 @@ azldev component update [flags] --permissive-config do not fail on unknown fields in TOML config files -C, --project string path to Azure Linux project -q, --quiet only enable minimal output + --spec-editor editor Select the RPM spec editor (legacy or experimental) (default legacy) -v, --verbose enable verbose output ``` diff --git a/docs/user/reference/config/overlays.md b/docs/user/reference/config/overlays.md index 3ab18791..20ba23c1 100644 --- a/docs/user/reference/config/overlays.md +++ b/docs/user/reference/config/overlays.md @@ -10,7 +10,13 @@ Overlays are defined within a component's configuration in your TOML config file ### Spec Overlays -These overlays modify `.spec` files using the structured spec parser, allowing precise targeting of tags and sections. +These overlays modify `.spec` files using the component command's `--spec-editor` option. The default `legacy` editor is line-oriented; `--spec-editor experimental` selects the structural editor, which preserves conditional and section structure. + +#### Known Limitations + +With the structural editor, content after a conditional wrapper's `%endif` cannot always be statically attributed to a section declared inside that wrapper. A scoped overlay may therefore not reach that content; use a narrowly anchored whole-spec `spec-search-replace` overlay instead. + +Sections generated by macros are unavailable to both editors because azldev does not evaluate RPM macros while editing specs. | Type | Description | Required Fields | |------|-------------|-----------------| diff --git a/internal/app/azldev/cmds/component/build.go b/internal/app/azldev/cmds/component/build.go index 60aed20f..91c3cf84 100644 --- a/internal/app/azldev/cmds/component/build.go +++ b/internal/app/azldev/cmds/component/build.go @@ -25,6 +25,7 @@ import ( ) type ComponentBuildOptions struct { + componentCommandOptions ComponentFilter components.ComponentFilter ContinueOnError bool @@ -88,7 +89,9 @@ func NewBuildCmd() *cobra.Command { BuildEnvPolicy: BuildEnvPreserveOnFailure, } - cmd := &cobra.Command{ + var cmd *cobra.Command + + cmd = &cobra.Command{ Use: "build", Short: "Build packages for components", Long: `Build RPM packages for one or more components using mock. @@ -121,6 +124,7 @@ builds can consume.`, azldev component build --local-repo-with-publish ./base/out -p liba -p libb`, RunE: azldev.RunFuncWithExtraArgs(func(env *azldev.Env, args []string) (interface{}, error) { options.ComponentFilter.ComponentNamePatterns = append(options.ComponentFilter.ComponentNamePatterns, args...) + options.SpecEditor = specEditorFromCommand(cmd) return SelectAndBuildComponents(env, options) }), @@ -280,7 +284,10 @@ func buildComponent( preparerOpts = append(preparerOpts, sources.WithMockProcessor(mockProcessor)) - sourcePreparer, err := sources.NewPreparer(sourceManager, env.FS(), env, env, preparerOpts...) + sourcePreparer, err := newSourcePreparer(sourceManager, env.FS(), env, env, append( + preparerOpts, + sources.WithSpecEditor(options.specEditorMode()), + )...) if err != nil { return ComponentBuildResults{}, fmt.Errorf("failed to create source preparer for component %q:\n%w", component.GetName(), err) diff --git a/internal/app/azldev/cmds/component/component.go b/internal/app/azldev/cmds/component/component.go index 9cfee2ea..d5656857 100644 --- a/internal/app/azldev/cmds/component/component.go +++ b/internal/app/azldev/cmds/component/component.go @@ -10,6 +10,10 @@ import ( // Called once when the app is initialized; registers any commands or callbacks with the app. func OnAppInit(app *azldev.App) { + app.AddTopLevelCommand(newComponentCmd(app)) +} + +func newComponentCmd(app *azldev.App) *cobra.Command { cmd := &cobra.Command{ Use: "component", Aliases: []string{"comp"}, @@ -22,7 +26,7 @@ Use subcommands to add, list, query, build, and prepare sources for components defined in the project configuration.`, } - app.AddTopLevelCommand(cmd) + addSpecEditorOption(cmd) addOnAppInit(app, cmd) buildOnAppInit(app, cmd) changedOnAppInit(app, cmd) @@ -33,4 +37,6 @@ components defined in the project configuration.`, queryOnAppInit(app, cmd) renderOnAppInit(app, cmd) updateOnAppInit(app, cmd) + + return cmd } diff --git a/internal/app/azldev/cmds/component/diffsources.go b/internal/app/azldev/cmds/component/diffsources.go index 8047bd9f..dae33234 100644 --- a/internal/app/azldev/cmds/component/diffsources.go +++ b/internal/app/azldev/cmds/component/diffsources.go @@ -23,6 +23,7 @@ import ( // DiffSourcesOptions holds the options for the diff-sources command. type DiffSourcesOptions struct { + componentCommandOptions ComponentFilter components.ComponentFilter OutputFile string @@ -36,7 +37,9 @@ func diffSourcesOnAppInit(_ *azldev.App, parentCmd *cobra.Command) { func NewDiffSourcesCmd() *cobra.Command { var options DiffSourcesOptions - cmd := &cobra.Command{ + var cmd *cobra.Command + + cmd = &cobra.Command{ Use: "diff-sources", Short: "Show the diff that overlays apply to a component's sources", Long: `Computes a unified diff showing the changes that overlays apply to a @@ -44,6 +47,7 @@ component's sources. Fetches the sources once, copies them, then applies overlays to the copy and displays the resulting diff between the two trees.`, RunE: azldev.RunFuncWithExtraArgs(func(env *azldev.Env, args []string) (interface{}, error) { options.ComponentFilter.ComponentNamePatterns = append(args, options.ComponentFilter.ComponentNamePatterns...) + options.SpecEditor = specEditorFromCommand(cmd) return DiffComponentSources(env, &options) }), @@ -97,8 +101,9 @@ func DiffComponentSources(env *azldev.Env, options *DiffSourcesOptions) (interfa return nil, fmt.Errorf("failed to create source manager:\n%w", err) } - preparer, err := sources.NewPreparer(sourceManager, env.FS(), env, env, - sources.WithUpstreamProvenance(sources.FedoraDistTag(distro.Ref.Name, distro.Version.ReleaseVer))) + preparer, err := newSourcePreparer(sourceManager, env.FS(), env, env, + sources.WithUpstreamProvenance(sources.FedoraDistTag(distro.Ref.Name, distro.Version.ReleaseVer)), + sources.WithSpecEditor(options.specEditorMode())) if err != nil { return nil, fmt.Errorf("failed to create source preparer:\n%w", err) } diff --git a/internal/app/azldev/cmds/component/preparesources.go b/internal/app/azldev/cmds/component/preparesources.go index ac48bdca..0575db2b 100644 --- a/internal/app/azldev/cmds/component/preparesources.go +++ b/internal/app/azldev/cmds/component/preparesources.go @@ -17,6 +17,7 @@ import ( ) type PrepareSourcesOptions struct { + componentCommandOptions ComponentFilter components.ComponentFilter OutputDir string @@ -34,7 +35,9 @@ func prepareOnAppInit(_ *azldev.App, sourceCmd *cobra.Command) { func NewPrepareSourcesCmd() *cobra.Command { var options PrepareSourcesOptions - cmd := &cobra.Command{ + var cmd *cobra.Command + + cmd = &cobra.Command{ Use: "prepare-sources", Aliases: []string{"prep-sources"}, Short: "Prepare buildable sources for components", @@ -53,6 +56,7 @@ Only one component may be selected at a time.`, azldev component prep-sources -p curl -o ./build/work/scratch/curl --skip-overlays --force`, RunE: azldev.RunFuncWithExtraArgs(func(env *azldev.Env, args []string) (interface{}, error) { options.ComponentFilter.ComponentNamePatterns = append(args, options.ComponentFilter.ComponentNamePatterns...) + options.SpecEditor = specEditorFromCommand(cmd) return nil, PrepareComponentSources(env, &options) }), @@ -132,7 +136,10 @@ func PrepareComponentSources(env *azldev.Env, options *PrepareSourcesOptions) er preparerOpts := buildPreparerOptions(env, distro, options) - preparer, err := sources.NewPreparer(sourceManager, env.FS(), env, env, preparerOpts...) + preparer, err := newSourcePreparer(sourceManager, env.FS(), env, env, append( + preparerOpts, + sources.WithSpecEditor(options.specEditorMode()), + )...) if err != nil { return fmt.Errorf("failed to create source preparer:\n%w", err) } diff --git a/internal/app/azldev/cmds/component/render.go b/internal/app/azldev/cmds/component/render.go index 163da0dd..fd6b75d2 100644 --- a/internal/app/azldev/cmds/component/render.go +++ b/internal/app/azldev/cmds/component/render.go @@ -19,6 +19,7 @@ import ( "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/sources" "github.com/microsoft/azure-linux-dev-tools/internal/global/opctx" "github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders" + "github.com/microsoft/azure-linux-dev-tools/internal/rpm/spec" "github.com/microsoft/azure-linux-dev-tools/internal/utils/dirdiff" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" @@ -29,6 +30,7 @@ import ( // RenderOptions holds the options for the render command. type RenderOptions struct { + componentCommandOptions ComponentFilter components.ComponentFilter OutputDir string OutputDirExplicit bool // True when --output-dir was explicitly passed on the CLI. @@ -88,6 +90,7 @@ valid with -a.`, RunE: azldev.RunFuncWithExtraArgs(func(env *azldev.Env, args []string) (interface{}, error) { options.ComponentFilter.ComponentNamePatterns = append(args, options.ComponentFilter.ComponentNamePatterns...) options.OutputDirExplicit = cmd.Flags().Changed("output-dir") + options.SpecEditor = specEditorFromCommand(cmd) return RenderComponents(env, &options) }), @@ -204,7 +207,7 @@ func RenderComponents(env *azldev.Env, options *RenderOptions) ([]*RenderResult, results := make([]*RenderResult, len(componentList)) // ── Phase 1: Parallel source preparation ── - prepared := parallelPrepare(env, mockProcessor, componentList, stagingDir, options.OutputDir, results) + prepared := parallelPrepare(env, mockProcessor, componentList, stagingDir, options, results) // ── Phase 2: Batch mock processing ── mockResultMap := batchMockProcess(env, mockProcessor, stagingDir, prepared) @@ -386,7 +389,7 @@ func parallelPrepare( mockProcessor *sources.MockProcessor, comps []components.Component, stagingDir string, - outputDir string, + options *RenderOptions, results []*RenderResult, ) []*preparedComponent { progressEvent := env.StartEvent("Preparing component sources", "count", len(comps)) @@ -406,7 +409,8 @@ func parallelPrepare( // workerEnv (captured) is the effective context for this call chain; // the parmap-supplied ctx is identical and unused here. //nolint:contextcheck // env carries the ctx - return prepareOneComponent(workerEnv, mockProcessor, comp, stagingDir, outputDir) + return prepareOneComponent( + workerEnv, mockProcessor, comp, stagingDir, options.OutputDir, options.specEditorMode()) }, ) @@ -418,7 +422,7 @@ func parallelPrepare( // Worker never started — ctx ended before parmap reached it. compName := comps[idx].GetName() - compOutputDir, nameErr := components.RenderedSpecDir(outputDir, compName) + compOutputDir, nameErr := components.RenderedSpecDir(options.OutputDir, compName) if nameErr != nil { compOutputDir = "(invalid)" } @@ -453,6 +457,7 @@ func prepareOneComponent( comp components.Component, stagingDir string, outputDir string, + specEditor spec.EditorMode, ) prepResult { componentName := comp.GetName() @@ -467,7 +472,7 @@ func prepareOneComponent( }} } - prep, err := prepareComponentSources(env, mockProcessor, comp, stagingDir) + prep, err := prepareComponentSources(env, mockProcessor, comp, stagingDir, specEditor) if err != nil { slog.Error("Failed to prepare component sources", "component", componentName, "error", err) @@ -493,6 +498,7 @@ func prepareComponentSources( mockProcessor *sources.MockProcessor, comp components.Component, stagingDir string, + specEditor spec.EditorMode, ) (*preparedComponent, error) { componentName := comp.GetName() @@ -531,7 +537,10 @@ func prepareComponentSources( sources.WithMockProcessor(mockProcessor), } - preparer, err := sources.NewPreparer(sourceManager, env.FS(), env, env, preparerOpts...) + preparer, err := newSourcePreparer(sourceManager, env.FS(), env, env, append( + preparerOpts, + sources.WithSpecEditor(specEditor), + )...) if err != nil { return nil, fmt.Errorf("creating source preparer for %#q:\n%w", componentName, err) } diff --git a/internal/app/azldev/cmds/component/sourcepreparer.go b/internal/app/azldev/cmds/component/sourcepreparer.go new file mode 100644 index 00000000..a16e0a00 --- /dev/null +++ b/internal/app/azldev/cmds/component/sourcepreparer.go @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package component + +import "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/sources" + +//nolint:gochecknoglobals // Replaced by isolated command tests to observe constructed source preparers. +var newSourcePreparer = sources.NewPreparer diff --git a/internal/app/azldev/cmds/component/speceditor.go b/internal/app/azldev/cmds/component/speceditor.go new file mode 100644 index 00000000..6c4467c0 --- /dev/null +++ b/internal/app/azldev/cmds/component/speceditor.go @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package component + +import ( + "fmt" + + "github.com/microsoft/azure-linux-dev-tools/internal/rpm/spec" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const ( + specEditorLegacy = "legacy" + specEditorExperimental = "experimental" +) + +// componentCommandOptions are shared by component subcommands that edit specs. +type componentCommandOptions struct { + SpecEditor spec.EditorMode +} + +func (o componentCommandOptions) specEditorMode() spec.EditorMode { + if o.SpecEditor == "" { + return spec.EditorLegacy + } + + return o.SpecEditor +} + +type specEditorFlagValue struct { + mode spec.EditorMode +} + +var _ pflag.Value = (*specEditorFlagValue)(nil) + +func newSpecEditorFlagValue() *specEditorFlagValue { + return &specEditorFlagValue{mode: spec.EditorLegacy} +} + +func (v *specEditorFlagValue) String() string { + if v.mode == spec.EditorStructural { + return specEditorExperimental + } + + return specEditorLegacy +} + +func (v *specEditorFlagValue) Set(value string) error { + switch value { + case specEditorLegacy: + v.mode = spec.EditorLegacy + case specEditorExperimental: + v.mode = spec.EditorStructural + default: + return fmt.Errorf( + "unsupported RPM spec editor %#q; expected %#q or %#q", + value, specEditorLegacy, specEditorExperimental, + ) + } + + return nil +} + +func (v *specEditorFlagValue) Type() string { + return "editor" +} + +func addSpecEditorOption(cmd *cobra.Command) { + cmd.PersistentFlags().Var( + newSpecEditorFlagValue(), + "spec-editor", + "Select the RPM spec editor (legacy or experimental)", + ) +} + +func specEditorFromCommand(cmd *cobra.Command) spec.EditorMode { + flag := cmd.Flags().Lookup("spec-editor") + if flag == nil { + return spec.EditorLegacy + } + + value, ok := flag.Value.(*specEditorFlagValue) + if !ok { + return spec.EditorLegacy + } + + return value.mode +} diff --git a/internal/app/azldev/cmds/component/speceditor_internal_test.go b/internal/app/azldev/cmds/component/speceditor_internal_test.go new file mode 100644 index 00000000..7bad2782 --- /dev/null +++ b/internal/app/azldev/cmds/component/speceditor_internal_test.go @@ -0,0 +1,287 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package component + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/sources" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/testutils" + "github.com/microsoft/azure-linux-dev-tools/internal/global/opctx" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders" + "github.com/microsoft/azure-linux-dev-tools/internal/rpm/spec" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/dirdiff" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms" + "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestComponentCommandOptionsDefaultsToLegacy(t *testing.T) { + assert.Equal(t, spec.EditorLegacy, componentCommandOptions{}.specEditorMode()) +} + +func TestSpecEditorOptionDefaultsToLegacy(t *testing.T) { + editor, err := executeSpecEditorProductionCommand(t, "render") + + require.NoError(t, err) + assert.Equal(t, spec.EditorLegacy, editor) +} + +func TestSpecEditorOptionParsesLegacy(t *testing.T) { + editor, err := executeSpecEditorProductionCommand(t, "render", "--spec-editor", specEditorLegacy) + + require.NoError(t, err) + assert.Equal(t, spec.EditorLegacy, editor) +} + +func TestSpecEditorOptionParsesExperimental(t *testing.T) { + editor, err := executeSpecEditorProductionCommand(t, "render", "--spec-editor", specEditorExperimental) + + require.NoError(t, err) + assert.Equal(t, spec.EditorStructural, editor) +} + +func TestSpecEditorOptionDoesNotLeakBetweenCommands(t *testing.T) { + experimental, err := executeSpecEditorProductionCommand(t, "render", "--spec-editor", specEditorExperimental) + require.NoError(t, err) + assert.Equal(t, spec.EditorStructural, experimental) + + legacy, err := executeSpecEditorProductionCommand(t, "render") + require.NoError(t, err) + assert.Equal(t, spec.EditorLegacy, legacy) +} + +func TestSpecEditorOptionIsInheritedByNonConsumingCommands(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + cmd := newComponentCmd(nil) + cmd.SetArgs([]string{"list", "--spec-editor", specEditorExperimental}) + + require.NoError(t, cmd.ExecuteContext(testEnv.Env)) +} + +func TestSpecEditorOptionRejectsUnsupportedValuesWithoutFallback(t *testing.T) { + executed := false + originalNewSourcePreparer := newSourcePreparer + newSourcePreparer = func( + sourceManager sourceproviders.SourceManager, + fileSystem opctx.FS, + eventListener opctx.EventListener, + dryRunnable opctx.DryRunnable, + options ...sources.PreparerOption, + ) (sources.SourcePreparer, error) { + executed = true + + return originalNewSourcePreparer(sourceManager, fileSystem, eventListener, dryRunnable, options...) + } + + t.Cleanup(func() { + newSourcePreparer = originalNewSourcePreparer + }) + + cmd := newComponentCmd(nil) + cmd.SetArgs([]string{"render", "--spec-editor", "structural"}) + + err := cmd.Execute() + + require.Error(t, err) + require.ErrorContains(t, err, "unsupported RPM spec editor `structural`; expected `legacy` or `experimental`") + assert.False(t, executed) +} + +func TestSpecEditorOptionRoutesToRelevantComponentCommands(t *testing.T) { + for _, testCase := range []struct { + name string + args []string + expected spec.EditorMode + }{ + { + name: "omitted flag uses legacy", + expected: spec.EditorLegacy, + }, + { + name: "explicit legacy uses legacy", + args: []string{"--spec-editor", specEditorLegacy}, + expected: spec.EditorLegacy, + }, + { + name: "experimental uses structural", + args: []string{"--spec-editor", specEditorExperimental}, + expected: spec.EditorStructural, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + for _, command := range []string{"render", "build", "prepare-sources", "diff-sources"} { + t.Run(command, func(t *testing.T) { + editor, err := executeSpecEditorProductionCommand(t, command, testCase.args...) + + if command == "render" { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, errSpecEditorObserved) + } + + assert.Equal(t, testCase.expected, editor) + }) + } + }) + } +} + +var errSpecEditorObserved = errors.New("source preparer observed spec editor") + +type specEditorObservingPreparer struct { + sources.SourcePreparer + fileSystem opctx.FS + editor *spec.EditorMode +} + +func (p *specEditorObservingPreparer) PrepareSources( + ctx context.Context, component components.Component, outputDir string, applyOverlays bool, +) error { + err := p.SourcePreparer.PrepareSources(ctx, component, outputDir, applyOverlays) + if observeErr := p.observePreparedSpec(component.GetName(), outputDir); observeErr != nil { + return observeErr + } + + if err != nil { + return err + } + + return errSpecEditorObserved +} + +func (p *specEditorObservingPreparer) DiffSources( + ctx context.Context, component components.Component, baseDir string, +) (*dirdiff.DiffResult, error) { + result, err := p.SourcePreparer.DiffSources(ctx, component, baseDir) + if err != nil { + return nil, err + } + + diff := result.String() + if !strings.Contains(diff, "Name: selected-by-overlay") { + return nil, errors.New("spec editor observation was not present in the source diff") + } + + if strings.Contains(diff, "-Name: macro-body") { + *p.editor = spec.EditorLegacy + } else { + *p.editor = spec.EditorStructural + } + + return nil, errSpecEditorObserved +} + +func (p *specEditorObservingPreparer) observePreparedSpec(componentName, outputDir string) error { + contents, err := fileutils.ReadFile(p.fileSystem, filepath.Join(outputDir, componentName+".spec")) + if err != nil { + return err + } + + if strings.Contains(string(contents), "\nName: macro-body\n") { + *p.editor = spec.EditorStructural + } else { + *p.editor = spec.EditorLegacy + } + + return nil +} + +func executeSpecEditorProductionCommand(t *testing.T, command string, args ...string) (spec.EditorMode, error) { + t.Helper() + + var editor spec.EditorMode + + testEnv := testutils.NewTestEnv(t) + addSpecEditorTestComponent(t, testEnv) + + originalNewSourcePreparer := newSourcePreparer + newSourcePreparer = func( + sourceManager sourceproviders.SourceManager, + fileSystem opctx.FS, + eventListener opctx.EventListener, + dryRunnable opctx.DryRunnable, + options ...sources.PreparerOption, + ) (sources.SourcePreparer, error) { + preparer, err := originalNewSourcePreparer(sourceManager, fileSystem, eventListener, dryRunnable, options...) + if err != nil { + return nil, err + } + + return &specEditorObservingPreparer{ + SourcePreparer: preparer, + fileSystem: fileSystem, + editor: &editor, + }, nil + } + + t.Cleanup(func() { + newSourcePreparer = originalNewSourcePreparer + }) + + cmd := newComponentCmd(nil) + cmd.SetArgs(append([]string{command}, append(specEditorCommandArgs(command), args...)...)) + + return editor, cmd.ExecuteContext(testEnv.Env) +} + +func specEditorCommandArgs(command string) []string { + switch command { + case "build": + return []string{"--without-git", "spec-editor-fixture"} + case "render": + return []string{"--output-dir", "/rendered", "spec-editor-fixture"} + case "prepare-sources": + return []string{"--without-git", "--output-dir", "/prepared", "spec-editor-fixture"} + case "diff-sources": + return []string{"spec-editor-fixture"} + default: + panic("unsupported command: " + command) + } +} + +func addSpecEditorTestComponent(t *testing.T, testEnv *testutils.TestEnv) { + t.Helper() + + const ( + componentName = "spec-editor-fixture" + specPath = "/project/specs/spec-editor-fixture/spec-editor-fixture.spec" + ) + + specContents := []string{ + "%global hidden() \\", + "Name: macro-body", + "Name: spec-editor-fixture", + "Version: 1", + "Release: 1", + "Summary: fixture", + "%description", + "fixture", + } + + require.NoError(t, fileutils.WriteFile( + testEnv.TestFS, specPath, []byte(strings.Join(specContents, "\n")+"\n"), fileperms.PublicFile)) + + testEnv.Config.Components[componentName] = projectconfig.ComponentConfig{ + Name: componentName, + Spec: projectconfig.SpecSource{ + SourceType: projectconfig.SpecSourceTypeLocal, + Path: specPath, + }, + Overlays: []projectconfig.ComponentOverlay{ + { + Type: projectconfig.ComponentOverlayUpdateSpecTag, + Tag: "Name", + Value: "selected-by-overlay", + }, + }, + } +} diff --git a/scenario/__snapshots__/TestMCPServerMode_1.snap.json b/scenario/__snapshots__/TestMCPServerMode_1.snap.json index 02459314..22c56748 100755 --- a/scenario/__snapshots__/TestMCPServerMode_1.snap.json +++ b/scenario/__snapshots__/TestMCPServerMode_1.snap.json @@ -83,6 +83,10 @@ "description": "only enable minimal output", "type": "boolean" }, + "spec-editor": { + "description": "Select the RPM spec editor (legacy or experimental)", + "type": "string" + }, "spec-path": { "description": "Spec path", "type": "string" @@ -185,6 +189,10 @@ "description": "skip lock file consistency checks", "type": "boolean" }, + "spec-editor": { + "description": "Select the RPM spec editor (legacy or experimental)", + "type": "string" + }, "spec-path": { "description": "Spec path", "type": "string" @@ -280,6 +288,10 @@ "description": "How to report rows for components that share a TOML file with others: show (keep row, count is coarse), omit (drop row).", "type": "string" }, + "spec-editor": { + "description": "Select the RPM spec editor (legacy or experimental)", + "type": "string" + }, "spec-path": { "description": "Spec path", "type": "string" @@ -365,6 +377,10 @@ "description": "only enable minimal output", "type": "boolean" }, + "spec-editor": { + "description": "Select the RPM spec editor (legacy or experimental)", + "type": "string" + }, "spec-path": { "description": "Spec path", "type": "string"