1use std::collections::BTreeMap;
54
55use console::Style;
56
57pub trait TreeNode {
65 fn label(&self) -> String;
66
67 fn status(&self) -> Option<NodeStatus> {
68 None
69 }
70
71 fn metadata(&self) -> Vec<(String, String)> {
75 Vec::new()
76 }
77
78 fn children(&self) -> Vec<&dyn TreeNode>;
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum NodeStatus {
85 Ok,
87 InProgress,
89 Pending,
91 Failed,
93 Custom {
95 glyph: String,
96 color: Option<String>,
97 },
98}
99
100impl NodeStatus {
101 fn glyph(&self) -> &str {
105 match self {
106 NodeStatus::Ok => "✔", NodeStatus::InProgress => "⏵", NodeStatus::Pending => "⏸", NodeStatus::Failed => "⚠", NodeStatus::Custom { glyph, .. } => glyph,
111 }
112 }
113
114 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#[derive(Debug, Clone)]
151pub enum OutputFormat {
152 Tree {
154 max_depth: Option<usize>,
157 color: bool,
161 },
162 Json,
163 Yaml,
164 Toml,
165}
166
167impl OutputFormat {
168 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
186pub 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
202fn 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
212fn 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 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 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 out.push_str(&node.label());
274
275 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 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 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
322fn 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!({}); Value::Object(obj)
353}
354
355fn 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
368fn 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#[cfg(test)]
386mod tests {
387 use super::*;
388
389 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 assert!(out.contains("hello-2.12.1"));
477 assert!(out.contains("bash-5.2"));
478 assert!(
480 !out.contains("glibc"),
481 "glibc leaked past depth limit: {out}"
482 );
483 assert!(!out.contains("ncurses"));
484 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 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}