Skip to main content

grex_cli/cli/verbs/
ls.rs

1//! `grex ls` — read-only tree listing of the pack graph.
2//!
3//! Walks the workspace from a root `pack.yaml` without cloning, fetching,
4//! or executing anything. The actual walk is delegated to the shared
5//! [`grex_core::build_ls_tree`] backend so the CLI and the MCP `ls` tool
6//! stay byte-aligned (FIX-2 deduplication of the v1.1.1 reviewer
7//! findings). This file owns only argument plumbing, error-envelope
8//! shaping, and the human-mode renderer.
9//!
10//! Output:
11//! * Default — box-drawing tree, one node per line, `(<type>)` suffix
12//!   per node, `~ ` prefix on synthetic packs and `(scripted, synthetic)`
13//!   in their suffix. Unsynced declared children render as
14//!   `<name> (declared, unsynced)`. Children whose on-disk
15//!   `.grex/pack.yaml` failed to parse/read render with an
16//!   `[error: parse]` (or `read` / `other`) suffix and the underlying
17//!   detail is echoed to stderr by the shared backend.
18//! * `--json` — `{"workspace": "<abs>", "tree": [<node>, ...]}` envelope
19//!   pretty-printed via `serde_json`. Each node carries
20//!   `{id, name, path, type, synthetic, children}` plus, when relevant,
21//!   `unsynced: true` or `error: {kind, message}`.
22//!
23//! Errors loading the **root** manifest surface as a structured envelope
24//! in JSON mode and as `stderr + exit 2` in human mode, matching the
25//! `sync` verb's usage convention. The envelope's `kind` is `"tree"`
26//! (matching the documented per-verb taxonomy in
27//! `man/reference/cli-json.md` — FIX-5).
28
29use crate::cli::args::{GlobalFlags, LsArgs};
30use anyhow::Result;
31use grex_core::{build_ls_tree, LsNode, LsTree};
32use tokio_util::sync::CancellationToken;
33
34pub fn run(args: LsArgs, global: &GlobalFlags, _cancel: &CancellationToken) -> Result<()> {
35    let pack_root = match args.pack_root.clone() {
36        Some(p) => p,
37        None => std::env::current_dir()?,
38    };
39
40    match build_ls_tree(&pack_root) {
41        Ok(tree) => {
42            if global.json {
43                emit_json(&tree);
44            } else {
45                render_tree(&tree);
46            }
47            Ok(())
48        }
49        Err(detail) => {
50            if global.json {
51                emit_json_error("tree", &detail);
52            } else {
53                eprintln!("grex ls: {detail}");
54            }
55            std::process::exit(2);
56        }
57    }
58}
59
60// --- Rendering -------------------------------------------------------------
61
62fn render_tree(tree: &LsTree) {
63    for root in &tree.tree {
64        println!("{}", node_label(root));
65        let count = root.children.len();
66        for (idx, child) in root.children.iter().enumerate() {
67            render_subtree(child, "", idx + 1 == count);
68        }
69    }
70}
71
72fn render_subtree(node: &LsNode, prefix: &str, is_last: bool) {
73    let connector = if is_last { "└── " } else { "├── " };
74    println!("{prefix}{connector}{}", node_label(node));
75    let next_prefix = format!("{prefix}{}", if is_last { "    " } else { "│   " });
76    let count = node.children.len();
77    for (idx, child) in node.children.iter().enumerate() {
78        render_subtree(child, &next_prefix, idx + 1 == count);
79    }
80}
81
82/// Build the trailing label for a single node. The four exclusive
83/// states (loaded / synthetic / unsynced / errored) each get a
84/// distinct visible suffix so an operator scanning `grex ls` output
85/// can triage at a glance.
86fn node_label(node: &LsNode) -> String {
87    if let Some(err) = &node.error {
88        return format!("{} ({}) [error: {}]", node.name, node.pack_type, err.kind);
89    }
90    if node.unsynced {
91        return format!("{} (declared, unsynced)", node.name);
92    }
93    let marker = if node.synthetic { "~ " } else { "" };
94    let suffix = if node.synthetic {
95        format!("({}, synthetic)", node.pack_type)
96    } else {
97        format!("({})", node.pack_type)
98    };
99    format!("{marker}{} {suffix}", node.name)
100}
101
102// --- JSON ------------------------------------------------------------------
103
104fn emit_json(tree: &LsTree) {
105    if let Ok(s) = serde_json::to_string_pretty(tree) {
106        println!("{s}");
107    }
108}
109
110fn emit_json_error(kind: &str, message: &str) {
111    let doc = serde_json::json!({
112        "verb": "ls",
113        "error": {
114            "kind": kind,
115            "message": message,
116        },
117    });
118    if let Ok(s) = serde_json::to_string(&doc) {
119        println!("{s}");
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use grex_core::LsNodeError;
127
128    fn leaf_loaded(name: &str, pack_type: &str) -> LsNode {
129        LsNode {
130            id: 0,
131            name: name.to_string(),
132            path: name.to_string(),
133            pack_type: pack_type.to_string(),
134            synthetic: false,
135            unsynced: false,
136            error: None,
137            children: Vec::new(),
138        }
139    }
140
141    fn leaf_synthetic(name: &str) -> LsNode {
142        LsNode {
143            id: 0,
144            name: name.to_string(),
145            path: name.to_string(),
146            pack_type: "scripted".to_string(),
147            synthetic: true,
148            unsynced: false,
149            error: None,
150            children: Vec::new(),
151        }
152    }
153
154    fn leaf_unsynced(name: &str) -> LsNode {
155        LsNode {
156            id: 0,
157            name: name.to_string(),
158            path: name.to_string(),
159            pack_type: "scripted".to_string(),
160            synthetic: false,
161            unsynced: true,
162            error: None,
163            children: Vec::new(),
164        }
165    }
166
167    fn leaf_errored(name: &str, kind: &str) -> LsNode {
168        LsNode {
169            id: 0,
170            name: name.to_string(),
171            path: name.to_string(),
172            pack_type: "scripted".to_string(),
173            synthetic: false,
174            unsynced: false,
175            error: Some(LsNodeError { kind: kind.to_string(), message: "boom".to_string() }),
176            children: Vec::new(),
177        }
178    }
179
180    #[test]
181    fn label_for_declarative_has_plain_suffix() {
182        assert_eq!(node_label(&leaf_loaded("warp-cfg", "declarative")), "warp-cfg (declarative)");
183    }
184
185    #[test]
186    fn label_for_synthetic_has_tilde_prefix_and_synthetic_suffix() {
187        assert_eq!(node_label(&leaf_synthetic("algo-leet")), "~ algo-leet (scripted, synthetic)");
188    }
189
190    #[test]
191    fn label_for_meta_has_meta_suffix() {
192        assert_eq!(node_label(&leaf_loaded("dev-env", "meta")), "dev-env (meta)");
193    }
194
195    #[test]
196    fn label_for_unsynced_marks_declared_unsynced() {
197        assert_eq!(node_label(&leaf_unsynced("alpha")), "alpha (declared, unsynced)");
198    }
199
200    #[test]
201    fn label_for_errored_carries_error_kind() {
202        assert_eq!(
203            node_label(&leaf_errored("corrupt", "parse")),
204            "corrupt (scripted) [error: parse]"
205        );
206    }
207}