Skip to content

Commit 0a0ff4d

Browse files
authored
feat: component group configs (#476)
1 parent deb53df commit 0a0ff4d

File tree

10 files changed

+469
-15
lines changed

10 files changed

+469
-15
lines changed

internal/app/azldev/core/components/resolver.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"log/slog"
1010
"path"
1111
"path/filepath"
12+
"slices"
13+
"sort"
1214
"strings"
1315

1416
"github.com/bmatcuk/doublestar/v4"
@@ -458,6 +460,7 @@ func applyInheritedDefaultsToComponent(
458460
) (result *projectconfig.ComponentConfig, err error) {
459461
var distroVer projectconfig.DistroVersionDefinition
460462

463+
// Find the distro.
461464
_, distroVer, err = env.Distro()
462465
if err != nil {
463466
return result, fmt.Errorf("failed to resolve current distro:\n%w", err)
@@ -467,6 +470,30 @@ func applyInheritedDefaultsToComponent(
467470
result = &projectconfig.ComponentConfig{}
468471
*result = deep.MustCopy(distroVer.DefaultComponentConfig)
469472

473+
// Find all component groups that this component belongs to and apply their defaults.
474+
if groupNames, ok := env.Config().GroupsByComponent[component.Name]; ok {
475+
// Sort the group names for deterministic layering of defaults.
476+
sortedGroupNames := slices.Clone(groupNames)
477+
sort.Strings(sortedGroupNames)
478+
479+
for _, groupName := range sortedGroupNames {
480+
// Find the group.
481+
groupConfig, ok := env.Config().ComponentGroups[groupName]
482+
if !ok {
483+
return result, fmt.Errorf("component group not found: %s", groupName)
484+
}
485+
486+
// Apply its defaults.
487+
err = result.MergeUpdatesFrom(&groupConfig.DefaultComponentConfig)
488+
if err != nil {
489+
return result, fmt.Errorf(
490+
"failed to apply defaults from component group '%s' to config for component '%s':\n%w",
491+
groupName, component.Name, err,
492+
)
493+
}
494+
}
495+
}
496+
470497
// Layer in the component's explicit config.
471498
err = result.MergeUpdatesFrom(&component)
472499
if err != nil {

internal/app/azldev/core/components/resolver_test.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,149 @@ func TestGetComponentGroupByName_GroupWithMatchingSpecs(t *testing.T) {
412412
assert.ElementsMatch(t, expectedComponents, group.Components)
413413
}
414414

415+
func TestApplyInheritedDefaults_GroupDefaults(t *testing.T) {
416+
// A component belongs to a group that defines build defaults.
417+
// The group defaults should be layered between distro defaults and the component's own config.
418+
env := testutils.NewTestEnv(t)
419+
420+
// Set up a component with its own build config.
421+
component := projectconfig.ComponentConfig{
422+
Name: "my-comp",
423+
Build: projectconfig.ComponentBuildConfig{
424+
With: []string{"feature-x"},
425+
},
426+
}
427+
env.Config.Components[component.Name] = component
428+
429+
// Set up a group with default build config.
430+
env.Config.ComponentGroups["my-group"] = projectconfig.ComponentGroupConfig{
431+
Components: []string{"my-comp"},
432+
DefaultComponentConfig: projectconfig.ComponentConfig{
433+
Build: projectconfig.ComponentBuildConfig{
434+
Without: []string{"docs"},
435+
},
436+
},
437+
}
438+
env.Config.GroupsByComponent["my-comp"] = []string{"my-group"}
439+
440+
filter := &components.ComponentFilter{IncludeAllComponents: true}
441+
442+
// Find!
443+
result, err := components.NewResolver(env.Env).FindComponents(filter)
444+
require.NoError(t, err)
445+
require.Len(t, result.Components(), 1)
446+
447+
resolved := result.Components()[0].GetConfig()
448+
449+
// Should have the component's own With setting.
450+
assert.Contains(t, resolved.Build.With, "feature-x")
451+
// Should also have the group's Without setting.
452+
assert.Contains(t, resolved.Build.Without, "docs")
453+
}
454+
455+
func TestApplyInheritedDefaults_MultipleGroupsDeterministicOrder(t *testing.T) {
456+
// A component belongs to two groups. Their defaults should be applied in
457+
// sorted group-name order for deterministic behavior.
458+
env := testutils.NewTestEnv(t)
459+
460+
component := projectconfig.ComponentConfig{Name: "my-comp"}
461+
env.Config.Components[component.Name] = component
462+
463+
// Group "aaa" adds with=["from-aaa"].
464+
env.Config.ComponentGroups["aaa"] = projectconfig.ComponentGroupConfig{
465+
Components: []string{"my-comp"},
466+
DefaultComponentConfig: projectconfig.ComponentConfig{
467+
Build: projectconfig.ComponentBuildConfig{
468+
With: []string{"from-aaa"},
469+
},
470+
},
471+
}
472+
473+
// Group "zzz" adds with=["from-zzz"].
474+
env.Config.ComponentGroups["zzz"] = projectconfig.ComponentGroupConfig{
475+
Components: []string{"my-comp"},
476+
DefaultComponentConfig: projectconfig.ComponentConfig{
477+
Build: projectconfig.ComponentBuildConfig{
478+
With: []string{"from-zzz"},
479+
},
480+
},
481+
}
482+
483+
env.Config.GroupsByComponent["my-comp"] = []string{"zzz", "aaa"}
484+
485+
filter := &components.ComponentFilter{IncludeAllComponents: true}
486+
487+
result, err := components.NewResolver(env.Env).FindComponents(filter)
488+
require.NoError(t, err)
489+
require.Len(t, result.Components(), 1)
490+
491+
resolved := result.Components()[0].GetConfig()
492+
493+
// Both group defaults should be applied.
494+
assert.Contains(t, resolved.Build.With, "from-aaa")
495+
assert.Contains(t, resolved.Build.With, "from-zzz")
496+
}
497+
498+
func TestApplyInheritedDefaults_ComponentOverridesGroupDefaults(t *testing.T) {
499+
// When a component explicitly sets a field that is also set by its group's
500+
// defaults, the component's value should take precedence via merging.
501+
env := testutils.NewTestEnv(t)
502+
503+
component := projectconfig.ComponentConfig{
504+
Name: "my-comp",
505+
Build: projectconfig.ComponentBuildConfig{
506+
Defines: map[string]string{"key": "comp-value"},
507+
},
508+
}
509+
env.Config.Components[component.Name] = component
510+
511+
env.Config.ComponentGroups["my-group"] = projectconfig.ComponentGroupConfig{
512+
Components: []string{"my-comp"},
513+
DefaultComponentConfig: projectconfig.ComponentConfig{
514+
Build: projectconfig.ComponentBuildConfig{
515+
Defines: map[string]string{"key": "group-value", "other": "group-only"},
516+
},
517+
},
518+
}
519+
env.Config.GroupsByComponent["my-comp"] = []string{"my-group"}
520+
521+
filter := &components.ComponentFilter{IncludeAllComponents: true}
522+
523+
result, err := components.NewResolver(env.Env).FindComponents(filter)
524+
require.NoError(t, err)
525+
require.Len(t, result.Components(), 1)
526+
527+
resolved := result.Components()[0].GetConfig()
528+
529+
// The component's own value should override the group's value.
530+
assert.Equal(t, "comp-value", resolved.Build.Defines["key"])
531+
// The group's other defaults should still be present.
532+
assert.Equal(t, "group-only", resolved.Build.Defines["other"])
533+
}
534+
535+
func TestApplyInheritedDefaults_NoGroupMembership(t *testing.T) {
536+
// A component that doesn't belong to any group should still resolve correctly
537+
// (only distro defaults + component config).
538+
env := testutils.NewTestEnv(t)
539+
540+
component := projectconfig.ComponentConfig{
541+
Name: "standalone",
542+
Build: projectconfig.ComponentBuildConfig{
543+
With: []string{"my-feature"},
544+
},
545+
}
546+
env.Config.Components[component.Name] = component
547+
548+
filter := &components.ComponentFilter{IncludeAllComponents: true}
549+
550+
result, err := components.NewResolver(env.Env).FindComponents(filter)
551+
require.NoError(t, err)
552+
require.Len(t, result.Components(), 1)
553+
554+
resolved := result.Components()[0].GetConfig()
555+
assert.Contains(t, resolved.Build.With, "my-feature")
556+
}
557+
415558
func TestFindAllSpecPaths_Nothing(t *testing.T) {
416559
env := testutils.NewTestEnv(t)
417560

internal/projectconfig/component.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,19 @@ type SourceFileReference struct {
6868
// say in a command line interface. Note that a component group does not uniquely "own" its components; a
6969
// component may belong to multiple groups, and components need not belong to any group.
7070
type ComponentGroupConfig struct {
71+
// A human-friendly description of this component group.
72+
Description string `toml:"description,omitempty" json:"description,omitempty" jsonschema:"title=Description,description=Description of this component group"`
73+
74+
// List of explicitly included components, identified by name.
75+
Components []string `toml:"components,omitempty" json:"components,omitempty" jsonschema:"title=Components,description=List of component names that are members of this group"`
76+
7177
// List of glob patterns specifying raw spec files that define components.
7278
SpecPathPatterns []string `toml:"specs,omitempty" json:"specs,omitempty" validate:"dive,required" jsonschema:"title=Spec path patterns,description=List of glob patterns identifying local specs for components in this group,example=SPECS/**/.spec"`
7379
// List of glob patterns specifying files to specifically ignore from spec selection.
7480
ExcludedPathPatterns []string `toml:"excluded-paths,omitempty" json:"excludedPaths,omitempty" jsonschema:"title=Excluded path patterns,description=List of glob patterns identifying local paths to exclude from spec selection,example=build/**"`
81+
82+
// Default configuration to apply to component members of this group.
83+
DefaultComponentConfig ComponentConfig `toml:"default-component-config,omitempty" json:"defaultComponentConfig,omitempty" jsonschema:"title=Default component configuration,description=Default component config inherited by all members of this component group"`
7584
}
7685

7786
// Returns a copy of the component group config with relative file paths converted to absolute
@@ -93,6 +102,8 @@ func (g ComponentGroupConfig) WithAbsolutePaths(referenceDir string) ComponentGr
93102
result.ExcludedPathPatterns[i] = makeAbsolute(referenceDir, result.ExcludedPathPatterns[i])
94103
}
95104

105+
result.DefaultComponentConfig = *(result.DefaultComponentConfig.WithAbsolutePaths(referenceDir))
106+
96107
return result
97108
}
98109

internal/projectconfig/component_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,59 @@ func TestComponentConfigWithAbsolutePaths(t *testing.T) {
106106
})
107107
}
108108

109+
func TestComponentGroupConfigWithAbsolutePaths_DefaultComponentConfig(t *testing.T) {
110+
const testRefDir = "/ref/dir"
111+
112+
t.Run("default config with relative spec path", func(t *testing.T) {
113+
group := projectconfig.ComponentGroupConfig{
114+
Components: []string{"comp-a"},
115+
DefaultComponentConfig: projectconfig.ComponentConfig{
116+
Spec: projectconfig.SpecSource{
117+
SourceType: projectconfig.SpecSourceTypeLocal,
118+
Path: "specs/test.spec",
119+
},
120+
},
121+
}
122+
123+
absGroup := group.WithAbsolutePaths(testRefDir)
124+
125+
// The default component config's spec path should be made absolute.
126+
assert.Equal(t, "/ref/dir/specs/test.spec", absGroup.DefaultComponentConfig.Spec.Path)
127+
128+
// Members should be preserved.
129+
assert.Equal(t, []string{"comp-a"}, absGroup.Components)
130+
})
131+
132+
t.Run("default config with empty fields", func(t *testing.T) {
133+
group := projectconfig.ComponentGroupConfig{
134+
Components: []string{"comp-a"},
135+
DefaultComponentConfig: projectconfig.ComponentConfig{},
136+
}
137+
138+
absGroup := group.WithAbsolutePaths(testRefDir)
139+
140+
// Empty default config should remain empty.
141+
assert.Equal(t, projectconfig.ComponentConfig{}, absGroup.DefaultComponentConfig)
142+
})
143+
144+
t.Run("default config with build settings", func(t *testing.T) {
145+
group := projectconfig.ComponentGroupConfig{
146+
DefaultComponentConfig: projectconfig.ComponentConfig{
147+
Build: projectconfig.ComponentBuildConfig{
148+
With: []string{"tests"},
149+
Without: []string{"docs"},
150+
},
151+
},
152+
}
153+
154+
absGroup := group.WithAbsolutePaths(testRefDir)
155+
156+
// Build config should be preserved as-is (no paths to fix).
157+
assert.Equal(t, []string{"tests"}, absGroup.DefaultComponentConfig.Build.With)
158+
assert.Equal(t, []string{"docs"}, absGroup.DefaultComponentConfig.Build.Without)
159+
})
160+
}
161+
109162
func TestMergeComponentUpdates(t *testing.T) {
110163
base := projectconfig.ComponentConfig{
111164
Build: projectconfig.ComponentBuildConfig{

0 commit comments

Comments
 (0)