From 2c497ed4e116292028ccbe7c765b44b69de45c77 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 09 Aug 2026 21:19:30 +0000 Subject: [PATCH] feat: resolve group selection --- internal/selection/groups.go | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ internal/selection/groups_test.go | 205 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 file(s) changed, 319 insertion(s)(+), 0 deletion(s)(-) diff --git a/internal/selection/groups.go b/internal/selection/groups.go new file mode 100644 --- /dev/null +++ b/internal/selection/groups.go @@ -0,0 +1,114 @@ +// This file resolves group selections for the repository-using commands +// (PLAN.md Sections 8.5, 11.2, and 11.5). CompiledOnly serves validate, whose +// explicit names must be current compiled groups; CompiledAndPersisted serves +// status, diff, and apply, whose explicit names may also come from persisted +// rows so a deleted group stays inspectable. Both reject unknown and +// duplicate arguments and return sorted typed selections. Selection is pure: +// the caller supplies the compiled group names and the persisted group names +// read from state, so no compiler execution, state read, or mutation occurs +// here. +package selection + +import ( + "fmt" + "slices" + "sort" +) + +// Selection is one validated group selection. Root reports whether root scope +// is included; Groups lists the sorted unique group names. A nil Groups with +// Root set means root scope plus every current group, mirroring the compiler +// convention that an empty selection filters nothing. +type Selection struct { + Root bool + Groups []string +} + +// PersistedGroups carries the distinct group names of a canonical repository +// pair's persisted rows, read once by the caller from the state store: Active +// names groups with at least one active file or alias row, All names groups +// with any row at all, active or retired. Layers are not considered, so rows +// of the inactive platform keep their group alive. +type PersistedGroups struct { + Active []string + All []string +} + +// CompiledOnly validates explicit group arguments against the current compiled +// groups (Section 11.2). No arguments select root scope plus every current +// group; unknown and duplicate arguments are errors. +func CompiledOnly(current []string, arguments []string) (Selection, error) { + if len(arguments) == 0 { + return Selection{Root: true}, nil + } + if err := rejectUnknown(arguments, current); err != nil { + return Selection{}, err + } + if err := rejectDuplicates(arguments); err != nil { + return Selection{}, err + } + return Selection{Groups: sortedUnique(arguments)}, nil +} + +// CompiledAndPersisted expands and validates a group selection against the +// current groups and the persisted rows (Section 8.5). No arguments select +// root scope plus every current and active state-only group; an explicit name +// may exist in the current plan or any active/retired state row. Unknown and +// duplicate arguments are errors. +func CompiledAndPersisted(current []string, persisted PersistedGroups, arguments []string) (Selection, error) { + if len(arguments) == 0 { + return Selection{Root: true, Groups: sortedUnique(union(current, persisted.Active))}, nil + } + known := union(current, persisted.All) + if err := rejectUnknown(arguments, known); err != nil { + return Selection{}, err + } + if err := rejectDuplicates(arguments); err != nil { + return Selection{}, err + } + return Selection{Groups: sortedUnique(arguments)}, nil +} + +// rejectUnknown fails when an argument is not among the known groups. +func rejectUnknown(arguments, known []string) error { + for _, argument := range arguments { + if !slices.Contains(known, argument) { + return fmt.Errorf("selection: unknown group %q", argument) + } + } + return nil +} + +// rejectDuplicates fails when an argument repeats. +func rejectDuplicates(arguments []string) error { + seen := make(map[string]bool, len(arguments)) + for _, argument := range arguments { + if seen[argument] { + return fmt.Errorf("selection: duplicate group %q", argument) + } + seen[argument] = true + } + return nil +} + +// union returns the combined members of both lists. +func union(first, second []string) []string { + return append(append([]string(nil), first...), second...) +} + +// sortedUnique returns the sorted unique members of items, or nil when empty. +func sortedUnique(items []string) []string { + if len(items) == 0 { + return nil + } + unique := make(map[string]bool, len(items)) + for _, item := range items { + unique[item] = true + } + names := make([]string, 0, len(unique)) + for name := range unique { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/internal/selection/groups_test.go b/internal/selection/groups_test.go new file mode 100644 --- /dev/null +++ b/internal/selection/groups_test.go @@ -0,0 +1,205 @@ +package selection + +import ( + "slices" + "testing" +) + +func TestGroupSelection(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"no arguments select root plus current", testGroupsNoArguments}, + {"no arguments add active state-only groups", testGroupsActiveStateOnly}, + {"no arguments exclude retired-only groups", testGroupsRetiredOnlyExcluded}, + {"explicit arguments are sorted", testGroupsExplicitOrder}, + {"root-only repository stays root-only", testGroupsRootOnly}, + {"case variants are exact", testGroupsCaseVariants}, + {"current groups validate explicitly", testGroupsCurrentExplicit}, + {"active rows validate explicit names", testGroupsActiveExplicit}, + {"retired rows validate explicit names", testGroupsRetiredExplicit}, + {"deleted groups are explicit-only", testGroupsDeletedExplicit}, + {"inactive-platform rows keep groups alive", testGroupsInactivePlatform}, + {"duplicate arguments are rejected", testGroupsDuplicates}, + {"unknown arguments are rejected", testGroupsUnknown}, + {"validate rejects state-only names", testGroupsValidateStrict}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testGroupsNoArguments(t *testing.T) { + selected, err := CompiledOnly([]string{"apps", "dotfiles"}, nil) + if err != nil { + t.Fatalf("CompiledOnly: %v", err) + } + if !selected.Root || selected.Groups != nil { + t.Fatalf("CompiledOnly no-args = %+v, want root plus all current", selected) + } + expanded, err := CompiledAndPersisted([]string{"apps"}, PersistedGroups{Active: []string{"ghost"}, All: []string{"ghost"}}, nil) + if err != nil { + t.Fatalf("CompiledAndPersisted: %v", err) + } + if !expanded.Root || !slices.Equal(expanded.Groups, []string{"apps", "ghost"}) { + t.Fatalf("CompiledAndPersisted no-args = %+v, want root plus current and active state-only", expanded) + } +} + +func testGroupsActiveStateOnly(t *testing.T) { + selected, err := CompiledAndPersisted([]string{"apps"}, PersistedGroups{Active: []string{"ghost"}, All: []string{"ghost"}}, nil) + if err != nil { + t.Fatalf("CompiledAndPersisted: %v", err) + } + if !selected.Root || !slices.Equal(selected.Groups, []string{"apps", "ghost"}) { + t.Fatalf("selection = %+v, want root plus apps and ghost", selected) + } +} + +func testGroupsRetiredOnlyExcluded(t *testing.T) { + selected, err := CompiledAndPersisted([]string{"apps"}, PersistedGroups{Active: nil, All: []string{"apps", "gone"}}, nil) + if err != nil { + t.Fatalf("CompiledAndPersisted: %v", err) + } + if !selected.Root || !slices.Equal(selected.Groups, []string{"apps"}) { + t.Fatalf("selection = %+v, want retired-only groups excluded", selected) + } +} + +func testGroupsExplicitOrder(t *testing.T) { + only, err := CompiledOnly([]string{"a", "b", "c"}, []string{"c", "a"}) + if err != nil { + t.Fatalf("CompiledOnly: %v", err) + } + if only.Root || !slices.Equal(only.Groups, []string{"a", "c"}) { + t.Fatalf("CompiledOnly selection = %+v, want sorted [a c]", only) + } + both, err := CompiledAndPersisted([]string{"a", "b", "c"}, PersistedGroups{All: []string{"c", "a"}}, []string{"c", "a"}) + if err != nil { + t.Fatalf("CompiledAndPersisted: %v", err) + } + if both.Root || !slices.Equal(both.Groups, []string{"a", "c"}) { + t.Fatalf("CompiledAndPersisted selection = %+v, want sorted [a c]", both) + } +} + +func testGroupsRootOnly(t *testing.T) { + only, err := CompiledOnly(nil, nil) + if err != nil { + t.Fatalf("CompiledOnly: %v", err) + } + if !only.Root || only.Groups != nil { + t.Fatalf("CompiledOnly selection = %+v, want root only", only) + } + persisted, err := CompiledAndPersisted(nil, PersistedGroups{}, nil) + if err != nil { + t.Fatalf("CompiledAndPersisted: %v", err) + } + if !persisted.Root || persisted.Groups != nil { + t.Fatalf("CompiledAndPersisted selection = %+v, want root only", persisted) + } + if _, err := CompiledOnly(nil, []string{"apps"}); err == nil { + t.Fatal("a root-only repository must reject explicit groups") + } +} + +func testGroupsCaseVariants(t *testing.T) { + if _, err := CompiledOnly([]string{"Apps"}, []string{"apps"}); err == nil { + t.Fatal("case-variant group must be rejected as unknown") + } + if _, err := CompiledAndPersisted([]string{}, PersistedGroups{All: []string{"Apps"}}, []string{"apps"}); err == nil { + t.Fatal("case-variant state group must be rejected as unknown") + } + if _, err := CompiledOnly([]string{"apps"}, []string{"Apps"}); err == nil { + t.Fatal("case-variant argument must not match the current group") + } +} + +func testGroupsCurrentExplicit(t *testing.T) { + selected, err := CompiledOnly([]string{"apps"}, []string{"apps"}) + if err != nil { + t.Fatalf("CompiledOnly: %v", err) + } + if selected.Root || !slices.Equal(selected.Groups, []string{"apps"}) { + t.Fatalf("selection = %+v, want explicit apps only", selected) + } +} + +func testGroupsActiveExplicit(t *testing.T) { + selected, err := CompiledAndPersisted([]string{}, PersistedGroups{Active: []string{"ghost"}, All: []string{"ghost"}}, []string{"ghost"}) + if err != nil { + t.Fatalf("CompiledAndPersisted: %v", err) + } + if selected.Root || !slices.Equal(selected.Groups, []string{"ghost"}) { + t.Fatalf("selection = %+v, want explicit ghost only", selected) + } +} + +func testGroupsRetiredExplicit(t *testing.T) { + selected, err := CompiledAndPersisted([]string{}, PersistedGroups{All: []string{"gone"}}, []string{"gone"}) + if err != nil { + t.Fatalf("CompiledAndPersisted: %v", err) + } + if selected.Root || !slices.Equal(selected.Groups, []string{"gone"}) { + t.Fatalf("selection = %+v, want explicit gone only", selected) + } +} + +func testGroupsDeletedExplicit(t *testing.T) { + selected, err := CompiledAndPersisted([]string{}, PersistedGroups{All: []string{"gone"}}, []string{"gone"}) + if err != nil { + t.Fatalf("CompiledAndPersisted: %v", err) + } + if selected.Root || !slices.Equal(selected.Groups, []string{"gone"}) { + t.Fatalf("selection = %+v, want explicit gone only", selected) + } + expanded, err := CompiledAndPersisted([]string{}, PersistedGroups{All: []string{"gone"}}, nil) + if err != nil { + t.Fatalf("CompiledAndPersisted: %v", err) + } + if !expanded.Root || expanded.Groups != nil { + t.Fatalf("no-args selection = %+v, want deleted groups excluded", expanded) + } +} + +func testGroupsInactivePlatform(t *testing.T) { + selected, err := CompiledAndPersisted([]string{}, PersistedGroups{Active: []string{"darwin-only"}}, nil) + if err != nil { + t.Fatalf("CompiledAndPersisted: %v", err) + } + if !selected.Root || !slices.Equal(selected.Groups, []string{"darwin-only"}) { + t.Fatalf("selection = %+v, want inactive-platform rows to keep the group active", selected) + } + explicit, err := CompiledAndPersisted([]string{}, PersistedGroups{All: []string{"darwin-only"}}, []string{"darwin-only"}) + if err != nil { + t.Fatalf("CompiledAndPersisted: %v", err) + } + if explicit.Root || !slices.Equal(explicit.Groups, []string{"darwin-only"}) { + t.Fatalf("explicit selection = %+v, want darwin-only", explicit) + } +} + +func testGroupsDuplicates(t *testing.T) { + if _, err := CompiledOnly([]string{"apps"}, []string{"apps", "apps"}); err == nil { + t.Fatal("duplicate argument must be rejected") + } + if _, err := CompiledAndPersisted([]string{}, PersistedGroups{All: []string{"apps"}}, []string{"apps", "apps"}); err == nil { + t.Fatal("duplicate argument must be rejected by the persisted path too") + } +} + +func testGroupsUnknown(t *testing.T) { + if _, err := CompiledOnly([]string{"apps"}, []string{"missing"}); err == nil { + t.Fatal("unknown argument must be rejected") + } + if _, err := CompiledAndPersisted([]string{}, PersistedGroups{All: []string{"apps"}}, []string{"missing"}); err == nil { + t.Fatal("unknown argument must be rejected by the persisted path too") + } +} + +func testGroupsValidateStrict(t *testing.T) { + if _, err := CompiledOnly([]string{}, []string{"ghost"}); err == nil { + t.Fatal("validate selection must reject a group that exists only in state") + } +} -- tangled.sh