Adds the recursive paths: schema and the cascade walker that threads
ancestor virtual contributions through to descendant levels.
Schema:
paths:
"*": # literal-segment or "*" segment-wildcard key
paths: # recursive — each step matches one segment
archive:
paths:
"*":
paths:
incoming:
title: "demo"
Each on-disk .zddc and the embedded defaults can declare paths:; the
walker collects every matching subtree and merges its contributions
into chain.Levels[depth] using mergeOverlay (per-field overlay with
on-disk most specific). The matched glob descends one segment at a
time; the value's own paths: becomes a new virtual source for deeper
matches.
Semantics:
- matchGlob: literal key first (case-insensitive on segment),
"*" wildcard fallback.
- mergeOverlay: top wins per-field on scalars; maps merge key-by-
key with top overriding; lists concat-dedupe; Paths replaces
(recursive walker threads it through naturally).
- inherit:false at any on-disk level drops accumulated ancestor
virtual sources AND zeroes chain.Embedded — the operator owns
every rule from that level outward.
- Behaviour is bit-identical when no .zddc declares paths:; the
walker reduces to the prior linear cascade.
Eight new tests cover the glob match table, ancestor-paths
contribution, on-disk-wins override, paths-absent bit-identical
behaviour, and inherit:false dropping ancestor paths: contributions.
All existing tests still pass.
Phase 3 next: populate defaults.zddc.yaml with the canonical
ZDDC convention via paths:, and replace apps.DefaultAppAt /
AppAvailableAt / AutoOwnCanonicalNames / VirtualOnlyCanonicalNames /
IsProjectRootFolder / IsArchivePartyFolder with cascade lookups.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
190 lines
5.9 KiB
Go
190 lines
5.9 KiB
Go
package zddc
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// TestMatchGlob_LiteralWinsOverWildcard — a literal key always beats
|
|
// the "*" fallback even when both are present.
|
|
func TestMatchGlob_LiteralWinsOverWildcard(t *testing.T) {
|
|
m := map[string]ZddcFile{
|
|
"archive": {Title: "literal"},
|
|
"*": {Title: "wildcard"},
|
|
}
|
|
got := matchGlob(m, "archive")
|
|
if got == nil || got.Title != "literal" {
|
|
t.Errorf("matchGlob(archive) = %+v, want literal", got)
|
|
}
|
|
}
|
|
|
|
// TestMatchGlob_CaseInsensitiveLiteral — operator writes lowercase,
|
|
// on-disk segment is PascalCase. Match should still hit.
|
|
func TestMatchGlob_CaseInsensitiveLiteral(t *testing.T) {
|
|
m := map[string]ZddcFile{"archive": {Title: "ok"}}
|
|
got := matchGlob(m, "Archive")
|
|
if got == nil || got.Title != "ok" {
|
|
t.Errorf("matchGlob(Archive) = %+v, want case-insensitive match", got)
|
|
}
|
|
}
|
|
|
|
// TestMatchGlob_WildcardFallback — no literal key, the * key matches
|
|
// any segment.
|
|
func TestMatchGlob_WildcardFallback(t *testing.T) {
|
|
m := map[string]ZddcFile{"*": {Title: "any"}}
|
|
got := matchGlob(m, "Project-1")
|
|
if got == nil || got.Title != "any" {
|
|
t.Errorf("matchGlob(Project-1) under * = %+v, want wildcard match", got)
|
|
}
|
|
}
|
|
|
|
// TestMatchGlob_NoMatch — no literal, no wildcard → nil.
|
|
func TestMatchGlob_NoMatch(t *testing.T) {
|
|
m := map[string]ZddcFile{"archive": {}}
|
|
if got := matchGlob(m, "working"); got != nil {
|
|
t.Errorf("matchGlob(working) = %+v, want nil", got)
|
|
}
|
|
}
|
|
|
|
// TestEffectivePolicy_PathsContributesViaWildcard — a root-level
|
|
// paths: tree with "*" / archive / incoming applies to a deep path
|
|
// even when none of those directories have a real .zddc.
|
|
func TestEffectivePolicy_PathsContributesViaWildcard(t *testing.T) {
|
|
resetCache()
|
|
root := t.TempDir()
|
|
// Root .zddc declares behaviour for any-project / archive /
|
|
// any-party / incoming via a nested paths: tree. The actual
|
|
// directories may not exist; the walker fires regardless.
|
|
writeZddc(t, root, `title: root
|
|
paths:
|
|
"*":
|
|
paths:
|
|
archive:
|
|
display:
|
|
incoming: "Inbox"
|
|
paths:
|
|
"*":
|
|
paths:
|
|
incoming:
|
|
title: "incoming-leaf"
|
|
`)
|
|
leaf := filepath.Join(root, "Project-1", "archive", "PartyA", "incoming")
|
|
if err := os.MkdirAll(leaf, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
chain, err := EffectivePolicy(root, leaf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// chain.Levels: [root, Project-1, archive, PartyA, incoming] → 5
|
|
if got, want := len(chain.Levels), 5; got != want {
|
|
t.Fatalf("len(chain.Levels) = %d, want %d", got, want)
|
|
}
|
|
// At /Project-1/archive/ the display override should now be
|
|
// merged in via the ancestor paths.
|
|
if got := chain.Levels[2].Display["incoming"]; got != "Inbox" {
|
|
t.Errorf("Levels[2].Display[incoming] = %q, want Inbox", got)
|
|
}
|
|
// At the leaf, the deepest paths: contribution sets title.
|
|
if got := chain.Levels[4].Title; got != "incoming-leaf" {
|
|
t.Errorf("Levels[4].Title = %q, want incoming-leaf", got)
|
|
}
|
|
}
|
|
|
|
// TestEffectivePolicy_PathsOnDiskWins — virtual contributions are
|
|
// lower specificity than on-disk; on-disk overrides per-field.
|
|
func TestEffectivePolicy_PathsOnDiskWins(t *testing.T) {
|
|
resetCache()
|
|
root := t.TempDir()
|
|
writeZddc(t, root, `paths:
|
|
archive:
|
|
title: "from-virtual"
|
|
`)
|
|
archive := filepath.Join(root, "archive")
|
|
if err := os.MkdirAll(archive, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeZddc(t, archive, `title: "from-on-disk"`)
|
|
|
|
chain, err := EffectivePolicy(root, archive)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// chain.Levels[1] is /archive — should reflect on-disk wins.
|
|
if got := chain.Levels[1].Title; got != "from-on-disk" {
|
|
t.Errorf("Levels[1].Title = %q, want %q (on-disk overrides virtual)",
|
|
got, "from-on-disk")
|
|
}
|
|
}
|
|
|
|
// TestEffectivePolicy_PathsAbsentIsBitIdentical — without a paths:
|
|
// declaration anywhere, the walker reduces to the prior linear
|
|
// cascade behaviour.
|
|
func TestEffectivePolicy_PathsAbsentIsBitIdentical(t *testing.T) {
|
|
resetCache()
|
|
root := t.TempDir()
|
|
writeZddc(t, root, `title: "root"
|
|
admins:
|
|
- admin@example.com
|
|
`)
|
|
leaf := filepath.Join(root, "Project-1", "archive")
|
|
if err := os.MkdirAll(leaf, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeZddc(t, leaf, `title: "leaf"`)
|
|
chain, err := EffectivePolicy(root, leaf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := chain.Levels[0].Title; got != "root" {
|
|
t.Errorf("Levels[0].Title = %q, want %q", got, "root")
|
|
}
|
|
if got := chain.Levels[2].Title; got != "leaf" {
|
|
t.Errorf("Levels[2].Title = %q, want %q", got, "leaf")
|
|
}
|
|
// Mid level has no .zddc and no paths: contribution → empty.
|
|
if got := chain.Levels[1].Title; got != "" {
|
|
t.Errorf("Levels[1].Title = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
// TestEffectivePolicy_InheritFalseDropsAncestorPaths — inherit:false
|
|
// at the on-disk root drops contributions from the embedded layer
|
|
// (already covered in defaults_test.go) AND drops contributions
|
|
// from ancestor paths: that hadn't yet matched. Here we set it on
|
|
// the project level: an embedded paths: contribution that would
|
|
// have applied to deeper levels is suppressed.
|
|
func TestEffectivePolicy_InheritFalseDropsAncestorPaths(t *testing.T) {
|
|
resetCache()
|
|
root := t.TempDir()
|
|
writeZddc(t, root, `paths:
|
|
Project-1:
|
|
title: "ancestor-virtual"
|
|
paths:
|
|
sub:
|
|
title: "ancestor-deep"
|
|
`)
|
|
sub := filepath.Join(root, "Project-1", "sub")
|
|
if err := os.MkdirAll(sub, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Project-1/.zddc sets inherit:false. Effect: the ancestor's
|
|
// /Project-1/sub/ contribution does NOT reach the sub level.
|
|
writeZddc(t, filepath.Join(root, "Project-1"),
|
|
`title: "standalone"
|
|
inherit: false
|
|
`)
|
|
chain, err := EffectivePolicy(root, sub)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := chain.Levels[1].Title; got != "standalone" {
|
|
t.Errorf("Levels[1].Title = %q, want standalone (inherit:false should drop ancestor virtual contribution)",
|
|
got)
|
|
}
|
|
if got := chain.Levels[2].Title; got != "" {
|
|
t.Errorf("Levels[2].Title = %q, want empty (ancestor paths: blocked by inherit:false)",
|
|
got)
|
|
}
|
|
}
|