Skip to main content

consortium_cli/
tree.rs

1//! Generic tree visualization for consortium CLIs.
2//!
3//! Mirrors `tree -L N` and `nh`-style output: depth-limited box-drawing
4//! tree with optional colored status badges. Plus machine-friendly
5//! formats — JSON, YAML, TOML — emitted from the same data model so
6//! `--format json` / `--format tree` produce different surface
7//! renderings of the same underlying tree.
8//!
9//! ## Usage
10//!
11//! Implement [`TreeNode`] for whatever you want to visualize. The
12//! renderer walks the tree generically.
13//!
14//! ```
15//! use consortium_cli::tree::{render, NodeStatus, OutputFormat, TreeNode};
16//!
17//! struct Pkg {
18//!     name: String,
19//!     status: NodeStatus,
20//!     deps: Vec<Pkg>,
21//! }
22//!
23//! impl TreeNode for Pkg {
24//!     fn label(&self) -> String { self.name.clone() }
25//!     fn status(&self) -> Option<NodeStatus> { Some(self.status.clone()) }
26//!     fn children(&self) -> Vec<&dyn TreeNode> {
27//!         self.deps.iter().map(|c| c as &dyn TreeNode).collect()
28//!     }
29//! }
30//!
31//! let root = Pkg {
32//!     name: "your-system".into(),
33//!     status: NodeStatus::Ok,
34//!     deps: vec![Pkg {
35//!         name: "hello-2.12.1".into(),
36//!         status: NodeStatus::InProgress,
37//!         deps: vec![],
38//!     }],
39//! };
40//!
41//! // tree -L 2 with colors
42//! let txt = render(&root, &OutputFormat::Tree {
43//!     max_depth: Some(2),
44//!     color: true,
45//! });
46//!
47//! // machine output
48//! let json = render(&root, &OutputFormat::Json);
49//! let yaml = render(&root, &OutputFormat::Yaml);
50//! let toml = render(&root, &OutputFormat::Toml);
51//! ```
52
53use std::collections::BTreeMap;
54
55use console::Style;
56
57// ============================================================================
58// Trait + types
59// ============================================================================
60
61/// A node in a tree. Implementors provide a label, optional status,
62/// optional metadata key/value pairs (rendered as a sub-line under the
63/// node label), and a list of children.
64pub trait TreeNode {
65    fn label(&self) -> String;
66
67    fn status(&self) -> Option<NodeStatus> {
68        None
69    }
70
71    /// Extra `(key, value)` pairs rendered inline after the label
72    /// (e.g. `"r0", "12ms"` → ` [r0=12ms]`). Useful for round IDs,
73    /// durations, sizes — anything that helps reading the tree.
74    fn metadata(&self) -> Vec<(String, String)> {
75        Vec::new()
76    }
77
78    fn children(&self) -> Vec<&dyn TreeNode>;
79}
80
81/// Status badge rendered next to a node. `Custom` lets callers add
82/// domain-specific badges without bloating this enum.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum NodeStatus {
85    /// Built / converged / done — green ✓
86    Ok,
87    /// Currently running — cyan →
88    InProgress,
89    /// Not started yet — dim ⏵
90    Pending,
91    /// Errored — red ✗
92    Failed,
93    /// Custom badge text + optional color name (`"yellow"`, `"magenta"`, ...).
94    Custom {
95        glyph: String,
96        color: Option<String>,
97    },
98}
99
100impl NodeStatus {
101    /// Glyphs match nix-output-monitor (nom)'s set, which `nh` uses
102    /// downstream. Kept verbatim so the visualization reads identical
103    /// to anyone familiar with `nh`/`nom`.
104    fn glyph(&self) -> &str {
105        match self {
106            NodeStatus::Ok => "✔",         // U+2714 heavy check mark
107            NodeStatus::InProgress => "⏵", // U+23F5 black medium right-pointing triangle
108            NodeStatus::Pending => "⏸",    // U+23F8 double vertical bar (paused/planned)
109            NodeStatus::Failed => "⚠",     // U+26A0 warning sign (matches nom; was ✗)
110            NodeStatus::Custom { glyph, .. } => glyph,
111        }
112    }
113
114    /// Colors match nom's palette: yellow+bold for running, plain green
115    /// for done, plain blue for planned, red+bold for failed.
116    fn style(&self) -> Style {
117        match self {
118            NodeStatus::Ok => Style::new().green(),
119            NodeStatus::InProgress => Style::new().yellow().bold(),
120            NodeStatus::Pending => Style::new().blue(),
121            NodeStatus::Failed => Style::new().red().bold(),
122            NodeStatus::Custom { color, .. } => match color.as_deref() {
123                Some("red") => Style::new().red(),
124                Some("green") => Style::new().green(),
125                Some("yellow") => Style::new().yellow(),
126                Some("blue") => Style::new().blue(),
127                Some("magenta") => Style::new().magenta(),
128                Some("cyan") => Style::new().cyan(),
129                _ => Style::new(),
130            },
131        }
132    }
133
134    fn as_str(&self) -> &str {
135        match self {
136            NodeStatus::Ok => "ok",
137            NodeStatus::InProgress => "in-progress",
138            NodeStatus::Pending => "pending",
139            NodeStatus::Failed => "failed",
140            NodeStatus::Custom { glyph, .. } => glyph,
141        }
142    }
143}
144
145// ============================================================================
146// Output format
147// ============================================================================
148
149/// What the renderer should produce.
150#[derive(Debug, Clone)]
151pub enum OutputFormat {
152    /// `tree -L N` style with optional ANSI color.
153    Tree {
154        /// `None` → unlimited; `Some(n)` → only render down to depth `n`
155        /// (root is depth 0; `Some(0)` shows just the root).
156        max_depth: Option<usize>,
157        /// Apply ANSI colors to status badges. Auto-disable when stdout
158        /// isn't a TTY is the caller's responsibility (use `is-terminal`
159        /// or `console::Term::is_term`).
160        color: bool,
161    },
162    Json,
163    Yaml,
164    Toml,
165}
166
167impl OutputFormat {
168    /// Parse from a CLI flag value: "tree", "json", "yaml", "toml".
169    /// `tree` defaults to no depth limit + auto color.
170    pub fn parse(s: &str) -> Result<Self, String> {
171        match s.to_ascii_lowercase().as_str() {
172            "tree" => Ok(OutputFormat::Tree {
173                max_depth: None,
174                color: true,
175            }),
176            "json" => Ok(OutputFormat::Json),
177            "yaml" | "yml" => Ok(OutputFormat::Yaml),
178            "toml" => Ok(OutputFormat::Toml),
179            other => Err(format!(
180                "unknown format '{other}' (expected: tree, json, yaml, toml)"
181            )),
182        }
183    }
184}
185
186// ============================================================================
187// Public render entry point
188// ============================================================================
189
190/// Render `root` as `format`. The renderer walks `root.children()`
191/// recursively — implementors are responsible for cycle prevention
192/// (most cascade / dep / dag trees are naturally acyclic).
193pub fn render(root: &dyn TreeNode, format: &OutputFormat) -> String {
194    match format {
195        OutputFormat::Tree { max_depth, color } => render_tree(root, *max_depth, *color),
196        OutputFormat::Json => render_json(root),
197        OutputFormat::Yaml => render_yaml(root),
198        OutputFormat::Toml => render_toml(root),
199    }
200}
201
202// ============================================================================
203// Tree text renderer (tree -L N style)
204// ============================================================================
205
206fn render_tree(root: &dyn TreeNode, max_depth: Option<usize>, color: bool) -> String {
207    let mut out = String::new();
208    render_tree_node(root, "", true, 0, max_depth, color, &mut out, true);
209    out
210}
211
212/// Continuation prefix for child rows. `│   ` for non-last branches,
213/// 4-space pad for last branches. Colored blue alongside the connectors
214/// when `color` is on so the structural chrome stays uniform.
215fn build_continuation_prefix(prefix: &str, is_root: bool, is_last: bool, color: bool) -> String {
216    if is_root {
217        String::new()
218    } else if is_last {
219        format!("{prefix}    ")
220    } else if color {
221        format!("{prefix}{}", Style::new().blue().apply_to("│   "))
222    } else {
223        format!("{prefix}│   ")
224    }
225}
226
227#[allow(clippy::too_many_arguments)]
228fn render_tree_node(
229    node: &dyn TreeNode,
230    prefix: &str,
231    is_last: bool,
232    depth: usize,
233    max_depth: Option<usize>,
234    color: bool,
235    out: &mut String,
236    is_root: bool,
237) {
238    if let Some(max) = max_depth {
239        if depth > max {
240            return;
241        }
242    }
243
244    // Leading connector. nom colors all structural chrome blue
245    // regardless of node status — keeps the tree shape readable when
246    // most nodes are warning-red mid-failure.
247    let connector = if is_root {
248        ""
249    } else if is_last {
250        "└── "
251    } else {
252        "├── "
253    };
254    out.push_str(prefix);
255    if color && !is_root {
256        out.push_str(&Style::new().blue().apply_to(connector).to_string());
257    } else {
258        out.push_str(connector);
259    }
260
261    // Status badge
262    if let Some(status) = node.status() {
263        let glyph = status.glyph();
264        if color {
265            out.push_str(&status.style().apply_to(glyph).to_string());
266        } else {
267            out.push_str(glyph);
268        }
269        out.push(' ');
270    }
271
272    // Label
273    out.push_str(&node.label());
274
275    // Inline metadata `[k=v k=v]`
276    let meta = node.metadata();
277    if !meta.is_empty() {
278        out.push_str(" [");
279        for (i, (k, v)) in meta.iter().enumerate() {
280            if i > 0 {
281                out.push(' ');
282            }
283            out.push_str(k);
284            out.push('=');
285            out.push_str(v);
286        }
287        out.push(']');
288    }
289    out.push('\n');
290
291    // Truncation marker if we're at the depth limit but have hidden children
292    let children = node.children();
293    if let Some(max) = max_depth {
294        if depth >= max && !children.is_empty() {
295            let next_prefix = build_continuation_prefix(prefix, is_root, is_last, color);
296            out.push_str(&next_prefix);
297            out.push_str("... ");
298            out.push_str(&format!("({} more)", children.len()));
299            out.push('\n');
300            return;
301        }
302    }
303
304    // Children
305    let next_prefix = build_continuation_prefix(prefix, is_root, is_last, color);
306    let child_count = children.len();
307    for (i, child) in children.iter().enumerate() {
308        let last = i == child_count - 1;
309        render_tree_node(
310            *child,
311            &next_prefix,
312            last,
313            depth + 1,
314            max_depth,
315            color,
316            out,
317            false,
318        );
319    }
320}
321
322// ============================================================================
323// Serializable tree intermediate
324// ============================================================================
325
326/// Convert any [`TreeNode`] into a generic value tree. Used by all
327/// machine-format renderers — JSON / YAML / TOML serialize this same
328/// shape.
329fn to_value_tree(node: &dyn TreeNode) -> serde_json::Value {
330    use serde_json::{json, Value};
331    let mut obj = serde_json::Map::new();
332    obj.insert("label".into(), Value::String(node.label()));
333    if let Some(status) = node.status() {
334        obj.insert("status".into(), Value::String(status.as_str().to_string()));
335    }
336    let meta = node.metadata();
337    if !meta.is_empty() {
338        let mut m = serde_json::Map::new();
339        for (k, v) in meta {
340            m.insert(k, Value::String(v));
341        }
342        obj.insert("metadata".into(), Value::Object(m));
343    }
344    let children = node.children();
345    if !children.is_empty() {
346        obj.insert(
347            "children".into(),
348            Value::Array(children.iter().map(|c| to_value_tree(*c)).collect()),
349        );
350    }
351    let _ = json!({}); // keeps the `json` import live across feature flags
352    Value::Object(obj)
353}
354
355// ============================================================================
356// Machine-format renderers
357// ============================================================================
358
359fn render_json(root: &dyn TreeNode) -> String {
360    serde_json::to_string_pretty(&to_value_tree(root))
361        .unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
362}
363
364fn render_yaml(root: &dyn TreeNode) -> String {
365    serde_yaml::to_string(&to_value_tree(root)).unwrap_or_else(|e| format!("error: {e}"))
366}
367
368/// TOML doesn't have an array-rooted document; we wrap the tree under
369/// a `tree` key. Serialization goes through serde so nested tables
370/// render in proper `[tree.children.0]` table form rather than inline.
371fn render_toml(root: &dyn TreeNode) -> String {
372    let value = to_value_tree(root);
373    let mut wrapper = BTreeMap::new();
374    wrapper.insert("tree".to_string(), value);
375    match toml::to_string(&wrapper) {
376        Ok(t) => t,
377        Err(e) => format!("# toml render error: {e}\n"),
378    }
379}
380
381// ============================================================================
382// Tests
383// ============================================================================
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    /// Test fixture: a simple labelled tree with optional status.
390    struct N {
391        label: String,
392        status: Option<NodeStatus>,
393        metadata: Vec<(String, String)>,
394        kids: Vec<N>,
395    }
396
397    impl N {
398        fn leaf(label: &str) -> Self {
399            Self {
400                label: label.into(),
401                status: None,
402                metadata: vec![],
403                kids: vec![],
404            }
405        }
406        fn with_status(mut self, s: NodeStatus) -> Self {
407            self.status = Some(s);
408            self
409        }
410        fn with_kids(mut self, kids: Vec<N>) -> Self {
411            self.kids = kids;
412            self
413        }
414        fn with_meta(mut self, k: &str, v: &str) -> Self {
415            self.metadata.push((k.into(), v.into()));
416            self
417        }
418    }
419
420    impl TreeNode for N {
421        fn label(&self) -> String {
422            self.label.clone()
423        }
424        fn status(&self) -> Option<NodeStatus> {
425            self.status.clone()
426        }
427        fn metadata(&self) -> Vec<(String, String)> {
428            self.metadata.clone()
429        }
430        fn children(&self) -> Vec<&dyn TreeNode> {
431            self.kids.iter().map(|c| c as &dyn TreeNode).collect()
432        }
433    }
434
435    fn sample() -> N {
436        N::leaf("your-system")
437            .with_status(NodeStatus::Ok)
438            .with_kids(vec![
439                N::leaf("hello-2.12.1")
440                    .with_status(NodeStatus::Ok)
441                    .with_meta("size", "1.2M")
442                    .with_kids(vec![N::leaf("glibc").with_status(NodeStatus::Ok)]),
443                N::leaf("bash-5.2")
444                    .with_status(NodeStatus::InProgress)
445                    .with_kids(vec![N::leaf("ncurses").with_status(NodeStatus::Pending)]),
446            ])
447    }
448
449    #[test]
450    fn tree_format_uses_box_drawing_chars() {
451        let out = render(
452            &sample(),
453            &OutputFormat::Tree {
454                max_depth: None,
455                color: false,
456            },
457        );
458        assert!(out.contains("├──"), "missing ├──: {out}");
459        assert!(out.contains("└──"), "missing └──: {out}");
460        assert!(out.contains("│"), "missing │: {out}");
461        assert!(out.contains("your-system"), "missing root label: {out}");
462        assert!(out.contains("hello-2.12.1"));
463        assert!(out.contains("glibc"));
464    }
465
466    #[test]
467    fn tree_max_depth_truncates_with_marker() {
468        let out = render(
469            &sample(),
470            &OutputFormat::Tree {
471                max_depth: Some(1),
472                color: false,
473            },
474        );
475        // root + first level visible; deeper truncated
476        assert!(out.contains("hello-2.12.1"));
477        assert!(out.contains("bash-5.2"));
478        // glibc + ncurses are at depth 2 → must be hidden
479        assert!(
480            !out.contains("glibc"),
481            "glibc leaked past depth limit: {out}"
482        );
483        assert!(!out.contains("ncurses"));
484        // truncation marker present
485        assert!(out.contains("more)"), "missing truncation marker: {out}");
486    }
487
488    #[test]
489    fn tree_max_depth_zero_shows_only_root() {
490        let out = render(
491            &sample(),
492            &OutputFormat::Tree {
493                max_depth: Some(0),
494                color: false,
495            },
496        );
497        assert!(out.contains("your-system"));
498        assert!(!out.contains("hello-2.12.1"));
499        assert!(out.contains("more)"));
500    }
501
502    #[test]
503    fn tree_metadata_renders_inline() {
504        let out = render(
505            &sample(),
506            &OutputFormat::Tree {
507                max_depth: None,
508                color: false,
509            },
510        );
511        assert!(out.contains("[size=1.2M]"), "missing metadata: {out}");
512    }
513
514    #[test]
515    fn tree_status_glyphs_present() {
516        // Glyph set matches nix-output-monitor (nom)/nh exactly:
517        //   ✔ done   ⏵ running   ⏸ pending   ⚠ failed
518        let out = render(
519            &sample(),
520            &OutputFormat::Tree {
521                max_depth: None,
522                color: false,
523            },
524        );
525        assert!(out.contains("✔"), "missing ✔ (Ok): {out}");
526        assert!(out.contains("⏵"), "missing ⏵ (InProgress): {out}");
527        assert!(out.contains("⏸"), "missing ⏸ (Pending): {out}");
528    }
529
530    #[test]
531    fn tree_failed_glyph_renders() {
532        let n = N::leaf("crashed").with_status(NodeStatus::Failed);
533        let out = render(
534            &n,
535            &OutputFormat::Tree {
536                max_depth: None,
537                color: false,
538            },
539        );
540        assert!(out.contains("⚠"), "missing ⚠ (Failed): {out}");
541    }
542
543    #[test]
544    fn json_format_round_trips() {
545        let out = render(&sample(), &OutputFormat::Json);
546        let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
547        assert_eq!(parsed["label"], "your-system");
548        assert_eq!(parsed["status"], "ok");
549        assert!(parsed["children"].is_array());
550        assert_eq!(parsed["children"][0]["label"], "hello-2.12.1");
551        assert_eq!(parsed["children"][0]["metadata"]["size"], "1.2M");
552        assert_eq!(parsed["children"][0]["children"][0]["label"], "glibc");
553    }
554
555    #[test]
556    fn yaml_format_round_trips() {
557        let out = render(&sample(), &OutputFormat::Yaml);
558        let parsed: serde_yaml::Value = serde_yaml::from_str(&out).expect("valid YAML");
559        assert_eq!(parsed["label"].as_str(), Some("your-system"));
560        assert_eq!(parsed["status"].as_str(), Some("ok"));
561    }
562
563    #[test]
564    fn toml_format_wraps_under_tree_key() {
565        let out = render(&sample(), &OutputFormat::Toml);
566        let parsed: toml::Value = toml::from_str(&out).expect("valid TOML");
567        assert_eq!(parsed["tree"]["label"].as_str(), Some("your-system"));
568    }
569
570    #[test]
571    fn output_format_parses_cli_strings() {
572        assert!(matches!(
573            OutputFormat::parse("tree").unwrap(),
574            OutputFormat::Tree { .. }
575        ));
576        assert!(matches!(
577            OutputFormat::parse("JSON").unwrap(),
578            OutputFormat::Json
579        ));
580        assert!(matches!(
581            OutputFormat::parse("yml").unwrap(),
582            OutputFormat::Yaml
583        ));
584        assert!(matches!(
585            OutputFormat::parse("toml").unwrap(),
586            OutputFormat::Toml
587        ));
588        assert!(OutputFormat::parse("xml").is_err());
589    }
590
591    #[test]
592    fn empty_children_render_correctly_in_all_formats() {
593        let lonely = N::leaf("only-me").with_status(NodeStatus::Pending);
594        let tree = render(
595            &lonely,
596            &OutputFormat::Tree {
597                max_depth: None,
598                color: false,
599            },
600        );
601        assert!(tree.contains("only-me"));
602        assert!(tree.contains("⏸"), "Pending should render as ⏸: {tree}");
603        let json = render(&lonely, &OutputFormat::Json);
604        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
605        assert!(
606            parsed.get("children").is_none(),
607            "should omit empty children"
608        );
609    }
610
611    #[test]
612    fn custom_status_glyph_renders() {
613        let n = N::leaf("custom").with_status(NodeStatus::Custom {
614            glyph: "?".into(),
615            color: Some("yellow".into()),
616        });
617        let out = render(
618            &n,
619            &OutputFormat::Tree {
620                max_depth: None,
621                color: false,
622            },
623        );
624        assert!(out.contains("? custom"), "missing custom glyph: {out}");
625    }
626}