feat(zddc): Phase 3a — populated defaults + cascade lookup helpers
Schema: - default_tool: string (tool name served at this dir's no-slash URL) - auto_own: *bool (mkdir post-hook auto-grants the creator) - virtual: *bool (never materialise on disk; aggregator routes) defaults.zddc.yaml: populated with the full canonical convention via paths:. Top-level "*" matches any project; nested archive/working/ staging/reviewing declare the project-stage tools; archive's "*" / mdl|incoming|received|issued tree declares the per-party surfaces. All four party folders and all four project-root folders get their default_tool; working / staging / archive/<party>/incoming get auto_own; reviewing / archive/<party>/mdl get virtual. None of these need on-disk dirs to exist. Lookups (zddc/internal/zddc/lookups.go): DefaultToolAt(root, dir) → cascade-resolved default tool name AutoOwnAt(root, dir) → does mkdir auto-own here? VirtualAt(root, dir) → never materialise on disk? IsDeclaredPath(root, dir) → does the cascade say anything about this dir? ChildrenDeclaredAt(root, dir)→ literal child names declared by Paths Each looks up via EffectivePolicy → leaf level → Embedded fallback, so operators' on-disk overrides win and the embedded baseline carries the convention. Tests cover the embedded convention, operator overrides, and inherit:false blocking the embedded layer. No consumer migration yet — that's Phase 3b. Behaviour is bit-identical for current callers since none of them consult the new lookups. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2f08418fb0
commit
ea0d29ed17
6 changed files with 450 additions and 14 deletions
|
|
@ -1300,7 +1300,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 19:54:48 · d84c190-dirty</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 20:00:21 · 2f08418-dirty</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -4,26 +4,80 @@
|
||||||
# at the on-disk root /.zddc (or any deeper level); to ignore this
|
# at the on-disk root /.zddc (or any deeper level); to ignore this
|
||||||
# file entirely, set `inherit: false` on an on-disk .zddc.
|
# file entirely, set `inherit: false` on an on-disk .zddc.
|
||||||
#
|
#
|
||||||
# Phase 1 of the .zddc-first-config rollout. Future phases will move
|
# To export an editable copy for an operator:
|
||||||
# the hardcoded canonical-folder behaviour (ProjectRootFolders,
|
|
||||||
# PartyFolders, apps.DefaultAppAt, etc.) into this file via a new
|
|
||||||
# `paths:` recursive map + a few new per-directory keys (default_tool,
|
|
||||||
# auto_own, virtual). For now this file is intentionally minimal —
|
|
||||||
# the plumbing exists, the schema doesn't.
|
|
||||||
#
|
|
||||||
# Read-only at runtime; the binary does not write to its embedded
|
|
||||||
# copy. To export an editable copy for an operator:
|
|
||||||
#
|
#
|
||||||
# zddc-server show-defaults > /var/lib/zddc/root/.zddc
|
# zddc-server show-defaults > /var/lib/zddc/root/.zddc
|
||||||
#
|
#
|
||||||
# That places this file at the on-disk root, where the operator can
|
# That places this file at the on-disk root, where the operator can
|
||||||
# edit it freely. The new file then takes the place of the embedded
|
# edit it freely. The new file then takes the place of the embedded
|
||||||
# one (no double-counting — both contribute to the cascade, leaf wins).
|
# one (both contribute to the cascade, on-disk wins per-field).
|
||||||
|
|
||||||
title: "ZDDC"
|
title: "ZDDC"
|
||||||
|
|
||||||
# Phase 1: empty acl + empty admins, equivalent to "the embedded
|
# Empty acl at this layer — rules come from on-disk .zddc files above.
|
||||||
# layer grants nothing; rules come from on-disk .zddc files above".
|
# A deployment with no on-disk root .zddc grants no access (consistent
|
||||||
# This preserves bit-identical behaviour for existing deployments.
|
# with prior behaviour); operators bootstrap by editing the root file.
|
||||||
acl:
|
acl:
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
|
# ── Canonical project structure ────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Every ZDDC project lives at a top-level directory. Under it the
|
||||||
|
# convention is four canonical folders: archive (formal record),
|
||||||
|
# working (in-progress workspace), staging (outbound prep), reviewing
|
||||||
|
# (purely virtual aggregator). Under archive/<party>/ the convention
|
||||||
|
# is four more: mdl (deliverables list), incoming (counterparty drop
|
||||||
|
# zone), received (immutable submittals), issued (immutable responses).
|
||||||
|
#
|
||||||
|
# All of this is expressed via the recursive paths: schema. None of
|
||||||
|
# the directories need to exist on disk — the cascade walker resolves
|
||||||
|
# behaviour from this declaration, so a fresh project lands on
|
||||||
|
# usable empty views at every well-known URL.
|
||||||
|
#
|
||||||
|
# Operators override any of this by mirroring the structure in an
|
||||||
|
# on-disk .zddc and changing what they need; on-disk values win.
|
||||||
|
|
||||||
|
paths:
|
||||||
|
# First segment under root is the project name; "*" matches any.
|
||||||
|
"*":
|
||||||
|
paths:
|
||||||
|
archive:
|
||||||
|
default_tool: archive
|
||||||
|
paths:
|
||||||
|
# Second segment under archive/ is the party name.
|
||||||
|
"*":
|
||||||
|
paths:
|
||||||
|
mdl:
|
||||||
|
default_tool: tables
|
||||||
|
# The mdl folder is virtual by convention — the
|
||||||
|
# tables tool serves it from the embedded default
|
||||||
|
# spec even when the on-disk folder doesn't exist.
|
||||||
|
virtual: true
|
||||||
|
incoming:
|
||||||
|
default_tool: classifier
|
||||||
|
# First write into incoming/ auto-creates an owner
|
||||||
|
# grant so the creator can manage their own drops.
|
||||||
|
auto_own: true
|
||||||
|
received:
|
||||||
|
default_tool: archive
|
||||||
|
# received/ is WORM — express as ACL elsewhere; the
|
||||||
|
# default convention is simply "no auto_own here".
|
||||||
|
issued:
|
||||||
|
default_tool: archive
|
||||||
|
working:
|
||||||
|
default_tool: mdedit
|
||||||
|
# working/ auto-owns the first creator + the per-user homes
|
||||||
|
# below.
|
||||||
|
auto_own: true
|
||||||
|
paths:
|
||||||
|
"*": # per-user home dir
|
||||||
|
default_tool: mdedit
|
||||||
|
auto_own: true
|
||||||
|
staging:
|
||||||
|
default_tool: transmittal
|
||||||
|
auto_own: true
|
||||||
|
reviewing:
|
||||||
|
default_tool: mdedit
|
||||||
|
# reviewing/ is purely virtual — the aggregator handler
|
||||||
|
# synthesises listings from received/ ↔ staging/ ↔ issued/.
|
||||||
|
virtual: true
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,28 @@ type ZddcFile struct {
|
||||||
// false. nil == defaults to true.
|
// false. nil == defaults to true.
|
||||||
Inherit *bool `yaml:"inherit,omitempty" json:"inherit,omitempty"`
|
Inherit *bool `yaml:"inherit,omitempty" json:"inherit,omitempty"`
|
||||||
|
|
||||||
|
// DefaultTool is the tool name served at this directory's
|
||||||
|
// no-slash URL form (e.g. /Project/working without trailing slash
|
||||||
|
// → mdedit). Empty means "no default" — the slash convention's
|
||||||
|
// browse listing wins and the no-slash form 302s. Cascades
|
||||||
|
// through Paths: an ancestor's Paths entry can set DefaultTool
|
||||||
|
// for a virtual descendant without anyone creating that dir.
|
||||||
|
DefaultTool string `yaml:"default_tool,omitempty" json:"default_tool,omitempty"`
|
||||||
|
|
||||||
|
// AutoOwn controls whether the file API's mkdir post-hook writes
|
||||||
|
// an auto-owned .zddc granting the creator rwcda at the new
|
||||||
|
// directory. Useful for working/staging/incoming-style drafting
|
||||||
|
// surfaces where the first creator should "own" what they
|
||||||
|
// created. Empty (nil) inherits via cascade.
|
||||||
|
AutoOwn *bool `yaml:"auto_own,omitempty" json:"auto_own,omitempty"`
|
||||||
|
|
||||||
|
// Virtual marks a directory as never-materialise-on-disk. The
|
||||||
|
// server treats requests under such a path as virtual routes
|
||||||
|
// rather than triggering EnsureCanonicalAncestors. The reviewing
|
||||||
|
// aggregator is the canonical example. Empty (nil) inherits via
|
||||||
|
// cascade.
|
||||||
|
Virtual *bool `yaml:"virtual,omitempty" json:"virtual,omitempty"`
|
||||||
|
|
||||||
// Paths declares virtual sub-directory rules without those
|
// Paths declares virtual sub-directory rules without those
|
||||||
// directories needing to exist on disk. Each key is a single path
|
// directories needing to exist on disk. Each key is a single path
|
||||||
// segment — either a literal name or `*` (matches any segment).
|
// segment — either a literal name or `*` (matches any segment).
|
||||||
|
|
|
||||||
179
zddc/internal/zddc/lookups.go
Normal file
179
zddc/internal/zddc/lookups.go
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
package zddc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultToolAt returns the cascade-resolved default tool name for
|
||||||
|
// the directory at dirPath. Empty when no cascade level (on-disk or
|
||||||
|
// virtual via Paths) has declared a DefaultTool.
|
||||||
|
//
|
||||||
|
// Used by the URL dispatcher to route no-slash directory URLs (e.g.
|
||||||
|
// /Project/working) to the appropriate tool. Replaces the hardcoded
|
||||||
|
// apps.DefaultAppAt matrix once consumers are migrated (Phase 3b).
|
||||||
|
func DefaultToolAt(fsRoot, dirPath string) string {
|
||||||
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if dt := leafLevel(chain).DefaultTool; dt != "" {
|
||||||
|
return dt
|
||||||
|
}
|
||||||
|
return chain.Embedded.DefaultTool
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoOwnAt reports whether mkdir at this directory should write an
|
||||||
|
// auto-owned .zddc. False (with explicit set) and unset both return
|
||||||
|
// false to the caller; the file API only auto-owns when at least one
|
||||||
|
// cascade level explicitly set auto_own: true.
|
||||||
|
//
|
||||||
|
// Replaces the AutoOwnCanonicalNames hardcoded list once the file
|
||||||
|
// API's mkdir hook is migrated.
|
||||||
|
func AutoOwnAt(fsRoot, dirPath string) bool {
|
||||||
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if v := leafLevel(chain).AutoOwn; v != nil {
|
||||||
|
return *v
|
||||||
|
}
|
||||||
|
if v := chain.Embedded.AutoOwn; v != nil {
|
||||||
|
return *v
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// VirtualAt reports whether the directory at dirPath is declared as
|
||||||
|
// purely virtual (never materialise on disk). Used by
|
||||||
|
// EnsureCanonicalAncestors to skip MkdirAll for these paths.
|
||||||
|
//
|
||||||
|
// Replaces the VirtualOnlyCanonicalNames hardcoded list once
|
||||||
|
// consumers are migrated.
|
||||||
|
func VirtualAt(fsRoot, dirPath string) bool {
|
||||||
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if v := leafLevel(chain).Virtual; v != nil {
|
||||||
|
return *v
|
||||||
|
}
|
||||||
|
if v := chain.Embedded.Virtual; v != nil {
|
||||||
|
return *v
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDeclaredPath reports whether dirPath is mentioned in the
|
||||||
|
// cascade — either by an on-disk .zddc at that level OR by any
|
||||||
|
// ancestor's paths: tree (including the embedded defaults).
|
||||||
|
//
|
||||||
|
// A declared path is one the cascade has *something to say about*
|
||||||
|
// even if the directory doesn't exist on disk. Used by listing
|
||||||
|
// fallbacks to decide whether a missing directory should return an
|
||||||
|
// empty listing (treat as virtual) vs 404 (truly unknown).
|
||||||
|
//
|
||||||
|
// Replaces IsProjectRootFolder + IsArchivePartyFolder once
|
||||||
|
// consumers are migrated.
|
||||||
|
func IsDeclaredPath(fsRoot, dirPath string) bool {
|
||||||
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(chain.Levels) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
leaf := leafLevel(chain)
|
||||||
|
// A non-empty merged level at the leaf means at least one
|
||||||
|
// contribution reached here — either an on-disk file, or an
|
||||||
|
// ancestor's paths: glob matched.
|
||||||
|
return !isZeroZddcFile(leaf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChildrenDeclaredAt returns the set of child directory names that
|
||||||
|
// the cascade declares should exist under dirPath. Includes
|
||||||
|
// wildcard "*" specs (caller decides how to expose those) and
|
||||||
|
// literal names. Used by fs.ListDirectory to inject virtual
|
||||||
|
// canonical-folder entries at a project root.
|
||||||
|
//
|
||||||
|
// Returns the literal names; "*" wildcards are NOT included
|
||||||
|
// (callers can't synthesise a meaningful name for a wildcard).
|
||||||
|
func ChildrenDeclaredAt(fsRoot, dirPath string) []string {
|
||||||
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
leaf := leafLevel(chain)
|
||||||
|
if len(leaf.Paths) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for k := range leaf.Paths {
|
||||||
|
if k == "*" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// leafLevel returns the deepest (most-specific) ZddcFile in chain.
|
||||||
|
// Caller's responsibility to check len(chain.Levels) > 0 — but
|
||||||
|
// returns ZddcFile{} on empty for ergonomic chaining.
|
||||||
|
func leafLevel(chain PolicyChain) ZddcFile {
|
||||||
|
if len(chain.Levels) == 0 {
|
||||||
|
return ZddcFile{}
|
||||||
|
}
|
||||||
|
return chain.Levels[len(chain.Levels)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// isZeroZddcFile reports whether zf carries no meaningful content.
|
||||||
|
// Used by IsDeclaredPath to distinguish "ancestor paths: matched
|
||||||
|
// and stamped something here" from "no contribution at all".
|
||||||
|
func isZeroZddcFile(zf ZddcFile) bool {
|
||||||
|
if zf.Title != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if zf.DefaultTool != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if zf.AutoOwn != nil || zf.Virtual != nil || zf.Inherit != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if zf.AppsPubKey != "" || zf.CreatedBy != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(zf.Admins) > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(zf.ACL.Permissions) > 0 || len(zf.ACL.Allow) > 0 || len(zf.ACL.Deny) > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if zf.ACL.Inherit != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(zf.Apps) > 0 || len(zf.Tables) > 0 || len(zf.Display) > 0 || len(zf.Paths) > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(zf.Roles) > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePathSegments turns dirPath (absolute, under fsRoot) into a
|
||||||
|
// slice of segments relative to fsRoot. Used by helpers that walk
|
||||||
|
// the cascade by segment. Returns nil for dirPath == fsRoot or for
|
||||||
|
// any path outside fsRoot.
|
||||||
|
func resolvePathSegments(fsRoot, dirPath string) []string {
|
||||||
|
fsRoot = filepath.Clean(fsRoot)
|
||||||
|
dirPath = filepath.Clean(dirPath)
|
||||||
|
if dirPath == fsRoot {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(fsRoot, dirPath)
|
||||||
|
if err != nil || strings.HasPrefix(rel, "..") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return strings.Split(rel, string(filepath.Separator))
|
||||||
|
}
|
||||||
172
zddc/internal/zddc/lookups_test.go
Normal file
172
zddc/internal/zddc/lookups_test.go
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
package zddc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDefaultToolAt_FromEmbeddedConvention — the canonical default-
|
||||||
|
// tool rules in defaults.zddc.yaml should resolve correctly for the
|
||||||
|
// well-known paths without any on-disk .zddc.
|
||||||
|
func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
|
resetCache()
|
||||||
|
root := t.TempDir()
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{filepath.Join(root, "Project-X", "archive"), "archive"},
|
||||||
|
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), "tables"},
|
||||||
|
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "classifier"},
|
||||||
|
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), "archive"},
|
||||||
|
{filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "archive"},
|
||||||
|
{filepath.Join(root, "Project-X", "working"), "mdedit"},
|
||||||
|
{filepath.Join(root, "Project-X", "working", "alice@example.com"), "mdedit"},
|
||||||
|
{filepath.Join(root, "Project-X", "staging"), "transmittal"},
|
||||||
|
{filepath.Join(root, "Project-X", "reviewing"), "mdedit"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := DefaultToolAt(root, tc.path)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("DefaultToolAt(%q) = %q, want %q",
|
||||||
|
tc.path[len(root):], got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAutoOwnAt_FromEmbeddedConvention — auto_own should be true for
|
||||||
|
// working/incoming/staging (per the convention) and false elsewhere.
|
||||||
|
func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
|
resetCache()
|
||||||
|
root := t.TempDir()
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{filepath.Join(root, "Project-X", "working"), true},
|
||||||
|
{filepath.Join(root, "Project-X", "working", "alice@example.com"), true},
|
||||||
|
{filepath.Join(root, "Project-X", "staging"), true},
|
||||||
|
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), true},
|
||||||
|
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
|
||||||
|
{filepath.Join(root, "Project-X", "archive", "Acme", "issued"), false},
|
||||||
|
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := AutoOwnAt(root, tc.path)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("AutoOwnAt(%q) = %v, want %v",
|
||||||
|
tc.path[len(root):], got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVirtualAt_FromEmbeddedConvention — reviewing/ and mdl/ are
|
||||||
|
// declared virtual; everything else (including working/staging/
|
||||||
|
// incoming) materialises on disk.
|
||||||
|
func TestVirtualAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
|
resetCache()
|
||||||
|
root := t.TempDir()
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{filepath.Join(root, "Project-X", "reviewing"), true},
|
||||||
|
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), true},
|
||||||
|
{filepath.Join(root, "Project-X", "working"), false},
|
||||||
|
{filepath.Join(root, "Project-X", "staging"), false},
|
||||||
|
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), false},
|
||||||
|
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := VirtualAt(root, tc.path)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("VirtualAt(%q) = %v, want %v",
|
||||||
|
tc.path[len(root):], got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsDeclaredPath_FromEmbeddedConvention — canonical paths under
|
||||||
|
// the convention are declared even on a fresh root; arbitrary paths
|
||||||
|
// are not.
|
||||||
|
func TestIsDeclaredPath_FromEmbeddedConvention(t *testing.T) {
|
||||||
|
resetCache()
|
||||||
|
root := t.TempDir()
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{filepath.Join(root, "Project-X", "archive"), true},
|
||||||
|
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), true},
|
||||||
|
{filepath.Join(root, "Project-X", "working"), true},
|
||||||
|
{filepath.Join(root, "Project-X", "reviewing"), true},
|
||||||
|
{filepath.Join(root, "Project-X", "junk"), false}, // not in convention
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := IsDeclaredPath(root, tc.path)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("IsDeclaredPath(%q) = %v, want %v",
|
||||||
|
tc.path[len(root):], got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChildrenDeclaredAt_FromEmbeddedConvention — at a project
|
||||||
|
// root, the four canonical children should be enumerated.
|
||||||
|
func TestChildrenDeclaredAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
|
resetCache()
|
||||||
|
root := t.TempDir()
|
||||||
|
got := ChildrenDeclaredAt(root, filepath.Join(root, "Project-X"))
|
||||||
|
want := map[string]bool{
|
||||||
|
"archive": true, "working": true, "staging": true, "reviewing": true,
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Errorf("ChildrenDeclaredAt = %v, want exactly %v keys", got, want)
|
||||||
|
}
|
||||||
|
for _, n := range got {
|
||||||
|
if !want[n] {
|
||||||
|
t.Errorf("unexpected child %q", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOperatorOverride_DefaultsAreSurfaceable — operator can override
|
||||||
|
// any of the canonical tool defaults by mirroring the structure in an
|
||||||
|
// on-disk .zddc. The override wins.
|
||||||
|
func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
|
||||||
|
resetCache()
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "Special", "working"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Operator declares that Special/working uses classifier
|
||||||
|
// instead of the embedded-default mdedit.
|
||||||
|
writeZddc(t, filepath.Join(root, "Special", "working"),
|
||||||
|
"default_tool: classifier\n")
|
||||||
|
|
||||||
|
if got := DefaultToolAt(root, filepath.Join(root, "Special", "working")); got != "classifier" {
|
||||||
|
t.Errorf("operator override should set default_tool=classifier, got %q", got)
|
||||||
|
}
|
||||||
|
// Default still applies at other projects.
|
||||||
|
if got := DefaultToolAt(root, filepath.Join(root, "Project-Y", "working")); got != "mdedit" {
|
||||||
|
t.Errorf("default convention should hold at unchanged paths, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInheritFalse_BlocksEmbeddedDefaults — at the on-disk root,
|
||||||
|
// inherit:false stops the embedded layer from contributing. The
|
||||||
|
// canonical paths are then no longer declared.
|
||||||
|
func TestInheritFalse_BlocksEmbeddedDefaults(t *testing.T) {
|
||||||
|
resetCache()
|
||||||
|
root := t.TempDir()
|
||||||
|
writeZddc(t, root, "inherit: false\n")
|
||||||
|
// Without the embedded defaults' paths: tree, IsDeclaredPath
|
||||||
|
// returns false for previously-canonical paths.
|
||||||
|
if IsDeclaredPath(root, filepath.Join(root, "Project-X", "archive")) {
|
||||||
|
t.Errorf("with inherit:false at root, archive should not be a declared path")
|
||||||
|
}
|
||||||
|
if DefaultToolAt(root, filepath.Join(root, "Project-X", "working")) != "" {
|
||||||
|
t.Errorf("with inherit:false at root, default_tool should be empty for working")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -66,6 +66,15 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
|
||||||
if top.Inherit != nil {
|
if top.Inherit != nil {
|
||||||
out.Inherit = top.Inherit
|
out.Inherit = top.Inherit
|
||||||
}
|
}
|
||||||
|
if top.DefaultTool != "" {
|
||||||
|
out.DefaultTool = top.DefaultTool
|
||||||
|
}
|
||||||
|
if top.AutoOwn != nil {
|
||||||
|
out.AutoOwn = top.AutoOwn
|
||||||
|
}
|
||||||
|
if top.Virtual != nil {
|
||||||
|
out.Virtual = top.Virtual
|
||||||
|
}
|
||||||
|
|
||||||
out.Admins = mergeStringSlice(out.Admins, top.Admins)
|
out.Admins = mergeStringSlice(out.Admins, top.Admins)
|
||||||
out.ACL.Allow = mergeStringSlice(out.ACL.Allow, top.ACL.Allow)
|
out.ACL.Allow = mergeStringSlice(out.ACL.Allow, top.ACL.Allow)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue