Skip to main content

alopex_cli/tui/
renderer.rs

1//! Unified TUI renderer for CLI command output.
2
3use crate::cli::OutputFormat;
4use crate::error::Result;
5use crate::models::{Column, Row, Value};
6use crate::output::formatter::create_formatter;
7
8use super::TuiApp;
9
10#[allow(clippy::too_many_arguments)]
11pub fn render_output<'a>(
12    columns: Vec<Column>,
13    rows: Vec<Row>,
14    connection_label: impl Into<String>,
15    context_message: Option<String>,
16    processing: bool,
17    status_message: Option<String>,
18    output_format: OutputFormat,
19    admin_launcher: Option<Box<dyn FnMut() -> Result<()> + 'a>>,
20) -> Result<()> {
21    let fallback_columns = columns.clone();
22    let fallback_rows = rows.clone();
23    let output_status = status_message_for(&columns, &rows);
24    let status_message = output_status.clone().or(status_message);
25    let rows = if output_status.is_some() {
26        Vec::new()
27    } else {
28        rows
29    };
30    let mut app = TuiApp::new(columns, rows, connection_label, processing)
31        .with_context_message(context_message)
32        .with_admin_launcher(admin_launcher);
33    if let Some(message) = status_message {
34        app = app.with_status_message(message);
35    }
36    if app.run().is_err() {
37        let mut formatter = create_formatter(output_format);
38        let mut writer = std::io::stdout().lock();
39        formatter.write_header(&mut writer, &fallback_columns)?;
40        for row in &fallback_rows {
41            formatter.write_row(&mut writer, row)?;
42        }
43        formatter.write_footer(&mut writer)?;
44        return Ok(());
45    }
46    Ok(())
47}
48
49fn status_message_for(columns: &[Column], rows: &[Row]) -> Option<String> {
50    let row = rows.first()?;
51    if rows.len() != 1 || columns.len() != 2 {
52        return None;
53    }
54    if !column_name_eq(&columns[0].name, "status") || !column_name_eq(&columns[1].name, "message") {
55        return None;
56    }
57    let status = row.columns.first().map(value_to_string)?;
58    let message = row.columns.get(1).map(value_to_string)?;
59    Some(format!("{status}: {message}"))
60}
61
62fn column_name_eq(name: &str, expected: &str) -> bool {
63    name.eq_ignore_ascii_case(expected)
64}
65
66fn value_to_string(value: &Value) -> String {
67    match value {
68        Value::Null => "NULL".to_string(),
69        Value::Bool(b) => b.to_string(),
70        Value::Int(i) => i.to_string(),
71        Value::Float(f) => format!("{f:.6}"),
72        Value::Text(text) => text.clone(),
73        Value::Bytes(bytes) => {
74            let hex: String = bytes
75                .iter()
76                .take(32)
77                .map(|byte| format!("{byte:02x}"))
78                .collect();
79            if bytes.len() > 32 {
80                format!("{hex}...")
81            } else {
82                hex
83            }
84        }
85        Value::Vector(values) => {
86            if values.len() <= 4 {
87                format!(
88                    "[{}]",
89                    values
90                        .iter()
91                        .map(|value| format!("{value:.4}"))
92                        .collect::<Vec<_>>()
93                        .join(", ")
94                )
95            } else {
96                format!(
97                    "[{}, ... ({} dims)]",
98                    values
99                        .iter()
100                        .take(3)
101                        .map(|value| format!("{value:.4}"))
102                        .collect::<Vec<_>>()
103                        .join(", "),
104                    values.len()
105                )
106            }
107        }
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::models::DataType;
115
116    #[test]
117    fn detects_status_message() {
118        let columns = vec![
119            Column::new("status", DataType::Text),
120            Column::new("message", DataType::Text),
121        ];
122        let rows = vec![Row::new(vec![
123            Value::Text("OK".to_string()),
124            Value::Text("Updated key".to_string()),
125        ])];
126
127        let message = status_message_for(&columns, &rows);
128        assert_eq!(message, Some("OK: Updated key".to_string()));
129    }
130
131    #[test]
132    fn ignores_non_status_output() {
133        let columns = vec![Column::new("id", DataType::Int)];
134        let rows = vec![Row::new(vec![Value::Int(1)])];
135
136        let message = status_message_for(&columns, &rows);
137        assert_eq!(message, None);
138    }
139
140    #[test]
141    fn keeps_rich_health_rows_visible() {
142        let columns = vec![
143            Column::new("status", DataType::Text),
144            Column::new("message", DataType::Text),
145            Column::new("degraded", DataType::Bool),
146        ];
147        let rows = vec![Row::new(vec![
148            Value::Text("degraded".to_string()),
149            Value::Text("cluster status degraded".to_string()),
150            Value::Bool(true),
151        ])];
152
153        let message = status_message_for(&columns, &rows);
154        assert_eq!(message, None);
155    }
156}