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 super::formatter::{CellValue, OwnedCellValue, 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_render::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 with sub-column support.
308    ///
309    /// Cells that correspond to columns with sub-columns should be
310    /// [`CellValue::Sub`]; all others should be [`CellValue::Single`].
311    pub fn row_cells(&self, values: &[CellValue<'_>]) -> String {
312        let content = self.formatter.format_row_cells(values);
313        self.wrap_row(&content)
314    }
315
316    /// Format a data row by extracting values from a serializable struct.
317    ///
318    /// This method extracts field values based on each column's `key` or `name`.
319    /// See [`TabularFormatter::row_from`] for details on field extraction.
320    ///
321    /// # Example
322    ///
323    /// ```rust,ignore
324    /// use serde::Serialize;
325    ///
326    /// #[derive(Serialize)]
327    /// struct Record { name: String, status: String }
328    ///
329    /// let table = Table::new(spec, 80);
330    /// let record = Record { name: "Alice".into(), status: "active".into() };
331    /// println!("{}", table.row_from(&record));
332    /// ```
333    pub fn row_from<T: serde::Serialize>(&self, value: &T) -> String {
334        let content = self.formatter.row_from(value);
335        self.wrap_row(&content)
336    }
337
338    /// Format a data row using the `TabularRow` trait.
339    ///
340    /// This method uses the optimized `to_row()` implementation generated by
341    /// `#[derive(TabularRow)]`, avoiding JSON serialization overhead.
342    ///
343    /// # Example
344    ///
345    /// ```rust,ignore
346    /// use standout_render::tabular::{TabularRow, Tabular, Table, BorderStyle};
347    ///
348    /// #[derive(Tabular, TabularRow)]
349    /// #[tabular(separator = " | ")]
350    /// struct Task {
351    ///     #[col(width = 8)]
352    ///     id: String,
353    ///     #[col(width = "fill")]
354    ///     title: String,
355    /// }
356    ///
357    /// let table = Table::from_type::<Task>(80).border(BorderStyle::Light);
358    /// let task = Task {
359    ///     id: "TSK-001".to_string(),
360    ///     title: "Implement feature".to_string(),
361    /// };
362    ///
363    /// println!("{}", table.row_from_trait(&task));
364    /// ```
365    pub fn row_from_trait<T: TabularRow>(&self, value: &T) -> String {
366        let content = self.formatter.row_from_trait(value);
367        self.wrap_row(&content)
368    }
369
370    /// Format the header row.
371    pub fn header_row(&self) -> String {
372        match &self.headers {
373            Some(headers) => {
374                // Format the headers first (handles truncation/padding)
375                let content = self.formatter.format_row(headers);
376
377                // Apply style after formatting to avoid style tags being truncated
378                let styled_content = if let Some(style) = &self.header_style {
379                    format!("[{}]{}[/{}]", style, content, style)
380                } else {
381                    content
382                };
383
384                self.wrap_row(&styled_content)
385            }
386            None => String::new(),
387        }
388    }
389
390    /// Generate a horizontal separator row.
391    pub fn separator_row(&self) -> String {
392        self.horizontal_line(LineType::Middle)
393    }
394
395    /// Generate the top border row.
396    pub fn top_border(&self) -> String {
397        self.horizontal_line(LineType::Top)
398    }
399
400    /// Generate the bottom border row.
401    pub fn bottom_border(&self) -> String {
402        self.horizontal_line(LineType::Bottom)
403    }
404
405    /// Wrap a row content with vertical borders.
406    fn wrap_row(&self, content: &str) -> String {
407        if self.border == BorderStyle::None {
408            return content.to_string();
409        }
410
411        let chars = self.border.chars();
412        format!("{}{}{}", chars.vertical, content, chars.vertical)
413    }
414
415    /// Generate a horizontal line (top, middle, or bottom).
416    fn horizontal_line(&self, line_type: LineType) -> String {
417        if self.border == BorderStyle::None {
418            return String::new();
419        }
420
421        let chars = self.border.chars();
422        let widths = self.formatter.widths();
423
424        // Calculate total content width
425        let content_width: usize = widths.iter().sum();
426        let sep_width = display_width(&self.formatter_separator());
427        let num_seps = widths.len().saturating_sub(1);
428        let total_content = content_width + (num_seps * sep_width);
429
430        let (left, _joint, right) = match line_type {
431            LineType::Top => (chars.top_left, chars.top_t, chars.top_right),
432            LineType::Middle => (chars.left_t, chars.cross, chars.right_t),
433            LineType::Bottom => (chars.bottom_left, chars.bottom_t, chars.bottom_right),
434        };
435
436        let mut line = String::new();
437        line.push(left);
438
439        for (i, &width) in widths.iter().enumerate() {
440            if i > 0 {
441                // Add joint for separator
442                for _ in 0..sep_width {
443                    line.push(chars.horizontal);
444                }
445                // The joint replaces the middle horizontal char
446                // Actually, for proper box drawing, we need joint at column boundaries
447            }
448            for _ in 0..width {
449                line.push(chars.horizontal);
450            }
451        }
452
453        // Add separators between columns
454        // For simplicity, we'll just draw a continuous line
455        // A more sophisticated version would place joints at column boundaries
456        line = format!(
457            "{}{}{}",
458            left,
459            std::iter::repeat_n(chars.horizontal, total_content).collect::<String>(),
460            right
461        );
462
463        line
464    }
465
466    /// Get the separator string from formatter.
467    fn formatter_separator(&self) -> String {
468        // Access separator through the Object trait
469        use minijinja::value::{Object, Value};
470        use std::sync::Arc;
471        let arc_formatter = Arc::new(self.formatter.clone());
472        arc_formatter
473            .get_value(&Value::from("separator"))
474            .map(|v| v.to_string())
475            .unwrap_or_default()
476    }
477
478    /// Render the complete table with all rows.
479    ///
480    /// Includes top border, header (if set), separator, data rows, and bottom border.
481    pub fn render<S: AsRef<str>>(&self, rows: &[Vec<S>]) -> String {
482        let mut output = Vec::new();
483
484        // Top border
485        let top = self.top_border();
486        if !top.is_empty() {
487            output.push(top);
488        }
489
490        // Header
491        let header = self.header_row();
492        if !header.is_empty() {
493            output.push(header);
494
495            // Separator after header
496            let sep = self.separator_row();
497            if !sep.is_empty() {
498                output.push(sep);
499            }
500        }
501
502        // Data rows (with optional separators between them)
503        let separator = if self.row_separator {
504            let sep = self.separator_row();
505            if sep.is_empty() {
506                None
507            } else {
508                Some(sep)
509            }
510        } else {
511            None
512        };
513
514        for (i, row) in rows.iter().enumerate() {
515            if i > 0 {
516                if let Some(ref sep) = separator {
517                    output.push(sep.clone());
518                }
519            }
520            output.push(self.row(row));
521        }
522
523        // Bottom border
524        let bottom = self.bottom_border();
525        if !bottom.is_empty() {
526            output.push(bottom);
527        }
528
529        output.join("\n")
530    }
531}
532
533/// Type of horizontal line.
534#[derive(Clone, Copy, Debug, PartialEq, Eq)]
535enum LineType {
536    Top,
537    Middle,
538    Bottom,
539}
540
541// ============================================================================
542// MiniJinja Object Implementation
543// ============================================================================
544
545impl minijinja::value::Object for Table {
546    fn get_value(self: &std::sync::Arc<Self>, key: &minijinja::Value) -> Option<minijinja::Value> {
547        match key.as_str()? {
548            "num_columns" => Some(minijinja::Value::from(self.num_columns())),
549            "border" => Some(minijinja::Value::from(format!("{:?}", self.get_border()))),
550            _ => None,
551        }
552    }
553
554    fn enumerate(self: &std::sync::Arc<Self>) -> minijinja::value::Enumerator {
555        minijinja::value::Enumerator::Str(&["num_columns", "border"])
556    }
557
558    fn call_method(
559        self: &std::sync::Arc<Self>,
560        _state: &minijinja::State,
561        name: &str,
562        args: &[minijinja::Value],
563    ) -> Result<minijinja::Value, minijinja::Error> {
564        match name {
565            "row" => {
566                // row([value1, value2, ...]) - format a data row
567                if args.is_empty() {
568                    return Err(minijinja::Error::new(
569                        minijinja::ErrorKind::MissingArgument,
570                        "row() requires an array of values",
571                    ));
572                }
573
574                let values_arg = &args[0];
575
576                if self.formatter.has_sub_columns() {
577                    // Sub-column aware path: detect nested arrays
578                    let outer_iter = match values_arg.try_iter() {
579                        Ok(iter) => iter,
580                        Err(_) => {
581                            let values = vec![values_arg.to_string()];
582                            return Ok(minijinja::Value::from(self.row(&values)));
583                        }
584                    };
585
586                    let mut owned_values: Vec<OwnedCellValue> = Vec::new();
587                    for (i, v) in outer_iter.enumerate() {
588                        let is_sub_col = self
589                            .formatter
590                            .columns()
591                            .get(i)
592                            .and_then(|c| c.sub_columns.as_ref())
593                            .is_some();
594
595                        if is_sub_col {
596                            if let Ok(inner_iter) = v.try_iter() {
597                                let sub_vals: Vec<String> =
598                                    inner_iter.map(|iv| iv.to_string()).collect();
599                                owned_values.push(OwnedCellValue::Sub(sub_vals));
600                            } else {
601                                owned_values.push(OwnedCellValue::Single(v.to_string()));
602                            }
603                        } else {
604                            owned_values.push(OwnedCellValue::Single(v.to_string()));
605                        }
606                    }
607
608                    let cell_values: Vec<CellValue<'_>> = owned_values
609                        .iter()
610                        .map(|ov| match ov {
611                            OwnedCellValue::Single(s) => CellValue::Single(s.as_str()),
612                            OwnedCellValue::Sub(v) => {
613                                CellValue::Sub(v.iter().map(|s| s.as_str()).collect())
614                            }
615                        })
616                        .collect();
617
618                    let formatted = self.row_cells(&cell_values);
619                    Ok(minijinja::Value::from(formatted))
620                } else {
621                    let values: Vec<String> = match values_arg.try_iter() {
622                        Ok(iter) => iter.map(|v| v.to_string()).collect(),
623                        Err(_) => vec![values_arg.to_string()],
624                    };
625
626                    let formatted = self.row(&values);
627                    Ok(minijinja::Value::from(formatted))
628                }
629            }
630            "row_from" => {
631                // row_from(object) - format a row by extracting values from an object
632                if args.is_empty() {
633                    return Err(minijinja::Error::new(
634                        minijinja::ErrorKind::MissingArgument,
635                        "row_from() requires an object argument",
636                    ));
637                }
638
639                // Convert MiniJinja Value to serde_json::Value for field extraction
640                let json_value = minijinja::value::Value::from_serialize(&args[0]);
641                let formatted = self.formatter.row_from(&json_value);
642                Ok(minijinja::Value::from(self.wrap_row(&formatted)))
643            }
644            "header_row" => {
645                // header_row() - format the header row
646                Ok(minijinja::Value::from(self.header_row()))
647            }
648            "separator_row" => {
649                // separator_row() - format a separator row
650                Ok(minijinja::Value::from(self.separator_row()))
651            }
652            "top_border" => {
653                // top_border() - format the top border
654                Ok(minijinja::Value::from(self.top_border()))
655            }
656            "bottom_border" => {
657                // bottom_border() - format the bottom border
658                Ok(minijinja::Value::from(self.bottom_border()))
659            }
660            "render_all" => {
661                // render_all(rows) - render complete table with all rows
662                if args.is_empty() {
663                    return Err(minijinja::Error::new(
664                        minijinja::ErrorKind::MissingArgument,
665                        "render_all() requires an array of rows",
666                    ));
667                }
668
669                let rows_iter = args[0].try_iter().map_err(|_| {
670                    minijinja::Error::new(
671                        minijinja::ErrorKind::InvalidOperation,
672                        "render_all() requires an array of rows",
673                    )
674                })?;
675
676                let rows: Vec<Vec<String>> = rows_iter
677                    .map(|row| {
678                        row.try_iter()
679                            .map(|iter| iter.map(|v| v.to_string()).collect())
680                            .unwrap_or_else(|_| vec![row.to_string()])
681                    })
682                    .collect();
683
684                let formatted = Table::render(self, &rows);
685                Ok(minijinja::Value::from(formatted))
686            }
687            _ => Err(minijinja::Error::new(
688                minijinja::ErrorKind::UnknownMethod,
689                format!("Table has no method '{}'", name),
690            )),
691        }
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698    use crate::tabular::Col;
699
700    fn simple_spec() -> TabularSpec {
701        TabularSpec::builder()
702            .column(Col::fixed(10))
703            .column(Col::fixed(8))
704            .separator("  ")
705            .build()
706    }
707
708    #[test]
709    fn table_no_border() {
710        let table = Table::new(simple_spec(), 80);
711        let row = table.row(&["Hello", "World"]);
712        // No border, just formatted content
713        assert!(!row.contains('│'));
714        assert!(row.contains("Hello"));
715    }
716
717    #[test]
718    fn table_with_ascii_border() {
719        let table = Table::new(simple_spec(), 80).border(BorderStyle::Ascii);
720        let row = table.row(&["Hello", "World"]);
721        // Should have vertical bars
722        assert!(row.starts_with('|'));
723        assert!(row.ends_with('|'));
724    }
725
726    #[test]
727    fn table_with_light_border() {
728        let table = Table::new(simple_spec(), 80).border(BorderStyle::Light);
729        let row = table.row(&["Hello", "World"]);
730        // Should have light box characters
731        assert!(row.starts_with('│'));
732        assert!(row.ends_with('│'));
733    }
734
735    #[test]
736    fn table_with_heavy_border() {
737        let table = Table::new(simple_spec(), 80).border(BorderStyle::Heavy);
738        let row = table.row(&["Hello", "World"]);
739        assert!(row.starts_with('┃'));
740        assert!(row.ends_with('┃'));
741    }
742
743    #[test]
744    fn table_with_double_border() {
745        let table = Table::new(simple_spec(), 80).border(BorderStyle::Double);
746        let row = table.row(&["Hello", "World"]);
747        assert!(row.starts_with('║'));
748        assert!(row.ends_with('║'));
749    }
750
751    #[test]
752    fn table_with_rounded_border() {
753        let table = Table::new(simple_spec(), 80).border(BorderStyle::Rounded);
754        let row = table.row(&["Hello", "World"]);
755        assert!(row.starts_with('│'));
756        assert!(row.ends_with('│'));
757    }
758
759    #[test]
760    fn table_header_row() {
761        let table = Table::new(simple_spec(), 80)
762            .border(BorderStyle::Light)
763            .header(vec!["Name", "Status"]);
764
765        let header = table.header_row();
766        assert!(header.contains("Name"));
767        assert!(header.contains("Status"));
768        assert!(header.starts_with('│'));
769    }
770
771    #[test]
772    fn table_header_with_style() {
773        let table = Table::new(simple_spec(), 80)
774            .header(vec!["Name", "Status"])
775            .header_style("header");
776
777        let header = table.header_row();
778        assert!(header.contains("[header]"));
779        assert!(header.contains("[/header]"));
780    }
781
782    #[test]
783    fn table_no_header() {
784        let table = Table::new(simple_spec(), 80);
785        let header = table.header_row();
786        assert!(header.is_empty());
787    }
788
789    #[test]
790    fn table_separator_row() {
791        let table = Table::new(simple_spec(), 80).border(BorderStyle::Light);
792        let sep = table.separator_row();
793        assert!(sep.contains('─'));
794        assert!(sep.starts_with('├'));
795        assert!(sep.ends_with('┤'));
796    }
797
798    #[test]
799    fn table_top_border() {
800        let table = Table::new(simple_spec(), 80).border(BorderStyle::Light);
801        let top = table.top_border();
802        assert!(top.contains('─'));
803        assert!(top.starts_with('┌'));
804        assert!(top.ends_with('┐'));
805    }
806
807    #[test]
808    fn table_bottom_border() {
809        let table = Table::new(simple_spec(), 80).border(BorderStyle::Light);
810        let bottom = table.bottom_border();
811        assert!(bottom.contains('─'));
812        assert!(bottom.starts_with('└'));
813        assert!(bottom.ends_with('┘'));
814    }
815
816    #[test]
817    fn table_render_full() {
818        let table = Table::new(simple_spec(), 80)
819            .border(BorderStyle::Light)
820            .header(vec!["Name", "Value"]);
821
822        let data = vec![vec!["Alice", "100"], vec!["Bob", "200"]];
823
824        let output = table.render(&data);
825        let lines: Vec<&str> = output.lines().collect();
826
827        // Should have: top border, header, separator, 2 data rows, bottom border
828        assert!(lines.len() >= 5);
829
830        // Top border
831        assert!(lines[0].starts_with('┌'));
832        // Header
833        assert!(lines[1].contains("Name"));
834        // Separator
835        assert!(lines[2].starts_with('├'));
836        // Data rows
837        assert!(lines[3].contains("Alice"));
838        assert!(lines[4].contains("Bob"));
839        // Bottom border
840        assert!(lines[5].starts_with('└'));
841    }
842
843    #[test]
844    fn table_render_no_border() {
845        let table = Table::new(simple_spec(), 80).header(vec!["Name", "Value"]);
846
847        let data = vec![vec!["Alice", "100"]];
848
849        let output = table.render(&data);
850        let lines: Vec<&str> = output.lines().collect();
851
852        // No borders, just header and data
853        assert!(lines.len() >= 2);
854        assert!(lines[0].contains("Name"));
855        assert!(lines[1].contains("Alice"));
856    }
857
858    #[test]
859    fn border_style_default() {
860        assert_eq!(BorderStyle::default(), BorderStyle::None);
861    }
862
863    #[test]
864    fn table_accessors() {
865        let table = Table::new(simple_spec(), 80).border(BorderStyle::Ascii);
866
867        assert_eq!(table.get_border(), BorderStyle::Ascii);
868        assert_eq!(table.num_columns(), 2);
869    }
870
871    #[test]
872    fn table_row_from() {
873        use serde::Serialize;
874
875        #[derive(Serialize)]
876        struct Record {
877            name: String,
878            status: String,
879        }
880
881        let spec = TabularSpec::builder()
882            .column(Col::fixed(10).key("name"))
883            .column(Col::fixed(8).key("status"))
884            .separator("  ")
885            .build();
886
887        let table = Table::new(spec, 80);
888        let record = Record {
889            name: "Alice".to_string(),
890            status: "active".to_string(),
891        };
892
893        let row = table.row_from(&record);
894        assert!(row.contains("Alice"));
895        assert!(row.contains("active"));
896    }
897
898    #[test]
899    fn table_row_from_with_border() {
900        use serde::Serialize;
901
902        #[derive(Serialize)]
903        struct Item {
904            id: u32,
905            value: String,
906        }
907
908        let spec = TabularSpec::builder()
909            .column(Col::fixed(5).key("id"))
910            .column(Col::fixed(10).key("value"))
911            .build();
912
913        let table = Table::new(spec, 80).border(BorderStyle::Light);
914        let item = Item {
915            id: 42,
916            value: "test".to_string(),
917        };
918
919        let row = table.row_from(&item);
920        assert!(row.starts_with('│'));
921        assert!(row.ends_with('│'));
922        assert!(row.contains("42"));
923        assert!(row.contains("test"));
924    }
925
926    #[test]
927    fn table_row_separator_option() {
928        let spec = TabularSpec::builder()
929            .column(Col::fixed(10))
930            .column(Col::fixed(8))
931            .build();
932
933        let table = Table::new(spec, 80)
934            .border(BorderStyle::Light)
935            .row_separator(true);
936
937        let data = vec![vec!["A", "1"], vec!["B", "2"], vec!["C", "3"]];
938        let output = table.render(&data);
939        let lines: Vec<&str> = output.lines().collect();
940
941        // Should have: top, A, sep, B, sep, C, bottom = 7 lines
942        // Count separator lines between data rows
943        let sep_count = lines.iter().filter(|l| l.starts_with('├')).count();
944        assert_eq!(sep_count, 2, "Expected 2 separators between 3 rows");
945    }
946
947    #[test]
948    fn table_row_separator_disabled_by_default() {
949        let spec = TabularSpec::builder()
950            .column(Col::fixed(10))
951            .column(Col::fixed(8))
952            .build();
953
954        let table = Table::new(spec, 80).border(BorderStyle::Light);
955
956        let data = vec![vec!["A", "1"], vec!["B", "2"]];
957        let output = table.render(&data);
958        let lines: Vec<&str> = output.lines().collect();
959
960        // No separators between data rows (only after header if present)
961        // Lines: top, A, B, bottom = 4 lines
962        assert_eq!(lines.len(), 4);
963    }
964
965    #[test]
966    fn table_header_from_columns_with_header_field() {
967        let spec = TabularSpec::builder()
968            .column(Col::fixed(10).header("Name"))
969            .column(Col::fixed(8).header("Status"))
970            .separator("  ")
971            .build();
972
973        let table = Table::new(spec, 80)
974            .header_from_columns()
975            .border(BorderStyle::Light);
976
977        let header = table.header_row();
978        assert!(header.contains("Name"));
979        assert!(header.contains("Status"));
980    }
981
982    #[test]
983    fn table_header_from_columns_fallback_to_key() {
984        let spec = TabularSpec::builder()
985            .column(Col::fixed(10).key("user_name"))
986            .column(Col::fixed(8).key("status"))
987            .separator("  ")
988            .build();
989
990        let table = Table::new(spec, 80).header_from_columns();
991
992        let header = table.header_row();
993        assert!(header.contains("user_name"));
994        assert!(header.contains("status"));
995    }
996
997    #[test]
998    fn table_header_from_columns_fallback_to_name() {
999        let spec = TabularSpec::builder()
1000            .column(Col::fixed(10).named("column1"))
1001            .column(Col::fixed(8).named("column2"))
1002            .separator("  ")
1003            .build();
1004
1005        let table = Table::new(spec, 80).header_from_columns();
1006
1007        let header = table.header_row();
1008        assert!(header.contains("column1"));
1009        assert!(header.contains("column2"));
1010    }
1011
1012    #[test]
1013    fn table_header_from_columns_priority_order() {
1014        // header > key > name
1015        let spec = TabularSpec::builder()
1016            .column(Col::fixed(10).header("Header").key("key").named("name"))
1017            .column(Col::fixed(10).key("key_only").named("name_only"))
1018            .column(Col::fixed(10).named("name_only2"))
1019            .separator("  ")
1020            .build();
1021
1022        let table = Table::new(spec, 80).header_from_columns();
1023
1024        let header = table.header_row();
1025        assert!(header.contains("Header")); // header takes precedence
1026        assert!(header.contains("key_only")); // key is fallback when no header
1027        assert!(header.contains("name_only2")); // name is fallback when no key
1028    }
1029
1030    #[test]
1031    fn table_header_from_columns_in_render() {
1032        let spec = TabularSpec::builder()
1033            .column(Col::fixed(10).header("Name"))
1034            .column(Col::fixed(8).header("Value"))
1035            .separator("  ")
1036            .build();
1037
1038        let table = Table::new(spec, 80)
1039            .header_from_columns()
1040            .border(BorderStyle::Light);
1041
1042        let data = vec![vec!["Alice", "100"]];
1043        let output = table.render(&data);
1044
1045        // Should have header row with proper values
1046        assert!(output.contains("Name"));
1047        assert!(output.contains("Value"));
1048        assert!(output.contains("Alice"));
1049        assert!(output.contains("100"));
1050    }
1051}