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