Skip to main content

entrenar_common/
output.rs

1//! Output formatting and table rendering (Visual Control principle).
2//!
3//! Provides consistent output formatting across all entrenar tools,
4//! supporting both human-readable tables and machine-parseable JSON.
5
6use serde::Serialize;
7
8/// A formatted table for terminal output.
9#[derive(Debug, Clone)]
10pub struct Table {
11    headers: Vec<String>,
12    rows: Vec<Vec<String>>,
13    column_widths: Vec<usize>,
14}
15
16impl Table {
17    /// Get the table headers.
18    pub fn headers(&self) -> &[String] {
19        &self.headers
20    }
21
22    /// Get the table rows.
23    pub fn rows(&self) -> &[Vec<String>] {
24        &self.rows
25    }
26
27    /// Render the table as a string.
28    pub fn render(&self) -> String {
29        if self.headers.is_empty() {
30            return String::new();
31        }
32
33        let mut output = String::new();
34
35        // Top border
36        output.push_str(&self.render_border('┌', '┬', '┐'));
37
38        // Header row
39        output.push_str(&self.render_row(&self.headers));
40
41        // Header separator
42        output.push_str(&self.render_border('├', '┼', '┤'));
43
44        // Data rows
45        for row in &self.rows {
46            output.push_str(&self.render_row(row));
47        }
48
49        // Bottom border
50        output.push_str(&self.render_border('└', '┴', '┘'));
51
52        output
53    }
54
55    fn render_border(&self, left: char, mid: char, right: char) -> String {
56        let mut line = String::new();
57        line.push(left);
58        for (i, width) in self.column_widths.iter().enumerate() {
59            line.push_str(&"─".repeat(*width + 2));
60            if i < self.column_widths.len() - 1 {
61                line.push(mid);
62            }
63        }
64        line.push(right);
65        line.push('\n');
66        line
67    }
68
69    fn render_row(&self, values: &[String]) -> String {
70        let mut line = String::new();
71        line.push('│');
72        for (i, (value, width)) in values.iter().zip(&self.column_widths).enumerate() {
73            line.push(' ');
74            line.push_str(&format!("{:width$}", value, width = *width));
75            line.push(' ');
76            if i < self.column_widths.len() - 1 {
77                line.push('│');
78            }
79        }
80        line.push('│');
81        line.push('\n');
82        line
83    }
84
85    /// Convert to JSON representation.
86    pub fn to_json(&self) -> String {
87        let records: Vec<_> = self
88            .rows
89            .iter()
90            .map(|row| {
91                self.headers
92                    .iter()
93                    .zip(row)
94                    .map(|(h, v)| (h.clone(), v.clone()))
95                    .collect::<std::collections::HashMap<_, _>>()
96            })
97            .collect();
98
99        serde_json::to_string_pretty(&records).unwrap_or_default()
100    }
101}
102
103/// Builder for creating tables.
104#[derive(Debug, Default)]
105pub struct TableBuilder {
106    headers: Vec<String>,
107    rows: Vec<Vec<String>>,
108}
109
110impl TableBuilder {
111    /// Create a new table builder.
112    pub fn new() -> Self {
113        Self::default()
114    }
115
116    /// Set the table headers.
117    pub fn headers(mut self, headers: Vec<impl Into<String>>) -> Self {
118        self.headers = headers.into_iter().map(Into::into).collect();
119        self
120    }
121
122    /// Add a row to the table.
123    pub fn row(mut self, row: Vec<impl Into<String>>) -> Self {
124        self.rows.push(row.into_iter().map(Into::into).collect());
125        self
126    }
127
128    /// Add multiple rows to the table.
129    pub fn rows(mut self, rows: Vec<Vec<impl Into<String> + Clone>>) -> Self {
130        for row in rows {
131            self.rows.push(row.into_iter().map(Into::into).collect());
132        }
133        self
134    }
135
136    /// Build the table.
137    pub fn build(self) -> Table {
138        let mut column_widths: Vec<usize> = self.headers.iter().map(String::len).collect();
139
140        for row in &self.rows {
141            for (i, cell) in row.iter().enumerate() {
142                if i < column_widths.len() {
143                    column_widths[i] = column_widths[i].max(cell.len());
144                }
145            }
146        }
147
148        Table {
149            headers: self.headers,
150            rows: self.rows,
151            column_widths,
152        }
153    }
154}
155
156/// Format bytes as human-readable size.
157pub fn format_bytes(bytes: u64) -> String {
158    const KB: u64 = 1024;
159    const MB: u64 = KB * 1024;
160    const GB: u64 = MB * 1024;
161    const TB: u64 = GB * 1024;
162
163    if bytes >= TB {
164        format!("{:.1} TB", bytes as f64 / TB as f64)
165    } else if bytes >= GB {
166        format!("{:.1} GB", bytes as f64 / GB as f64)
167    } else if bytes >= MB {
168        format!("{:.1} MB", bytes as f64 / MB as f64)
169    } else if bytes >= KB {
170        format!("{:.1} KB", bytes as f64 / KB as f64)
171    } else {
172        format!("{bytes} B")
173    }
174}
175
176/// Format a large number with commas.
177pub fn format_number(n: u64) -> String {
178    let s = n.to_string();
179    let mut result = String::new();
180    for (i, c) in s.chars().rev().enumerate() {
181        if i > 0 && i % 3 == 0 {
182            result.push(',');
183        }
184        result.push(c);
185    }
186    result.chars().rev().collect()
187}
188
189/// Format a duration in human-readable form.
190pub fn format_duration(seconds: f64) -> String {
191    if seconds < 60.0 {
192        format!("{seconds:.1}s")
193    } else if seconds < 3600.0 {
194        let mins = (seconds / 60.0).floor();
195        let secs = seconds % 60.0;
196        format!("{mins}m {secs:.0}s")
197    } else {
198        let hours = (seconds / 3600.0).floor();
199        let mins = ((seconds % 3600.0) / 60.0).floor();
200        format!("{hours}h {mins}m")
201    }
202}
203
204/// Structured output that can be rendered as table or JSON.
205#[derive(Debug, Clone, Serialize)]
206#[allow(clippy::type_complexity)]
207pub struct StructuredOutput<T: Serialize> {
208    pub data: T,
209    #[serde(skip)]
210    pub table_headers: Vec<String>,
211    #[serde(skip)]
212    pub row_fn: Option<fn(&T) -> Vec<Vec<String>>>,
213}
214
215impl<T: Serialize> StructuredOutput<T> {
216    /// Create new structured output.
217    pub fn new(data: T) -> Self {
218        Self {
219            data,
220            table_headers: vec![],
221            row_fn: None,
222        }
223    }
224
225    /// Render as JSON.
226    pub fn to_json(&self) -> String {
227        serde_json::to_string_pretty(&self.data).unwrap_or_default()
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn test_table_builder() {
237        let table = TableBuilder::new()
238            .headers(vec!["Name", "Value", "Type"])
239            .row(vec!["alpha", "0.7", "float"])
240            .row(vec!["temperature", "4.0", "float"])
241            .build();
242
243        assert_eq!(table.headers().len(), 3);
244        assert_eq!(table.rows().len(), 2);
245    }
246
247    #[test]
248    fn test_table_render() {
249        let table = TableBuilder::new()
250            .headers(vec!["A", "B"])
251            .row(vec!["1", "2"])
252            .build();
253
254        let rendered = table.render();
255        assert!(rendered.contains('┌'));
256        assert!(rendered.contains('│'));
257        assert!(rendered.contains('└'));
258        assert!(rendered.contains('A'));
259        assert!(rendered.contains('1'));
260    }
261
262    #[test]
263    fn test_table_to_json() {
264        let table = TableBuilder::new()
265            .headers(vec!["name", "value"])
266            .row(vec!["test", "123"])
267            .build();
268
269        let json = table.to_json();
270        assert!(json.contains("\"name\""));
271        assert!(json.contains("\"test\""));
272        assert!(json.contains("\"value\""));
273        assert!(json.contains("\"123\""));
274    }
275
276    #[test]
277    fn test_format_bytes() {
278        assert_eq!(format_bytes(500), "500 B");
279        assert_eq!(format_bytes(1500), "1.5 KB");
280        assert_eq!(format_bytes(1_500_000), "1.4 MB");
281        assert_eq!(format_bytes(1_500_000_000), "1.4 GB");
282        assert_eq!(format_bytes(1_500_000_000_000), "1.4 TB");
283    }
284
285    #[test]
286    fn test_format_number() {
287        assert_eq!(format_number(0), "0");
288        assert_eq!(format_number(999), "999");
289        assert_eq!(format_number(1000), "1,000");
290        assert_eq!(format_number(1_000_000), "1,000,000");
291        assert_eq!(format_number(7_000_000_000), "7,000,000,000");
292    }
293
294    #[test]
295    fn test_format_duration() {
296        assert_eq!(format_duration(30.0), "30.0s");
297        assert_eq!(format_duration(90.0), "1m 30s");
298        assert_eq!(format_duration(3700.0), "1h 1m");
299    }
300
301    #[test]
302    fn test_column_widths_adapt_to_content() {
303        let table = TableBuilder::new()
304            .headers(vec!["Short", "X"])
305            .row(vec!["A very long value here", "Y"])
306            .build();
307
308        let rendered = table.render();
309        // The long value should fit in the table
310        assert!(rendered.contains("A very long value here"));
311    }
312
313    #[test]
314    fn test_empty_table_renders_empty() {
315        let table = TableBuilder::new().build();
316        let rendered = table.render();
317        assert!(rendered.is_empty());
318    }
319
320    #[test]
321    fn test_table_with_no_rows() {
322        let table = TableBuilder::new().headers(vec!["A", "B"]).build();
323
324        let rendered = table.render();
325        assert!(rendered.contains('A'));
326        assert!(rendered.contains('B'));
327        // Should still have borders
328        assert!(rendered.contains('┌'));
329        assert!(rendered.contains('└'));
330    }
331
332    #[test]
333    fn test_table_builder_rows_method() {
334        let table = TableBuilder::new()
335            .headers(vec!["X", "Y"])
336            .rows(vec![vec!["1", "2"], vec!["3", "4"]])
337            .build();
338
339        assert_eq!(table.rows().len(), 2);
340        assert_eq!(table.rows()[0], vec!["1", "2"]);
341        assert_eq!(table.rows()[1], vec!["3", "4"]);
342    }
343
344    #[test]
345    fn test_format_bytes_edge_cases() {
346        assert_eq!(format_bytes(0), "0 B");
347        assert_eq!(format_bytes(1), "1 B");
348        assert_eq!(format_bytes(1023), "1023 B");
349        assert_eq!(format_bytes(1024), "1.0 KB");
350    }
351
352    #[test]
353    fn test_format_number_edge_cases() {
354        assert_eq!(format_number(1), "1");
355        assert_eq!(format_number(12), "12");
356        assert_eq!(format_number(123), "123");
357        assert_eq!(format_number(1234), "1,234");
358        assert_eq!(format_number(12345), "12,345");
359        assert_eq!(format_number(123456), "123,456");
360    }
361
362    #[test]
363    fn test_format_duration_edge_cases() {
364        assert_eq!(format_duration(0.0), "0.0s");
365        assert_eq!(format_duration(59.9), "59.9s");
366        assert_eq!(format_duration(60.0), "1m 0s");
367        assert_eq!(format_duration(3599.0), "59m 59s");
368        assert_eq!(format_duration(3600.0), "1h 0m");
369    }
370
371    #[test]
372    fn test_structured_output_to_json() {
373        #[derive(serde::Serialize)]
374        struct TestData {
375            name: String,
376            value: i32,
377        }
378
379        let output = StructuredOutput::new(TestData {
380            name: "test".into(),
381            value: 42,
382        });
383
384        let json = output.to_json();
385        assert!(json.contains("\"name\""));
386        assert!(json.contains("\"test\""));
387        assert!(json.contains("42"));
388    }
389
390    #[test]
391    fn test_table_json_empty_rows() {
392        let table = TableBuilder::new().headers(vec!["a", "b"]).build();
393
394        let json = table.to_json();
395        assert_eq!(json, "[]");
396    }
397
398    #[test]
399    fn test_table_json_multiple_rows() {
400        let table = TableBuilder::new()
401            .headers(vec!["key", "val"])
402            .row(vec!["x", "1"])
403            .row(vec!["y", "2"])
404            .build();
405
406        let json = table.to_json();
407        assert!(json.contains("\"key\""));
408        assert!(json.contains("\"val\""));
409        assert!(json.contains("\"x\""));
410        assert!(json.contains("\"y\""));
411    }
412}