Skip to main content

grex_core/tree/
ls.rs

1//! Read-only `ls` tree builder.
2//!
3//! Shared structured-tree backend for the `grex ls` CLI surface and the
4//! MCP `ls` tool. Both surfaces walk the workspace identically — the
5//! only divergence is the rendering layer: CLI prints box-drawing
6//! characters in human mode; MCP returns the tree as JSON inside a
7//! `CallToolResult` envelope. Keeping the walk + JSON shape in one place
8//! prevents drift between the two surfaces (the v1.1.1 parity blocker
9//! tracked in `tests/parity.rs::parity_ls`).
10//!
11//! The walk is strictly read-only: no clone, no fetch, no exec. Each
12//! on-disk child resolves into one of four states:
13//!
14//! 1. *Loaded* — `.grex/pack.yaml` parsed cleanly. Real manifest values
15//!    flow into the [`LsNode`].
16//! 2. *Synthetic* — no `.grex/pack.yaml` but `.git/` is present (and the
17//!    destination itself is not a symlink, per the FIX-1 hardening in
18//!    [`super::walker::dest_has_git_repo`]). The walker synthesises a
19//!    leaf scripted manifest via [`super::synthesize_plain_git_manifest`]
20//!    so future shape changes flow here automatically.
21//! 3. *Unsynced* — declared in the parent manifest but the destination
22//!    directory is absent on disk. Surfaced as a placeholder node so a
23//!    fresh meta-pack checkout (pre-`grex sync`) does not look like an
24//!    empty tree.
25//! 4. *Errored* — `.grex/pack.yaml` exists but failed to read or parse.
26//!    The node carries an `error: {kind, message}` envelope so JSON
27//!    consumers see the failure without the verb aborting.
28
29use std::collections::HashMap;
30use std::path::{Path, PathBuf};
31
32use serde::Serialize;
33
34use crate::pack::{ChildRef, PackManifest, PackType};
35
36use super::error::TreeError;
37use super::loader::{FsPackLoader, PackLoader};
38use super::walker::{dest_has_git_repo, synthesize_plain_git_manifest};
39
40/// Top-level envelope shared by CLI `--json` and MCP `ls`.
41///
42/// Field shape pinned by `man/reference/cli-json.md` §"ls": `{workspace,
43/// tree[]}`. The `tree` array always has length 1 for a successful
44/// build (the root pack); kept as an array so future surfaces that walk
45/// from a workspace dir with multiple sibling packs can extend without
46/// a schema break.
47#[derive(Debug, Clone, Serialize)]
48pub struct LsTree {
49    /// Absolute path to the resolved workspace (the dir containing the
50    /// root pack's `.grex/`, or the pack root itself for the
51    /// flat-sibling layout).
52    pub workspace: String,
53    /// Root nodes. Currently always a single entry — the root pack.
54    pub tree: Vec<LsNode>,
55}
56
57/// One node in the structured `ls` tree.
58///
59/// `id` is a stable in-walk counter so JSON consumers can address a
60/// specific node without path-string parsing. `synthetic = true` marks
61/// plain-git children whose pack manifest was synthesised in-memory by
62/// the walker (no `.grex/pack.yaml` on disk); `type` is always
63/// `"scripted"` for synthetic nodes per the v1.1.1 design.
64///
65/// The flags `synthetic`, `unsynced`, and `error` are mutually
66/// exclusive — at most one is set per node. A successfully loaded real
67/// manifest leaves all three at their default (`false`/`None`).
68#[derive(Debug, Clone, Serialize)]
69pub struct LsNode {
70    /// Stable in-walk identifier (depth-first, root = 0).
71    pub id: usize,
72    /// Pack name (manifest `name:` for real packs, child path for
73    /// synthetic / unsynced / errored ones).
74    pub name: String,
75    /// Absolute on-disk path of the pack root.
76    pub path: String,
77    /// Pack flavour discriminator. Snake-case label from
78    /// [`PackType::as_str`] — `"meta"`, `"declarative"`, or
79    /// `"scripted"`.
80    #[serde(rename = "type")]
81    pub pack_type: String,
82    /// True iff the manifest was synthesised in-memory (plain-git
83    /// child). See `man/reference/pack-spec.md` §"Plain-git children".
84    pub synthetic: bool,
85    /// True iff the child is declared in its parent's manifest but its
86    /// destination directory is absent on disk. Distinguishes a
87    /// fresh-checkout state (which pre-FIX-4 was rendered as an empty
88    /// tree) from a fully-synced workspace. Skipped from JSON output
89    /// when `false` so the v1.1.1 baseline JSON shape is unchanged for
90    /// the common case.
91    #[serde(default, skip_serializing_if = "is_false")]
92    pub unsynced: bool,
93    /// `Some` when the child's manifest exists but failed to read or
94    /// parse. Surfaces partial-corruption to JSON consumers without
95    /// aborting the entire walk. Skipped from JSON output when `None`.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub error: Option<LsNodeError>,
98    /// Recursively walked children. Empty for leaves (declarative and
99    /// scripted packs and synthetic plain-git children).
100    pub children: Vec<LsNode>,
101}
102
103/// Per-node error envelope. `kind` is one of `"parse"`, `"read"`, or
104/// `"other"` — matching the JSON taxonomy documented in
105/// `man/reference/cli-json.md` §"ls".
106#[derive(Debug, Clone, Serialize)]
107pub struct LsNodeError {
108    /// Short discriminator: `"parse"` (YAML didn't deserialise),
109    /// `"read"` (IO error reading the file), `"other"` (any other
110    /// `TreeError` variant — e.g. a `PackNameMismatch` surfacing
111    /// without the walker's git stages running).
112    pub kind: String,
113    /// Human-readable underlying error message (`Display` of the
114    /// originating `TreeError`).
115    pub message: String,
116}
117
118#[allow(clippy::trivially_copy_pass_by_ref)]
119fn is_false(b: &bool) -> bool {
120    !*b
121}
122
123/// Build the structured tree rooted at `pack_root`.
124///
125/// `pack_root` is either a directory holding `.grex/pack.yaml` or the
126/// YAML file itself. Returns the same in-memory shape both the CLI
127/// `--json` mode and MCP `ls` tool emit; the human-mode CLI renderer
128/// reads off the same struct so both surfaces stay byte-aligned.
129///
130/// # Errors
131///
132/// Returns a human-readable error string when the **root** manifest
133/// cannot be loaded (missing, unreadable, or invalid YAML). Per-child
134/// failures are surfaced inside the tree itself rather than aborting:
135///
136/// * Children declared but absent on disk render as nodes with
137///   `unsynced = true`.
138/// * Children whose `.grex/pack.yaml` failed to read or parse render
139///   with `error = Some(...)` plus a stderr line carrying the same
140///   detail.
141pub fn build_ls_tree(pack_root: &Path) -> Result<LsTree, String> {
142    let loader = FsPackLoader::new();
143    let root_manifest = loader.load(pack_root).map_err(|e| format!("{e}"))?;
144    let workspace = workspace_dir_for(pack_root);
145    // v1.2.0 Stage 1.i: fold every per-meta lockfile in the tree into a
146    // single (meta_dir, segment) → synthetic lookup so the render layer
147    // can preserve the legacy `~` glyph for v1.1.1 carry-over entries
148    // even when the on-disk manifest is real (i.e., the walker's
149    // synthesis fallback no longer fires). Stage 0 LOCKED decision #3.
150    //
151    // Read-only and tolerant: any lockfile read failure degrades to an
152    // empty index — `ls` is a diagnostic surface, never aborts on
153    // missing/corrupt sidecars.
154    let synthetic_index = build_synthetic_index(&workspace);
155    let mut counter: usize = 0;
156    let id = next_id(&mut counter);
157    let children =
158        walk_children(&loader, &workspace, &root_manifest, &mut counter, &synthetic_index);
159    Ok(LsTree {
160        workspace: workspace.display().to_string(),
161        tree: vec![LsNode {
162            id,
163            name: root_manifest.name.clone(),
164            path: pack_root.display().to_string(),
165            pack_type: root_manifest.r#type.as_str().to_string(),
166            synthetic: false,
167            unsynced: false,
168            error: None,
169            children,
170        }],
171    })
172}
173
174/// Build the parent-relative synthetic lookup: every per-meta lockfile
175/// folded into `(parent_meta_dir, segment) → synthetic`. Empty on any
176/// read error — `ls` stays best-effort. The recursive descent mirrors
177/// [`read_lockfile_tree`]'s topology; reading per-meta directly lets us
178/// key entries by their true parent meta, which the nested-walk render
179/// frame can then probe.
180fn build_synthetic_index(workspace: &Path) -> HashMap<(PathBuf, String), bool> {
181    let mut idx = HashMap::new();
182    populate_synthetic_index(workspace, &mut idx);
183    idx
184}
185
186/// Recursive descent that mirrors `read_lockfile_tree`'s fold, populating
187/// the `(parent_meta, segment) → synthetic` index. Tolerates missing /
188/// corrupt sidecars: a bad meta is skipped, never aborts the index.
189fn populate_synthetic_index(meta_dir: &Path, idx: &mut HashMap<(PathBuf, String), bool>) {
190    if let Ok(entries) = crate::lockfile::read_meta_lockfile(meta_dir) {
191        for entry in &entries {
192            idx.insert((meta_dir.to_path_buf(), entry.path.clone()), entry.synthetic);
193        }
194    }
195    // Discover declared children via the manifest, recurse into metas.
196    let manifest_path = meta_dir.join(".grex").join("pack.yaml");
197    let raw = match std::fs::read_to_string(&manifest_path) {
198        Ok(s) => s,
199        Err(_) => return,
200    };
201    let manifest = match crate::pack::parse(&raw) {
202        Ok(m) => m,
203        Err(_) => return,
204    };
205    for child in &manifest.children {
206        let segment = child.path.clone().unwrap_or_else(|| child.effective_path());
207        let child_meta = meta_dir.join(&segment);
208        if child_meta.join(".grex").join("pack.yaml").is_file() {
209            populate_synthetic_index(&child_meta, idx);
210        }
211    }
212}
213
214/// Resolve where children live on disk. When `pack_root` is a YAML
215/// file, children sit beside the directory holding `.grex/`. Otherwise
216/// the pack root IS the workspace root (post-v1.1.0 flat-sibling
217/// layout).
218fn workspace_dir_for(pack_root: &Path) -> PathBuf {
219    if has_yaml_extension(pack_root) {
220        // <ws>/.grex/pack.yaml → <ws>
221        pack_root
222            .parent()
223            .and_then(Path::parent)
224            .map_or_else(|| pack_root.to_path_buf(), Path::to_path_buf)
225    } else {
226        pack_root.to_path_buf()
227    }
228}
229
230fn has_yaml_extension(path: &Path) -> bool {
231    matches!(path.extension().and_then(|e| e.to_str()), Some("yaml" | "yml"))
232}
233
234/// Walk the children of a meta whose dir is `current_meta`. v1.2.0 makes
235/// child-dest resolution parent-relative — `dest = current_meta.join(
236/// child.effective_path())` — so a nested meta no longer flattens its
237/// grandchildren under the workspace root. The legacy
238/// `current_meta == workspace` shape (v1.1.x flat-sibling layout) is
239/// preserved by passing `workspace` as the initial frame.
240fn walk_children(
241    loader: &FsPackLoader,
242    current_meta: &Path,
243    parent: &PackManifest,
244    counter: &mut usize,
245    synthetic_index: &HashMap<(PathBuf, String), bool>,
246) -> Vec<LsNode> {
247    let mut out = Vec::with_capacity(parent.children.len());
248    for child in &parent.children {
249        let segment = child.effective_path();
250        let dest = current_meta.join(&segment);
251        // Stage 1.i: lockfile-driven synthetic glyph. Probe the index
252        // with the (parent_meta, segment) key. Missing or v1.2.0 entry
253        // (synthetic=false) → no glyph; legacy carry-over → `~`.
254        let lock_synthetic =
255            synthetic_index.get(&(current_meta.to_path_buf(), segment.clone())).copied();
256        out.push(load_child_node(loader, child, &dest, counter, synthetic_index, lock_synthetic));
257    }
258    out
259}
260
261/// Resolve a single declared child into an [`LsNode`]. Centralises the
262/// four-way branch over loader outcomes so the recursion in
263/// `walk_children` stays simple. The branching mirrors the per-fix
264/// taxonomy: parsed-ok, synthetic plain-git, unsynced placeholder,
265/// errored (parse / read / other).
266fn load_child_node(
267    loader: &FsPackLoader,
268    child: &ChildRef,
269    dest: &Path,
270    counter: &mut usize,
271    synthetic_index: &HashMap<(PathBuf, String), bool>,
272    lock_synthetic: Option<bool>,
273) -> LsNode {
274    match loader.load(dest) {
275        Ok(manifest) => {
276            // Stage 1.i: a real manifest is normally non-synthetic, but
277            // a v1.1.1 carry-over lockentry can flip the glyph back on
278            // (Stage 0 LOCKED decision #3). v1.2.0 entries always have
279            // synthetic=false so this collapses to non-synthetic.
280            let synthetic = lock_synthetic.unwrap_or(false);
281            loaded_node(loader, &manifest, dest, counter, synthetic, synthetic_index)
282        }
283        Err(TreeError::ManifestNotFound(_)) if dest_has_git_repo(dest) => {
284            let manifest = synthesize_plain_git_manifest(child);
285            loaded_node(loader, &manifest, dest, counter, true, synthetic_index)
286        }
287        Err(TreeError::ManifestNotFound(_)) => unsynced_node(child, dest, counter),
288        Err(e @ TreeError::ManifestParse { .. }) => errored_node(child, dest, counter, "parse", &e),
289        Err(e @ TreeError::ManifestRead(_)) => errored_node(child, dest, counter, "read", &e),
290        Err(e) => errored_node(child, dest, counter, "other", &e),
291    }
292}
293
294/// Build an `LsNode` for a successfully loaded (real) or
295/// canonical-synthesised manifest. Recurses through `walk_children` so
296/// any future synthesised manifest carrying nested children would walk
297/// transparently. The next recursion frame uses `dest` as its
298/// `current_meta` — that is the v1.2.0 parent-relative descent.
299fn loaded_node(
300    loader: &FsPackLoader,
301    manifest: &PackManifest,
302    dest: &Path,
303    counter: &mut usize,
304    synthetic: bool,
305    synthetic_index: &HashMap<(PathBuf, String), bool>,
306) -> LsNode {
307    let id = next_id(counter);
308    let children = walk_children(loader, dest, manifest, counter, synthetic_index);
309    LsNode {
310        id,
311        name: manifest.name.clone(),
312        path: dest.display().to_string(),
313        pack_type: manifest.r#type.as_str().to_string(),
314        synthetic,
315        unsynced: false,
316        error: None,
317        children,
318    }
319}
320
321/// Build the FIX-4 placeholder for a declared-but-absent child.
322fn unsynced_node(child: &ChildRef, dest: &Path, counter: &mut usize) -> LsNode {
323    let id = next_id(counter);
324    LsNode {
325        id,
326        name: child.effective_path(),
327        path: dest.display().to_string(),
328        pack_type: PackType::Scripted.as_str().to_string(),
329        synthetic: false,
330        unsynced: true,
331        error: None,
332        children: Vec::new(),
333    }
334}
335
336/// Build the FIX-3 placeholder for a child whose manifest failed to
337/// load with a recoverable error (parse / read / other). Echoes the
338/// underlying detail to stderr so operators see the same signal in
339/// human and JSON modes.
340fn errored_node(
341    child: &ChildRef,
342    dest: &Path,
343    counter: &mut usize,
344    kind: &str,
345    err: &TreeError,
346) -> LsNode {
347    let message = format!("{err}");
348    eprintln!("grex ls: {}: {message}", child.effective_path());
349    let id = next_id(counter);
350    LsNode {
351        id,
352        name: child.effective_path(),
353        path: dest.display().to_string(),
354        pack_type: PackType::Scripted.as_str().to_string(),
355        synthetic: false,
356        unsynced: false,
357        error: Some(LsNodeError { kind: kind.to_string(), message }),
358        children: Vec::new(),
359    }
360}
361
362fn next_id(counter: &mut usize) -> usize {
363    let id = *counter;
364    *counter += 1;
365    id
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use std::fs;
372    use tempfile::tempdir;
373
374    #[test]
375    fn build_ls_tree_emits_root_for_meta_pack() {
376        let dir = tempdir().unwrap();
377        let root = dir.path();
378        fs::create_dir_all(root.join(".grex")).unwrap();
379        fs::write(root.join(".grex/pack.yaml"), "schema_version: \"1\"\nname: rootp\ntype: meta\n")
380            .unwrap();
381
382        let tree = build_ls_tree(root).expect("root manifest loads");
383        assert_eq!(tree.tree.len(), 1);
384        let node = &tree.tree[0];
385        assert_eq!(node.name, "rootp");
386        assert_eq!(node.pack_type, "meta");
387        assert!(!node.synthetic);
388        assert!(!node.unsynced);
389        assert!(node.error.is_none());
390        assert!(node.children.is_empty());
391    }
392
393    #[test]
394    fn build_ls_tree_surfaces_synthetic_plain_git_child() {
395        let dir = tempdir().unwrap();
396        let root = dir.path();
397        fs::create_dir_all(root.join(".grex")).unwrap();
398        fs::write(
399            root.join(".grex/pack.yaml"),
400            "schema_version: \"1\"\nname: rootp\ntype: meta\nchildren:\n  - url: file:///dev/null\n    path: alpha\n",
401        )
402        .unwrap();
403        // Plain-git child slot: `.git/` only, no `.grex/`.
404        fs::create_dir_all(root.join("alpha/.git")).unwrap();
405
406        let tree = build_ls_tree(root).expect("root manifest loads");
407        let root_node = &tree.tree[0];
408        assert_eq!(root_node.children.len(), 1);
409        let child = &root_node.children[0];
410        assert!(child.synthetic, "plain-git child must be flagged synthetic");
411        assert_eq!(child.pack_type, "scripted");
412        assert_eq!(child.name, "alpha");
413        assert!(!child.unsynced);
414        assert!(child.error.is_none());
415        assert!(child.children.is_empty());
416    }
417
418    #[test]
419    fn build_ls_tree_returns_error_for_missing_manifest() {
420        let dir = tempdir().unwrap();
421        let err = build_ls_tree(dir.path()).expect_err("missing manifest is fatal");
422        assert!(!err.is_empty(), "error string must be human-readable");
423    }
424
425    /// FIX-4: a child declared in the parent manifest whose destination
426    /// directory does not exist on disk MUST surface as an unsynced
427    /// placeholder. Pre-FIX-4 the walk silently elided these, making a
428    /// fresh meta-pack checkout look like an empty tree.
429    #[test]
430    fn build_ls_tree_surfaces_unsynced_child_placeholder() {
431        let dir = tempdir().unwrap();
432        let root = dir.path();
433        fs::create_dir_all(root.join(".grex")).unwrap();
434        fs::write(
435            root.join(".grex/pack.yaml"),
436            "schema_version: \"1\"\nname: rootp\ntype: meta\nchildren:\n  - url: file:///dev/null\n    path: alpha\n  - url: file:///dev/null\n    path: beta\n",
437        )
438        .unwrap();
439        // No `alpha/` or `beta/` on disk.
440
441        let tree = build_ls_tree(root).expect("root manifest loads");
442        let root_node = &tree.tree[0];
443        assert_eq!(root_node.children.len(), 2, "both declared children must appear");
444        for (idx, expected) in ["alpha", "beta"].iter().enumerate() {
445            let child = &root_node.children[idx];
446            assert_eq!(child.name, *expected);
447            assert!(!child.synthetic);
448            assert!(child.unsynced, "unsynced placeholder expected for `{expected}`");
449            assert!(child.error.is_none());
450        }
451    }
452
453    /// FIX-3: a child whose on-disk `.grex/pack.yaml` is corrupt YAML
454    /// MUST surface with `error.kind == "parse"` rather than being
455    /// silently elided. This is the read-only diagnostic surface — the
456    /// verb keeps walking the rest of the tree.
457    #[test]
458    fn build_ls_tree_surfaces_parse_error_on_corrupt_child_yaml() {
459        let dir = tempdir().unwrap();
460        let root = dir.path();
461        fs::create_dir_all(root.join(".grex")).unwrap();
462        fs::write(
463            root.join(".grex/pack.yaml"),
464            "schema_version: \"1\"\nname: rootp\ntype: meta\nchildren:\n  - url: file:///dev/null\n    path: corrupt\n",
465        )
466        .unwrap();
467        fs::create_dir_all(root.join("corrupt/.grex")).unwrap();
468        // Garbage YAML — not a mapping at all.
469        fs::write(root.join("corrupt/.grex/pack.yaml"), "::: not yaml ::: : :\n").unwrap();
470
471        let tree = build_ls_tree(root).expect("root manifest loads");
472        let root_node = &tree.tree[0];
473        assert_eq!(root_node.children.len(), 1);
474        let child = &root_node.children[0];
475        let err = child.error.as_ref().expect("parse-error child must carry error envelope");
476        assert_eq!(err.kind, "parse");
477        assert!(!err.message.is_empty());
478        assert!(!child.synthetic);
479        assert!(!child.unsynced);
480    }
481
482    // ---- Stage 1.i: v1.2.0 nested + lockfile-driven ~ glyph ----
483
484    use crate::lockfile::{write_meta_lockfile, LockEntry};
485    use chrono::{TimeZone, Utc};
486
487    fn ts_for_test() -> chrono::DateTime<Utc> {
488        Utc.with_ymd_and_hms(2026, 4, 29, 10, 0, 0).unwrap()
489    }
490
491    fn entry_with_path(id: &str, path: &str, synthetic: bool) -> LockEntry {
492        let mut e = LockEntry::new(id, "deadbeef", "main", ts_for_test(), "h", "1");
493        e.path = path.into();
494        e.synthetic = synthetic;
495        e
496    }
497
498    /// AC: a v1.2.0 nested meta tree (root → meta-child → grandchild)
499    /// renders every level. Each level's manifest is real (no on-disk
500    /// synthesis), so the rendered tree exercises the recursive
501    /// `walk_children` path on real ManifestTree shape.
502    #[test]
503    fn test_ls_renders_v1_2_0_nested_layout() {
504        let dir = tempdir().unwrap();
505        let root = dir.path();
506        fs::create_dir_all(root.join(".grex")).unwrap();
507        fs::write(
508            root.join(".grex/pack.yaml"),
509            "schema_version: \"1\"\nname: root\ntype: meta\nchildren:\n  - url: file:///dev/null\n    path: alpha\n",
510        )
511        .unwrap();
512        // alpha is itself a meta with a grandchild.
513        fs::create_dir_all(root.join("alpha/.grex")).unwrap();
514        fs::write(
515            root.join("alpha/.grex/pack.yaml"),
516            "schema_version: \"1\"\nname: alpha\ntype: meta\nchildren:\n  - url: file:///dev/null\n    path: gamma\n",
517        )
518        .unwrap();
519        fs::create_dir_all(root.join("alpha/gamma/.grex")).unwrap();
520        fs::write(
521            root.join("alpha/gamma/.grex/pack.yaml"),
522            "schema_version: \"1\"\nname: gamma\ntype: declarative\n",
523        )
524        .unwrap();
525
526        let tree = build_ls_tree(root).expect("root manifest loads");
527        let root_node = &tree.tree[0];
528        assert_eq!(root_node.name, "root");
529        assert_eq!(root_node.children.len(), 1);
530        let alpha = &root_node.children[0];
531        assert_eq!(alpha.name, "alpha");
532        assert_eq!(alpha.pack_type, "meta");
533        assert_eq!(alpha.children.len(), 1, "nested meta must surface its grandchild");
534        let gamma = &alpha.children[0];
535        assert_eq!(gamma.name, "gamma");
536        assert_eq!(gamma.pack_type, "declarative");
537        assert!(!gamma.synthetic);
538    }
539
540    /// AC: a v1.1.1 carry-over lockentry (synthetic=true) drives the
541    /// `~` glyph even when the on-disk manifest is real. Stage 0 LOCKED
542    /// decision #3: keep `~` for legacy synthetic; v1.2.0 entries never
543    /// set the flag.
544    #[test]
545    fn test_ls_renders_legacy_synthetic_with_tilde_glyph() {
546        let dir = tempdir().unwrap();
547        let root = dir.path();
548        fs::create_dir_all(root.join(".grex")).unwrap();
549        fs::write(
550            root.join(".grex/pack.yaml"),
551            "schema_version: \"1\"\nname: root\ntype: meta\nchildren:\n  - url: file:///dev/null\n    path: legacy\n",
552        )
553        .unwrap();
554        // Real manifest on the child — the on-disk synthesis fallback is
555        // NOT triggered here. Synthetic-ness must come from the lockfile.
556        fs::create_dir_all(root.join("legacy/.grex")).unwrap();
557        fs::write(
558            root.join("legacy/.grex/pack.yaml"),
559            "schema_version: \"1\"\nname: legacy\ntype: scripted\n",
560        )
561        .unwrap();
562        // Legacy lockentry: synthetic=true.
563        write_meta_lockfile(root, &[entry_with_path("legacy", "legacy", true)]).unwrap();
564
565        let tree = build_ls_tree(root).expect("root manifest loads");
566        let child = &tree.tree[0].children[0];
567        assert_eq!(child.name, "legacy");
568        assert!(
569            child.synthetic,
570            "legacy lockentry with synthetic=true must drive the ~ glyph in render layer",
571        );
572    }
573
574    /// AC: a fresh v1.2.0 lockentry (synthetic=false) must NOT carry
575    /// the `~` glyph. The flag self-extincts as v1.1.1 carryovers are
576    /// rewritten under v1.2.0.
577    #[test]
578    fn test_ls_v1_2_0_entry_no_glyph() {
579        let dir = tempdir().unwrap();
580        let root = dir.path();
581        fs::create_dir_all(root.join(".grex")).unwrap();
582        fs::write(
583            root.join(".grex/pack.yaml"),
584            "schema_version: \"1\"\nname: root\ntype: meta\nchildren:\n  - url: file:///dev/null\n    path: fresh\n",
585        )
586        .unwrap();
587        fs::create_dir_all(root.join("fresh/.grex")).unwrap();
588        fs::write(
589            root.join("fresh/.grex/pack.yaml"),
590            "schema_version: \"1\"\nname: fresh\ntype: scripted\n",
591        )
592        .unwrap();
593        // v1.2.0-shaped entry: synthetic=false.
594        write_meta_lockfile(root, &[entry_with_path("fresh", "fresh", false)]).unwrap();
595
596        let tree = build_ls_tree(root).expect("root manifest loads");
597        let child = &tree.tree[0].children[0];
598        assert_eq!(child.name, "fresh");
599        assert!(!child.synthetic, "v1.2.0 entry (synthetic=false) must not carry the ~ glyph");
600    }
601
602    /// AC: `read_lockfile_tree` is wired across multi-meta trees so a
603    /// legacy synthetic entry under a nested meta still flips the glyph.
604    /// Disjoint-partition fold (W2) means each meta's lockfile drives its
605    /// own children's flags.
606    #[test]
607    fn test_ls_uses_read_lockfile_tree() {
608        let dir = tempdir().unwrap();
609        let root = dir.path();
610        // root (meta) → alpha (meta with legacy synthetic lockentry on root) →
611        //                 gamma (real declarative grandchild, fresh under alpha).
612        fs::create_dir_all(root.join(".grex")).unwrap();
613        fs::write(
614            root.join(".grex/pack.yaml"),
615            "schema_version: \"1\"\nname: root\ntype: meta\nchildren:\n  - url: file:///dev/null\n    path: alpha\n",
616        )
617        .unwrap();
618        // root lockfile: alpha is legacy synthetic.
619        write_meta_lockfile(root, &[entry_with_path("alpha", "alpha", true)]).unwrap();
620
621        fs::create_dir_all(root.join("alpha/.grex")).unwrap();
622        fs::write(
623            root.join("alpha/.grex/pack.yaml"),
624            "schema_version: \"1\"\nname: alpha\ntype: meta\nchildren:\n  - url: file:///dev/null\n    path: gamma\n",
625        )
626        .unwrap();
627        // alpha's per-meta lockfile: gamma is fresh v1.2.0 (synthetic=false).
628        write_meta_lockfile(&root.join("alpha"), &[entry_with_path("gamma", "gamma", false)])
629            .unwrap();
630
631        fs::create_dir_all(root.join("alpha/gamma/.grex")).unwrap();
632        fs::write(
633            root.join("alpha/gamma/.grex/pack.yaml"),
634            "schema_version: \"1\"\nname: gamma\ntype: declarative\n",
635        )
636        .unwrap();
637
638        let tree = build_ls_tree(root).expect("root manifest loads");
639        let alpha = &tree.tree[0].children[0];
640        assert_eq!(alpha.name, "alpha");
641        assert!(alpha.synthetic, "root's lockfile flags alpha synthetic");
642        assert_eq!(alpha.children.len(), 1);
643        let gamma = &alpha.children[0];
644        assert_eq!(gamma.name, "gamma");
645        assert!(!gamma.synthetic, "alpha's lockfile leaves gamma fresh (synthetic=false)");
646    }
647}