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. // // 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=browse propagates to working/alice/notes/ even when // no .zddc declares browse 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 "" } for i := len(chain.Levels) - 1; i >= 0; i-- { if dt := chain.Levels[i].DefaultTool; dt != "" { return dt } } return chain.Embedded.DefaultTool } // DirToolAt returns the cascade-resolved tool name served at the // directory's TRAILING-SLASH URL form. Walks chain.Levels leaf→root // (then the embedded defaults), returning the first non-empty // DirTool. Floors at "browse": an undeclared directory serves the // file-tree navigator, which is the site-wide convention. So callers // never need to special-case the empty result. // // This is the slash half of the slash/no-slash routing convention; // DefaultToolAt is the no-slash half. func DirToolAt(fsRoot, dirPath string) string { chain, err := EffectivePolicy(fsRoot, dirPath) if err != nil { return "browse" } for i := len(chain.Levels) - 1; i >= 0; i-- { if dt := chain.Levels[i].DirTool; dt != "" { return dt } } if dt := chain.Embedded.DirTool; dt != "" { return dt } return "browse" } // AutoOwnAt reports whether mkdir at THIS specific directory should // write an auto-owned .zddc. Leaf-only lookup — auto-own does NOT // propagate to descendants (creating working/alice/notes/sub/ does // not auto-own sub/; only the explicitly-declared per-user home is // auto-owned). func AutoOwnAt(fsRoot, dirPath string) bool { chain, err := EffectivePolicy(fsRoot, dirPath) if err != nil { return false } leaf := leafLevel(chain) if leaf.AutoOwn != nil { return *leaf.AutoOwn } if v := chain.Embedded.AutoOwn; v != nil { return *v } return false } // AutoOwnFencedAt reports whether the auto-own .zddc at this dir // should be written with `inherit: false` (private to creator). // Leaf-only, same semantic as AutoOwnAt. func AutoOwnFencedAt(fsRoot, dirPath string) bool { chain, err := EffectivePolicy(fsRoot, dirPath) if err != nil { return false } leaf := leafLevel(chain) if leaf.AutoOwnFenced != nil { return *leaf.AutoOwnFenced } if v := chain.Embedded.AutoOwnFenced; v != nil { return *v } return false } // DropTargetAt reports whether THIS specific directory accepts // drag-drop uploads from a client (e.g. browse's drop-zone overlay). // Leaf-only — the property describes a specific path, not a subtree. func DropTargetAt(fsRoot, dirPath string) bool { chain, err := EffectivePolicy(fsRoot, dirPath) if err != nil { return false } leaf := leafLevel(chain) if leaf.DropTarget != nil { return *leaf.DropTarget } if v := chain.Embedded.DropTarget; v != nil { return *v } return false } // VirtualAt reports whether THIS specific directory is declared as // purely virtual (never materialise on disk). Leaf-only: the virtual // property describes a particular path, not a subtree. A child of a // virtual directory is not automatically virtual itself. func VirtualAt(fsRoot, dirPath string) bool { chain, err := EffectivePolicy(fsRoot, dirPath) if err != nil { return false } leaf := leafLevel(chain) if leaf.Virtual != nil { return *leaf.Virtual } 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). 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) } // AvailableToolsAt returns the cascade-unioned list of tool names // the server may auto-serve at this directory. Built by walking // chain.Levels from leaf to root, then the embedded defaults, and // concat-deduping each level's AvailableTools. // // Empty result means no auto-routed tools are allowed at this path. // The dispatcher gates auto-route fallbacks against this list; // explicit static /tool.html files bypass the gate. func AvailableToolsAt(fsRoot, dirPath string) []string { chain, err := EffectivePolicy(fsRoot, dirPath) if err != nil { return nil } seen := make(map[string]struct{}) var out []string add := func(list []string) { for _, t := range list { if _, ok := seen[t]; ok { continue } seen[t] = struct{}{} out = append(out, t) } } for i := len(chain.Levels) - 1; i >= 0; i-- { add(chain.Levels[i].AvailableTools) } add(chain.Embedded.AvailableTools) return out } // IsToolAvailableAt reports whether tool is in the cascade's // available-tools union at dirPath. The dispatcher gates apps- // subsystem auto-route on this; explicit on-disk .html files // always serve regardless. func IsToolAvailableAt(fsRoot, dirPath, tool string) bool { for _, t := range AvailableToolsAt(fsRoot, dirPath) { if t == tool { return true } } return false } // 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 } // CanonicalFolderAt returns the canonical-folder name for THIS specific // directory — one of "archive", "working", "staging", "reviewing", // "incoming", "received", "issued", "mdl" — or "" if the path is not // at a canonical-folder slot. // // Detection is structural against the canonical project layout declared // in defaults.zddc.yaml: top-level /{archive,working,staging, // reviewing} and the second-level archive//{mdl,incoming, // received,issued}. Operators don't rename these slots (the cascade // keys them by literal name); a custom layout that does is on its own. // // Used by the browse SPA to scope-gate context-menu actions (Accept, // Stage/Unstage, Create Transmittal folder) without re-implementing the // cascade client-side. Surfaced via the X-ZDDC-Canonical-Folder header. func CanonicalFolderAt(fsRoot, dirPath string) string { segs := resolvePathSegments(fsRoot, dirPath) // / if len(segs) == 2 { switch segs[1] { case "archive", "working", "staging", "reviewing": return segs[1] } return "" } // /archive// if len(segs) == 4 && segs[1] == "archive" { switch segs[3] { case "incoming", "received", "issued", "mdl": return segs[3] } } return "" } // OnPlanReviewAt returns the cascade-resolved Plan Review configuration // for dirPath, or nil if no level (on-disk, virtual via Paths, or // embedded) declares one. Walks chain.Levels from leaf toward root, // returning the first non-nil OnPlanReview. The block has to be present // somewhere in the ancestry for the "Plan Review" menu item to surface // in the browse client and for the composite endpoint to know where to // scaffold workflow folders. func OnPlanReviewAt(fsRoot, dirPath string) *OnPlanReviewConfig { chain, err := EffectivePolicy(fsRoot, dirPath) if err != nil { return nil } for i := len(chain.Levels) - 1; i >= 0; i-- { if cfg := chain.Levels[i].OnPlanReview; cfg != nil { return cfg } } return chain.Embedded.OnPlanReview } // 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 != "" || zf.DirTool != "" { return false } if zf.AutoOwn != nil || zf.AutoOwnFenced != nil || zf.Virtual != nil || zf.DropTarget != nil || zf.Inherit != nil { return false } if zf.ReceivedPath != "" || zf.PlannedReviewDate != "" || zf.PlannedResponseDate != "" || zf.OnPlanReview != nil { return false } if len(zf.AvailableTools) > 0 { return false } if zf.AppsPubKey != "" || zf.CreatedBy != "" { return false } if zf.Worm != nil { // non-nil even when empty — marks a WORM zone return false } if len(zf.Admins) > 0 { return false } if len(zf.ACL.Permissions) > 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)) }