Skip to main content

standout_render/tabular/
decorator.rs

1//! Table decorator for adding borders, headers, and formatting.
2//!
3//! This module provides a `Table` type that wraps a `TabularSpec` and adds
4//! decorative elements like borders, headers, and separators.
5//!
6//! # Example
7//!
8//! ```rust
9//! use standout_render::tabular::{Table, TabularSpec, Col, BorderStyle};
10//!
11//! let spec = TabularSpec::builder()
12//!     .column(Col::fixed(20))
13//!     .column(Col::fixed(10))
14//!     .column(Col::fixed(8))
15//!     .separator("  ")
16//!     .build();
17//!
18//! let table = Table::new(spec, 80)
19//!     .border(BorderStyle::Light)
20//!     .header(vec!["Name", "Status", "Count"]);
21//!
22//! // Render header
23//! println!("{}", table.header_row());
24//! println!("{}", table.separator_row());
25//!
26//! // Render data rows
27//! println!("{}", table.row(&["Alice", "Active", "42"]));
28//! println!("{}", table.row(&["Bob", "Pending", "17"]));
29//!
30//! // Or render everything at once
31//! let data = vec![
32//!     vec!["Alice", "Active", "42"],
33//!     vec!["Bob", "Pending", "17"],
34//! ];
35//! println!("{}", table.render(&data));
36//! ```
37
38use std::sync::atomic::{AtomicUsize, Ordering};
39
40use super::formatter::{CellValue, OwnedCellValue, TabularFormatter};
41use super::traits::{Tabular, TabularRow};
42use super::types::{FlatDataSpec, TabularSpec};
43use super::util::display_width;
44
45/// Border style for table decoration.
46#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
47pub enum BorderStyle {
48    /// No borders.
49    #[default]
50    None,
51    /// ASCII borders: +, -, |
52    Ascii,
53    /// Light Unicode box-drawing characters: ┌, ─, ┐, │, └, ┘, ├, ┼, ┤, ┬, ┴
54    Light,
55    /// Heavy Unicode box-drawing characters: ┏, ━, ┓, ┃, ┗, ┛, ┣, ╋, ┫, ┳, ┻
56    Heavy,
57    /// Double-line Unicode box-drawing: ╔, ═, ╗, ║, ╚, ╝, ╠, ╬, ╣, ╦, ╩
58    Double,
59    /// Rounded corners with light lines: ╭, ─, ╮, │, ╰, ╯, ├, ┼, ┤, ┬, ┴
60    Rounded,
61}
62
63impl BorderStyle {
64    /// Get the box-drawing characters for this border style.
65    ///
66    /// Returns a tuple of (horizontal, vertical, top_left, top_right, bottom_left,
67    /// bottom_right, left_t, cross, right_t, top_t, bottom_t).
68    fn chars(&self) -> BorderChars {
69        match self {
70            BorderStyle::None => BorderChars::empty(),
71            BorderStyle::Ascii => BorderChars {
72                horizontal: '-',
73                vertical: '|',
74                top_left: '+',
75                top_right: '+',
76                bottom_left: '+',
77                bottom_right: '+',
78                left_t: '+',
79                cross: '+',
80                right_t: '+',
81                top_t: '+',
82                bottom_t: '+',
83            },
84            BorderStyle::Light => BorderChars {
85                horizontal: '─',
86                vertical: '│',
87                top_left: '┌',
88                top_right: '┐',
89                bottom_left: '└',
90                bottom_right: '┘',
91                left_t: '├',
92                cross: '┼',
93                right_t: '┤',
94                top_t: '┬',
95                bottom_t: '┴',
96            },
97            BorderStyle::Heavy => BorderChars {
98                horizontal: '━',
99                vertical: '┃',
100                top_left: '┏',
101                top_right: '┓',
102                bottom_left: '┗',
103                bottom_right: '┛',
104                left_t: '┣',
105                cross: '╋',
106                right_t: '┫',
107                top_t: '┳',
108                bottom_t: '┻',
109            },
110            BorderStyle::Double => BorderChars {
111                horizontal: '═',
112                vertical: '║',
113                top_left: '╔',
114                top_right: '╗',
115                bottom_left: '╚',
116                bottom_right: '╝',
117                left_t: '╠',
118                cross: '╬',
119                right_t: '╣',
120                top_t: '╦',
121                bottom_t: '╩',
122            },
123            BorderStyle::Rounded => BorderChars {
124                horizontal: '─',
125                vertical: '│',
126                top_left: '╭',
127                top_right: '╮',
128                bottom_left: '╰',
129                bottom_right: '╯',
130                left_t: '├',
131                cross: '┼',
132                right_t: '┤',
133                top_t: '┬',
134                bottom_t: '┴',
135            },
136        }
137    }
138}
139
140/// Box-drawing characters for a border style.
141#[derive(Clone, Copy, Debug)]
142struct BorderChars {
143    horizontal: char,
144    vertical: char,
145    top_left: char,
146    top_right: char,
147    bottom_left: char,
148    bottom_right: char,
149    left_t: char,
150    cross: char,
151    right_t: char,
152    top_t: char,
153    bottom_t: char,
154}
155
156impl BorderChars {
157    fn empty() -> Self {
158        BorderChars {
159            horizontal: ' ',
160            vertical: ' ',
161            top_left: ' ',
162            top_right: ' ',
163            bottom_left: ' ',
164            bottom_right: ' ',
165            left_t: ' ',
166            cross: ' ',
167            right_t: ' ',
168            top_t: ' ',
169            bottom_t: ' ',
170        }
171    }
172}
173
174/// A decorated table with borders, headers, and separators.
175///
176/// Supports alternating row styles (odd/even) via [`row_styles`](Table::row_styles).
177/// When set, data rows are automatically wrapped in `[style]...[/style]` tags
178/// that alternate between the two style names.
179pub struct Table {
180    /// The underlying formatter.
181    formatter: TabularFormatter,
182    /// Column headers.
183    headers: Option<Vec<String>>,
184    /// Border style.
185    border: BorderStyle,
186    /// Header style name (for styling header cells).
187    header_style: Option<String>,
188    /// Whether to add separators between data rows.
189    row_separator: bool,
190    /// Alternating row style names: (odd_style, even_style).
191    /// Row 0 uses even, row 1 uses odd, etc.
192    row_styles: Option<(String, String)>,
193    /// Counter for tracking data row index (for alternating styles).
194    row_counter: AtomicUsize,
195}
196
197impl Clone for Table {
198    fn clone(&self) -> Self {
199        Self {
200            formatter: self.formatter.clone(),
201            headers: self.headers.clone(),
202            border: self.border,
203            header_style: self.header_style.clone(),
204            row_separator: self.row_separator,
205            row_styles: self.row_styles.clone(),
206            row_counter: AtomicUsize::new(self.row_counter.load(Ordering::Relaxed)),
207        }
208    }
209}
210
211impl std::fmt::Debug for Table {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        f.debug_struct("Table")
214            .field("formatter", &self.formatter)
215            .field("headers", &self.headers)
216            .field("border", &self.border)
217            .field("header_style", &self.header_style)
218            .field("row_separator", &self.row_separator)
219            .field("row_styles", &self.row_styles)
220            .field("row_counter", &self.row_counter.load(Ordering::Relaxed))
221            .finish()
222    }
223}
224
225impl Table {
226    /// Create a new table with the given spec and total width.
227    pub fn new(spec: TabularSpec, total_width: usize) -> Self {
228        let formatter = TabularFormatter::new(&spec, total_width);
229        Table {
230            formatter,
231            headers: None,
232            border: BorderStyle::None,
233            header_style: None,
234            row_separator: false,
235            row_styles: None,
236            row_counter: AtomicUsize::new(0),
237        }
238    }
239
240    /// Create a table from a raw FlatDataSpec.
241    pub fn from_spec(spec: &FlatDataSpec, total_width: usize) -> Self {
242        let formatter = TabularFormatter::new(spec, total_width);
243        Table {
244            formatter,
245            headers: None,
246            border: BorderStyle::None,
247            header_style: None,
248            row_separator: false,
249            row_styles: None,
250            row_counter: AtomicUsize::new(0),
251        }
252    }
253
254    /// Create a table from a type that implements `Tabular`.
255    ///
256    /// This constructor uses the `TabularSpec` generated by the `#[derive(Tabular)]`
257    /// macro to configure the table.
258    ///
259    /// # Example
260    ///
261    /// ```rust,ignore
262    /// use standout_render::tabular::{Tabular, Table, BorderStyle};
263    /// use serde::Serialize;
264    ///
265    /// #[derive(Serialize, Tabular)]
266    /// #[tabular(separator = " | ")]
267    /// struct Task {
268    ///     #[col(width = 8, header = "ID")]
269    ///     id: String,
270    ///     #[col(width = "fill", header = "Title")]
271    ///     title: String,
272    /// }
273    ///
274    /// let table = Table::from_type::<Task>(80)
275    ///     .header_from_columns()
276    ///     .border(BorderStyle::Light);
277    /// ```
278    pub fn from_type<T: Tabular>(total_width: usize) -> Self {
279        let spec = T::tabular_spec();
280        Self::new(spec, total_width)
281    }
282
283    /// Set the border style.
284    pub fn border(mut self, border: BorderStyle) -> Self {
285        self.border = border;
286        self
287    }
288
289    /// Set the column headers.
290    pub fn header<S: Into<String>, I: IntoIterator<Item = S>>(mut self, headers: I) -> Self {
291        self.headers = Some(headers.into_iter().map(|s| s.into()).collect());
292        self
293    }
294
295    /// Set headers automatically from column specifications.
296    ///
297    /// For each column, uses (in order of preference):
298    /// 1. The `header` field if set
299    /// 2. The `key` field if set
300    /// 3. The `name` field if set
301    /// 4. Empty string
302    ///
303    /// # Example
304    ///
305    /// ```rust,ignore
306    /// let spec = TabularSpec::builder()
307    ///     .column(Col::fixed(8).header("ID"))
308    ///     .column(Col::min(10).key("author").header("Author"))
309    ///     .column(Col::fill().named("message"))  // Uses name as fallback
310    ///     .build();
311    ///
312    /// let table = Table::new(spec, 80)
313    ///     .header_from_columns()  // Headers: ["ID", "Author", "message"]
314    ///     .border(BorderStyle::Light);
315    /// ```
316    pub fn header_from_columns(mut self) -> Self {
317        self.headers = Some(self.formatter.extract_headers());
318        self
319    }
320
321    /// Set the header style name.
322    pub fn header_style(mut self, style: impl Into<String>) -> Self {
323        self.header_style = Some(style.into());
324        self
325    }
326
327    /// Enable row separators between data rows.
328    pub fn row_separator(mut self, enable: bool) -> Self {
329        self.row_separator = enable;
330        self
331    }
332
333    /// Set alternating row styles for even and odd data rows.
334    ///
335    /// When set, each data row is wrapped in `[style]...[/style]` tags
336    /// that alternate between the two style names. Row 0 uses `even_style`,
337    /// row 1 uses `odd_style`, and so on.
338    ///
339    /// # Example
340    ///
341    /// ```rust,ignore
342    /// let table = Table::new(spec, 80)
343    ///     .row_styles("table_row_even", "table_row_odd");
344    /// ```
345    pub fn row_styles(
346        mut self,
347        even_style: impl Into<String>,
348        odd_style: impl Into<String>,
349    ) -> Self {
350        self.row_styles = Some((odd_style.into(), even_style.into()));
351        self
352    }
353
354    /// Get the border style.
355    pub fn get_border(&self) -> BorderStyle {
356        self.border
357    }
358
359    /// Get the number of columns.
360    pub fn num_columns(&self) -> usize {
361        self.formatter.num_columns()
362    }
363
364    /// Format a data row.
365    pub fn row<S: AsRef<str>>(&self, values: &[S]) -> String {
366        let content = self.formatter.format_row(values);
367        self.wrap_data_row(&content)
368    }
369
370    /// Format a data row with sub-column support.
371    ///
372    /// Cells that correspond to columns with sub-columns should be
373    /// [`CellValue::Sub`]; all others should be [`CellValue::Single`].
374    pub fn row_cells(&self, values: &[CellValue<'_>]) -> String {
375        let content = self.formatter.format_row_cells(values);
376        self.wrap_data_row(&content)
377    }
378
379    /// Format a data row by extracting values from a serializable struct.
380    ///
381    /// This method extracts field values based on each column's `key` or `name`.
382    /// See [`TabularFormatter::row_from`] for details on field extraction.
383    ///
384    /// # Example
385    ///
386    /// ```rust,ignore
387    /// use serde::Serialize;
388    ///
389    /// #[derive(Serialize)]
390    /// struct Record { name: String, status: String }
391    ///
392    /// let table = Table::new(spec, 80);
393    /// let record = Record { name: "Alice".into(), status: "active".into() };
394    /// println!("{}", table.row_from(&record));
395    /// ```
396    pub fn row_from<T: serde::Serialize>(&self, value: &T) -> String {
397        let content = self.formatter.row_from(value);
398        self.wrap_data_row(&content)
399    }
400
401    /// Format a data row using the `TabularRow` trait.
402    ///
403    /// This method uses the optimized `to_row()` implementation generated by
404    /// `#[derive(TabularRow)]`, avoiding JSON serialization overhead.
405    ///
406    /// # Example
407    ///
408    /// ```rust,ignore
409    /// use standout_render::tabular::{TabularRow, Tabular, Table, BorderStyle};
410    ///
411    /// #[derive(Tabular, TabularRow)]
412    /// #[tabular(separator = " | ")]
413    /// struct Task {
414    ///     #[col(width = 8)]
415    ///     id: String,
416    ///     #[col(width = "fill")]
417    ///     title: String,
418    /// }
419    ///
420    /// let table = Table::from_type::<Task>(80).border(BorderStyle::Light);
421    /// let task = Task {
422    ///     id: "TSK-001".to_string(),
423    ///     title: "Implement feature".to_string(),
424    /// };
425    ///
426    /// println!("{}", table.row_from_trait(&task));
427    /// ```
428    pub fn row_from_trait<T: TabularRow>(&self, value: &T) -> String {
429        let content = self.formatter.row_from_trait(value);
430        self.wrap_data_row(&content)
431    }
432
433    /// Format the header row.
434    pub fn header_row(&self) -> String {
435        match &self.headers {
436            Some(headers) => {
437                // Format the headers first (handles truncation/padding)
438                let content = self.formatter.format_row(headers);
439
440                // Apply style after formatting to avoid style tags being truncated
441                let styled_content = if let Some(style) = &self.header_style {
442                    format!("[{}]{}[/{}]", style, content, style)
443                } else {
444                    content
445                };
446
447                self.wrap_row(&styled_content)
448            }
449            None => String::new(),
450        }
451    }
452
453    /// Generate a horizontal separator row.
454    pub fn separator_row(&self) -> String {
455        self.horizontal_line(LineType::Middle)
456    }
457
458    /// Generate the top border row.
459    pub fn top_border(&self) -> String {
460        self.horizontal_line(LineType::Top)
461    }
462
463    /// Generate the bottom border row.
464    pub fn bottom_border(&self) -> String {
465        self.horizontal_line(LineType::Bottom)
466    }
467
468    /// Wrap a data row with alternating style (if set) and borders.
469    fn wrap_data_row(&self, content: &str) -> String {
470        let bordered = self.wrap_row(content);
471        if let Some((odd_style, even_style)) = &self.row_styles {
472            let index = self.row_counter.fetch_add(1, Ordering::Relaxed);
473            let style = if index.is_multiple_of(2) {
474                even_style
475            } else {
476                odd_style
477            };
478            format!("[{}]{}[/{}]", style, bordered, style)
479        } else {
480            bordered
481        }
482    }
483
484    /// Wrap a row content with vertical borders.
485    fn wrap_row(&self, content: &str) -> String {
486        if self.border == BorderStyle::None {
487            return content.to_string();
488        }
489
490        let chars = self.border.chars();
491        format!("{}{}{}", chars.vertical, content, chars.vertical)
492    }
493
494    /// Generate a horizontal line (top, middle, or bottom).
495    fn horizontal_line(&self, line_type: LineType) -> String {
496        if self.border == BorderStyle::None {
497            return String::new();
498        }
499
500        let chars = self.border.chars();
501        let widths = self.formatter.widths();
502
503        // Calculate total content width
504        let content_width: usize = widths.iter().sum();
505        let sep_width = display_width(&self.formatter_separator());
506        let num_seps = widths.len().saturating_sub(1);
507        let total_content = content_width + (num_seps * sep_width);
508
509        let (left, _joint, right) = match line_type {
510            LineType::Top => (chars.top_left, chars.top_t, chars.top_right),
511            LineType::Middle => (chars.left_t, chars.cross, chars.right_t),
512            LineType::Bottom => (chars.bottom_left, chars.bottom_t, chars.bottom_right),
513        };
514
515        let mut line = String::new();
516        line.push(left);
517
518        for (i, &width) in widths.iter().enumerate() {
519            if i > 0 {
520                // Add joint for separator
521                for _ in 0..sep_width {
522                    line.push(chars.horizontal);
523                }
524                // The joint replaces the middle horizontal char
525                // Actually, for proper box drawing, we need joint at column boundaries
526            }
527            for _ in 0..width {
528                line.push(chars.horizontal);
529            }
530        }
531
532        // Add separators between columns
533        // For simplicity, we'll just draw a continuous line
534        // A more sophisticated version would place joints at column boundaries
535        line = format!(
536            "{}{}{}",
537            left,
538            std::iter::repeat_n(chars.horizontal, total_content).collect::<String>(),
539            right
540        );
541
542        line
543    }
544
545    /// Get the separator string from formatter.
546    fn formatter_separator(&self) -> String {
547        // Access separator through the Object trait
548        use minijinja::value::{Object, Value};
549        use std::sync::Arc;
550        let arc_formatter = Arc::new(self.formatter.clone());
551        arc_formatter
552            .get_value(&Value::from("separator"))
553            .map(|v| v.to_string())
554            .unwrap_or_default()
555    }
556
557    /// Render the complete table with all rows.
558    ///
559    /// Includes top border, header (if set), separator, data rows, and bottom border.
560    pub fn render<S: AsRef<str>>(&self, rows: &[Vec<S>]) -> String {
561        self.row_counter.store(0, Ordering::Relaxed);
562        let mut output = Vec::new();
563
564        // Top border
565        let top = self.top_border();
566        if !top.is_empty() {
567            output.push(top);
568        }
569
570        // Header
571        let header = self.header_row();
572        if !header.is_empty() {
573            output.push(header);
574
575            // Separator after header
576            let sep = self.separator_row();
577            if !sep.is_empty() {
578                output.push(sep);
579            }
580        }
581
582        // Data rows (with optional separators between them)
583        let separator = if self.row_separator {
584            let sep = self.separator_row();
585            if sep.is_empty() {
586                None
587            } else {
588                Some(sep)
589            }
590        } else {
591            None
592        };
593
594        for (i, row) in rows.iter().enumerate() {
595            if i > 0 {
596                if let Some(ref sep) = separator {
597                    output.push(sep.clone());
598                }
599            }
600            output.push(self.row(row));
601        }
602
603        // Bottom border
604        let bottom = self.bottom_border();
605        if !bottom.is_empty() {
606            output.push(bottom);
607        }
608
609        output.join("\n")
610    }
611}
612
613/// Type of horizontal line.
614#[derive(Clone, Copy, Debug, PartialEq, Eq)]
615enum LineType {
616    Top,
617    Middle,
618    Bottom,
619}
620
621// ============================================================================
622// MiniJinja Object Implementation
623// ============================================================================
624
625impl minijinja::value::Object for Table {
626    fn get_value(self: &std::sync::Arc<Self>, key: &minijinja::Value) -> Option<minijinja::Value> {
627        match key.as_str()? {
628            "num_columns" => Some(minijinja::Value::from(self.num_columns())),
629            "border" => Some(minijinja::Value::from(format!("{:?}", self.get_border()))),
630            _ => None,
631        }
632    }
633
634    fn enumerate(self: &std::sync::Arc<Self>) -> minijinja::value::Enumerator {
635        minijinja::value::Enumerator::Str(&["num_columns", "border"])
636    }
637
638    fn call_method(
639        self: &std::sync::Arc<Self>,
640        _state: &minijinja::State,
641        name: &str,
642        args: &[minijinja::Value],
643    ) -> Result<minijinja::Value, minijinja::Error> {
644        match name {
645            "row" => {
646                // row([value1, value2, ...]) - format a data row
647                if args.is_empty() {
648                    return Err(minijinja::Error::new(
649                        minijinja::ErrorKind::MissingArgument,
650                        "row() requires an array of values",
651                    ));
652                }
653
654                let values_arg = &args[0];
655
656                if self.formatter.has_sub_columns() {
657                    // Sub-column aware path: detect nested arrays
658                    let outer_iter = match values_arg.try_iter() {
659                        Ok(iter) => iter,
660                        Err(_) => {
661                            let values = vec![values_arg.to_string()];
662                            return Ok(minijinja::Value::from(self.row(&values)));
663                        }
664                    };
665
666                    let mut owned_values: Vec<OwnedCellValue> = Vec::new();
667                    for (i, v) in outer_iter.enumerate() {
668                        let is_sub_col = self
669                            .formatter
670                            .columns()
671                            .get(i)
672                            .and_then(|c| c.sub_columns.as_ref())
673                            .is_some();
674
675                        if is_sub_col {
676                            if let Ok(inner_iter) = v.try_iter() {
677                                let sub_vals: Vec<String> =
678                                    inner_iter.map(|iv| iv.to_string()).collect();
679                                owned_values.push(OwnedCellValue::Sub(sub_vals));
680                            } else {
681                                owned_values.push(OwnedCellValue::Single(v.to_string()));
682                            }
683                        } else {
684                            owned_values.push(OwnedCellValue::Single(v.to_string()));
685                        }
686                    }
687
688                    let cell_values: Vec<CellValue<'_>> = owned_values
689                        .iter()
690                        .map(|ov| match ov {
691                            OwnedCellValue::Single(s) => CellValue::Single(s.as_str()),
692                            OwnedCellValue::Sub(v) => {
693                                CellValue::Sub(v.iter().map(|s| s.as_str()).collect())
694                            }
695                        })
696                        .collect();
697
698                    let formatted = self.row_cells(&cell_values);
699                    Ok(minijinja::Value::from(formatted))
700                } else {
701                    let values: Vec<String> = match values_arg.try_iter() {
702                        Ok(iter) => iter.map(|v| v.to_string()).collect(),
703                        Err(_) => vec![values_arg.to_string()],
704                    };
705
706                    let formatted = self.row(&values);
707                    Ok(minijinja::Value::from(formatted))
708                }
709            }
710            "row_from" => {
711                // row_from(object) - format a row by extracting values from an object
712                if args.is_empty() {
713                    return Err(minijinja::Error::new(
714                        minijinja::ErrorKind::MissingArgument,
715                        "row_from() requires an object argument",
716                    ));
717                }
718
719                // Convert MiniJinja Value to serde_json::Value for field extraction
720                let json_value = minijinja::value::Value::from_serialize(&args[0]);
721                let formatted = self.formatter.row_from(&json_value);
722                Ok(minijinja::Value::from(self.wrap_data_row(&formatted)))
723            }
724            "header_row" => {
725                // header_row() - format the header row
726                Ok(minijinja::Value::from(self.header_row()))
727            }
728            "separator_row" => {
729                // separator_row() - format a separator row
730                Ok(minijinja::Value::from(self.separator_row()))
731            }
732            "top_border" => {
733                // top_border() - format the top border
734                Ok(minijinja::Value::from(self.top_border()))
735            }
736            "bottom_border" => {
737                // bottom_border() - format the bottom border
738                Ok(minijinja::Value::from(self.bottom_border()))
739            }
740            "render_all" => {
741                // render_all(rows) - render complete table with all rows
742                if args.is_empty() {
743                    return Err(minijinja::Error::new(
744                        minijinja::ErrorKind::MissingArgument,
745                        "render_all() requires an array of rows",
746                    ));
747                }
748
749                let rows_iter = args[0].try_iter().map_err(|_| {
750                    minijinja::Error::new(
751                        minijinja::ErrorKind::InvalidOperation,
752                        "render_all() requires an array of rows",
753                    )
754                })?;
755
756                let rows: Vec<Vec<String>> = rows_iter
757                    .map(|row| {
758                        row.try_iter()
759                            .map(|iter| iter.map(|v| v.to_string()).collect())
760                            .unwrap_or_else(|_| vec![row.to_string()])
761                    })
762                    .collect();
763
764                let formatted = Table::render(self, &rows);
765                Ok(minijinja::Value::from(formatted))
766            }
767            _ => Err(minijinja::Error::new(
768                minijinja::ErrorKind::UnknownMethod,
769                format!("Table has no method '{}'", name),
770            )),
771        }
772    }
773}
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778    use crate::tabular::Col;
779
780    fn simple_spec() -> TabularSpec {
781        TabularSpec::builder()
782            .column(Col::fixed(10))
783            .column(Col::fixed(8))
784            .separator("  ")
785            .build()
786    }
787
788    #[test]
789    fn table_no_border() {
790        let table = Table::new(simple_spec(), 80);
791        let row = table.row(&["Hello", "World"]);
792        // No border, just formatted content
793        assert!(!row.contains('│'));
794        assert!(row.contains("Hello"));
795    }
796
797    #[test]
798    fn table_with_ascii_border() {
799        let table = Table::new(simple_spec(), 80).border(BorderStyle::Ascii);
800        let row = table.row(&["Hello", "World"]);
801        // Should have vertical bars
802        assert!(row.starts_with('|'));
803        assert!(row.ends_with('|'));
804    }
805
806    #[test]
807    fn table_with_light_border() {
808        let table = Table::new(simple_spec(), 80).border(BorderStyle::Light);
809        let row = table.row(&["Hello", "World"]);
810        // Should have light box characters
811        assert!(row.starts_with('│'));
812        assert!(row.ends_with('│'));
813    }
814
815    #[test]
816    fn table_with_heavy_border() {
817        let table = Table::new(simple_spec(), 80).border(BorderStyle::Heavy);
818        let row = table.row(&["Hello", "World"]);
819        assert!(row.starts_with('┃'));
820        assert!(row.ends_with('┃'));
821    }
822
823    #[test]
824    fn table_with_double_border() {
825        let table = Table::new(simple_spec(), 80).border(BorderStyle::Double);
826        let row = table.row(&["Hello", "World"]);
827        assert!(row.starts_with('║'));
828        assert!(row.ends_with('║'));
829    }
830
831    #[test]
832    fn table_with_rounded_border() {
833        let table = Table::new(simple_spec(), 80).border(BorderStyle::Rounded);
834        let row = table.row(&["Hello", "World"]);
835        assert!(row.starts_with('│'));
836        assert!(row.ends_with('│'));
837    }
838
839    #[test]
840    fn table_header_row() {
841        let table = Table::new(simple_spec(), 80)
842            .border(BorderStyle::Light)
843            .header(vec!["Name", "Status"]);
844
845        let header = table.header_row();
846        assert!(header.contains("Name"));
847        assert!(header.contains("Status"));
848        assert!(header.starts_with('│'));
849    }
850
851    #[test]
852    fn table_header_with_style() {
853        let table = Table::new(simple_spec(), 80)
854            .header(vec!["Name", "Status"])
855            .header_style("header");
856
857        let header = table.header_row();
858        assert!(header.contains("[header]"));
859        assert!(header.contains("[/header]"));
860    }
861
862    #[test]
863    fn table_no_header() {
864        let table = Table::new(simple_spec(), 80);
865        let header = table.header_row();
866        assert!(header.is_empty());
867    }
868
869    #[test]
870    fn table_separator_row() {
871        let table = Table::new(simple_spec(), 80).border(BorderStyle::Light);
872        let sep = table.separator_row();
873        assert!(sep.contains('─'));
874        assert!(sep.starts_with('├'));
875        assert!(sep.ends_with('┤'));
876    }
877
878    #[test]
879    fn table_top_border() {
880        let table = Table::new(simple_spec(), 80).border(BorderStyle::Light);
881        let top = table.top_border();
882        assert!(top.contains('─'));
883        assert!(top.starts_with('┌'));
884        assert!(top.ends_with('┐'));
885    }
886
887    #[test]
888    fn table_bottom_border() {
889        let table = Table::new(simple_spec(), 80).border(BorderStyle::Light);
890        let bottom = table.bottom_border();
891        assert!(bottom.contains('─'));
892        assert!(bottom.starts_with('└'));
893        assert!(bottom.ends_with('┘'));
894    }
895
896    #[test]
897    fn table_render_full() {
898        let table = Table::new(simple_spec(), 80)
899            .border(BorderStyle::Light)
900            .header(vec!["Name", "Value"]);
901
902        let data = vec![vec!["Alice", "100"], vec!["Bob", "200"]];
903
904        let output = table.render(&data);
905        let lines: Vec<&str> = output.lines().collect();
906
907        // Should have: top border, header, separator, 2 data rows, bottom border
908        assert!(lines.len() >= 5);
909
910        // Top border
911        assert!(lines[0].starts_with('┌'));
912        // Header
913        assert!(lines[1].contains("Name"));
914        // Separator
915        assert!(lines[2].starts_with('├'));
916        // Data rows
917        assert!(lines[3].contains("Alice"));
918        assert!(lines[4].contains("Bob"));
919        // Bottom border
920        assert!(lines[5].starts_with('└'));
921    }
922
923    #[test]
924    fn table_render_no_border() {
925        let table = Table::new(simple_spec(), 80).header(vec!["Name", "Value"]);
926
927        let data = vec![vec!["Alice", "100"]];
928
929        let output = table.render(&data);
930        let lines: Vec<&str> = output.lines().collect();
931
932        // No borders, just header and data
933        assert!(lines.len() >= 2);
934        assert!(lines[0].contains("Name"));
935        assert!(lines[1].contains("Alice"));
936    }
937
938    #[test]
939    fn border_style_default() {
940        assert_eq!(BorderStyle::default(), BorderStyle::None);
941    }
942
943    #[test]
944    fn table_accessors() {
945        let table = Table::new(simple_spec(), 80).border(BorderStyle::Ascii);
946
947        assert_eq!(table.get_border(), BorderStyle::Ascii);
948        assert_eq!(table.num_columns(), 2);
949    }
950
951    #[test]
952    fn table_row_from() {
953        use serde::Serialize;
954
955        #[derive(Serialize)]
956        struct Record {
957            name: String,
958            status: String,
959        }
960
961        let spec = TabularSpec::builder()
962            .column(Col::fixed(10).key("name"))
963            .column(Col::fixed(8).key("status"))
964            .separator("  ")
965            .build();
966
967        let table = Table::new(spec, 80);
968        let record = Record {
969            name: "Alice".to_string(),
970            status: "active".to_string(),
971        };
972
973        let row = table.row_from(&record);
974        assert!(row.contains("Alice"));
975        assert!(row.contains("active"));
976    }
977
978    #[test]
979    fn table_row_from_with_border() {
980        use serde::Serialize;
981
982        #[derive(Serialize)]
983        struct Item {
984            id: u32,
985            value: String,
986        }
987
988        let spec = TabularSpec::builder()
989            .column(Col::fixed(5).key("id"))
990            .column(Col::fixed(10).key("value"))
991            .build();
992
993        let table = Table::new(spec, 80).border(BorderStyle::Light);
994        let item = Item {
995            id: 42,
996            value: "test".to_string(),
997        };
998
999        let row = table.row_from(&item);
1000        assert!(row.starts_with('│'));
1001        assert!(row.ends_with('│'));
1002        assert!(row.contains("42"));
1003        assert!(row.contains("test"));
1004    }
1005
1006    #[test]
1007    fn table_row_separator_option() {
1008        let spec = TabularSpec::builder()
1009            .column(Col::fixed(10))
1010            .column(Col::fixed(8))
1011            .build();
1012
1013        let table = Table::new(spec, 80)
1014            .border(BorderStyle::Light)
1015            .row_separator(true);
1016
1017        let data = vec![vec!["A", "1"], vec!["B", "2"], vec!["C", "3"]];
1018        let output = table.render(&data);
1019        let lines: Vec<&str> = output.lines().collect();
1020
1021        // Should have: top, A, sep, B, sep, C, bottom = 7 lines
1022        // Count separator lines between data rows
1023        let sep_count = lines.iter().filter(|l| l.starts_with('├')).count();
1024        assert_eq!(sep_count, 2, "Expected 2 separators between 3 rows");
1025    }
1026
1027    #[test]
1028    fn table_row_separator_disabled_by_default() {
1029        let spec = TabularSpec::builder()
1030            .column(Col::fixed(10))
1031            .column(Col::fixed(8))
1032            .build();
1033
1034        let table = Table::new(spec, 80).border(BorderStyle::Light);
1035
1036        let data = vec![vec!["A", "1"], vec!["B", "2"]];
1037        let output = table.render(&data);
1038        let lines: Vec<&str> = output.lines().collect();
1039
1040        // No separators between data rows (only after header if present)
1041        // Lines: top, A, B, bottom = 4 lines
1042        assert_eq!(lines.len(), 4);
1043    }
1044
1045    #[test]
1046    fn table_header_from_columns_with_header_field() {
1047        let spec = TabularSpec::builder()
1048            .column(Col::fixed(10).header("Name"))
1049            .column(Col::fixed(8).header("Status"))
1050            .separator("  ")
1051            .build();
1052
1053        let table = Table::new(spec, 80)
1054            .header_from_columns()
1055            .border(BorderStyle::Light);
1056
1057        let header = table.header_row();
1058        assert!(header.contains("Name"));
1059        assert!(header.contains("Status"));
1060    }
1061
1062    #[test]
1063    fn table_header_from_columns_fallback_to_key() {
1064        let spec = TabularSpec::builder()
1065            .column(Col::fixed(10).key("user_name"))
1066            .column(Col::fixed(8).key("status"))
1067            .separator("  ")
1068            .build();
1069
1070        let table = Table::new(spec, 80).header_from_columns();
1071
1072        let header = table.header_row();
1073        assert!(header.contains("user_name"));
1074        assert!(header.contains("status"));
1075    }
1076
1077    #[test]
1078    fn table_header_from_columns_fallback_to_name() {
1079        let spec = TabularSpec::builder()
1080            .column(Col::fixed(10).named("column1"))
1081            .column(Col::fixed(8).named("column2"))
1082            .separator("  ")
1083            .build();
1084
1085        let table = Table::new(spec, 80).header_from_columns();
1086
1087        let header = table.header_row();
1088        assert!(header.contains("column1"));
1089        assert!(header.contains("column2"));
1090    }
1091
1092    #[test]
1093    fn table_header_from_columns_priority_order() {
1094        // header > key > name
1095        let spec = TabularSpec::builder()
1096            .column(Col::fixed(10).header("Header").key("key").named("name"))
1097            .column(Col::fixed(10).key("key_only").named("name_only"))
1098            .column(Col::fixed(10).named("name_only2"))
1099            .separator("  ")
1100            .build();
1101
1102        let table = Table::new(spec, 80).header_from_columns();
1103
1104        let header = table.header_row();
1105        assert!(header.contains("Header")); // header takes precedence
1106        assert!(header.contains("key_only")); // key is fallback when no header
1107        assert!(header.contains("name_only2")); // name is fallback when no key
1108    }
1109
1110    #[test]
1111    fn table_header_from_columns_in_render() {
1112        let spec = TabularSpec::builder()
1113            .column(Col::fixed(10).header("Name"))
1114            .column(Col::fixed(8).header("Value"))
1115            .separator("  ")
1116            .build();
1117
1118        let table = Table::new(spec, 80)
1119            .header_from_columns()
1120            .border(BorderStyle::Light);
1121
1122        let data = vec![vec!["Alice", "100"]];
1123        let output = table.render(&data);
1124
1125        // Should have header row with proper values
1126        assert!(output.contains("Name"));
1127        assert!(output.contains("Value"));
1128        assert!(output.contains("Alice"));
1129        assert!(output.contains("100"));
1130    }
1131}