oxur_cli/table/
mod.rs

1//! Styled table rendering for Oxur tools
2//!
3//! Provides a flexible table builder with TOML-based theming for terminal output.
4//!
5//! # Examples
6//!
7//! ## Basic Usage
8//!
9//! ```no_run
10//! use oxur_cli::table::{OxurTable, Tabled};
11//!
12//! #[derive(Tabled)]
13//! struct Employee {
14//!     #[tabled(rename = "Name")]
15//!     name: String,
16//!     #[tabled(rename = "Age")]
17//!     age: u32,
18//!     #[tabled(rename = "Role")]
19//!     role: String,
20//! }
21//!
22//! let employees = vec![
23//!     Employee { name: "Alice".into(), age: 30, role: "Engineer".into() },
24//!     Employee { name: "Bob".into(), age: 25, role: "Designer".into() },
25//! ];
26//!
27//! let table = OxurTable::new(employees).render();
28//! println!("{}", table);
29//! ```
30//!
31//! ## With Title and Footer
32//!
33//! ```no_run
34//! use oxur_cli::table::{OxurTable, Tabled};
35//!
36//! #[derive(Tabled)]
37//! struct Row {
38//!     #[tabled(rename = "ID")]
39//!     id: u32,
40//!     #[tabled(rename = "Name")]
41//!     name: String,
42//! }
43//!
44//! let data = vec![
45//!     Row { id: 1, name: "Alice".into() },
46//!     Row { id: 2, name: "Bob".into() },
47//! ];
48//!
49//! let table = OxurTable::new(data)
50//!     .with_title("USER LIST")
51//!     .with_footer()
52//!     .render();
53//! println!("{}", table);
54//! ```
55
56use tabled::Table;
57
58pub mod config;
59pub mod helpers;
60mod themes;
61
62pub use config::TableStyleConfig;
63pub use tabled::Tabled; // Re-export for convenience
64
65// Re-exports for advanced usage (manual table building and cell coloring)
66pub use tabled::builder::Builder;
67pub use tabled::settings::object::Cell;
68pub use tabled::settings::Color as TabledColor;
69
70/// A themed table builder for terminal output
71///
72/// Creates tables with the default Oxur theme (warm orange sunset colors).
73/// Supports any data type that implements `Tabled`.
74///
75/// The table can optionally include:
76/// - A **title row** at the top (styled with the theme's title colors)
77/// - A **footer row** at the bottom (styled with the theme's footer colors)
78pub struct OxurTable<T: Tabled> {
79    data: Vec<T>,
80    theme: TableStyleConfig,
81    title: Option<String>,
82    has_footer: bool,
83}
84
85impl<T: Tabled> OxurTable<T> {
86    /// Create a new table with data, using the default Oxur theme
87    ///
88    /// # Examples
89    ///
90    /// ```no_run
91    /// use oxur_cli::table::{OxurTable, Tabled};
92    ///
93    /// #[derive(Tabled)]
94    /// struct Row {
95    ///     #[tabled(rename = "ID")]
96    ///     id: u32,
97    ///     #[tabled(rename = "Name")]
98    ///     name: String,
99    /// }
100    ///
101    /// let data = vec![
102    ///     Row { id: 1, name: "Alice".into() },
103    ///     Row { id: 2, name: "Bob".into() },
104    /// ];
105    ///
106    /// let table = OxurTable::new(data);
107    /// ```
108    pub fn new(data: Vec<T>) -> Self {
109        Self { data, theme: TableStyleConfig::default(), title: None, has_footer: false }
110    }
111
112    /// Add a title row at the top of the table
113    ///
114    /// The title will span all columns and be styled with the theme's title colors.
115    ///
116    /// # Examples
117    ///
118    /// ```no_run
119    /// use oxur_cli::table::{OxurTable, Tabled};
120    ///
121    /// #[derive(Tabled)]
122    /// struct Row { name: String }
123    ///
124    /// let data = vec![Row { name: "Test".into() }];
125    /// let table = OxurTable::new(data)
126    ///     .with_title("MY TABLE")
127    ///     .render();
128    /// ```
129    pub fn with_title(mut self, title: impl Into<String>) -> Self {
130        self.title = Some(title.into());
131        self
132    }
133
134    /// Add a blank footer row at the bottom of the table
135    ///
136    /// The footer provides visual closure and is styled with the theme's footer colors.
137    ///
138    /// # Examples
139    ///
140    /// ```no_run
141    /// use oxur_cli::table::{OxurTable, Tabled};
142    ///
143    /// #[derive(Tabled)]
144    /// struct Row { name: String }
145    ///
146    /// let data = vec![Row { name: "Test".into() }];
147    /// let table = OxurTable::new(data)
148    ///     .with_footer()
149    ///     .render();
150    /// ```
151    pub fn with_footer(mut self) -> Self {
152        self.has_footer = true;
153        self
154    }
155
156    /// Render the table as a styled string for terminal output
157    ///
158    /// # Examples
159    ///
160    /// ```no_run
161    /// use oxur_cli::table::{OxurTable, Tabled};
162    ///
163    /// #[derive(Tabled)]
164    /// struct Row {
165    ///     name: String,
166    /// }
167    ///
168    /// let data = vec![Row { name: "Test".into() }];
169    /// let output = OxurTable::new(data).render();
170    /// println!("{}", output);
171    /// ```
172    pub fn render(self) -> String {
173        // If no title and no footer, use the simple path
174        if self.title.is_none() && !self.has_footer {
175            let mut table = Table::new(&self.data);
176            self.theme.apply_to_table::<T>(&mut table);
177            return table.to_string();
178        }
179
180        // Use Builder to add title and/or footer rows to the data
181        let col_count = T::headers().len();
182        let mut builder = Builder::default();
183
184        // Add title row (first column has title, rest are empty)
185        if let Some(ref title) = self.title {
186            let mut title_row = vec![title.clone()];
187            title_row.extend(std::iter::repeat_n(String::new(), col_count.saturating_sub(1)));
188            builder.push_record(title_row);
189        }
190
191        // Add header row (column names from Tabled)
192        let headers: Vec<String> = T::headers().iter().map(|h| h.to_string()).collect();
193        builder.push_record(headers);
194
195        // Add data rows
196        for item in &self.data {
197            let fields: Vec<String> = T::fields(item).iter().map(|f| f.to_string()).collect();
198            builder.push_record(fields);
199        }
200
201        // Add footer row (all empty)
202        if self.has_footer {
203            let footer_row: Vec<String> = std::iter::repeat_n(String::new(), col_count).collect();
204            builder.push_record(footer_row);
205        }
206
207        let mut table = builder.build();
208        self.theme.apply_to_table::<T>(&mut table);
209        table.to_string()
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[derive(Tabled)]
218    struct TestRow {
219        #[tabled(rename = "ID")]
220        id: u32,
221        #[tabled(rename = "Name")]
222        name: String,
223        #[tabled(rename = "Status")]
224        status: String,
225    }
226
227    // ===== OxurTable::new tests =====
228
229    #[test]
230    fn test_new_creates_table_with_default_theme() {
231        let data = vec![TestRow { id: 1, name: "Alice".into(), status: "Active".into() }];
232
233        let table = OxurTable::new(data);
234
235        // Verify the table was created (we can't easily inspect internals)
236        // Just ensure it doesn't panic
237        assert_eq!(table.data.len(), 1);
238    }
239
240    #[test]
241    fn test_new_with_empty_data() {
242        let data: Vec<TestRow> = vec![];
243        let table = OxurTable::new(data);
244        assert_eq!(table.data.len(), 0);
245    }
246
247    #[test]
248    fn test_new_with_multiple_rows() {
249        let data = vec![
250            TestRow { id: 1, name: "Alice".into(), status: "Active".into() },
251            TestRow { id: 2, name: "Bob".into(), status: "Inactive".into() },
252            TestRow { id: 3, name: "Charlie".into(), status: "Active".into() },
253        ];
254
255        let table = OxurTable::new(data);
256        assert_eq!(table.data.len(), 3);
257    }
258
259    // ===== OxurTable::render tests =====
260
261    #[test]
262    fn test_render_produces_output() {
263        let data = vec![TestRow { id: 1, name: "Alice".into(), status: "Active".into() }];
264
265        let table = OxurTable::new(data);
266        let output = table.render();
267
268        // Verify output is not empty and contains data
269        assert!(!output.is_empty());
270        assert!(output.contains("Alice"));
271        assert!(output.contains("Active"));
272    }
273
274    #[test]
275    fn test_render_empty_data() {
276        let data: Vec<TestRow> = vec![];
277        let table = OxurTable::new(data);
278        let output = table.render();
279
280        // Should still produce some output (at least headers)
281        assert!(!output.is_empty());
282    }
283
284    #[test]
285    fn test_render_multiple_rows() {
286        let data = vec![
287            TestRow { id: 1, name: "Alice".into(), status: "Active".into() },
288            TestRow { id: 2, name: "Bob".into(), status: "Inactive".into() },
289        ];
290
291        let table = OxurTable::new(data);
292        let output = table.render();
293
294        assert!(output.contains("Alice"));
295        assert!(output.contains("Bob"));
296        assert!(output.contains("Active"));
297        assert!(output.contains("Inactive"));
298    }
299
300    #[test]
301    fn test_render_includes_headers() {
302        let data = vec![TestRow { id: 1, name: "Test".into(), status: "OK".into() }];
303
304        let table = OxurTable::new(data);
305        let output = table.render();
306
307        // Headers from #[tabled(rename = "...")] should be present
308        assert!(output.contains("ID"));
309        assert!(output.contains("Name"));
310        assert!(output.contains("Status"));
311    }
312
313    #[test]
314    fn test_render_contains_ansi_codes() {
315        let data = vec![TestRow { id: 1, name: "Test".into(), status: "OK".into() }];
316
317        let table = OxurTable::new(data);
318        let output = table.render();
319
320        // Should contain ANSI escape codes for colors
321        // (The theme applies colors, so there should be escape sequences)
322        assert!(output.contains("\x1b[") || output.contains("\u{001b}["));
323    }
324
325    // ===== Integration tests =====
326
327    #[test]
328    fn test_table_with_special_characters() {
329        let data = vec![TestRow { id: 1, name: "Test & \"Special\"".into(), status: "OK".into() }];
330
331        let table = OxurTable::new(data);
332        let output = table.render();
333
334        assert!(output.contains("Test & \"Special\""));
335    }
336
337    #[test]
338    fn test_table_with_unicode() {
339        let data = vec![TestRow { id: 1, name: "Ñoño 日本語".into(), status: "✓".into() }];
340
341        let table = OxurTable::new(data);
342        let output = table.render();
343
344        assert!(output.contains("Ñoño"));
345        assert!(output.contains("日本語"));
346        assert!(output.contains("✓"));
347    }
348
349    #[test]
350    fn test_table_with_long_text() {
351        let long_name = "A".repeat(100);
352        let data = vec![TestRow { id: 1, name: long_name.clone(), status: "OK".into() }];
353
354        let table = OxurTable::new(data);
355        let output = table.render();
356
357        // Long text should be in the output (might be wrapped or truncated by tabled)
358        assert!(output.contains(&long_name[..50])); // At least first 50 chars
359    }
360
361    #[test]
362    fn test_table_with_empty_strings() {
363        let data = vec![TestRow { id: 1, name: "".into(), status: "".into() }];
364
365        let table = OxurTable::new(data);
366        let output = table.render();
367
368        // Should handle empty strings gracefully
369        assert!(!output.is_empty());
370        assert!(output.contains("ID")); // Headers should still be present
371    }
372
373    #[test]
374    fn test_different_struct_type() {
375        #[derive(Tabled)]
376        struct DifferentRow {
377            #[tabled(rename = "Col1")]
378            col1: String,
379            #[tabled(rename = "Col2")]
380            col2: i32,
381        }
382
383        let data = vec![DifferentRow { col1: "Test".into(), col2: 42 }];
384
385        let table = OxurTable::new(data);
386        let output = table.render();
387
388        assert!(output.contains("Test"));
389        assert!(output.contains("42"));
390        assert!(output.contains("Col1"));
391        assert!(output.contains("Col2"));
392    }
393
394    // ===== with_title tests =====
395
396    #[test]
397    fn test_with_title_adds_title_row() {
398        let data = vec![TestRow { id: 1, name: "Alice".into(), status: "Active".into() }];
399
400        let table = OxurTable::new(data).with_title("MY TABLE");
401        let output = table.render();
402
403        // Title should appear in the output
404        assert!(output.contains("MY TABLE"));
405        // Data should still be present
406        assert!(output.contains("Alice"));
407    }
408
409    #[test]
410    fn test_with_title_string_slice() {
411        let data = vec![TestRow { id: 1, name: "Test".into(), status: "OK".into() }];
412
413        let table = OxurTable::new(data).with_title("TITLE FROM &str");
414        let output = table.render();
415
416        assert!(output.contains("TITLE FROM &str"));
417    }
418
419    #[test]
420    fn test_with_title_owned_string() {
421        let data = vec![TestRow { id: 1, name: "Test".into(), status: "OK".into() }];
422        let title = String::from("OWNED TITLE");
423
424        let table = OxurTable::new(data).with_title(title);
425        let output = table.render();
426
427        assert!(output.contains("OWNED TITLE"));
428    }
429
430    // ===== with_footer tests =====
431
432    #[test]
433    fn test_with_footer_adds_footer_row() {
434        let data = vec![TestRow { id: 1, name: "Alice".into(), status: "Active".into() }];
435
436        let table = OxurTable::new(data).with_footer();
437        let output = table.render();
438
439        // Data should be present
440        assert!(output.contains("Alice"));
441        // Output should be longer than without footer (footer adds a row)
442        let without_footer =
443            OxurTable::new(vec![TestRow { id: 1, name: "Alice".into(), status: "Active".into() }])
444                .render();
445        assert!(output.len() > without_footer.len());
446    }
447
448    // ===== with_title and with_footer combined tests =====
449
450    #[test]
451    fn test_with_title_and_footer() {
452        let data = vec![
453            TestRow { id: 1, name: "Alice".into(), status: "Active".into() },
454            TestRow { id: 2, name: "Bob".into(), status: "Inactive".into() },
455        ];
456
457        let table = OxurTable::new(data).with_title("USER LIST").with_footer();
458        let output = table.render();
459
460        // Title should appear
461        assert!(output.contains("USER LIST"));
462        // Data should be present
463        assert!(output.contains("Alice"));
464        assert!(output.contains("Bob"));
465        // Headers should be present
466        assert!(output.contains("ID"));
467        assert!(output.contains("Name"));
468        assert!(output.contains("Status"));
469    }
470
471    #[test]
472    fn test_chaining_order_does_not_matter() {
473        let data1 = vec![TestRow { id: 1, name: "Test".into(), status: "OK".into() }];
474        let data2 = vec![TestRow { id: 1, name: "Test".into(), status: "OK".into() }];
475
476        let output1 = OxurTable::new(data1).with_title("TITLE").with_footer().render();
477        let output2 = OxurTable::new(data2).with_footer().with_title("TITLE").render();
478
479        // Both should produce the same result
480        assert_eq!(output1, output2);
481    }
482
483    #[test]
484    fn test_empty_data_with_title_and_footer() {
485        let data: Vec<TestRow> = vec![];
486
487        let table = OxurTable::new(data).with_title("EMPTY TABLE").with_footer();
488        let output = table.render();
489
490        // Title should appear
491        assert!(output.contains("EMPTY TABLE"));
492        // Headers should be present
493        assert!(output.contains("ID"));
494    }
495}