Skip to main content

cli_engine/output/
human.rs

1use std::{
2    collections::BTreeMap,
3    fmt,
4    sync::{Arc, OnceLock, RwLock},
5};
6
7use serde_json::Value;
8
9use super::Envelope;
10
11/// Column definition for registered human table views.
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub struct TableColumn {
14    /// JSON field path.
15    pub field: String,
16    /// Display header.
17    pub header: String,
18}
19
20impl TableColumn {
21    /// Creates a table column from a JSON field path and display header.
22    #[must_use]
23    pub fn new(field: impl Into<String>, header: impl Into<String>) -> Self {
24        Self {
25            field: field.into(),
26            header: header.into(),
27        }
28    }
29}
30
31/// Human view definition keyed by schema id.
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct HumanViewDef {
34    /// Schema id, usually the command path.
35    pub schema_id: String,
36    /// Columns rendered for matching object or list data.
37    pub columns: Vec<TableColumn>,
38}
39
40impl HumanViewDef {
41    /// Creates a column-based human view for a schema id or command path.
42    #[must_use]
43    pub fn new(schema_id: impl Into<String>, columns: impl Into<Vec<TableColumn>>) -> Self {
44        Self {
45            schema_id: schema_id.into(),
46            columns: columns.into(),
47        }
48    }
49}
50
51/// Function used to render custom human output for a JSON value.
52pub type HumanViewFn = Arc<dyn Fn(&Value) -> String + Send + Sync>;
53
54/// Custom human renderer wrapper.
55#[derive(Clone)]
56pub struct HumanViewRenderer {
57    render: HumanViewFn,
58}
59
60impl HumanViewRenderer {
61    /// Creates a custom renderer.
62    #[must_use]
63    pub fn new(render: impl Fn(&Value) -> String + Send + Sync + 'static) -> Self {
64        Self {
65            render: Arc::new(render),
66        }
67    }
68
69    /// Renders data with the custom renderer.
70    #[must_use]
71    pub fn render(&self, data: &Value) -> String {
72        (self.render)(data)
73    }
74}
75
76impl fmt::Debug for HumanViewRenderer {
77    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78        formatter
79            .debug_struct("HumanViewRenderer")
80            .finish_non_exhaustive()
81    }
82}
83
84/// Registry of human column and custom-renderer views.
85#[derive(Clone, Debug, Default)]
86pub struct HumanViewRegistry {
87    by_schema_id: BTreeMap<String, Vec<TableColumn>>,
88    custom_by_schema_id: BTreeMap<String, HumanViewRenderer>,
89}
90
91impl HumanViewRegistry {
92    /// Creates an empty registry.
93    #[must_use]
94    pub fn new() -> Self {
95        Self::default()
96    }
97
98    /// Registers a column-based human view.
99    pub fn register(&mut self, view: HumanViewDef) {
100        self.by_schema_id.insert(view.schema_id, view.columns);
101    }
102
103    /// Registers a custom renderer for a schema id.
104    pub fn register_func(
105        &mut self,
106        schema_id: impl Into<String>,
107        render: impl Fn(&Value) -> String + Send + Sync + 'static,
108    ) {
109        self.custom_by_schema_id
110            .insert(schema_id.into(), HumanViewRenderer::new(render));
111    }
112
113    /// Merges another registry into this one.
114    pub fn merge(&mut self, other: &Self) {
115        self.by_schema_id.extend(other.by_schema_id.clone());
116        self.custom_by_schema_id
117            .extend(other.custom_by_schema_id.clone());
118    }
119
120    /// Returns column definitions for a schema id.
121    #[must_use]
122    pub fn columns(&self, schema_id: &str) -> Option<&[TableColumn]> {
123        self.by_schema_id.get(schema_id).map(Vec::as_slice)
124    }
125
126    /// Returns the custom renderer for a schema id.
127    #[must_use]
128    pub fn custom(&self, schema_id: &str) -> Option<&HumanViewRenderer> {
129        self.custom_by_schema_id.get(schema_id)
130    }
131}
132
133static GLOBAL_HUMAN_VIEW_REGISTRY: OnceLock<RwLock<HumanViewRegistry>> = OnceLock::new();
134
135fn global_human_view_registry() -> &'static RwLock<HumanViewRegistry> {
136    GLOBAL_HUMAN_VIEW_REGISTRY.get_or_init(|| RwLock::new(HumanViewRegistry::new()))
137}
138
139/// Registers a process-global column view.
140pub fn register_global_human_view(view: HumanViewDef) {
141    let mut registry = global_human_view_registry()
142        .write()
143        .unwrap_or_else(|poisoned| poisoned.into_inner());
144    registry.register(view);
145}
146
147/// Registers a process-global custom human renderer.
148pub fn register_global_human_view_func(
149    schema_id: impl Into<String>,
150    render: impl Fn(&Value) -> String + Send + Sync + 'static,
151) {
152    let mut registry = global_human_view_registry()
153        .write()
154        .unwrap_or_else(|poisoned| poisoned.into_inner());
155    registry.register_func(schema_id, render);
156}
157
158/// Looks up global columns for a schema id.
159#[must_use]
160pub fn lookup_global_human_view_columns(schema_id: &str) -> Option<Vec<TableColumn>> {
161    global_human_view_registry()
162        .read()
163        .unwrap_or_else(|poisoned| poisoned.into_inner())
164        .columns(schema_id)
165        .map(<[TableColumn]>::to_vec)
166}
167
168/// Looks up a global custom renderer for a schema id.
169#[must_use]
170pub fn lookup_global_human_view_func(schema_id: &str) -> Option<HumanViewRenderer> {
171    global_human_view_registry()
172        .read()
173        .unwrap_or_else(|poisoned| poisoned.into_inner())
174        .custom(schema_id)
175        .cloned()
176}
177
178/// Returns a snapshot of the process-global human view registry.
179#[must_use]
180pub fn global_human_view_registry_snapshot() -> HumanViewRegistry {
181    global_human_view_registry()
182        .read()
183        .unwrap_or_else(|poisoned| poisoned.into_inner())
184        .clone()
185}
186
187/// Renders an envelope using generic human output.
188#[must_use]
189pub fn render_human(envelope: &Envelope) -> String {
190    render_human_with_view(envelope, None)
191}
192
193/// Renders an envelope using a human view registry.
194#[must_use]
195pub fn render_human_with_registry(envelope: &Envelope, registry: &HumanViewRegistry) -> String {
196    let system = envelope
197        .metadata
198        .as_ref()
199        .map(|metadata| metadata.system.as_str())
200        .unwrap_or_default();
201    render_human_with_registry_for_schema(envelope, registry, system)
202}
203
204/// Renders an envelope using registry entries for a specific schema id.
205#[must_use]
206pub fn render_human_with_registry_for_schema(
207    envelope: &Envelope,
208    registry: &HumanViewRegistry,
209    schema_id: &str,
210) -> String {
211    if let Some(error) = &envelope.error {
212        return format!("Error: {}\n", error.message);
213    }
214    if let Some(data) = &envelope.data
215        && let Some(custom) = registry.custom(schema_id)
216    {
217        return custom.render(data);
218    }
219    render_human_with_view(envelope, registry.columns(schema_id))
220}
221
222/// Renders an envelope using explicit table columns.
223#[must_use]
224pub fn render_human_with_view(envelope: &Envelope, columns: Option<&[TableColumn]>) -> String {
225    if let Some(error) = &envelope.error {
226        return format!("Error: {}\n", error.message);
227    }
228    let Some(data) = &envelope.data else {
229        return "(no data)\n".to_owned();
230    };
231    if let Some(columns) = columns {
232        return match data {
233            Value::Array(items) => render_array_with_columns(items, columns),
234            Value::Object(map) => render_object_with_columns(map, columns),
235            Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
236                format!("{}\n", format_value(data))
237            }
238        };
239    }
240    match data {
241        Value::Array(items) => render_array(items),
242        Value::Object(map) => {
243            if map.is_empty() {
244                "(no data)\n".to_owned()
245            } else {
246                let mut keys = map.keys().collect::<Vec<_>>();
247                keys.sort();
248                let mut out = String::new();
249                for key in keys {
250                    out.push_str(&format!("{key}: {}\n", format_value(&map[key])));
251                }
252                out
253            }
254        }
255        other => format!("{}\n", format_plain_value(other)),
256    }
257}
258
259fn render_array_with_columns(items: &[Value], columns: &[TableColumn]) -> String {
260    if items.is_empty() {
261        return "(no results)\n".to_owned();
262    }
263    if !items.iter().all(Value::is_object) {
264        return render_array_lines(items);
265    }
266    let mut widths = columns
267        .iter()
268        .map(|column| column.header.len())
269        .collect::<Vec<_>>();
270    let rows = items
271        .iter()
272        .map(|item| {
273            columns
274                .iter()
275                .enumerate()
276                .map(|(index, column)| {
277                    let value = item
278                        .as_object()
279                        .and_then(|map| map.get(&column.field))
280                        .map_or_else(String::new, format_value);
281                    widths[index] = widths[index].max(value.len()).min(40);
282                    value
283                })
284                .collect::<Vec<_>>()
285        })
286        .collect::<Vec<_>>();
287    render_table(
288        &columns
289            .iter()
290            .map(|column| column.header.clone())
291            .collect::<Vec<_>>(),
292        &widths,
293        &rows,
294    )
295}
296
297fn render_object_with_columns(
298    map: &serde_json::Map<String, Value>,
299    columns: &[TableColumn],
300) -> String {
301    if map.is_empty() {
302        return "(no data)\n".to_owned();
303    }
304    let mut out = String::new();
305    for column in columns {
306        let value = map
307            .get(&column.field)
308            .map_or_else(String::new, format_value);
309        out.push_str(&format!("{}: {value}\n", column.header));
310    }
311    out
312}
313
314fn render_array(items: &[Value]) -> String {
315    if items.is_empty() {
316        return "(no results)\n".to_owned();
317    }
318    let Some(first) = items.first() else {
319        return "(no results)\n".to_owned();
320    };
321    let Value::Object(first_map) = first else {
322        return render_array_lines(items);
323    };
324    if !items.iter().all(Value::is_object) {
325        return render_array_lines(items);
326    }
327    let mut cols = first_map.keys().cloned().collect::<Vec<_>>();
328    cols.sort();
329    if cols.is_empty() {
330        return "(no results)\n".to_owned();
331    }
332    let mut widths = cols.iter().map(String::len).collect::<Vec<_>>();
333    let rows = items
334        .iter()
335        .map(|item| {
336            cols.iter()
337                .enumerate()
338                .map(|(index, col)| {
339                    let value = item
340                        .as_object()
341                        .and_then(|map| map.get(col))
342                        .map_or_else(String::new, format_value);
343                    widths[index] = widths[index].max(value.len()).min(40);
344                    value
345                })
346                .collect::<Vec<_>>()
347        })
348        .collect::<Vec<_>>();
349
350    render_table(&cols, &widths, &rows)
351}
352
353fn render_array_lines(items: &[Value]) -> String {
354    let mut out = String::new();
355    for item in items {
356        out.push_str(&format!("{}\n", format_plain_value(item)));
357    }
358    out
359}
360
361fn render_table(headers: &[String], widths: &[usize], rows: &[Vec<String>]) -> String {
362    let mut out = String::new();
363    for (index, header) in headers.iter().enumerate() {
364        if index > 0 {
365            out.push_str("  ");
366        }
367        out.push_str(&format!(
368            "{:<width$}",
369            header.to_uppercase(),
370            width = widths[index]
371        ));
372    }
373    out.push('\n');
374    for (index, width) in widths.iter().enumerate() {
375        if index > 0 {
376            out.push_str("  ");
377        }
378        out.push_str(&"-".repeat(*width));
379    }
380    out.push('\n');
381    for row in rows {
382        for (index, value) in row.iter().enumerate() {
383            if index > 0 {
384                out.push_str("  ");
385            }
386            out.push_str(&format!(
387                "{:<width$}",
388                truncate(value, widths[index]),
389                width = widths[index]
390            ));
391        }
392        out.push('\n');
393    }
394    out.push_str(&format!("\n({} rows)\n", rows.len()));
395    out
396}
397
398fn format_value(value: &Value) -> String {
399    match value {
400        Value::Null => String::new(),
401        Value::Bool(true) => "yes".to_owned(),
402        Value::Bool(false) => "no".to_owned(),
403        Value::Number(number) => format_number(number),
404        Value::String(value) => value.clone(),
405        Value::Array(items) => items
406            .iter()
407            .map(format_value)
408            .collect::<Vec<_>>()
409            .join(", "),
410        Value::Object(_) => serde_json::to_string(value).unwrap_or_else(|_| "{}".to_owned()),
411    }
412}
413
414fn format_plain_value(value: &Value) -> String {
415    match value {
416        Value::Null => "<nil>".to_owned(),
417        Value::Bool(value) => value.to_string(),
418        Value::Number(number) => format_number(number),
419        Value::String(value) => value.clone(),
420        Value::Array(items) => {
421            let values = items
422                .iter()
423                .map(format_plain_value)
424                .collect::<Vec<_>>()
425                .join(" ");
426            format!("[{values}]")
427        }
428        Value::Object(object) => {
429            let mut pairs = object
430                .iter()
431                .map(|(key, value)| (key.clone(), value.clone()))
432                .collect::<Vec<_>>();
433            pairs.sort_by(|left, right| left.0.cmp(&right.0));
434            let object = pairs
435                .into_iter()
436                .collect::<serde_json::Map<String, Value>>();
437            serde_json::to_string(&Value::Object(object)).unwrap_or_else(|_| "{}".to_owned())
438        }
439    }
440}
441
442fn truncate(value: &str, width: usize) -> String {
443    if value.len() <= width {
444        return value.to_owned();
445    }
446    if width <= 3 {
447        return value.chars().take(width).collect();
448    }
449    let mut out = value.chars().take(width - 3).collect::<String>();
450    out.push_str("...");
451    out
452}
453
454fn format_number(number: &serde_json::Number) -> String {
455    number.to_string()
456}