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;
7use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
8use serde::Serialize;
9use std::fmt::Display;
10use std::io;
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
220            .into_iter()
221            .map(|v| Cell::new(v.to_string()))
222            .collect();
223        self.table.add_row(cells);
224        self
225    }
226
227    /// Add a row with colored status.
228    pub fn status_row(
229        mut self,
230        values: impl IntoIterator<Item = impl Display>,
231        status: StatusType,
232    ) -> Self {
233        let values: Vec<String> = values.into_iter().map(|v| v.to_string()).collect();
234        let mut cells: Vec<Cell> = values.iter().map(|v| Cell::new(v)).collect();
235
236        if self.colors && !cells.is_empty() {
237            let color = match status {
238                StatusType::Success => Color::Green,
239                StatusType::Warning => Color::Yellow,
240                StatusType::Error => Color::Red,
241                StatusType::Info => Color::Blue,
242                StatusType::Neutral => Color::White,
243            };
244            // Color the last cell (usually the status)
245            if let Some(last) = cells.last_mut() {
246                *last = Cell::new(&values[values.len() - 1]).fg(color);
247            }
248        }
249        self.table.add_row(cells);
250        self
251    }
252
253    /// Add a dim summary/ellipsis row spanning all columns.
254    pub fn summary_row(mut self, message: &str, col_count: usize) -> Self {
255        let mut cells = vec![Cell::new(message)];
256        for _ in 1..col_count {
257            cells.push(Cell::new(""));
258        }
259        if self.colors {
260            cells[0] = Cell::new(message).fg(Color::DarkGrey);
261        }
262        self.table.add_row(cells);
263        self
264    }
265
266    /// Build and return the table.
267    pub fn build(self) -> Table {
268        self.table
269    }
270
271    /// Print the table.
272    pub fn print(self) {
273        println!("{}", self.table);
274    }
275}
276
277// =============================================================================
278// Paginated Table
279// =============================================================================
280
281/// Renders a large number of rows with automatic pagination.
282///
283/// When `total <= max_visible`, all rows are shown. Otherwise, the first
284/// `head_count` rows, a summary row ("... N more ..."), and the last
285/// `tail_count` rows are displayed.
286pub struct PaginatedTable {
287    max_visible: usize,
288    head_count: usize,
289    tail_count: usize,
290}
291
292impl Default for PaginatedTable {
293    fn default() -> Self {
294        Self {
295            max_visible: 20,
296            head_count: 10,
297            tail_count: 5,
298        }
299    }
300}
301
302impl PaginatedTable {
303    /// Create with custom thresholds.
304    pub fn new(max_visible: usize, head_count: usize, tail_count: usize) -> Self {
305        Self {
306            max_visible,
307            head_count,
308            tail_count,
309        }
310    }
311
312    /// Render rows into the given `TableBuilder`.
313    ///
314    /// `row_fn(index)` returns `(cells, status)` for the row at `index` (0-based).
315    /// `col_count` is the number of columns (used for the summary row span).
316    pub fn render<F>(
317        self,
318        mut builder: TableBuilder,
319        total: usize,
320        col_count: usize,
321        row_fn: F,
322    ) -> TableBuilder
323    where
324        F: Fn(usize) -> (Vec<String>, StatusType),
325    {
326        if total <= self.max_visible {
327            for i in 0..total {
328                let (cells, status) = row_fn(i);
329                builder = builder.status_row(cells, status);
330            }
331        } else {
332            for i in 0..self.head_count {
333                let (cells, status) = row_fn(i);
334                builder = builder.status_row(cells, status);
335            }
336            let omitted = total - self.head_count - self.tail_count;
337            builder = builder.summary_row(&format!("... {} more devices ...", omitted), col_count);
338            for i in (total - self.tail_count)..total {
339                let (cells, status) = row_fn(i);
340                builder = builder.status_row(cells, status);
341            }
342        }
343        builder
344    }
345}
346
347/// Status type for colored output.
348#[derive(Debug, Clone, Copy)]
349pub enum StatusType {
350    Success,
351    Warning,
352    Error,
353    Info,
354    Neutral,
355}
356
357/// Protocol status display.
358#[derive(Debug, Clone, Serialize)]
359pub struct ProtocolStatus {
360    pub protocol: String,
361    pub devices: usize,
362    pub points: usize,
363    pub status: String,
364    pub uptime: String,
365}
366
367/// Device summary for list output.
368#[derive(Debug, Clone, Serialize)]
369pub struct DeviceSummary {
370    pub id: String,
371    pub name: String,
372    pub protocol: String,
373    pub status: String,
374    pub points: usize,
375    pub last_update: String,
376}
377
378/// Validation result for output.
379#[derive(Debug, Clone, Serialize)]
380pub struct ValidationResult {
381    pub valid: bool,
382    pub errors: Vec<ValidationError>,
383    pub warnings: Vec<ValidationWarning>,
384}
385
386#[derive(Debug, Clone, Serialize)]
387pub struct ValidationError {
388    pub path: String,
389    pub message: String,
390}
391
392#[derive(Debug, Clone, Serialize)]
393pub struct ValidationWarning {
394    pub path: String,
395    pub message: String,
396}