cellstate/entity/
output.rs

1//! Output formatting for entity commands.
2//!
3//! Supports two modes:
4//! - **Human**: Styled terminal output with colored statuses, shortened UUIDs,
5//!   and section headers. Designed for interactive use.
6//! - **JSON**: Pretty-printed JSON for machine consumption and agent piping.
7
8use console::style;
9use serde::Serialize;
10use serde_json::Value;
11
12/// Output configuration, driven by the `--json` global flag.
13pub struct OutputConfig {
14    pub json: bool,
15}
16
17impl OutputConfig {
18    /// Print a single typed value.
19    pub fn print<T: Serialize>(&self, value: &T) {
20        let v = serde_json::to_value(value).unwrap_or(Value::Null);
21        self.print_value(&v);
22    }
23
24    /// Print a raw JSON value.
25    pub fn print_value(&self, value: &Value) {
26        if self.json {
27            println!(
28                "{}",
29                serde_json::to_string_pretty(value).unwrap_or_default()
30            );
31        } else {
32            print_human(value, 0);
33        }
34    }
35
36    /// Print a list of items with a total count.
37    pub fn print_list<T: Serialize>(&self, items: &[T], total: i64) {
38        if self.json {
39            let count = items.len();
40            let out = serde_json::json!({
41                "items": items,
42                "count": count,
43                "total": total,
44            });
45            println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
46        } else {
47            for item in items {
48                let v = serde_json::to_value(item).unwrap_or(Value::Null);
49                print_human(&v, 0);
50                println!();
51            }
52            println!("{} total", style(total).dim());
53        }
54    }
55}
56
57// ── Human-readable output ───────────────────────────────────────────────────
58
59fn print_human(value: &Value, indent: usize) {
60    match value {
61        Value::Object(map) => {
62            for (key, val) in map {
63                // Skip HATEOAS links and null values in human output
64                if key == "_links" || key == "links" {
65                    continue;
66                }
67                if val.is_null() {
68                    continue;
69                }
70                let prefix = " ".repeat(indent);
71                match val {
72                    Value::Object(_) => {
73                        println!("{prefix}{}", style(key).bold());
74                        print_human(val, indent + 2);
75                    }
76                    Value::Array(arr) => {
77                        println!("{prefix}{}: [{}]", style(key).dim(), arr.len());
78                    }
79                    _ => {
80                        let display = format_value(key, val);
81                        println!("{prefix}  {}: {display}", style(key).dim());
82                    }
83                }
84            }
85        }
86        _ => println!("{value}"),
87    }
88}
89
90fn format_value(key: &str, value: &Value) -> String {
91    match value {
92        Value::String(s) => {
93            // Shorten UUID-like ID fields
94            if key.ends_with("_id") && s.len() > 8 && looks_like_uuid(s) {
95                format!("{}...", &s[..8])
96            } else if key == "status" {
97                // Color status values
98                match s.as_str() {
99                    "active" | "idle" => style(s).green().to_string(),
100                    "completed" | "success" => style(s).cyan().to_string(),
101                    "failed" | "error" | "offline" | "killed" => style(s).red().to_string(),
102                    "suspended" | "pending" => style(s).yellow().to_string(),
103                    _ => s.clone(),
104                }
105            } else {
106                s.clone()
107            }
108        }
109        Value::Number(n) => n.to_string(),
110        Value::Bool(b) => b.to_string(),
111        _ => value.to_string(),
112    }
113}
114
115/// Quick check if a string looks like a UUID (8-4-4-4-12 hex pattern).
116fn looks_like_uuid(s: &str) -> bool {
117    s.len() == 36 && s.as_bytes().get(8) == Some(&b'-')
118}