Skip to main content

cli_engine/output/
human.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
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    /// Whether any human view (column-based or custom) is registered for a
133    /// schema id. Such a view selects its own columns from the full payload, so
134    /// callers must not pre-project the data before handing it to the renderer.
135    #[must_use]
136    pub fn has_view(&self, schema_id: &str) -> bool {
137        self.by_schema_id.contains_key(schema_id)
138            || self.custom_by_schema_id.contains_key(schema_id)
139    }
140}
141
142static GLOBAL_HUMAN_VIEW_REGISTRY: OnceLock<RwLock<HumanViewRegistry>> = OnceLock::new();
143
144fn global_human_view_registry() -> &'static RwLock<HumanViewRegistry> {
145    GLOBAL_HUMAN_VIEW_REGISTRY.get_or_init(|| RwLock::new(HumanViewRegistry::new()))
146}
147
148/// Registers a process-global column view.
149pub fn register_global_human_view(view: HumanViewDef) {
150    let mut registry = global_human_view_registry()
151        .write()
152        .unwrap_or_else(|poisoned| poisoned.into_inner());
153    registry.register(view);
154}
155
156/// Registers a process-global custom human renderer.
157pub fn register_global_human_view_func(
158    schema_id: impl Into<String>,
159    render: impl Fn(&Value) -> String + Send + Sync + 'static,
160) {
161    let mut registry = global_human_view_registry()
162        .write()
163        .unwrap_or_else(|poisoned| poisoned.into_inner());
164    registry.register_func(schema_id, render);
165}
166
167/// Looks up global columns for a schema id.
168#[must_use]
169pub fn lookup_global_human_view_columns(schema_id: &str) -> Option<Vec<TableColumn>> {
170    global_human_view_registry()
171        .read()
172        .unwrap_or_else(|poisoned| poisoned.into_inner())
173        .columns(schema_id)
174        .map(<[TableColumn]>::to_vec)
175}
176
177/// Looks up a global custom renderer for a schema id.
178#[must_use]
179pub fn lookup_global_human_view_func(schema_id: &str) -> Option<HumanViewRenderer> {
180    global_human_view_registry()
181        .read()
182        .unwrap_or_else(|poisoned| poisoned.into_inner())
183        .custom(schema_id)
184        .cloned()
185}
186
187/// Returns a snapshot of the process-global human view registry.
188#[must_use]
189pub fn global_human_view_registry_snapshot() -> HumanViewRegistry {
190    global_human_view_registry()
191        .read()
192        .unwrap_or_else(|poisoned| poisoned.into_inner())
193        .clone()
194}
195
196/// Renders an envelope using generic human output.
197#[must_use]
198pub fn render_human(envelope: &Envelope) -> String {
199    render_human_with_view(envelope, None)
200}
201
202/// Renders an envelope using a human view registry.
203#[must_use]
204pub fn render_human_with_registry(envelope: &Envelope, registry: &HumanViewRegistry) -> String {
205    let system = envelope
206        .metadata
207        .as_ref()
208        .map(|metadata| metadata.system.as_str())
209        .unwrap_or_default();
210    render_human_with_registry_for_schema(envelope, registry, system)
211}
212
213/// Renders an envelope using registry entries for a specific schema id.
214///
215/// Shows every column of the registered view. Use
216/// [`render_human_with_registry_selected`] to narrow the columns to a field
217/// selection.
218#[must_use]
219pub fn render_human_with_registry_for_schema(
220    envelope: &Envelope,
221    registry: &HumanViewRegistry,
222    schema_id: &str,
223) -> String {
224    render_human_with_registry_selected(envelope, registry, schema_id, "")
225}
226
227/// Renders an envelope using a registered view, narrowed to `fields`.
228///
229/// `fields` uses the same comma-separated syntax as `--fields`: an empty
230/// string, `all`, or `*` keeps every column; otherwise only the view columns
231/// whose `field` is listed are shown. A custom view renderer receives the full
232/// data and ignores `fields`.
233#[must_use]
234pub fn render_human_with_registry_selected(
235    envelope: &Envelope,
236    registry: &HumanViewRegistry,
237    schema_id: &str,
238    fields: &str,
239) -> String {
240    if let Some(error) = &envelope.error {
241        return format!("Error: {}\n", error.message);
242    }
243    if let Some(data) = &envelope.data
244        && let Some(custom) = registry.custom(schema_id)
245    {
246        return custom.render(data);
247    }
248    match registry.columns(schema_id) {
249        Some(columns) => {
250            let selected = select_columns(columns, fields);
251            render_human_with_view(envelope, Some(&selected))
252        }
253        None => render_human_with_view(envelope, None),
254    }
255}
256
257/// Narrows view columns to a `--fields`-style selection. An empty string,
258/// `all`, or `*` keeps every column; otherwise a column survives when its
259/// `field` appears in the comma-separated list.
260fn select_columns(columns: &[TableColumn], fields: &str) -> Vec<TableColumn> {
261    let fields = fields.trim();
262    if fields.is_empty() || fields == "all" || fields == "*" {
263        return columns.to_vec();
264    }
265    let allowed: BTreeSet<&str> = fields
266        .split(',')
267        .map(str::trim)
268        .filter(|part| !part.is_empty())
269        .collect();
270    columns
271        .iter()
272        .filter(|column| allowed.contains(column.field.as_str()))
273        .cloned()
274        .collect()
275}
276
277/// Renders an envelope using explicit table columns.
278#[must_use]
279pub fn render_human_with_view(envelope: &Envelope, columns: Option<&[TableColumn]>) -> String {
280    if let Some(error) = &envelope.error {
281        return format!("Error: {}\n", error.message);
282    }
283    let Some(data) = &envelope.data else {
284        return "(no data)\n".to_owned();
285    };
286    if let Some(columns) = columns {
287        return match data {
288            Value::Array(items) => render_array_with_columns(items, columns),
289            Value::Object(map) => render_object_with_columns(map, columns),
290            Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
291                format!("{}\n", format_value(data))
292            }
293        };
294    }
295    match data {
296        Value::Array(items) => render_array(items),
297        Value::Object(map) => {
298            if map.is_empty() {
299                "(no data)\n".to_owned()
300            } else {
301                let mut keys = map.keys().collect::<Vec<_>>();
302                keys.sort();
303                let mut out = String::new();
304                for key in keys {
305                    out.push_str(&format!("{key}: {}\n", format_value(&map[key])));
306                }
307                out
308            }
309        }
310        other => format!("{}\n", format_plain_value(other)),
311    }
312}
313
314fn render_array_with_columns(items: &[Value], columns: &[TableColumn]) -> String {
315    if items.is_empty() {
316        return "(no results)\n".to_owned();
317    }
318    if !items.iter().all(Value::is_object) {
319        return render_array_lines(items);
320    }
321    let mut widths = columns
322        .iter()
323        .map(|column| column.header.len())
324        .collect::<Vec<_>>();
325    let rows = items
326        .iter()
327        .map(|item| {
328            columns
329                .iter()
330                .enumerate()
331                .map(|(index, column)| {
332                    let value = item
333                        .as_object()
334                        .and_then(|map| map.get(&column.field))
335                        .map_or_else(String::new, format_value);
336                    widths[index] = widths[index].max(value.len()).min(40);
337                    value
338                })
339                .collect::<Vec<_>>()
340        })
341        .collect::<Vec<_>>();
342    render_table(
343        &columns
344            .iter()
345            .map(|column| column.header.clone())
346            .collect::<Vec<_>>(),
347        &widths,
348        &rows,
349    )
350}
351
352fn render_object_with_columns(
353    map: &serde_json::Map<String, Value>,
354    columns: &[TableColumn],
355) -> String {
356    if map.is_empty() {
357        return "(no data)\n".to_owned();
358    }
359    let mut out = String::new();
360    for column in columns {
361        let value = map
362            .get(&column.field)
363            .map_or_else(String::new, format_value);
364        out.push_str(&format!("{}: {value}\n", column.header));
365    }
366    out
367}
368
369fn render_array(items: &[Value]) -> String {
370    if items.is_empty() {
371        return "(no results)\n".to_owned();
372    }
373    let Some(first) = items.first() else {
374        return "(no results)\n".to_owned();
375    };
376    let Value::Object(first_map) = first else {
377        return render_array_lines(items);
378    };
379    if !items.iter().all(Value::is_object) {
380        return render_array_lines(items);
381    }
382    let mut cols = first_map.keys().cloned().collect::<Vec<_>>();
383    cols.sort();
384    if cols.is_empty() {
385        return "(no results)\n".to_owned();
386    }
387    let mut widths = cols.iter().map(String::len).collect::<Vec<_>>();
388    let rows = items
389        .iter()
390        .map(|item| {
391            cols.iter()
392                .enumerate()
393                .map(|(index, col)| {
394                    let value = item
395                        .as_object()
396                        .and_then(|map| map.get(col))
397                        .map_or_else(String::new, format_value);
398                    widths[index] = widths[index].max(value.len()).min(40);
399                    value
400                })
401                .collect::<Vec<_>>()
402        })
403        .collect::<Vec<_>>();
404
405    render_table(&cols, &widths, &rows)
406}
407
408fn render_array_lines(items: &[Value]) -> String {
409    let mut out = String::new();
410    for item in items {
411        out.push_str(&format!("{}\n", format_plain_value(item)));
412    }
413    out
414}
415
416fn render_table(headers: &[String], widths: &[usize], rows: &[Vec<String>]) -> String {
417    let mut out = String::new();
418    for (index, header) in headers.iter().enumerate() {
419        if index > 0 {
420            out.push_str("  ");
421        }
422        out.push_str(&format!(
423            "{:<width$}",
424            header.to_uppercase(),
425            width = widths[index]
426        ));
427    }
428    out.push('\n');
429    for (index, width) in widths.iter().enumerate() {
430        if index > 0 {
431            out.push_str("  ");
432        }
433        out.push_str(&"-".repeat(*width));
434    }
435    out.push('\n');
436    for row in rows {
437        for (index, value) in row.iter().enumerate() {
438            if index > 0 {
439                out.push_str("  ");
440            }
441            out.push_str(&format!(
442                "{:<width$}",
443                truncate(value, widths[index]),
444                width = widths[index]
445            ));
446        }
447        out.push('\n');
448    }
449    out.push_str(&format!("\n({} rows)\n", rows.len()));
450    out
451}
452
453fn format_value(value: &Value) -> String {
454    match value {
455        Value::Null => String::new(),
456        Value::Bool(true) => "yes".to_owned(),
457        Value::Bool(false) => "no".to_owned(),
458        Value::Number(number) => format_number(number),
459        Value::String(value) => value.clone(),
460        Value::Array(items) => items
461            .iter()
462            .map(format_value)
463            .collect::<Vec<_>>()
464            .join(", "),
465        Value::Object(_) => serde_json::to_string(value).unwrap_or_else(|_| "{}".to_owned()),
466    }
467}
468
469fn format_plain_value(value: &Value) -> String {
470    match value {
471        Value::Null => "<nil>".to_owned(),
472        Value::Bool(value) => value.to_string(),
473        Value::Number(number) => format_number(number),
474        Value::String(value) => value.clone(),
475        Value::Array(items) => {
476            let values = items
477                .iter()
478                .map(format_plain_value)
479                .collect::<Vec<_>>()
480                .join(" ");
481            format!("[{values}]")
482        }
483        Value::Object(object) => {
484            let mut pairs = object
485                .iter()
486                .map(|(key, value)| (key.clone(), value.clone()))
487                .collect::<Vec<_>>();
488            pairs.sort_by(|left, right| left.0.cmp(&right.0));
489            let object = pairs
490                .into_iter()
491                .collect::<serde_json::Map<String, Value>>();
492            serde_json::to_string(&Value::Object(object)).unwrap_or_else(|_| "{}".to_owned())
493        }
494    }
495}
496
497fn truncate(value: &str, width: usize) -> String {
498    if value.len() <= width {
499        return value.to_owned();
500    }
501    if width <= 3 {
502        return value.chars().take(width).collect();
503    }
504    let mut out = value.chars().take(width - 3).collect::<String>();
505    out.push_str("...");
506    out
507}
508
509fn format_number(number: &serde_json::Number) -> String {
510    number.to_string()
511}