diff --git a/zddc/internal/apps/availability.go b/zddc/internal/apps/availability.go index 0feba33..f2c3174 100644 --- a/zddc/internal/apps/availability.go +++ b/zddc/internal/apps/availability.go @@ -3,6 +3,8 @@ package apps import ( "path/filepath" "strings" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) // AppAvailableAt reports whether app's virtual HTML can be served at @@ -95,6 +97,16 @@ func inAncestorWithName(root, requestDir string, names ...string) bool { // // requestDir and root are absolute filesystem paths; requestDir must // be under root (otherwise "" is returned). +// +// Phase 3b: delegates to zddc.DefaultToolAt, which resolves the +// answer from the .zddc cascade (operator on-disk + embedded +// defaults). The convention previously hardcoded in the switch +// statement below now lives in zddc/internal/zddc/defaults.zddc.yaml +// and is overridable per-directory by operators. +// +// Project root itself (depth-1) still returns "" — the cascade +// doesn't declare a default tool for it (landing is handled by the +// dispatcher's own depth-1 check). func DefaultAppAt(root, requestDir string) string { root = filepath.Clean(root) requestDir = filepath.Clean(requestDir) @@ -107,25 +119,9 @@ func DefaultAppAt(root, requestDir string) string { } parts := strings.Split(rel, string(filepath.Separator)) if len(parts) < 2 { - // Project root itself — no default tool. + // Project root itself — no default tool from the cascade. + // (Landing is handled separately by the dispatcher.) return "" } - // The project segment is parts[0]; canonical folder is parts[1]. - canonical := strings.ToLower(parts[1]) - switch canonical { - case "archive": - // Inside archive/. Check for the mdl sub-case at depth 4 - // (parts: project, archive, party, mdl). - if len(parts) >= 4 && strings.EqualFold(parts[3], "mdl") { - return "tables" - } - return "archive" - case "staging": - return "transmittal" - case "working": - return "mdedit" - case "reviewing": - return "mdedit" - } - return "" + return zddc.DefaultToolAt(root, requestDir) } diff --git a/zddc/internal/apps/availability_test.go b/zddc/internal/apps/availability_test.go index 9b71294..19788bb 100644 --- a/zddc/internal/apps/availability_test.go +++ b/zddc/internal/apps/availability_test.go @@ -86,11 +86,13 @@ func TestDefaultAppAt(t *testing.T) { {root + "/Project-A/working/2026-06-15_x (DFT) - y", "mdedit"}, {root + "/Project-A/staging", "transmittal"}, {root + "/Project-A/staging/2026-06-15_x (DFT) - y", "transmittal"}, - // archive: at the archive root, party folders, and per-party - // subfolders (incoming/received/issued). + // archive: at the archive root, party folders default to archive. + // Per-party subfolders override per their function: + // incoming → classifier (the bulk-rename workflow) + // received / issued → archive (WORM record browser) {root + "/Project-A/archive", "archive"}, {root + "/Project-A/archive/Acme", "archive"}, - {root + "/Project-A/archive/Acme/incoming", "archive"}, + {root + "/Project-A/archive/Acme/incoming", "classifier"}, {root + "/Project-A/archive/Acme/issued", "archive"}, {root + "/Project-A/archive/Acme/received", "archive"}, // mdl wins over the broader archive rule. diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 01a5479..c021d09 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1300,7 +1300,7 @@ body.help-open .app-header {
ZDDC Table - v0.0.17-alpha · 2026-05-11 20:00:21 · 2f08418-dirty + v0.0.17-alpha · 2026-05-11 20:05:10 · ea0d29e-dirty
diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go index ab55ad8..56f9f25 100644 --- a/zddc/internal/zddc/lookups.go +++ b/zddc/internal/zddc/lookups.go @@ -9,34 +9,44 @@ import ( // 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). +// Lookup walks chain.Levels from leaf toward root, returning the +// first non-empty value. This implements the "parent applies to +// descendants unless overridden" cascade rule: a working/ folder's +// default_tool=mdedit propagates to working/alice/notes/ even when +// no .zddc declares mdedit at the deeper levels. +// +// Used by the URL dispatcher to route no-slash directory URLs. +// Replaces apps.DefaultAppAt 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 + for i := len(chain.Levels) - 1; i >= 0; i-- { + if dt := chain.Levels[i].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. +// auto-owned .zddc. Returns the deepest explicit value found in the +// cascade (true OR false), falling back to false when nothing set +// anywhere. *bool semantics let descendants explicitly disable an +// ancestor's auto_own: true. // -// Replaces the AutoOwnCanonicalNames hardcoded list once the file -// API's mkdir hook is migrated. +// Replaces AutoOwnCanonicalNames 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 + for i := len(chain.Levels) - 1; i >= 0; i-- { + if v := chain.Levels[i].AutoOwn; v != nil { + return *v + } } if v := chain.Embedded.AutoOwn; v != nil { return *v @@ -45,18 +55,19 @@ func AutoOwnAt(fsRoot, dirPath string) bool { } // 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. +// purely virtual. Walks the cascade like DefaultToolAt; deepest +// explicit value wins. // -// Replaces the VirtualOnlyCanonicalNames hardcoded list once -// consumers are migrated. +// Replaces VirtualOnlyCanonicalNames 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 + for i := len(chain.Levels) - 1; i >= 0; i-- { + if v := chain.Levels[i].Virtual; v != nil { + return *v + } } if v := chain.Embedded.Virtual; v != nil { return *v diff --git a/zddc/internal/zddc/lookups_test.go b/zddc/internal/zddc/lookups_test.go index 82e76b3..4c1f68c 100644 --- a/zddc/internal/zddc/lookups_test.go +++ b/zddc/internal/zddc/lookups_test.go @@ -154,6 +154,41 @@ func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) { } } +// TestDefaultToolAt_PropagatesToDescendants — once an ancestor sets +// default_tool, descendants inherit it unless they override. So a +// path under working/ that isn't explicitly declared in paths: still +// gets mdedit as its default tool. +func TestDefaultToolAt_PropagatesToDescendants(t *testing.T) { + resetCache() + root := t.TempDir() + // Deep path under working/ — not explicitly mentioned in paths:. + deep := filepath.Join(root, "Project-X", "working", "alice@example.com", "notes", "sub", "deep") + if got := DefaultToolAt(root, deep); got != "mdedit" { + t.Errorf("DefaultToolAt(%q) = %q, want mdedit (cascade propagation)", + deep[len(root):], got) + } +} + +// TestAutoOwnAt_DescendantCanDisable — explicit auto_own:false at a +// descendant overrides an ancestor's auto_own:true. +func TestAutoOwnAt_DescendantCanDisable(t *testing.T) { + resetCache() + root := t.TempDir() + deepDir := filepath.Join(root, "Project-X", "working", "alice@example.com") + if err := os.MkdirAll(deepDir, 0o755); err != nil { + t.Fatal(err) + } + writeZddc(t, deepDir, "auto_own: false\n") + if got := AutoOwnAt(root, deepDir); got != false { + t.Errorf("AutoOwnAt(%q) = %v, want false (descendant override)", deepDir, got) + } + // Ancestor still has it true. + ancestor := filepath.Join(root, "Project-X", "working") + if got := AutoOwnAt(root, ancestor); got != true { + t.Errorf("AutoOwnAt(%q) = %v, want true (ancestor untouched)", ancestor, got) + } +} + // TestInheritFalse_BlocksEmbeddedDefaults — at the on-disk root, // inherit:false stops the embedded layer from contributing. The // canonical paths are then no longer declared.