Skip to main content

mabi_cli/
output.rs

1//! Output formatting and display.
2//!
3//! Provides flexible output formatting for CLI commands.
4
5use comfy_table::{presets, Cell, Color, ContentArrangement, Table};
6use console::{style, Style};
7use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
8use serde::Serialize;
9use std::fmt::Display;
10use std::io::{self, Write};
11use std::time::Duration;
12
13/// Output format options.
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
15pub enum OutputFormat {
16    /// Human-readable table format.
17    #[default]
18    Table,
19    /// JSON format.
20    Json,
21    /// YAML format.
22    Yaml,
23    /// Compact single-line format.
24    Compact,
25}
26
27impl OutputFormat {
28    /// Parse from string.
29    pub fn from_str(s: &str) -> Option<Self> {
30        match s.to_lowercase().as_str() {
31            "table" => Some(Self::Table),
32            "json" => Some(Self::Json),
33            "yaml" => Some(Self::Yaml),
34            "compact" => Some(Self::Compact),
35            _ => None,
36        }
37    }
38}
39
40/// Output writer with configurable format.
41pub struct OutputWriter {
42    format: OutputFormat,
43    colors: bool,
44    multi_progress: MultiProgress,
45}
46
47impl OutputWriter {
48    /// Create a new output writer.
49    pub fn new(format: OutputFormat, colors: bool) -> Self {
50        Self {
51            format,
52            colors,
53            multi_progress: MultiProgress::new(),
54        }
55    }
56
57    /// Get the output format.
58    pub fn format(&self) -> OutputFormat {
59        self.format
60    }
61
62    /// Check if colors are enabled.
63    pub fn colors_enabled(&self) -> bool {
64        self.colors
65    }
66
67    /// Write a success message.
68    pub fn success(&self, msg: impl Display) {
69        if self.colors {
70            println!("{} {}", style("✓").green().bold(), msg);
71        } else {
72            println!("[OK] {}", msg);
73        }
74    }
75
76    /// Write an error message.
77    pub fn error(&self, msg: impl Display) {
78        if self.colors {
79            eprintln!("{} {}", style("✗").red().bold(), msg);
80        } else {
81            eprintln!("[ERROR] {}", msg);
82        }
83    }
84
85    /// Write a warning message.
86    pub fn warning(&self, msg: impl Display) {
87        if self.colors {
88            println!("{} {}", style("⚠").yellow().bold(), msg);
89        } else {
90            println!("[WARN] {}", msg);
91        }
92    }
93
94    /// Write an info message.
95    pub fn info(&self, msg: impl Display) {
96        if self.colors {
97            println!("{} {}", style("ℹ").blue().bold(), msg);
98        } else {
99            println!("[INFO] {}", msg);
100        }
101    }
102
103    /// Write a header.
104    pub fn header(&self, msg: impl Display) {
105        if self.colors {
106            println!("\n{}", style(msg.to_string()).cyan().bold());
107            println!("{}", style("─".repeat(40)).dim());
108        } else {
109            println!("\n=== {} ===", msg);
110        }
111    }
112
113    /// Write a key-value pair.
114    pub fn kv(&self, key: impl Display, value: impl Display) {
115        if self.colors {
116            println!("  {}: {}", style(key.to_string()).dim(), value);
117        } else {
118            println!("  {}: {}", key, value);
119        }
120    }
121
122    /// Write data in the configured format.
123    pub fn write<T: Serialize>(&self, data: &T) -> io::Result<()> {
124        match self.format {
125            OutputFormat::Json => {
126                let output = serde_json::to_string_pretty(data)?;
127                println!("{}", output);
128            }
129            OutputFormat::Yaml => {
130                let output = serde_yaml::to_string(data)
131                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
132                println!("{}", output);
133            }
134            OutputFormat::Compact => {
135                let output = serde_json::to_string(data)?;
136                println!("{}", output);
137            }
138            OutputFormat::Table => {
139                // For Table format, caller should use write_table
140                let output = serde_json::to_string_pretty(data)?;
141                println!("{}", output);
142            }
143        }
144        Ok(())
145    }
146
147    /// Create a new progress bar.
148    pub fn progress(&self, total: u64, msg: impl Into<String>) -> ProgressBar {
149        let pb = self.multi_progress.add(ProgressBar::new(total));
150        pb.set_style(
151            ProgressStyle::with_template(if self.colors {
152                "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} {msg}"
153            } else {
154                "[{elapsed_precise}] [{bar:40}] {pos}/{len} {msg}"
155            })
156            .unwrap()
157            .progress_chars("█▓░"),
158        );
159        pb.set_message(msg.into());
160        pb
161    }
162
163    /// Create a spinner.
164    pub fn spinner(&self, msg: impl Into<String>) -> ProgressBar {
165        let pb = self.multi_progress.add(ProgressBar::new_spinner());
166        pb.set_style(
167            ProgressStyle::with_template(if self.colors {
168                "{spinner:.green} {msg}"
169            } else {
170                "[*] {msg}"
171            })
172            .unwrap(),
173        );
174        pb.set_message(msg.into());
175        pb.enable_steady_tick(Duration::from_millis(100));
176        pb
177    }
178
179    /// Get the multi-progress instance.
180    pub fn multi_progress(&self) -> &MultiProgress {
181        &self.multi_progress
182    }
183}
184
185/// Table builder for structured output.
186pub struct TableBuilder {
187    table: Table,
188    colors: bool,
189}
190
191impl TableBuilder {
192    /// Create a new table builder.
193    pub fn new(colors: bool) -> Self {
194        let mut table = Table::new();
195        table.load_preset(presets::UTF8_FULL_CONDENSED);
196        table.set_content_arrangement(ContentArrangement::Dynamic);
197
198        Self { table, colors }
199    }
200
201    /// Set the table header.
202    pub fn header(mut self, columns: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
203        let cells: Vec<Cell> = columns
204            .into_iter()
205            .map(|c| {
206                if self.colors {
207                    Cell::new(c.as_ref()).fg(Color::Cyan)
208                } else {
209                    Cell::new(c.as_ref())
210                }
211            })
212            .collect();
213        self.table.set_header(cells);
214        self
215    }
216
217    /// Add a row to the table.
218    pub fn row(mut self, values: impl IntoIterator<Item = impl Display>) -> Self {
219        let cells: Vec<Cell> = values.into_iter().map(|v| Cell::new(v.to_string())).collect();
220        self.table.add_row(cells);
221        self
222    }
223
224    /// Add a row with colored status.
225    pub fn status_row(
226        mut self,
227        values: impl IntoIterator<Item = impl Display>,
228        status: StatusType,
229    ) -> Self {
230        let values: Vec<String> = values.into_iter().map(|v| v.to_string()).collect();
231        let mut cells: Vec<Cell> = values.iter().map(|v| Cell::new(v)).collect();
232
233        if self.colors && !cells.is_empty() {
234            let color = match status {
235                StatusType::Success => Color::Green,
236                StatusType::Warning => Color::Yellow,
237                StatusType::Error => Color::Red,
238                StatusType::Info => Color::Blue,
239                StatusType::Neutral => Color::White,
240            };
241            // Color the last cell (usually the status)
242            if let Some(last) = cells.last_mut() {
243                *last = Cell::new(&values[values.len() - 1]).fg(color);
244            }
245        }
246        self.table.add_row(cells);
247        self
248    }
249
250    /// Add a dim summary/ellipsis row spanning all columns.
251    pub fn summary_row(mut self, message: &str, col_count: usize) -> Self {
252        let mut cells = vec![Cell::new(message)];
253        for _ in 1..col_count {
254            cells.push(Cell::new(""));
255        }
256        if self.colors {
257            cells[0] = Cell::new(message).fg(Color::DarkGrey);
258        }
259        self.table.add_row(cells);
260        self
261    }
262
263    /// Build and return the table.
264    pub fn build(self) -> Table {
265        self.table
266    }
267
268    /// Print the table.
269    pub fn print(self) {
270        println!("{}", self.table);
271    }
272}
273
274// =============================================================================
275// Paginated Table
276// =============================================================================
277
278/// Renders a large number of rows with automatic pagination.
279///
280/// When `total <= max_visible`, all rows are shown. Otherwise, the first
281/// `head_count` rows, a summary row ("... N more ..."), and the last
282/// `tail_count` rows are displayed.
283pub struct PaginatedTable {
284    max_visible: usize,
285    head_count: usize,
286    tail_count: usize,
287}
288
289impl Default for PaginatedTable {
290    fn default() -> Self {
291        Self {
292            max_visible: 20,
293            head_count: 10,
294            tail_count: 5,
295        }
296    }
297}
298
299impl PaginatedTable {
300    /// Create with custom thresholds.
301    pub fn new(max_visible: usize, head_count: usize, tail_count: usize) -> Self {
302        Self { max_visible, head_count, tail_count }
303    }
304
305    /// Render rows into the given `TableBuilder`.
306    ///
307    /// `row_fn(index)` returns `(cells, status)` for the row at `index` (0-based).
308    /// `col_count` is the number of columns (used for the summary row span).
309    pub fn render<F>(self, mut builder: TableBuilder, total: usize, col_count: usize, row_fn: F) -> TableBuilder
310    where
311        F: Fn(usize) -> (Vec<String>, StatusType),
312    {
313        if total <= self.max_visible {
314            for i in 0..total {
315                let (cells, status) = row_fn(i);
316                builder = builder.status_row(cells, status);
317            }
318        } else {
319            for i in 0..self.head_count {
320                let (cells, status) = row_fn(i);
321                builder = builder.status_row(cells, status);
322            }
323            let omitted = total - self.head_count - self.tail_count;
324            builder = builder.summary_row(
325                &format!("... {} more devices ...", omitted),
326                col_count,
327            );
328            for i in (total - self.tail_count)..total {
329                let (cells, status) = row_fn(i);
330                builder = builder.status_row(cells, status);
331            }
332        }
333        builder
334    }
335}
336
337/// Status type for colored output.
338#[derive(Debug, Clone, Copy)]
339pub enum StatusType {
340    Success,
341    Warning,
342    Error,
343    Info,
344    Neutral,
345}
346
347/// Protocol status display.
348#[derive(Debug, Clone, Serialize)]
349pub struct ProtocolStatus {
350    pub protocol: String,
351    pub devices: usize,
352    pub points: usize,
353    pub status: String,
354    pub uptime: String,
355}
356
357/// Device summary for list output.
358#[derive(Debug, Clone, Serialize)]
359pub struct DeviceSummary {
360    pub id: String,
361    pub name: String,
362    pub protocol: String,
363    pub status: String,
364    pub points: usize,
365    pub last_update: String,
366}
367
368/// Validation result for output.
369#[derive(Debug, Clone, Serialize)]
370pub struct ValidationResult {
371    pub valid: bool,
372    pub errors: Vec<ValidationError>,
373    pub warnings: Vec<ValidationWarning>,
374}
375
376#[derive(Debug, Clone, Serialize)]
377pub struct ValidationError {
378    pub path: String,
379    pub message: String,
380}
381
382#[derive(Debug, Clone, Serialize)]
383pub struct ValidationWarning {
384    pub path: String,
385    pub message: String,
386}