Skip to main content

standout_render/tabular/
formatter.rs

1//! Table row formatter.
2//!
3//! This module provides the `TabularFormatter` type that formats data rows
4//! according to a table specification, producing aligned output.
5//!
6//! # Template Integration
7//!
8//! `TabularFormatter` implements `minijinja::value::Object`, allowing it to be
9//! used in templates when injected via context:
10//!
11//! ```jinja
12//! {% for item in items %}
13//! {{ table.row([item.name, item.value, item.status]) }}
14//! {% endfor %}
15//! ```
16//!
17//! Available methods in templates:
18//! - `row(values)`: Format a row with the given values (array)
19//! - `num_columns`: Get the number of columns
20//!
21//! # Example with Context Injection
22//!
23//! ```rust,ignore
24//! use standout_render::tabular::{TabularSpec, Column, Width, TabularFormatter};
25//! use standout_render::context::ContextRegistry;
26//!
27//! let spec = TabularSpec::builder()
28//!     .column(Column::new(Width::Fixed(10)))
29//!     .column(Column::new(Width::Fill))
30//!     .separator("  ")
31//!     .build();
32//!
33//! let mut registry = ContextRegistry::new();
34//! registry.add_provider("table", |ctx| {
35//!     let formatter = TabularFormatter::new(&spec, ctx.terminal_width.unwrap_or(80));
36//!     minijinja::Value::from_object(formatter)
37//! });
38//! ```
39
40use minijinja::value::{Enumerator, Object, Value};
41use serde::Serialize;
42use serde_json::Value as JsonValue;
43use std::sync::Arc;
44
45use super::resolve::ResolvedWidths;
46use super::traits::TabularRow;
47use super::types::{
48    Align, Anchor, Column, FlatDataSpec, Overflow, SubColumns, TabularSpec, TruncateAt, Width,
49};
50use super::util::{
51    display_width, pad_center, pad_left, pad_right, truncate_end, truncate_middle, truncate_start,
52    visible_width, wrap_indent,
53};
54
55/// Formats table rows according to a specification.
56///
57/// The formatter holds resolved column widths and produces formatted rows.
58/// It supports row-by-row formatting for interleaved output patterns.
59///
60/// # Example
61///
62/// ```rust
63/// use standout_render::tabular::{FlatDataSpec, Column, Width, TabularFormatter};
64///
65/// let spec = FlatDataSpec::builder()
66///     .column(Column::new(Width::Fixed(8)))
67///     .column(Column::new(Width::Fill))
68///     .column(Column::new(Width::Fixed(10)))
69///     .separator("  ")
70///     .build();
71///
72/// let formatter = TabularFormatter::new(&spec, 80);
73///
74/// // Format rows one at a time (enables interleaved output)
75/// let row1 = formatter.format_row(&["abc123", "path/to/file.rs", "pending"]);
76/// println!("{}", row1);
77/// println!("  └─ Note: needs review");  // Interleaved content
78/// let row2 = formatter.format_row(&["def456", "src/lib.rs", "done"]);
79/// println!("{}", row2);
80/// ```
81#[derive(Clone, Debug)]
82pub struct TabularFormatter {
83    /// Column specifications.
84    columns: Vec<Column>,
85    /// Resolved widths for each column.
86    widths: Vec<usize>,
87    /// Column separator string.
88    separator: String,
89    /// Row prefix string.
90    prefix: String,
91    /// Row suffix string.
92    suffix: String,
93    /// Total target width for anchor calculations.
94    total_width: usize,
95}
96
97impl TabularFormatter {
98    /// Create a new formatter by resolving widths from the spec.
99    ///
100    /// # Arguments
101    ///
102    /// * `spec` - Table specification
103    /// * `total_width` - Total available width including decorations
104    pub fn new(spec: &FlatDataSpec, total_width: usize) -> Self {
105        let resolved = spec.resolve_widths(total_width);
106        Self::from_resolved_with_width(spec, resolved, total_width)
107    }
108
109    /// Create a formatter with pre-resolved widths.
110    ///
111    /// Use this when you've already calculated widths (e.g., from data).
112    pub fn from_resolved(spec: &FlatDataSpec, resolved: ResolvedWidths) -> Self {
113        // Calculate total width from resolved widths + overhead
114        let content_width: usize = resolved.widths.iter().sum();
115        let overhead = spec.decorations.overhead(resolved.widths.len());
116        let total_width = content_width + overhead;
117        Self::from_resolved_with_width(spec, resolved, total_width)
118    }
119
120    /// Create a formatter with pre-resolved widths and explicit total width.
121    pub fn from_resolved_with_width(
122        spec: &FlatDataSpec,
123        resolved: ResolvedWidths,
124        total_width: usize,
125    ) -> Self {
126        TabularFormatter {
127            columns: spec.columns.clone(),
128            widths: resolved.widths,
129            separator: spec.decorations.column_sep.clone(),
130            prefix: spec.decorations.row_prefix.clone(),
131            suffix: spec.decorations.row_suffix.clone(),
132            total_width,
133        }
134    }
135
136    /// Create a formatter from explicit widths and columns.
137    ///
138    /// This is useful for direct construction without a full FlatDataSpec.
139    pub fn with_widths(columns: Vec<Column>, widths: Vec<usize>) -> Self {
140        let total_width = widths.iter().sum();
141        TabularFormatter {
142            columns,
143            widths,
144            separator: String::new(),
145            prefix: String::new(),
146            suffix: String::new(),
147            total_width,
148        }
149    }
150
151    /// Create a formatter from a type that implements `Tabular`.
152    ///
153    /// This constructor uses the `TabularSpec` generated by the `#[derive(Tabular)]`
154    /// macro to configure the formatter.
155    ///
156    /// # Example
157    ///
158    /// ```rust,ignore
159    /// use standout_render::tabular::{Tabular, TabularFormatter};
160    /// use serde::Serialize;
161    ///
162    /// #[derive(Serialize, Tabular)]
163    /// struct Task {
164    ///     #[col(width = 8)]
165    ///     id: String,
166    ///     #[col(width = "fill")]
167    ///     title: String,
168    /// }
169    ///
170    /// let formatter = TabularFormatter::from_type::<Task>(80);
171    /// ```
172    pub fn from_type<T: super::traits::Tabular>(total_width: usize) -> Self {
173        let spec: TabularSpec = T::tabular_spec();
174        Self::new(&spec, total_width)
175    }
176
177    /// Set the total target width (for anchor gap calculations).
178    pub fn total_width(mut self, width: usize) -> Self {
179        self.total_width = width;
180        self
181    }
182
183    /// Set the column separator.
184    pub fn separator(mut self, sep: impl Into<String>) -> Self {
185        self.separator = sep.into();
186        self
187    }
188
189    /// Set the row prefix.
190    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
191        self.prefix = prefix.into();
192        self
193    }
194
195    /// Set the row suffix.
196    pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
197        self.suffix = suffix.into();
198        self
199    }
200
201    /// Format a single row of values.
202    ///
203    /// Values are truncated/padded according to the column specifications.
204    /// Missing values use the column's null representation.
205    ///
206    /// # Arguments
207    ///
208    /// * `values` - Slice of cell values (strings)
209    ///
210    /// # Example
211    ///
212    /// ```rust
213    /// use standout_render::tabular::{FlatDataSpec, Column, Width, TabularFormatter};
214    ///
215    /// let spec = FlatDataSpec::builder()
216    ///     .column(Column::new(Width::Fixed(10)))
217    ///     .column(Column::new(Width::Fixed(8)))
218    ///     .separator(" | ")
219    ///     .build();
220    ///
221    /// let formatter = TabularFormatter::new(&spec, 80);
222    /// let output = formatter.format_row(&["Hello", "World"]);
223    /// assert_eq!(output, "Hello      | World   ");
224    /// ```
225    pub fn format_row<S: AsRef<str>>(&self, values: &[S]) -> String {
226        // If any column has sub-columns, delegate to format_row_cells
227        // wrapping plain strings as CellValue::Single
228        if self.columns.iter().any(|c| c.sub_columns.is_some()) {
229            let cell_values: Vec<CellValue<'_>> = values
230                .iter()
231                .map(|s| CellValue::Single(s.as_ref()))
232                .collect();
233            return self.format_row_cells(&cell_values);
234        }
235
236        let mut result = String::new();
237        result.push_str(&self.prefix);
238
239        // Find anchor transition point and calculate gap
240        let (anchor_gap, anchor_transition) = self.calculate_anchor_gap();
241
242        for (i, col) in self.columns.iter().enumerate() {
243            // Insert separator (or anchor gap at transition point)
244            if i > 0 {
245                if anchor_gap > 0 && i == anchor_transition {
246                    // Insert anchor gap instead of separator
247                    result.push_str(&" ".repeat(anchor_gap));
248                } else {
249                    result.push_str(&self.separator);
250                }
251            }
252
253            let width = self.widths.get(i).copied().unwrap_or(0);
254            let value = values.get(i).map(|s| s.as_ref()).unwrap_or(&col.null_repr);
255
256            let formatted = format_cell(value, width, col);
257            result.push_str(&formatted);
258        }
259
260        result.push_str(&self.suffix);
261        result
262    }
263
264    /// Format a row with cell values that may include sub-column arrays.
265    ///
266    /// For columns with sub-columns, pass `CellValue::Sub(vec![...])`.
267    /// For regular columns, pass `CellValue::Single("...")`.
268    ///
269    /// # Example
270    ///
271    /// ```rust
272    /// use standout_render::tabular::{
273    ///     FlatDataSpec, Column, Width, TabularFormatter, CellValue,
274    ///     SubColumns, SubCol,
275    /// };
276    ///
277    /// let sub_cols = SubColumns::new(
278    ///     vec![SubCol::fill(), SubCol::bounded(0, 20).right()],
279    ///     " ",
280    /// ).unwrap();
281    ///
282    /// let spec = FlatDataSpec::builder()
283    ///     .column(Column::new(Width::Fixed(4)))
284    ///     .column(Column::new(Width::Fill).sub_columns(sub_cols))
285    ///     .column(Column::new(Width::Fixed(6)).right())
286    ///     .separator("  ")
287    ///     .build();
288    ///
289    /// let formatter = TabularFormatter::new(&spec, 60);
290    /// let row = formatter.format_row_cells(&[
291    ///     CellValue::Single("1."),
292    ///     CellValue::Sub(vec!["Gallery Navigation", "[feature]"]),
293    ///     CellValue::Single("4d"),
294    /// ]);
295    /// ```
296    pub fn format_row_cells(&self, values: &[CellValue<'_>]) -> String {
297        let mut result = String::new();
298        result.push_str(&self.prefix);
299
300        let (anchor_gap, anchor_transition) = self.calculate_anchor_gap();
301
302        for (i, col) in self.columns.iter().enumerate() {
303            if i > 0 {
304                if anchor_gap > 0 && i == anchor_transition {
305                    result.push_str(&" ".repeat(anchor_gap));
306                } else {
307                    result.push_str(&self.separator);
308                }
309            }
310
311            let width = self.widths.get(i).copied().unwrap_or(0);
312
313            if let Some(sub_cols) = &col.sub_columns {
314                // Sub-column formatting
315                let sub_values: Vec<&str> = match values.get(i) {
316                    Some(CellValue::Sub(v)) => v.clone(),
317                    Some(CellValue::Single(s)) => vec![s],
318                    None => vec![],
319                };
320                let formatted = format_sub_cells(sub_cols, &sub_values, width);
321                result.push_str(&formatted);
322            } else {
323                // Normal cell formatting
324                let value = match values.get(i) {
325                    Some(CellValue::Single(s)) => *s,
326                    Some(CellValue::Sub(v)) => v.first().copied().unwrap_or(&col.null_repr),
327                    None => &col.null_repr,
328                };
329                let formatted = format_cell(value, width, col);
330                result.push_str(&formatted);
331            }
332        }
333
334        result.push_str(&self.suffix);
335        result
336    }
337
338    /// Calculate the anchor gap size and transition point.
339    ///
340    /// Returns (gap_size, transition_index) where:
341    /// - gap_size is the number of spaces to insert between left and right groups
342    /// - transition_index is the column index where right-anchored columns start
343    fn calculate_anchor_gap(&self) -> (usize, usize) {
344        // Find first right-anchored column
345        let transition = self
346            .columns
347            .iter()
348            .position(|c| c.anchor == Anchor::Right)
349            .unwrap_or(self.columns.len());
350
351        // If no right-anchored columns or all columns are right-anchored, no gap
352        if transition == 0 || transition == self.columns.len() {
353            return (0, transition);
354        }
355
356        // Calculate current content width
357        let prefix_width = display_width(&self.prefix);
358        let suffix_width = display_width(&self.suffix);
359        let sep_width = display_width(&self.separator);
360        let content_width: usize = self.widths.iter().sum();
361        let num_seps = self.columns.len().saturating_sub(1);
362        let current_total = prefix_width + content_width + (num_seps * sep_width) + suffix_width;
363
364        // Calculate gap - the extra space available to push right columns to the right
365        if current_total >= self.total_width {
366            // No room for a gap
367            (0, transition)
368        } else {
369            // Gap = extra space, minus one separator (which we replace with the gap)
370            let extra = self.total_width - current_total;
371            // The gap replaces one separator, so add sep_width back to the gap
372            (extra + sep_width, transition)
373        }
374    }
375
376    /// Format multiple rows.
377    ///
378    /// Returns a vector of formatted row strings.
379    pub fn format_rows<S: AsRef<str>>(&self, rows: &[Vec<S>]) -> Vec<String> {
380        rows.iter().map(|row| self.format_row(row)).collect()
381    }
382
383    /// Format a row that may produce multiple output lines (due to wrapping).
384    ///
385    /// If any cell wraps to multiple lines, the output contains multiple lines
386    /// with proper vertical alignment. Cells are top-aligned.
387    ///
388    /// # Example
389    ///
390    /// ```rust
391    /// use standout_render::tabular::{FlatDataSpec, Column, Width, Overflow, TabularFormatter};
392    ///
393    /// let spec = FlatDataSpec::builder()
394    ///     .column(Column::new(Width::Fixed(10)).wrap())
395    ///     .column(Column::new(Width::Fixed(8)))
396    ///     .separator("  ")
397    ///     .build();
398    ///
399    /// let formatter = TabularFormatter::new(&spec, 80);
400    /// let lines = formatter.format_row_lines(&["This is a long text", "Short"]);
401    /// // Returns multiple lines if the first column wraps
402    /// ```
403    pub fn format_row_lines<S: AsRef<str>>(&self, values: &[S]) -> Vec<String> {
404        // Format each cell
405        let cell_outputs: Vec<CellOutput> = self
406            .columns
407            .iter()
408            .enumerate()
409            .map(|(i, col)| {
410                let width = self.widths.get(i).copied().unwrap_or(0);
411                let value = values.get(i).map(|s| s.as_ref()).unwrap_or(&col.null_repr);
412                format_cell_lines(value, width, col)
413            })
414            .collect();
415
416        // Find max lines needed
417        let max_lines = cell_outputs
418            .iter()
419            .map(|c| c.line_count())
420            .max()
421            .unwrap_or(1);
422
423        // If only single line, use simple path
424        if max_lines == 1 {
425            return vec![self.format_row(values)];
426        }
427
428        // Build output lines with anchor support
429        let (anchor_gap, anchor_transition) = self.calculate_anchor_gap();
430        let mut output = Vec::with_capacity(max_lines);
431
432        for line_idx in 0..max_lines {
433            let mut row = String::new();
434            row.push_str(&self.prefix);
435
436            for (i, (cell, col)) in cell_outputs.iter().zip(self.columns.iter()).enumerate() {
437                if i > 0 {
438                    if anchor_gap > 0 && i == anchor_transition {
439                        row.push_str(&" ".repeat(anchor_gap));
440                    } else {
441                        row.push_str(&self.separator);
442                    }
443                }
444
445                let width = self.widths.get(i).copied().unwrap_or(0);
446                let line = cell.line(line_idx, width, col.align);
447                row.push_str(&line);
448            }
449
450            row.push_str(&self.suffix);
451            output.push(row);
452        }
453
454        output
455    }
456
457    /// Get the resolved width for a column by index.
458    pub fn column_width(&self, index: usize) -> Option<usize> {
459        self.widths.get(index).copied()
460    }
461
462    /// Get all resolved column widths.
463    pub fn widths(&self) -> &[usize] {
464        &self.widths
465    }
466
467    /// Get the number of columns.
468    pub fn num_columns(&self) -> usize {
469        self.columns.len()
470    }
471
472    /// Returns `true` if any column has sub-columns defined.
473    pub fn has_sub_columns(&self) -> bool {
474        self.columns.iter().any(|c| c.sub_columns.is_some())
475    }
476
477    /// Get the column specifications.
478    pub fn columns(&self) -> &[Column] {
479        &self.columns
480    }
481
482    /// Extract headers from column specifications.
483    ///
484    /// For each column, uses (in order of preference):
485    /// 1. The `header` field if set
486    /// 2. The `key` field if set
487    /// 3. The `name` field if set
488    /// 4. Empty string
489    ///
490    /// This is useful for `Table::header_from_columns()`.
491    pub fn extract_headers(&self) -> Vec<String> {
492        self.columns
493            .iter()
494            .map(|col| {
495                col.header
496                    .as_deref()
497                    .or(col.key.as_deref())
498                    .or(col.name.as_deref())
499                    .unwrap_or("")
500                    .to_string()
501            })
502            .collect()
503    }
504
505    /// Format a row by extracting values from a serializable struct.
506    ///
507    /// This method extracts field values from the struct based on each column's
508    /// `key` or `name` field. Supports dot notation for nested field access
509    /// (e.g., "user.email").
510    ///
511    /// # Arguments
512    ///
513    /// * `value` - Any serializable value to extract fields from
514    ///
515    /// # Example
516    ///
517    /// ```rust
518    /// use standout_render::tabular::{FlatDataSpec, Column, Width, TabularFormatter};
519    /// use serde::Serialize;
520    ///
521    /// #[derive(Serialize)]
522    /// struct Record {
523    ///     name: String,
524    ///     status: String,
525    ///     count: u32,
526    /// }
527    ///
528    /// let spec = FlatDataSpec::builder()
529    ///     .column(Column::new(Width::Fixed(20)).key("name"))
530    ///     .column(Column::new(Width::Fixed(10)).key("status"))
531    ///     .column(Column::new(Width::Fixed(5)).key("count"))
532    ///     .separator("  ")
533    ///     .build();
534    ///
535    /// let formatter = TabularFormatter::new(&spec, 80);
536    /// let record = Record {
537    ///     name: "example".to_string(),
538    ///     status: "active".to_string(),
539    ///     count: 42,
540    /// };
541    ///
542    /// let row = formatter.row_from(&record);
543    /// assert!(row.contains("example"));
544    /// assert!(row.contains("active"));
545    /// assert!(row.contains("42"));
546    /// ```
547    pub fn row_from<T: Serialize>(&self, value: &T) -> String {
548        let values = self.extract_values(value);
549        let string_refs: Vec<&str> = values.iter().map(|s| s.as_str()).collect();
550        self.format_row(&string_refs)
551    }
552
553    /// Format a row with potential multi-line output from a serializable struct.
554    ///
555    /// Same as `row_from` but handles word-wrap columns that may produce
556    /// multiple output lines.
557    pub fn row_lines_from<T: Serialize>(&self, value: &T) -> Vec<String> {
558        let values = self.extract_values(value);
559        let string_refs: Vec<&str> = values.iter().map(|s| s.as_str()).collect();
560        self.format_row_lines(&string_refs)
561    }
562
563    /// Format a row using the `TabularRow` trait.
564    ///
565    /// This method uses the optimized `to_row()` implementation generated by
566    /// `#[derive(TabularRow)]`, avoiding JSON serialization overhead.
567    ///
568    /// # Example
569    ///
570    /// ```rust,ignore
571    /// use standout_render::tabular::{TabularRow, TabularFormatter};
572    ///
573    /// #[derive(TabularRow)]
574    /// struct Task {
575    ///     id: String,
576    ///     title: String,
577    /// }
578    ///
579    /// let task = Task {
580    ///     id: "TSK-001".to_string(),
581    ///     title: "Implement feature".to_string(),
582    /// };
583    ///
584    /// let formatter = TabularFormatter::from_type::<Task>(80);
585    /// let row = formatter.row_from_trait(&task);
586    /// ```
587    pub fn row_from_trait<T: TabularRow>(&self, value: &T) -> String {
588        let values = value.to_row();
589        self.format_row(&values)
590    }
591
592    /// Format a row with potential multi-line output using the `TabularRow` trait.
593    ///
594    /// Same as `row_from_trait` but handles word-wrap columns that may produce
595    /// multiple output lines.
596    pub fn row_lines_from_trait<T: TabularRow>(&self, value: &T) -> Vec<String> {
597        let values = value.to_row();
598        self.format_row_lines(&values)
599    }
600
601    /// Extract values from a serializable struct based on column keys.
602    fn extract_values<T: Serialize>(&self, value: &T) -> Vec<String> {
603        // Convert to JSON for field access
604        let json = match serde_json::to_value(value) {
605            Ok(v) => v,
606            Err(_) => return vec![String::new(); self.columns.len()],
607        };
608
609        self.columns
610            .iter()
611            .map(|col| {
612                // Use key first, fall back to name
613                let key = col.key.as_ref().or(col.name.as_ref());
614
615                match key {
616                    Some(k) => extract_field(&json, k),
617                    None => col.null_repr.clone(),
618                }
619            })
620            .collect()
621    }
622}
623
624/// Extract a field value from JSON using dot notation.
625///
626/// Supports paths like "user.email" or "items.0.name".
627fn extract_field(value: &JsonValue, path: &str) -> String {
628    let mut current = value;
629
630    for part in path.split('.') {
631        match current {
632            JsonValue::Object(map) => {
633                current = match map.get(part) {
634                    Some(v) => v,
635                    None => return String::new(),
636                };
637            }
638            JsonValue::Array(arr) => {
639                // Try to parse as index
640                if let Ok(idx) = part.parse::<usize>() {
641                    current = match arr.get(idx) {
642                        Some(v) => v,
643                        None => return String::new(),
644                    };
645                } else {
646                    return String::new();
647                }
648            }
649            _ => return String::new(),
650        }
651    }
652
653    // Convert final value to string
654    match current {
655        JsonValue::String(s) => s.clone(),
656        JsonValue::Number(n) => n.to_string(),
657        JsonValue::Bool(b) => b.to_string(),
658        JsonValue::Null => String::new(),
659        // For arrays/objects, use JSON representation
660        _ => current.to_string(),
661    }
662}
663
664// ============================================================================
665// MiniJinja Object Implementation
666// ============================================================================
667
668impl Object for TabularFormatter {
669    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
670        match key.as_str()? {
671            "num_columns" => Some(Value::from(self.num_columns())),
672            "widths" => {
673                let widths: Vec<Value> = self.widths.iter().map(|&w| Value::from(w)).collect();
674                Some(Value::from(widths))
675            }
676            "separator" => Some(Value::from(self.separator.clone())),
677            _ => None,
678        }
679    }
680
681    fn enumerate(self: &Arc<Self>) -> Enumerator {
682        Enumerator::Str(&["num_columns", "widths", "separator"])
683    }
684
685    fn call_method(
686        self: &Arc<Self>,
687        _state: &minijinja::State,
688        name: &str,
689        args: &[Value],
690    ) -> Result<Value, minijinja::Error> {
691        match name {
692            "row" => {
693                // row([value1, value2, ...]) - format a row
694                if args.is_empty() {
695                    return Err(minijinja::Error::new(
696                        minijinja::ErrorKind::MissingArgument,
697                        "row() requires an array of values",
698                    ));
699                }
700
701                let values_arg = &args[0];
702                let has_sub_columns = self.columns.iter().any(|c| c.sub_columns.is_some());
703
704                if has_sub_columns {
705                    // Sub-column aware path: detect nested arrays
706                    let outer_iter = match values_arg.try_iter() {
707                        Ok(iter) => iter,
708                        Err(_) => {
709                            let values = vec![values_arg.to_string()];
710                            let formatted = self.format_row(&values);
711                            return Ok(Value::from(formatted));
712                        }
713                    };
714
715                    // Collect all values, detecting nested arrays for sub-column cells
716                    let mut owned_values: Vec<OwnedCellValue> = Vec::new();
717                    for (i, v) in outer_iter.enumerate() {
718                        let is_sub_col = self
719                            .columns
720                            .get(i)
721                            .and_then(|c| c.sub_columns.as_ref())
722                            .is_some();
723
724                        if is_sub_col {
725                            if let Ok(inner_iter) = v.try_iter() {
726                                let sub_vals: Vec<String> =
727                                    inner_iter.map(|iv| iv.to_string()).collect();
728                                owned_values.push(OwnedCellValue::Sub(sub_vals));
729                            } else {
730                                owned_values.push(OwnedCellValue::Single(v.to_string()));
731                            }
732                        } else {
733                            owned_values.push(OwnedCellValue::Single(v.to_string()));
734                        }
735                    }
736
737                    // Build CellValue references from owned data
738                    let cell_values: Vec<CellValue<'_>> = owned_values
739                        .iter()
740                        .map(|ov| match ov {
741                            OwnedCellValue::Single(s) => CellValue::Single(s.as_str()),
742                            OwnedCellValue::Sub(v) => {
743                                CellValue::Sub(v.iter().map(|s| s.as_str()).collect())
744                            }
745                        })
746                        .collect();
747
748                    let formatted = self.format_row_cells(&cell_values);
749                    Ok(Value::from(formatted))
750                } else {
751                    // Fast path: no sub-columns, flatten all values to strings
752                    let values: Vec<String> = match values_arg.try_iter() {
753                        Ok(iter) => iter.map(|v| v.to_string()).collect(),
754                        Err(_) => vec![values_arg.to_string()],
755                    };
756
757                    let formatted = self.format_row(&values);
758                    Ok(Value::from(formatted))
759                }
760            }
761            "column_width" => {
762                // column_width(index) - get width of a specific column
763                if args.is_empty() {
764                    return Err(minijinja::Error::new(
765                        minijinja::ErrorKind::MissingArgument,
766                        "column_width() requires an index argument",
767                    ));
768                }
769
770                let index = args[0].as_usize().ok_or_else(|| {
771                    minijinja::Error::new(
772                        minijinja::ErrorKind::InvalidOperation,
773                        "column_width() index must be a number",
774                    )
775                })?;
776
777                match self.column_width(index) {
778                    Some(w) => Ok(Value::from(w)),
779                    None => Ok(Value::from(())),
780                }
781            }
782            _ => Err(minijinja::Error::new(
783                minijinja::ErrorKind::UnknownMethod,
784                format!("TabularFormatter has no method '{}'", name),
785            )),
786        }
787    }
788}
789
790/// Format a single cell value according to column spec.
791fn format_cell(value: &str, width: usize, col: &Column) -> String {
792    // If style_from_value is set, use the value as the style
793    let style_override = if col.style_from_value {
794        Some(value)
795    } else {
796        None
797    };
798    let style = style_override.or(col.style.as_deref());
799    format_value(value, width, col.align, &col.overflow, style)
800}
801
802/// Format a value with the given width, alignment, overflow, and optional style.
803///
804/// This is the core formatting function used by both regular cells and sub-cells.
805/// It correctly handles BBCode tags in `value`: tags are stripped for width
806/// measurement and truncation, but preserved in the output when the content fits.
807fn format_value(
808    value: &str,
809    width: usize,
810    align: Align,
811    overflow: &Overflow,
812    style: Option<&str>,
813) -> String {
814    if width == 0 {
815        return String::new();
816    }
817
818    let stripped = standout_bbparser::strip_tags(value);
819    let current_width = display_width(&stripped);
820
821    if current_width > width {
822        // Content overflows — truncate the stripped text (tags are lost)
823        let truncated = match overflow {
824            Overflow::Truncate { at, marker } => match at {
825                TruncateAt::End => truncate_end(&stripped, width, marker),
826                TruncateAt::Start => truncate_start(&stripped, width, marker),
827                TruncateAt::Middle => truncate_middle(&stripped, width, marker),
828            },
829            Overflow::Clip => truncate_end(&stripped, width, ""),
830            Overflow::Expand => {
831                // Don't truncate — pad is also skipped below
832                return apply_style(value, style);
833            }
834            Overflow::Wrap { .. } => {
835                // Single-line fallback; multi-line wrapping handled by format_cell_lines
836                truncate_end(&stripped, width, "…")
837            }
838        };
839
840        let padded = match align {
841            Align::Left => pad_right(&truncated, width),
842            Align::Right => pad_left(&truncated, width),
843            Align::Center => pad_center(&truncated, width),
844        };
845        apply_style(&padded, style)
846    } else {
847        // Content fits — pad the original value (preserving tags) using manual spacing
848        let padding = width - current_width;
849        let padded = match align {
850            Align::Left => format!("{}{}", value, " ".repeat(padding)),
851            Align::Right => format!("{}{}", " ".repeat(padding), value),
852            Align::Center => {
853                let left_pad = padding / 2;
854                let right_pad = padding - left_pad;
855                format!("{}{}{}", " ".repeat(left_pad), value, " ".repeat(right_pad))
856            }
857        };
858        apply_style(&padded, style)
859    }
860}
861
862// ============================================================================
863// Sub-Column Support
864// ============================================================================
865
866/// A cell value that may be a single string or a list of sub-values.
867///
868/// Used with [`TabularFormatter::format_row_cells`] for columns that have
869/// sub-columns. Regular columns use `Single`, columns with sub-columns use `Sub`.
870#[derive(Clone, Debug)]
871pub enum CellValue<'a> {
872    /// Single string value for a regular column.
873    Single(&'a str),
874    /// Multiple sub-values for a column with sub-columns.
875    Sub(Vec<&'a str>),
876}
877
878impl<'a> From<&'a str> for CellValue<'a> {
879    fn from(s: &'a str) -> Self {
880        CellValue::Single(s)
881    }
882}
883
884/// Owned version of CellValue for use in MiniJinja Object methods
885/// where we need to own the string data before borrowing into CellValue.
886pub(crate) enum OwnedCellValue {
887    Single(String),
888    Sub(Vec<String>),
889}
890
891/// Resolve sub-column widths for a single row.
892///
893/// Non-grower sub-columns get their content width clamped to bounds.
894/// The grower gets all remaining space. Separators between zero-width
895/// sub-columns are skipped.
896///
897/// Returns a Vec of resolved widths such that:
898/// `sum(widths) + separator_overhead == parent_width`
899fn resolve_sub_widths(sub_cols: &SubColumns, values: &[&str], parent_width: usize) -> Vec<usize> {
900    let sep_width = display_width(&sub_cols.separator);
901    let n = sub_cols.columns.len();
902    let mut widths = vec![0usize; n];
903    let mut grower_index = 0;
904
905    // Pass 1: resolve non-grower widths from content
906    for (i, sub_col) in sub_cols.columns.iter().enumerate() {
907        match &sub_col.width {
908            Width::Fill => {
909                grower_index = i;
910            }
911            Width::Fixed(w) => {
912                widths[i] = *w;
913            }
914            Width::Bounded { min, max } => {
915                let content_w = values.get(i).map(|v| visible_width(v)).unwrap_or(0);
916                let min_w = min.unwrap_or(0);
917                let max_w = max.unwrap_or(usize::MAX);
918                widths[i] = content_w.max(min_w).min(max_w);
919            }
920            Width::Fraction(_) => {} // validated away at construction
921        }
922    }
923
924    // Pass 2: compute separator overhead
925    // The grower is always "visible" for separator purposes—even at zero width it
926    // participates in the join so separators flanking it are emitted.  Only truly
927    // zero-width *non-grower* columns are elided.
928    let visible_non_growers = widths
929        .iter()
930        .enumerate()
931        .filter(|&(i, &w)| i != grower_index && w > 0)
932        .count();
933    let visible_count = visible_non_growers + 1; // +1 for grower
934    let sep_overhead = visible_count.saturating_sub(1) * sep_width;
935    let available = parent_width.saturating_sub(sep_overhead);
936
937    // Pass 3: clamp non-grower total if it exceeds available content budget
938    let non_grower_total: usize = widths
939        .iter()
940        .enumerate()
941        .filter(|&(i, _)| i != grower_index)
942        .map(|(_, &w)| w)
943        .sum();
944
945    if non_grower_total > available {
946        let mut excess = non_grower_total - available;
947        // Shrink from the last non-grower backwards
948        for i in (0..n).rev() {
949            if i == grower_index || widths[i] == 0 || excess == 0 {
950                continue;
951            }
952            let reduction = excess.min(widths[i]);
953            widths[i] -= reduction;
954            excess -= reduction;
955        }
956    }
957
958    // Pass 4: grower absorbs remaining space
959    let clamped_total: usize = widths
960        .iter()
961        .enumerate()
962        .filter(|&(i, _)| i != grower_index)
963        .map(|(_, &w)| w)
964        .sum();
965    widths[grower_index] = available.saturating_sub(clamped_total);
966
967    widths
968}
969
970/// Format a cell that has sub-columns, producing a string of exactly
971/// `parent_width` display columns.
972///
973/// Sub-column widths are resolved per-row from actual content. The grower
974/// sub-column absorbs remaining space. Zero-width *non-grower* columns are
975/// skipped entirely (no separator emitted). The grower is always present in
976/// the output—even at zero width—so separator counts stay correct.
977fn format_sub_cells(sub_cols: &SubColumns, values: &[&str], parent_width: usize) -> String {
978    if parent_width == 0 {
979        return String::new();
980    }
981
982    let widths = resolve_sub_widths(sub_cols, values, parent_width);
983    let grower_index = sub_cols
984        .columns
985        .iter()
986        .position(|c| matches!(c.width, Width::Fill))
987        .unwrap_or(0);
988    let sep = &sub_cols.separator;
989    let mut parts: Vec<String> = Vec::new();
990
991    for (i, (sub_col, &width)) in sub_cols.columns.iter().zip(widths.iter()).enumerate() {
992        // Skip zero-width non-grower columns (they produce no output and no separator)
993        if width == 0 && i != grower_index {
994            continue;
995        }
996        if width == 0 {
997            // Grower at zero width: emit empty string so separators flanking it are correct
998            parts.push(String::new());
999        } else {
1000            let value = values.get(i).copied().unwrap_or(&sub_col.null_repr);
1001            parts.push(format_value(
1002                value,
1003                width,
1004                sub_col.align,
1005                &sub_col.overflow,
1006                sub_col.style.as_deref(),
1007            ));
1008        }
1009    }
1010
1011    parts.join(sep)
1012}
1013
1014/// Result of formatting a cell, which may be single or multi-line.
1015#[derive(Clone, Debug, PartialEq, Eq)]
1016pub enum CellOutput {
1017    /// Single line of formatted text.
1018    Single(String),
1019    /// Multiple lines (from word-wrap).
1020    Multi(Vec<String>),
1021}
1022
1023impl CellOutput {
1024    /// Returns true if this is a single-line output.
1025    pub fn is_single(&self) -> bool {
1026        matches!(self, CellOutput::Single(_))
1027    }
1028
1029    /// Returns the number of lines.
1030    pub fn line_count(&self) -> usize {
1031        match self {
1032            CellOutput::Single(_) => 1,
1033            CellOutput::Multi(lines) => lines.len().max(1),
1034        }
1035    }
1036
1037    /// Get a specific line, padding to width if needed.
1038    ///
1039    /// Content may contain BBCode from `apply_style`, so we use `visible_width`
1040    /// for measurement and manual padding to avoid miscounting tags.
1041    pub fn line(&self, index: usize, width: usize, align: Align) -> String {
1042        let content = match self {
1043            CellOutput::Single(s) if index == 0 => s.as_str(),
1044            CellOutput::Multi(lines) => lines.get(index).map(|s| s.as_str()).unwrap_or(""),
1045            _ => "",
1046        };
1047
1048        let content_width = visible_width(content);
1049        if content_width >= width {
1050            return content.to_string();
1051        }
1052        let padding = width - content_width;
1053        match align {
1054            Align::Left => format!("{}{}", content, " ".repeat(padding)),
1055            Align::Right => format!("{}{}", " ".repeat(padding), content),
1056            Align::Center => {
1057                let left_pad = padding / 2;
1058                let right_pad = padding - left_pad;
1059                format!(
1060                    "{}{}{}",
1061                    " ".repeat(left_pad),
1062                    content,
1063                    " ".repeat(right_pad)
1064                )
1065            }
1066        }
1067    }
1068
1069    /// Convert to a single string (first line for Multi).
1070    pub fn to_single(&self) -> String {
1071        match self {
1072            CellOutput::Single(s) => s.clone(),
1073            CellOutput::Multi(lines) => lines.first().cloned().unwrap_or_default(),
1074        }
1075    }
1076}
1077
1078/// Wrap content with style tags if a style is specified.
1079fn apply_style(content: &str, style: Option<&str>) -> String {
1080    match style {
1081        Some(s) if !s.is_empty() => format!("[{}]{}[/{}]", s, content, s),
1082        _ => content.to_string(),
1083    }
1084}
1085
1086/// Format a cell with potential multi-line output (for Wrap mode).
1087fn format_cell_lines(value: &str, width: usize, col: &Column) -> CellOutput {
1088    if width == 0 {
1089        return CellOutput::Single(String::new());
1090    }
1091
1092    let stripped = standout_bbparser::strip_tags(value);
1093    let current_width = display_width(&stripped);
1094
1095    // Determine style: style_from_value takes precedence
1096    let style = if col.style_from_value {
1097        Some(value)
1098    } else {
1099        col.style.as_deref()
1100    };
1101
1102    match &col.overflow {
1103        Overflow::Wrap { indent } => {
1104            if current_width <= width {
1105                // Fits on one line — pad original value (preserving tags) manually
1106                let padding = width - current_width;
1107                let padded = match col.align {
1108                    Align::Left => format!("{}{}", value, " ".repeat(padding)),
1109                    Align::Right => format!("{}{}", " ".repeat(padding), value),
1110                    Align::Center => {
1111                        let left_pad = padding / 2;
1112                        let right_pad = padding - left_pad;
1113                        format!("{}{}{}", " ".repeat(left_pad), value, " ".repeat(right_pad))
1114                    }
1115                };
1116                CellOutput::Single(apply_style(&padded, style))
1117            } else {
1118                // Wrap to multiple lines — tags are stripped (same as truncation)
1119                let wrapped = wrap_indent(&stripped, width, *indent);
1120                let padded: Vec<String> = wrapped
1121                    .into_iter()
1122                    .map(|line| {
1123                        let padded_line = match col.align {
1124                            Align::Left => pad_right(&line, width),
1125                            Align::Right => pad_left(&line, width),
1126                            Align::Center => pad_center(&line, width),
1127                        };
1128                        apply_style(&padded_line, style)
1129                    })
1130                    .collect();
1131                if padded.len() == 1 {
1132                    CellOutput::Single(padded.into_iter().next().unwrap())
1133                } else {
1134                    CellOutput::Multi(padded)
1135                }
1136            }
1137        }
1138        // All other modes are single-line
1139        _ => CellOutput::Single(format_cell(value, width, col)),
1140    }
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145    use super::*;
1146    use crate::tabular::{TabularSpec, Width};
1147
1148    fn simple_spec() -> FlatDataSpec {
1149        FlatDataSpec::builder()
1150            .column(Column::new(Width::Fixed(10)))
1151            .column(Column::new(Width::Fixed(8)))
1152            .separator(" | ")
1153            .build()
1154    }
1155
1156    #[test]
1157    fn format_basic_row() {
1158        let formatter = TabularFormatter::new(&simple_spec(), 80);
1159        let output = formatter.format_row(&["Hello", "World"]);
1160        assert_eq!(output, "Hello      | World   ");
1161    }
1162
1163    #[test]
1164    fn format_row_with_truncation() {
1165        let spec = FlatDataSpec::builder()
1166            .column(Column::new(Width::Fixed(8)))
1167            .build();
1168        let formatter = TabularFormatter::new(&spec, 80);
1169
1170        let output = formatter.format_row(&["Hello World"]);
1171        assert_eq!(output, "Hello W…");
1172    }
1173
1174    #[test]
1175    fn format_row_right_align() {
1176        let spec = FlatDataSpec::builder()
1177            .column(Column::new(Width::Fixed(10)).align(Align::Right))
1178            .build();
1179        let formatter = TabularFormatter::new(&spec, 80);
1180
1181        let output = formatter.format_row(&["42"]);
1182        assert_eq!(output, "        42");
1183    }
1184
1185    #[test]
1186    fn format_row_center_align() {
1187        let spec = FlatDataSpec::builder()
1188            .column(Column::new(Width::Fixed(10)).align(Align::Center))
1189            .build();
1190        let formatter = TabularFormatter::new(&spec, 80);
1191
1192        let output = formatter.format_row(&["hi"]);
1193        assert_eq!(output, "    hi    ");
1194    }
1195
1196    #[test]
1197    fn format_row_truncate_start() {
1198        let spec = FlatDataSpec::builder()
1199            .column(Column::new(Width::Fixed(10)).truncate(TruncateAt::Start))
1200            .build();
1201        let formatter = TabularFormatter::new(&spec, 80);
1202
1203        let output = formatter.format_row(&["/path/to/file.rs"]);
1204        assert_eq!(display_width(&output), 10);
1205        assert!(output.starts_with("…"));
1206    }
1207
1208    #[test]
1209    fn format_row_truncate_middle() {
1210        let spec = FlatDataSpec::builder()
1211            .column(Column::new(Width::Fixed(10)).truncate(TruncateAt::Middle))
1212            .build();
1213        let formatter = TabularFormatter::new(&spec, 80);
1214
1215        let output = formatter.format_row(&["abcdefghijklmno"]);
1216        assert_eq!(display_width(&output), 10);
1217        assert!(output.contains("…"));
1218    }
1219
1220    #[test]
1221    fn format_row_with_null() {
1222        let spec = FlatDataSpec::builder()
1223            .column(Column::new(Width::Fixed(10)))
1224            .column(Column::new(Width::Fixed(8)).null_repr("N/A"))
1225            .separator("  ")
1226            .build();
1227        let formatter = TabularFormatter::new(&spec, 80);
1228
1229        // Only provide first value - second uses null_repr
1230        let output = formatter.format_row(&["value"]);
1231        assert!(output.contains("N/A"));
1232    }
1233
1234    #[test]
1235    fn format_row_with_decorations() {
1236        let spec = FlatDataSpec::builder()
1237            .column(Column::new(Width::Fixed(10)))
1238            .column(Column::new(Width::Fixed(8)))
1239            .separator(" │ ")
1240            .prefix("│ ")
1241            .suffix(" │")
1242            .build();
1243        let formatter = TabularFormatter::new(&spec, 80);
1244
1245        let output = formatter.format_row(&["Hello", "World"]);
1246        assert!(output.starts_with("│ "));
1247        assert!(output.ends_with(" │"));
1248        assert!(output.contains(" │ "));
1249    }
1250
1251    #[test]
1252    fn format_multiple_rows() {
1253        let formatter = TabularFormatter::new(&simple_spec(), 80);
1254        let rows = vec![vec!["a", "1"], vec!["b", "2"], vec!["c", "3"]];
1255
1256        let output = formatter.format_rows(&rows);
1257        assert_eq!(output.len(), 3);
1258    }
1259
1260    #[test]
1261    fn format_row_fill_column() {
1262        let spec = FlatDataSpec::builder()
1263            .column(Column::new(Width::Fixed(5)))
1264            .column(Column::new(Width::Fill))
1265            .column(Column::new(Width::Fixed(5)))
1266            .separator("  ")
1267            .build();
1268
1269        // Total: 30, overhead: 4 (2 separators), fixed: 10, fill: 16
1270        let formatter = TabularFormatter::new(&spec, 30);
1271        let _output = formatter.format_row(&["abc", "middle", "xyz"]);
1272
1273        // Check that widths are as expected
1274        assert_eq!(formatter.widths(), &[5, 16, 5]);
1275    }
1276
1277    #[test]
1278    fn formatter_accessors() {
1279        let spec = FlatDataSpec::builder()
1280            .column(Column::new(Width::Fixed(10)))
1281            .column(Column::new(Width::Fixed(8)))
1282            .build();
1283        let formatter = TabularFormatter::new(&spec, 80);
1284
1285        assert_eq!(formatter.num_columns(), 2);
1286        assert_eq!(formatter.column_width(0), Some(10));
1287        assert_eq!(formatter.column_width(1), Some(8));
1288        assert_eq!(formatter.column_width(2), None);
1289    }
1290
1291    #[test]
1292    fn format_empty_spec() {
1293        let spec = FlatDataSpec::builder().build();
1294        let formatter = TabularFormatter::new(&spec, 80);
1295
1296        let output = formatter.format_row::<&str>(&[]);
1297        assert_eq!(output, "");
1298    }
1299
1300    #[test]
1301    fn format_with_ansi() {
1302        let spec = FlatDataSpec::builder()
1303            .column(Column::new(Width::Fixed(10)))
1304            .build();
1305        let formatter = TabularFormatter::new(&spec, 80);
1306
1307        let styled = "\x1b[31mred\x1b[0m";
1308        let output = formatter.format_row(&[styled]);
1309
1310        // ANSI codes should be preserved, display width should be 10
1311        assert!(output.contains("\x1b[31m"));
1312        assert_eq!(display_width(&output), 10);
1313    }
1314
1315    #[test]
1316    fn format_with_explicit_widths() {
1317        let columns = vec![Column::new(Width::Fixed(5)), Column::new(Width::Fixed(10))];
1318        let formatter = TabularFormatter::with_widths(columns, vec![5, 10]).separator(" - ");
1319
1320        let output = formatter.format_row(&["hi", "there"]);
1321        assert_eq!(output, "hi    - there     ");
1322    }
1323
1324    // ============================================================================
1325    // Object Trait Tests
1326    // ============================================================================
1327
1328    #[test]
1329    fn object_get_num_columns() {
1330        let formatter = Arc::new(TabularFormatter::new(&simple_spec(), 80));
1331        let value = formatter.get_value(&Value::from("num_columns"));
1332        assert_eq!(value, Some(Value::from(2)));
1333    }
1334
1335    #[test]
1336    fn object_get_widths() {
1337        let spec = TabularSpec::builder()
1338            .column(Column::new(Width::Fixed(10)))
1339            .column(Column::new(Width::Fixed(8)))
1340            .build();
1341        let formatter = Arc::new(TabularFormatter::new(&spec, 80));
1342
1343        let value = formatter.get_value(&Value::from("widths"));
1344        assert!(value.is_some());
1345        let widths = value.unwrap();
1346        // Check we can iterate over the widths
1347        assert!(widths.try_iter().is_ok());
1348    }
1349
1350    #[test]
1351    fn object_get_separator() {
1352        let spec = TabularSpec::builder()
1353            .column(Column::new(Width::Fixed(10)))
1354            .separator(" | ")
1355            .build();
1356        let formatter = Arc::new(TabularFormatter::new(&spec, 80));
1357
1358        let value = formatter.get_value(&Value::from("separator"));
1359        assert_eq!(value, Some(Value::from(" | ")));
1360    }
1361
1362    #[test]
1363    fn object_get_unknown_returns_none() {
1364        let formatter = Arc::new(TabularFormatter::new(&simple_spec(), 80));
1365        let value = formatter.get_value(&Value::from("unknown"));
1366        assert_eq!(value, None);
1367    }
1368
1369    #[test]
1370    fn object_row_method_via_template() {
1371        use minijinja::Environment;
1372
1373        let spec = TabularSpec::builder()
1374            .column(Column::new(Width::Fixed(10)))
1375            .column(Column::new(Width::Fixed(8)))
1376            .separator(" | ")
1377            .build();
1378        let formatter = TabularFormatter::new(&spec, 80);
1379
1380        let mut env = Environment::new();
1381        env.add_template("test", "{{ table.row(['Hello', 'World']) }}")
1382            .unwrap();
1383
1384        let tmpl = env.get_template("test").unwrap();
1385        let output = tmpl
1386            .render(minijinja::context! { table => Value::from_object(formatter) })
1387            .unwrap();
1388
1389        assert_eq!(output, "Hello      | World   ");
1390    }
1391
1392    #[test]
1393    fn object_row_method_in_loop() {
1394        use minijinja::Environment;
1395
1396        let spec = TabularSpec::builder()
1397            .column(Column::new(Width::Fixed(8)))
1398            .column(Column::new(Width::Fixed(6)))
1399            .separator("  ")
1400            .build();
1401        let formatter = TabularFormatter::new(&spec, 80);
1402
1403        let mut env = Environment::new();
1404        env.add_template(
1405            "test",
1406            "{% for item in items %}{{ table.row([item.name, item.value]) }}\n{% endfor %}",
1407        )
1408        .unwrap();
1409
1410        let tmpl = env.get_template("test").unwrap();
1411        let output = tmpl
1412            .render(minijinja::context! {
1413                table => Value::from_object(formatter),
1414                items => vec![
1415                    minijinja::context! { name => "Alice", value => "100" },
1416                    minijinja::context! { name => "Bob", value => "200" },
1417                ]
1418            })
1419            .unwrap();
1420
1421        assert!(output.contains("Alice"));
1422        assert!(output.contains("Bob"));
1423    }
1424
1425    #[test]
1426    fn object_column_width_method_via_template() {
1427        use minijinja::Environment;
1428
1429        let spec = TabularSpec::builder()
1430            .column(Column::new(Width::Fixed(10)))
1431            .column(Column::new(Width::Fixed(8)))
1432            .build();
1433        let formatter = TabularFormatter::new(&spec, 80);
1434
1435        let mut env = Environment::new();
1436        env.add_template(
1437            "test",
1438            "{{ table.column_width(0) }}-{{ table.column_width(1) }}",
1439        )
1440        .unwrap();
1441
1442        let tmpl = env.get_template("test").unwrap();
1443        let output = tmpl
1444            .render(minijinja::context! { table => Value::from_object(formatter) })
1445            .unwrap();
1446
1447        assert_eq!(output, "10-8");
1448    }
1449
1450    #[test]
1451    fn object_attribute_access_via_template() {
1452        use minijinja::Environment;
1453
1454        let spec = TabularSpec::builder()
1455            .column(Column::new(Width::Fixed(10)))
1456            .column(Column::new(Width::Fixed(8)))
1457            .separator(" | ")
1458            .build();
1459        let formatter = TabularFormatter::new(&spec, 80);
1460
1461        let mut env = Environment::new();
1462        env.add_template(
1463            "test",
1464            "cols={{ table.num_columns }}, sep='{{ table.separator }}'",
1465        )
1466        .unwrap();
1467
1468        let tmpl = env.get_template("test").unwrap();
1469        let output = tmpl
1470            .render(minijinja::context! { table => Value::from_object(formatter) })
1471            .unwrap();
1472
1473        assert_eq!(output, "cols=2, sep=' | '");
1474    }
1475
1476    // ============================================================================
1477    // Overflow Mode Tests (Phase 4)
1478    // ============================================================================
1479
1480    #[test]
1481    fn format_cell_clip_no_marker() {
1482        let spec = FlatDataSpec::builder()
1483            .column(Column::new(Width::Fixed(5)).clip())
1484            .build();
1485        let formatter = TabularFormatter::new(&spec, 80);
1486
1487        let output = formatter.format_row(&["Hello World"]);
1488        // Clip truncates without marker
1489        assert_eq!(display_width(&output), 5);
1490        assert!(!output.contains("…"));
1491        assert!(output.starts_with("Hello"));
1492    }
1493
1494    #[test]
1495    fn format_cell_expand_overflows() {
1496        // Expand mode lets content overflow
1497        let col = Column::new(Width::Fixed(5)).overflow(Overflow::Expand);
1498        let output = format_cell("Hello World", 5, &col);
1499
1500        // Should NOT be truncated
1501        assert_eq!(output, "Hello World");
1502        assert_eq!(display_width(&output), 11); // Full width
1503    }
1504
1505    #[test]
1506    fn format_cell_expand_pads_when_short() {
1507        let col = Column::new(Width::Fixed(10)).overflow(Overflow::Expand);
1508        let output = format_cell("Hi", 10, &col);
1509
1510        // Should be padded to width
1511        assert_eq!(output, "Hi        ");
1512        assert_eq!(display_width(&output), 10);
1513    }
1514
1515    #[test]
1516    fn format_cell_wrap_single_line() {
1517        // Content fits, no wrapping needed
1518        let col = Column::new(Width::Fixed(20)).wrap();
1519        let output = format_cell_lines("Short text", 20, &col);
1520
1521        assert!(output.is_single());
1522        assert_eq!(output.line_count(), 1);
1523        assert_eq!(display_width(&output.to_single()), 20);
1524    }
1525
1526    #[test]
1527    fn format_cell_wrap_multi_line() {
1528        let col = Column::new(Width::Fixed(10)).wrap();
1529        let output = format_cell_lines("This is a longer text that wraps", 10, &col);
1530
1531        assert!(!output.is_single());
1532        assert!(output.line_count() > 1);
1533
1534        // Each line should be padded to width
1535        if let CellOutput::Multi(lines) = &output {
1536            for line in lines {
1537                assert_eq!(display_width(line), 10);
1538            }
1539        }
1540    }
1541
1542    #[test]
1543    fn format_cell_wrap_with_indent() {
1544        let col = Column::new(Width::Fixed(15)).overflow(Overflow::Wrap { indent: 2 });
1545        let output = format_cell_lines("First line then continuation", 15, &col);
1546
1547        if let CellOutput::Multi(lines) = output {
1548            // First line should start normally
1549            assert!(lines[0].starts_with("First"));
1550            // Subsequent lines should be indented
1551            if lines.len() > 1 {
1552                // The line content should start with spaces due to indent
1553                let second_trimmed = lines[1].trim_start();
1554                assert!(lines[1].len() > second_trimmed.len()); // Has leading spaces
1555            }
1556        }
1557    }
1558
1559    #[test]
1560    fn format_row_lines_single_line() {
1561        let spec = FlatDataSpec::builder()
1562            .column(Column::new(Width::Fixed(10)))
1563            .column(Column::new(Width::Fixed(8)))
1564            .separator("  ")
1565            .build();
1566        let formatter = TabularFormatter::new(&spec, 80);
1567
1568        let lines = formatter.format_row_lines(&["Hello", "World"]);
1569        assert_eq!(lines.len(), 1);
1570        assert_eq!(lines[0], formatter.format_row(&["Hello", "World"]));
1571    }
1572
1573    #[test]
1574    fn format_row_lines_multi_line() {
1575        let spec = FlatDataSpec::builder()
1576            .column(Column::new(Width::Fixed(8)).wrap())
1577            .column(Column::new(Width::Fixed(6)))
1578            .separator("  ")
1579            .build();
1580        let formatter = TabularFormatter::new(&spec, 80);
1581
1582        let lines = formatter.format_row_lines(&["This is long", "Short"]);
1583
1584        // Should have multiple lines due to wrapping
1585        assert!(!lines.is_empty());
1586
1587        // Each line should have consistent width
1588        let expected_width = display_width(&lines[0]);
1589        for line in &lines {
1590            assert_eq!(display_width(line), expected_width);
1591        }
1592    }
1593
1594    #[test]
1595    fn format_row_lines_mixed_columns() {
1596        // One column wraps, others don't
1597        let spec = FlatDataSpec::builder()
1598            .column(Column::new(Width::Fixed(6))) // truncates
1599            .column(Column::new(Width::Fixed(10)).wrap()) // wraps
1600            .column(Column::new(Width::Fixed(4))) // truncates
1601            .separator(" ")
1602            .build();
1603        let formatter = TabularFormatter::new(&spec, 80);
1604
1605        let lines = formatter.format_row_lines(&["aaaaa", "this text wraps here", "bbbb"]);
1606
1607        // Multiple lines due to middle column wrapping
1608        assert!(!lines.is_empty());
1609    }
1610
1611    // ============================================================================
1612    // CellOutput Tests
1613    // ============================================================================
1614
1615    #[test]
1616    fn cell_output_single_accessors() {
1617        let cell = CellOutput::Single("Hello".to_string());
1618
1619        assert!(cell.is_single());
1620        assert_eq!(cell.line_count(), 1);
1621        assert_eq!(cell.to_single(), "Hello");
1622    }
1623
1624    #[test]
1625    fn cell_output_multi_accessors() {
1626        let cell = CellOutput::Multi(vec!["Line 1".to_string(), "Line 2".to_string()]);
1627
1628        assert!(!cell.is_single());
1629        assert_eq!(cell.line_count(), 2);
1630        assert_eq!(cell.to_single(), "Line 1");
1631    }
1632
1633    #[test]
1634    fn cell_output_line_accessor() {
1635        let cell = CellOutput::Multi(vec!["First".to_string(), "Second".to_string()]);
1636
1637        // Get first line, padded to 10
1638        let line0 = cell.line(0, 10, Align::Left);
1639        assert_eq!(line0, "First     ");
1640        assert_eq!(display_width(&line0), 10);
1641
1642        // Get second line
1643        let line1 = cell.line(1, 10, Align::Right);
1644        assert_eq!(line1, "    Second");
1645
1646        // Out of bounds returns empty padded
1647        let line2 = cell.line(2, 10, Align::Left);
1648        assert_eq!(line2, "          ");
1649    }
1650
1651    // ============================================================================
1652    // Anchor Tests (Phase 5)
1653    // ============================================================================
1654
1655    #[test]
1656    fn format_row_all_left_anchor_no_gap() {
1657        // All columns left-anchored - no gap inserted
1658        let spec = FlatDataSpec::builder()
1659            .column(Column::new(Width::Fixed(5)))
1660            .column(Column::new(Width::Fixed(5)))
1661            .separator(" ")
1662            .build();
1663        let formatter = TabularFormatter::new(&spec, 50);
1664
1665        let output = formatter.format_row(&["A", "B"]);
1666        // Total content: 5 + 1 + 5 = 11, no gap
1667        assert_eq!(output, "A     B    ");
1668        assert_eq!(display_width(&output), 11);
1669    }
1670
1671    #[test]
1672    fn format_row_with_right_anchor() {
1673        // Left column + right column with gap
1674        let spec = FlatDataSpec::builder()
1675            .column(Column::new(Width::Fixed(5))) // left-anchored
1676            .column(Column::new(Width::Fixed(5)).anchor_right()) // right-anchored
1677            .separator(" ")
1678            .build();
1679
1680        // Total: 30, content: 5 + 5 = 10, sep: 1, overhead: 11
1681        // Gap: 30 - 11 + 1 = 20 (replaces separator)
1682        let formatter = TabularFormatter::new(&spec, 30);
1683
1684        let output = formatter.format_row(&["L", "R"]);
1685        assert_eq!(display_width(&output), 30);
1686        // Left content at start, right content at end
1687        assert!(output.starts_with("L    "));
1688        assert!(output.ends_with("R    "));
1689    }
1690
1691    #[test]
1692    fn format_row_with_right_anchor_exact_fit() {
1693        // When total_width equals content width, no gap
1694        let spec = FlatDataSpec::builder()
1695            .column(Column::new(Width::Fixed(10)))
1696            .column(Column::new(Width::Fixed(10)).anchor_right())
1697            .separator("  ")
1698            .build();
1699
1700        // Total: 22 (10 + 2 + 10), no extra space
1701        let formatter = TabularFormatter::new(&spec, 22);
1702
1703        let output = formatter.format_row(&["Left", "Right"]);
1704        assert_eq!(display_width(&output), 22);
1705        // Normal separator, no gap
1706        assert!(output.contains("  ")); // Original separator preserved
1707    }
1708
1709    #[test]
1710    fn format_row_all_right_anchor_no_gap() {
1711        // All columns right-anchored - no gap needed
1712        let spec = FlatDataSpec::builder()
1713            .column(Column::new(Width::Fixed(5)).anchor_right())
1714            .column(Column::new(Width::Fixed(5)).anchor_right())
1715            .separator(" ")
1716            .build();
1717        let formatter = TabularFormatter::new(&spec, 50);
1718
1719        let output = formatter.format_row(&["A", "B"]);
1720        // No transition from left to right, so no gap
1721        assert_eq!(output, "A     B    ");
1722    }
1723
1724    #[test]
1725    fn format_row_multiple_anchors() {
1726        // Two left, two right
1727        let spec = FlatDataSpec::builder()
1728            .column(Column::new(Width::Fixed(4))) // L1
1729            .column(Column::new(Width::Fixed(4))) // L2
1730            .column(Column::new(Width::Fixed(4)).anchor_right()) // R1
1731            .column(Column::new(Width::Fixed(4)).anchor_right()) // R2
1732            .separator(" ")
1733            .build();
1734
1735        // Content: 4*4 = 16, seps: 3, overhead: 19
1736        // Total: 40, gap: 40 - 19 + 1 = 22
1737        let formatter = TabularFormatter::new(&spec, 40);
1738
1739        let output = formatter.format_row(&["A", "B", "C", "D"]);
1740        assert_eq!(display_width(&output), 40);
1741        // Left group at start, right group at end
1742        assert!(output.starts_with("A    B   "));
1743    }
1744
1745    #[test]
1746    fn calculate_anchor_gap_no_transition() {
1747        let spec = FlatDataSpec::builder()
1748            .column(Column::new(Width::Fixed(10)))
1749            .column(Column::new(Width::Fixed(10)))
1750            .build();
1751        let formatter = TabularFormatter::new(&spec, 50);
1752
1753        let (gap, transition) = formatter.calculate_anchor_gap();
1754        assert_eq!(transition, 2); // No right-anchored columns
1755        assert_eq!(gap, 0);
1756    }
1757
1758    #[test]
1759    fn calculate_anchor_gap_with_transition() {
1760        let spec = FlatDataSpec::builder()
1761            .column(Column::new(Width::Fixed(10)))
1762            .column(Column::new(Width::Fixed(10)).anchor_right())
1763            .separator(" ")
1764            .build();
1765        let formatter = TabularFormatter::new(&spec, 50);
1766
1767        let (gap, transition) = formatter.calculate_anchor_gap();
1768        assert_eq!(transition, 1); // Second column is right-anchored
1769        assert!(gap > 0);
1770    }
1771
1772    #[test]
1773    fn format_row_lines_with_anchor() {
1774        // Multi-line output should also respect anchors
1775        let spec = FlatDataSpec::builder()
1776            .column(Column::new(Width::Fixed(8)).wrap())
1777            .column(Column::new(Width::Fixed(6)).anchor_right())
1778            .separator(" ")
1779            .build();
1780        let formatter = TabularFormatter::new(&spec, 40);
1781
1782        let lines = formatter.format_row_lines(&["This is text", "Right"]);
1783
1784        // All lines should have consistent width due to anchor
1785        for line in &lines {
1786            assert_eq!(display_width(line), 40);
1787        }
1788    }
1789
1790    // ============================================================================
1791    // Struct Extraction Tests (Phase 6)
1792    // ============================================================================
1793
1794    #[test]
1795    fn row_from_simple_struct() {
1796        #[derive(Serialize)]
1797        struct Record {
1798            name: String,
1799            value: i32,
1800        }
1801
1802        let spec = FlatDataSpec::builder()
1803            .column(Column::new(Width::Fixed(10)).key("name"))
1804            .column(Column::new(Width::Fixed(5)).key("value"))
1805            .separator("  ")
1806            .build();
1807        let formatter = TabularFormatter::new(&spec, 80);
1808
1809        let record = Record {
1810            name: "Test".to_string(),
1811            value: 42,
1812        };
1813
1814        let row = formatter.row_from(&record);
1815        assert!(row.contains("Test"));
1816        assert!(row.contains("42"));
1817    }
1818
1819    #[test]
1820    fn row_from_uses_name_as_fallback() {
1821        #[derive(Serialize)]
1822        struct Item {
1823            title: String,
1824        }
1825
1826        let spec = FlatDataSpec::builder()
1827            .column(Column::new(Width::Fixed(15)).named("title"))
1828            .build();
1829        let formatter = TabularFormatter::new(&spec, 80);
1830
1831        let item = Item {
1832            title: "Hello".to_string(),
1833        };
1834
1835        let row = formatter.row_from(&item);
1836        assert!(row.contains("Hello"));
1837    }
1838
1839    #[test]
1840    fn row_from_nested_field() {
1841        #[derive(Serialize)]
1842        struct User {
1843            email: String,
1844        }
1845
1846        #[derive(Serialize)]
1847        struct Record {
1848            user: User,
1849            status: String,
1850        }
1851
1852        let spec = FlatDataSpec::builder()
1853            .column(Column::new(Width::Fixed(20)).key("user.email"))
1854            .column(Column::new(Width::Fixed(10)).key("status"))
1855            .separator("  ")
1856            .build();
1857        let formatter = TabularFormatter::new(&spec, 80);
1858
1859        let record = Record {
1860            user: User {
1861                email: "test@example.com".to_string(),
1862            },
1863            status: "active".to_string(),
1864        };
1865
1866        let row = formatter.row_from(&record);
1867        assert!(row.contains("test@example.com"));
1868        assert!(row.contains("active"));
1869    }
1870
1871    #[test]
1872    fn row_from_array_index() {
1873        #[derive(Serialize)]
1874        struct Record {
1875            items: Vec<String>,
1876        }
1877
1878        let spec = FlatDataSpec::builder()
1879            .column(Column::new(Width::Fixed(10)).key("items.0"))
1880            .column(Column::new(Width::Fixed(10)).key("items.1"))
1881            .build();
1882        let formatter = TabularFormatter::new(&spec, 80);
1883
1884        let record = Record {
1885            items: vec!["First".to_string(), "Second".to_string()],
1886        };
1887
1888        let row = formatter.row_from(&record);
1889        assert!(row.contains("First"));
1890        assert!(row.contains("Second"));
1891    }
1892
1893    #[test]
1894    fn row_from_missing_field_uses_null_repr() {
1895        #[derive(Serialize)]
1896        struct Record {
1897            present: String,
1898        }
1899
1900        let spec = FlatDataSpec::builder()
1901            .column(Column::new(Width::Fixed(10)).key("present"))
1902            .column(Column::new(Width::Fixed(10)).key("missing").null_repr("-"))
1903            .build();
1904        let formatter = TabularFormatter::new(&spec, 80);
1905
1906        let record = Record {
1907            present: "value".to_string(),
1908        };
1909
1910        let row = formatter.row_from(&record);
1911        assert!(row.contains("value"));
1912        // Missing field should show empty (extract_field returns empty string)
1913    }
1914
1915    #[test]
1916    fn row_from_no_key_uses_null_repr() {
1917        #[derive(Serialize)]
1918        struct Record {
1919            value: String,
1920        }
1921
1922        let spec = FlatDataSpec::builder()
1923            .column(Column::new(Width::Fixed(10)).null_repr("N/A"))
1924            .build();
1925        let formatter = TabularFormatter::new(&spec, 80);
1926
1927        let record = Record {
1928            value: "test".to_string(),
1929        };
1930
1931        let row = formatter.row_from(&record);
1932        assert!(row.contains("N/A"));
1933    }
1934
1935    #[test]
1936    fn row_from_various_types() {
1937        #[derive(Serialize)]
1938        struct Record {
1939            string_val: String,
1940            int_val: i64,
1941            float_val: f64,
1942            bool_val: bool,
1943        }
1944
1945        let spec = FlatDataSpec::builder()
1946            .column(Column::new(Width::Fixed(10)).key("string_val"))
1947            .column(Column::new(Width::Fixed(10)).key("int_val"))
1948            .column(Column::new(Width::Fixed(10)).key("float_val"))
1949            .column(Column::new(Width::Fixed(10)).key("bool_val"))
1950            .build();
1951        let formatter = TabularFormatter::new(&spec, 80);
1952
1953        let record = Record {
1954            string_val: "text".to_string(),
1955            int_val: 123,
1956            float_val: 9.87,
1957            bool_val: true,
1958        };
1959
1960        let row = formatter.row_from(&record);
1961        assert!(row.contains("text"));
1962        assert!(row.contains("123"));
1963        assert!(row.contains("9.87"));
1964        assert!(row.contains("true"));
1965    }
1966
1967    #[test]
1968    fn extract_field_simple() {
1969        let json = serde_json::json!({
1970            "name": "Alice",
1971            "age": 30
1972        });
1973
1974        assert_eq!(extract_field(&json, "name"), "Alice");
1975        assert_eq!(extract_field(&json, "age"), "30");
1976        assert_eq!(extract_field(&json, "missing"), "");
1977    }
1978
1979    #[test]
1980    fn extract_field_nested() {
1981        let json = serde_json::json!({
1982            "user": {
1983                "profile": {
1984                    "email": "test@example.com"
1985                }
1986            }
1987        });
1988
1989        assert_eq!(
1990            extract_field(&json, "user.profile.email"),
1991            "test@example.com"
1992        );
1993        assert_eq!(extract_field(&json, "user.missing"), "");
1994    }
1995
1996    #[test]
1997    fn extract_field_array() {
1998        let json = serde_json::json!({
1999            "items": ["a", "b", "c"]
2000        });
2001
2002        assert_eq!(extract_field(&json, "items.0"), "a");
2003        assert_eq!(extract_field(&json, "items.1"), "b");
2004        assert_eq!(extract_field(&json, "items.10"), ""); // Out of bounds
2005    }
2006
2007    #[test]
2008    fn row_lines_from_struct() {
2009        #[derive(Serialize)]
2010        struct Record {
2011            description: String,
2012            status: String,
2013        }
2014
2015        let spec = FlatDataSpec::builder()
2016            .column(Column::new(Width::Fixed(10)).key("description").wrap())
2017            .column(Column::new(Width::Fixed(6)).key("status"))
2018            .separator("  ")
2019            .build();
2020        let formatter = TabularFormatter::new(&spec, 80);
2021
2022        let record = Record {
2023            description: "A longer description that wraps".to_string(),
2024            status: "OK".to_string(),
2025        };
2026
2027        let lines = formatter.row_lines_from(&record);
2028        // Should have multiple lines due to wrapping
2029        assert!(!lines.is_empty());
2030    }
2031
2032    // ============================================================================
2033    // Style Tests (Phase 7)
2034    // ============================================================================
2035
2036    #[test]
2037    fn format_cell_with_style() {
2038        let spec = FlatDataSpec::builder()
2039            .column(Column::new(Width::Fixed(10)).style("header"))
2040            .build();
2041        let formatter = TabularFormatter::new(&spec, 80);
2042
2043        let output = formatter.format_row(&["Hello"]);
2044        // Should wrap in style tags
2045        assert!(output.starts_with("[header]"));
2046        assert!(output.ends_with("[/header]"));
2047        assert!(output.contains("Hello"));
2048    }
2049
2050    #[test]
2051    fn format_cell_style_from_value() {
2052        let spec = FlatDataSpec::builder()
2053            .column(Column::new(Width::Fixed(10)).style_from_value())
2054            .build();
2055        let formatter = TabularFormatter::new(&spec, 80);
2056
2057        let output = formatter.format_row(&["error"]);
2058        // Value "error" becomes the style
2059        assert!(output.contains("[error]"));
2060        assert!(output.contains("[/error]"));
2061    }
2062
2063    #[test]
2064    fn format_cell_no_style() {
2065        let spec = FlatDataSpec::builder()
2066            .column(Column::new(Width::Fixed(10)))
2067            .build();
2068        let formatter = TabularFormatter::new(&spec, 80);
2069
2070        let output = formatter.format_row(&["Hello"]);
2071        // No style tags
2072        assert!(!output.contains("["));
2073        assert!(!output.contains("]"));
2074        assert!(output.contains("Hello"));
2075    }
2076
2077    #[test]
2078    fn format_cell_style_overrides_style_from_value() {
2079        // When both style and style_from_value are set, style_from_value wins
2080        let mut col = Column::new(Width::Fixed(10));
2081        col.style = Some("default".to_string());
2082        col.style_from_value = true;
2083
2084        let spec = FlatDataSpec::builder().column(col).build();
2085        let formatter = TabularFormatter::new(&spec, 80);
2086
2087        let output = formatter.format_row(&["custom"]);
2088        // style_from_value takes precedence
2089        assert!(output.contains("[custom]"));
2090        assert!(output.contains("[/custom]"));
2091    }
2092
2093    #[test]
2094    fn format_row_multiple_styled_columns() {
2095        let spec = FlatDataSpec::builder()
2096            .column(Column::new(Width::Fixed(8)).style("name"))
2097            .column(Column::new(Width::Fixed(8)).style("status"))
2098            .separator("  ")
2099            .build();
2100        let formatter = TabularFormatter::new(&spec, 80);
2101
2102        let output = formatter.format_row(&["Alice", "Active"]);
2103        assert!(output.contains("[name]"));
2104        assert!(output.contains("[status]"));
2105    }
2106
2107    #[test]
2108    fn format_cell_lines_with_style() {
2109        let spec = FlatDataSpec::builder()
2110            .column(Column::new(Width::Fixed(10)).wrap().style("text"))
2111            .build();
2112        let formatter = TabularFormatter::new(&spec, 80);
2113
2114        let lines = formatter.format_row_lines(&["This is a long text that wraps"]);
2115
2116        // Each line should have style tags
2117        for line in &lines {
2118            assert!(line.contains("[text]"));
2119            assert!(line.contains("[/text]"));
2120        }
2121    }
2122
2123    // ============================================================================
2124    // Extract Headers Tests
2125    // ============================================================================
2126
2127    #[test]
2128    fn extract_headers_from_header_field() {
2129        let spec = FlatDataSpec::builder()
2130            .column(Column::new(Width::Fixed(10)).header("Name"))
2131            .column(Column::new(Width::Fixed(8)).header("Status"))
2132            .build();
2133        let formatter = TabularFormatter::new(&spec, 80);
2134
2135        let headers = formatter.extract_headers();
2136        assert_eq!(headers, vec!["Name", "Status"]);
2137    }
2138
2139    #[test]
2140    fn extract_headers_fallback_to_key() {
2141        let spec = FlatDataSpec::builder()
2142            .column(Column::new(Width::Fixed(10)).key("user_name"))
2143            .column(Column::new(Width::Fixed(8)).key("status"))
2144            .build();
2145        let formatter = TabularFormatter::new(&spec, 80);
2146
2147        let headers = formatter.extract_headers();
2148        assert_eq!(headers, vec!["user_name", "status"]);
2149    }
2150
2151    #[test]
2152    fn extract_headers_fallback_to_name() {
2153        let spec = FlatDataSpec::builder()
2154            .column(Column::new(Width::Fixed(10)).named("col1"))
2155            .column(Column::new(Width::Fixed(8)).named("col2"))
2156            .build();
2157        let formatter = TabularFormatter::new(&spec, 80);
2158
2159        let headers = formatter.extract_headers();
2160        assert_eq!(headers, vec!["col1", "col2"]);
2161    }
2162
2163    #[test]
2164    fn extract_headers_priority_order() {
2165        // header > key > name > ""
2166        let spec = FlatDataSpec::builder()
2167            .column(
2168                Column::new(Width::Fixed(10))
2169                    .header("Header")
2170                    .key("key")
2171                    .named("name"),
2172            )
2173            .column(
2174                Column::new(Width::Fixed(10))
2175                    .key("key_only")
2176                    .named("name_only"),
2177            )
2178            .column(Column::new(Width::Fixed(10)).named("name_only"))
2179            .column(Column::new(Width::Fixed(10))) // No header, key, or name
2180            .build();
2181        let formatter = TabularFormatter::new(&spec, 80);
2182
2183        let headers = formatter.extract_headers();
2184        assert_eq!(headers, vec!["Header", "key_only", "name_only", ""]);
2185    }
2186
2187    #[test]
2188    fn extract_headers_empty_spec() {
2189        let spec = FlatDataSpec::builder().build();
2190        let formatter = TabularFormatter::new(&spec, 80);
2191
2192        let headers = formatter.extract_headers();
2193        assert!(headers.is_empty());
2194    }
2195
2196    // ============================================================================
2197    // Sub-Column Tests
2198    // ============================================================================
2199
2200    use crate::tabular::{SubCol, SubColumns};
2201
2202    fn padz_spec() -> (FlatDataSpec, SubColumns) {
2203        let sub_cols =
2204            SubColumns::new(vec![SubCol::fill(), SubCol::bounded(0, 20).right()], " ").unwrap();
2205
2206        let spec = FlatDataSpec::builder()
2207            .column(Column::new(Width::Fixed(4)))
2208            .column(Column::new(Width::Fill).sub_columns(sub_cols.clone()))
2209            .column(Column::new(Width::Fixed(6)).right())
2210            .separator("  ")
2211            .build();
2212
2213        (spec, sub_cols)
2214    }
2215
2216    #[test]
2217    fn sub_column_basic_title_and_tag() {
2218        let (spec, _) = padz_spec();
2219        let formatter = TabularFormatter::new(&spec, 60);
2220
2221        let row = formatter.format_row_cells(&[
2222            CellValue::Single("1."),
2223            CellValue::Sub(vec!["Gallery Navigation", "[feature]"]),
2224            CellValue::Single("4d"),
2225        ]);
2226
2227        assert!(row.contains("Gallery Navigation"));
2228        assert!(row.contains("[feature]"));
2229        assert!(row.contains("1."));
2230        assert!(row.contains("4d"));
2231        assert_eq!(display_width(&row), 60);
2232    }
2233
2234    #[test]
2235    fn sub_column_tag_absent() {
2236        let (spec, _) = padz_spec();
2237        let formatter = TabularFormatter::new(&spec, 60);
2238
2239        let row = formatter.format_row_cells(&[
2240            CellValue::Single("3."),
2241            CellValue::Sub(vec!["Fixing Layout of Image Nav", ""]),
2242            CellValue::Single("4d"),
2243        ]);
2244
2245        assert!(row.contains("Fixing Layout of Image Nav"));
2246        // With empty tag, title should get all the space
2247        assert_eq!(display_width(&row), 60);
2248    }
2249
2250    #[test]
2251    fn sub_column_grower_gets_remaining_space() {
2252        let sub_cols = SubColumns::new(vec![SubCol::fill(), SubCol::fixed(10)], "  ").unwrap();
2253
2254        let widths = resolve_sub_widths(&sub_cols, &["title", "fixed"], 50);
2255        // fixed=10, sep=2, grower=50-10-2=38
2256        assert_eq!(widths[0], 38);
2257        assert_eq!(widths[1], 10);
2258    }
2259
2260    #[test]
2261    fn sub_column_non_grower_respects_fixed() {
2262        let sub_cols = SubColumns::new(vec![SubCol::fill(), SubCol::fixed(15)], " ").unwrap();
2263
2264        let widths = resolve_sub_widths(&sub_cols, &["x", "y"], 40);
2265        assert_eq!(widths[1], 15); // Always exact for Fixed
2266        assert_eq!(widths[0], 24); // 40 - 15 - 1
2267    }
2268
2269    #[test]
2270    fn sub_column_non_grower_respects_bounded() {
2271        let sub_cols = SubColumns::new(vec![SubCol::fill(), SubCol::bounded(5, 20)], " ").unwrap();
2272
2273        // Content "short" is 5 chars, clamped to [5, 20] = 5
2274        let widths = resolve_sub_widths(&sub_cols, &["title", "short"], 40);
2275        assert_eq!(widths[1], 5);
2276        assert_eq!(widths[0], 34); // 40 - 5 - 1
2277
2278        // Content "a very long tag value" is 21 chars, clamped to 20
2279        let widths2 = resolve_sub_widths(&sub_cols, &["title", "a very long tag value!"], 40);
2280        assert_eq!(widths2[1], 20);
2281        assert_eq!(widths2[0], 19); // 40 - 20 - 1
2282
2283        // Content "" is 0 chars, clamped to min=5
2284        let widths3 = resolve_sub_widths(&sub_cols, &["title", ""], 40);
2285        assert_eq!(widths3[1], 5);
2286    }
2287
2288    #[test]
2289    fn sub_column_bounded_min_zero() {
2290        let sub_cols = SubColumns::new(vec![SubCol::fill(), SubCol::bounded(0, 20)], " ").unwrap();
2291
2292        // Empty content, min=0 means the sub-column gets 0 width
2293        let widths = resolve_sub_widths(&sub_cols, &["title", ""], 40);
2294        assert_eq!(widths[1], 0);
2295        // With zero-width non-grower, grower gets all space
2296        // visible count = 0 non-zero non-grower + 1 grower = 1, seps = 0
2297        assert_eq!(widths[0], 40);
2298    }
2299
2300    #[test]
2301    fn sub_column_separator_skipped_for_zero_width() {
2302        let sub_cols = SubColumns::new(vec![SubCol::fill(), SubCol::bounded(0, 20)], "  ").unwrap();
2303
2304        // With non-zero tag
2305        let result1 = format_sub_cells(&sub_cols, &["Title", "tag"], 30);
2306        assert!(result1.contains("  ")); // Separator present
2307        assert_eq!(display_width(&result1), 30);
2308
2309        // With zero-width tag
2310        let result2 = format_sub_cells(&sub_cols, &["Title", ""], 30);
2311        assert_eq!(display_width(&result2), 30);
2312        // No wasted separator space
2313    }
2314
2315    #[test]
2316    fn sub_column_alignment() {
2317        let sub_cols = SubColumns::new(
2318            vec![
2319                SubCol::fill(), // left-aligned by default
2320                SubCol::fixed(10).right(),
2321            ],
2322            " ",
2323        )
2324        .unwrap();
2325
2326        let result = format_sub_cells(&sub_cols, &["Left", "Right"], 30);
2327        // "Left" should be left-aligned (padded right)
2328        assert!(result.starts_with("Left"));
2329        // "Right" should be right-aligned (padded left)
2330        assert!(result.ends_with("     Right"));
2331        assert_eq!(display_width(&result), 30);
2332    }
2333
2334    #[test]
2335    fn sub_column_grower_truncation() {
2336        let sub_cols = SubColumns::new(vec![SubCol::fill(), SubCol::fixed(15)], " ").unwrap();
2337
2338        // Parent=25, fixed=15, sep=1, grower=9
2339        // Title "A very long title that exceeds" is 30 chars, gets truncated to 9
2340        let result = format_sub_cells(
2341            &sub_cols,
2342            &["A very long title that exceeds", "fixed-col"],
2343            25,
2344        );
2345        assert_eq!(display_width(&result), 25);
2346        assert!(result.contains("…")); // Truncation marker
2347    }
2348
2349    #[test]
2350    fn sub_column_style_application() {
2351        let sub_cols = SubColumns::new(
2352            vec![SubCol::fill(), SubCol::bounded(0, 20).right().style("tag")],
2353            " ",
2354        )
2355        .unwrap();
2356
2357        let result = format_sub_cells(&sub_cols, &["Title", "feature"], 40);
2358        assert!(result.contains("[tag]"));
2359        assert!(result.contains("[/tag]"));
2360        assert!(result.contains("feature"));
2361    }
2362
2363    #[test]
2364    fn sub_column_grower_zero_width() {
2365        // When non-grower widths eat everything, the fixed col is clamped
2366        // so total still fits: grower(0) + sep(1) + fixed(19) = 20
2367        let sub_cols = SubColumns::new(vec![SubCol::fill(), SubCol::fixed(20)], " ").unwrap();
2368
2369        let widths = resolve_sub_widths(&sub_cols, &["title", "fixed"], 20);
2370        assert_eq!(widths[0], 0); // Grower gets nothing
2371        assert_eq!(widths[1], 19); // Clamped: 20 - 1 sep = 19 available
2372
2373        // Output still has correct width
2374        let result = format_sub_cells(&sub_cols, &["title", "fixed"], 20);
2375        assert_eq!(display_width(&result), 20);
2376    }
2377
2378    #[test]
2379    fn sub_column_all_empty() {
2380        let sub_cols = SubColumns::new(vec![SubCol::fill(), SubCol::bounded(0, 20)], " ").unwrap();
2381
2382        let result = format_sub_cells(&sub_cols, &["", ""], 30);
2383        assert_eq!(display_width(&result), 30);
2384    }
2385
2386    #[test]
2387    fn sub_column_plain_string_fallback() {
2388        // When format_row gets a plain string for a sub-column column,
2389        // it should wrap it as Single and use the grower
2390        let (spec, _) = padz_spec();
2391        let formatter = TabularFormatter::new(&spec, 60);
2392
2393        let row = formatter.format_row(&["1.", "Just a title", "4d"]);
2394        // Should still produce valid output
2395        assert_eq!(display_width(&row), 60);
2396        assert!(row.contains("Just a title"));
2397    }
2398
2399    #[test]
2400    fn sub_column_format_row_cells_api() {
2401        let sub_cols =
2402            SubColumns::new(vec![SubCol::fill(), SubCol::bounded(0, 15).right()], " ").unwrap();
2403
2404        let spec = FlatDataSpec::builder()
2405            .column(Column::new(Width::Fixed(3)))
2406            .column(Column::new(Width::Fill).sub_columns(sub_cols))
2407            .separator("  ")
2408            .build();
2409
2410        let formatter = TabularFormatter::new(&spec, 50);
2411
2412        // Row with tag
2413        let row1 = formatter.format_row_cells(&[
2414            CellValue::Single("1."),
2415            CellValue::Sub(vec!["Title", "[bug]"]),
2416        ]);
2417        assert_eq!(display_width(&row1), 50);
2418        assert!(row1.contains("Title"));
2419        assert!(row1.contains("[bug]"));
2420
2421        // Row without tag
2422        let row2 = formatter.format_row_cells(&[
2423            CellValue::Single("2."),
2424            CellValue::Sub(vec!["Longer Title Here", ""]),
2425        ]);
2426        assert_eq!(display_width(&row2), 50);
2427        assert!(row2.contains("Longer Title Here"));
2428    }
2429
2430    #[test]
2431    fn sub_column_via_template() {
2432        use minijinja::Environment;
2433
2434        let sub_cols =
2435            SubColumns::new(vec![SubCol::fill(), SubCol::bounded(0, 15).right()], " ").unwrap();
2436
2437        let spec = TabularSpec::builder()
2438            .column(Column::new(Width::Fixed(4)))
2439            .column(Column::new(Width::Fill).sub_columns(sub_cols))
2440            .separator("  ")
2441            .build();
2442        let formatter = TabularFormatter::new(&spec, 50);
2443
2444        let mut env = Environment::new();
2445        env.add_template("test", "{{ t.row(['1.', ['My Title', '[tag]']]) }}")
2446            .unwrap();
2447
2448        let tmpl = env.get_template("test").unwrap();
2449        let output = tmpl
2450            .render(minijinja::context! { t => Value::from_object(formatter) })
2451            .unwrap();
2452
2453        assert_eq!(display_width(&output), 50);
2454        assert!(output.contains("My Title"));
2455        assert!(output.contains("[tag]"));
2456    }
2457
2458    #[test]
2459    fn sub_column_multiple_rows_alignment() {
2460        let sub_cols =
2461            SubColumns::new(vec![SubCol::fill(), SubCol::bounded(0, 15).right()], " ").unwrap();
2462
2463        let spec = FlatDataSpec::builder()
2464            .column(Column::new(Width::Fixed(4)))
2465            .column(Column::new(Width::Fill).sub_columns(sub_cols))
2466            .column(Column::new(Width::Fixed(4)).right())
2467            .separator("  ")
2468            .build();
2469
2470        let formatter = TabularFormatter::new(&spec, 60);
2471
2472        let rows = vec![
2473            vec![
2474                CellValue::Single("1."),
2475                CellValue::Sub(vec!["GitHub integration", "[feature]"]),
2476                CellValue::Single("8h"),
2477            ],
2478            vec![
2479                CellValue::Single("2."),
2480                CellValue::Sub(vec!["Bug : Static", "[bug]"]),
2481                CellValue::Single("4d"),
2482            ],
2483            vec![
2484                CellValue::Single("3."),
2485                CellValue::Sub(vec!["Fixing Layout of Image Nav", ""]),
2486                CellValue::Single("4d"),
2487            ],
2488        ];
2489
2490        for (i, row) in rows.iter().enumerate() {
2491            let output = formatter.format_row_cells(row);
2492            assert_eq!(
2493                display_width(&output),
2494                60,
2495                "Row {} has wrong width: '{}'",
2496                i,
2497                output
2498            );
2499        }
2500    }
2501    // ============================================================================
2502    // BBCode-aware width tests (issue #104)
2503    // ============================================================================
2504
2505    #[test]
2506    fn format_value_bbcode_preserves_tags_when_fitting() {
2507        let overflow = Overflow::Truncate {
2508            at: TruncateAt::End,
2509            marker: "…".to_string(),
2510        };
2511        // "hello" is 5 visible chars, column is 10 — tags should be preserved
2512        let result = format_value("[bold]hello[/bold]", 10, Align::Left, &overflow, None);
2513        let stripped = standout_bbparser::strip_tags(&result);
2514        assert_eq!(display_width(&stripped), 10, "visible width should be 10");
2515        assert!(
2516            result.contains("[bold]hello[/bold]"),
2517            "tags should be preserved when content fits"
2518        );
2519    }
2520
2521    #[test]
2522    fn format_value_bbcode_truncation() {
2523        let overflow = Overflow::Truncate {
2524            at: TruncateAt::End,
2525            marker: "…".to_string(),
2526        };
2527        // "[red]hello world[/red]" has 11 visible chars, column is 8
2528        let result = format_value("[red]hello world[/red]", 8, Align::Left, &overflow, None);
2529        let stripped = standout_bbparser::strip_tags(&result);
2530        assert_eq!(
2531            display_width(&stripped),
2532            8,
2533            "truncated output should be exactly 8 visible columns"
2534        );
2535    }
2536
2537    #[test]
2538    fn format_value_bbcode_right_align() {
2539        let overflow = Overflow::Truncate {
2540            at: TruncateAt::End,
2541            marker: "…".to_string(),
2542        };
2543        let result = format_value("[dim]hi[/dim]", 6, Align::Right, &overflow, None);
2544        let stripped = standout_bbparser::strip_tags(&result);
2545        assert_eq!(display_width(&stripped), 6);
2546        // Should have leading spaces then the tagged content
2547        assert!(result.contains("[dim]hi[/dim]"));
2548        assert!(result.starts_with("    "));
2549    }
2550
2551    #[test]
2552    fn format_value_bbcode_with_style() {
2553        let overflow = Overflow::Truncate {
2554            at: TruncateAt::End,
2555            marker: "…".to_string(),
2556        };
2557        // BBCode input + column style applied
2558        let result = format_value("[dim]ok[/dim]", 8, Align::Left, &overflow, Some("green"));
2559        let stripped = standout_bbparser::strip_tags(&result);
2560        assert_eq!(display_width(&stripped), 8);
2561        // Should have outer [green]...[/green] wrapper
2562        assert!(result.starts_with("[green]"));
2563        assert!(result.ends_with("[/green]"));
2564    }
2565
2566    #[test]
2567    fn format_cell_lines_bbcode_wrap() {
2568        let col = Column::new(Width::Fixed(10)).overflow(Overflow::Wrap { indent: 0 });
2569        // "[bold]hello world foo[/bold]" → 15 visible chars, width 10
2570        let result = format_cell_lines("[bold]hello world foo[/bold]", 10, &col);
2571        match result {
2572            CellOutput::Multi(lines) => {
2573                for line in &lines {
2574                    let stripped = standout_bbparser::strip_tags(line);
2575                    assert!(
2576                        display_width(&stripped) <= 10,
2577                        "wrapped line '{}' exceeds column width (visible: {})",
2578                        line,
2579                        display_width(&stripped)
2580                    );
2581                }
2582            }
2583            CellOutput::Single(s) => {
2584                let stripped = standout_bbparser::strip_tags(&s);
2585                assert!(display_width(&stripped) <= 10, "single line should fit");
2586            }
2587        }
2588    }
2589
2590    #[test]
2591    fn format_cell_lines_bbcode_fits_preserves_tags() {
2592        let col = Column::new(Width::Fixed(10)).overflow(Overflow::Wrap { indent: 0 });
2593        // "hi" is 2 visible chars, fits in 10
2594        let result = format_cell_lines("[bold]hi[/bold]", 10, &col);
2595        match result {
2596            CellOutput::Single(s) => {
2597                assert!(
2598                    s.contains("[bold]hi[/bold]"),
2599                    "tags should be preserved when content fits"
2600                );
2601                let stripped = standout_bbparser::strip_tags(&s);
2602                assert_eq!(display_width(&stripped), 10);
2603            }
2604            _ => panic!("expected Single output"),
2605        }
2606    }
2607
2608    #[test]
2609    fn cell_output_line_bbcode_padding() {
2610        // Simulate a styled CellOutput with BBCode
2611        let output = CellOutput::Single("[green]ok[/green]".to_string());
2612        let line = output.line(0, 8, Align::Left);
2613        let stripped = standout_bbparser::strip_tags(&line);
2614        assert_eq!(
2615            display_width(&stripped),
2616            8,
2617            "CellOutput::line should pad to correct visible width"
2618        );
2619    }
2620
2621    #[test]
2622    fn resolve_sub_widths_bbcode() {
2623        use crate::tabular::{SubCol, SubColumns};
2624        let sub_cols =
2625            SubColumns::new(vec![SubCol::fill(), SubCol::bounded(0, 30).right()], " ").unwrap();
2626        // Second value has BBCode — "[tag]" is 5 visible chars
2627        let widths = resolve_sub_widths(&sub_cols, &["Title", "[dim][tag][/dim]"], 30);
2628        // [dim][tag][/dim] visible width is 5, so bounded should resolve to 5
2629        assert_eq!(
2630            widths[1], 5,
2631            "bounded sub-col should use visible width, not raw string length"
2632        );
2633        assert_eq!(
2634            widths[0] + widths[1] + 1, // +1 for separator " "
2635            30,
2636            "widths + separator should equal parent width"
2637        );
2638    }
2639}
2640
2641#[cfg(test)]
2642mod proptests {
2643    use super::*;
2644    use crate::tabular::{SubCol, SubColumns};
2645    use proptest::prelude::*;
2646
2647    proptest! {
2648        #[test]
2649        fn sub_column_output_width_equals_parent(
2650            parent_width in 10usize..100,
2651            title_len in 0usize..50,
2652            tag_len in 0usize..30,
2653            bounded_max in 5usize..30,
2654        ) {
2655            let sub_cols = SubColumns::new(
2656                vec![SubCol::fill(), SubCol::bounded(0, bounded_max)],
2657                " ",
2658            ).unwrap();
2659
2660            let title: String = "x".repeat(title_len);
2661            let tag: String = "y".repeat(tag_len);
2662            let values: Vec<&str> = vec![&title, &tag];
2663
2664            let result = format_sub_cells(&sub_cols, &values, parent_width);
2665            prop_assert_eq!(
2666                display_width(&result),
2667                parent_width,
2668                "sub-cell output must exactly fill parent width. Got '{}' (dw={}), expected {}",
2669                result, display_width(&result), parent_width
2670            );
2671        }
2672
2673        #[test]
2674        fn sub_column_non_grower_respects_bounds(
2675            parent_width in 30usize..100,
2676            min_w in 0usize..10,
2677            max_w_offset in 1usize..20,
2678            content_len in 0usize..40,
2679        ) {
2680            let max_w = min_w + max_w_offset; // ensure max > min
2681            let sub_cols = SubColumns::new(
2682                vec![SubCol::fill(), SubCol::bounded(min_w, max_w)],
2683                " ",
2684            ).unwrap();
2685
2686            let content: String = "z".repeat(content_len);
2687            let values = vec!["title", content.as_str()];
2688            let widths = resolve_sub_widths(&sub_cols, &values, parent_width);
2689
2690            let bounded_width = widths[1];
2691            prop_assert!(
2692                bounded_width >= min_w,
2693                "bounded width {} < min {}", bounded_width, min_w
2694            );
2695            prop_assert!(
2696                bounded_width <= max_w,
2697                "bounded width {} > max {}", bounded_width, max_w
2698            );
2699        }
2700
2701        #[test]
2702        fn sub_column_width_arithmetic(
2703            parent_width in 10usize..100,
2704            fixed_width in 1usize..15,
2705            title_len in 0usize..50,
2706        ) {
2707            let sub_cols = SubColumns::new(
2708                vec![SubCol::fill(), SubCol::fixed(fixed_width)],
2709                "  ",
2710            ).unwrap();
2711
2712            let title: String = "t".repeat(title_len);
2713            let values = vec![title.as_str(), "fixed"];
2714            let widths = resolve_sub_widths(&sub_cols, &values, parent_width);
2715
2716            let sep_width = display_width(&sub_cols.separator);
2717            // Grower is always visible; only zero-width non-growers are skipped.
2718            // For 2 sub-cols with grower at [0], the visible count includes the
2719            // grower plus any non-zero non-grower columns.
2720            let visible_non_growers: usize = if widths[1] > 0 { 1 } else { 0 };
2721            let visible_count: usize = visible_non_growers + 1; // +1 for grower
2722            let sep_overhead = visible_count.saturating_sub(1) * sep_width;
2723            let total: usize = widths.iter().sum::<usize>() + sep_overhead;
2724
2725            prop_assert_eq!(
2726                total, parent_width,
2727                "widths {:?} + sep_overhead {} != parent {}",
2728                widths, sep_overhead, parent_width
2729            );
2730        }
2731
2732        #[test]
2733        fn sub_column_output_three_sub_cols(
2734            parent_width in 20usize..100,
2735            prefix_len in 0usize..20,
2736            tag_len in 0usize..15,
2737        ) {
2738            let sub_cols = SubColumns::new(
2739                vec![
2740                    SubCol::bounded(0, 10),
2741                    SubCol::fill(),
2742                    SubCol::bounded(0, 15).right(),
2743                ],
2744                " ",
2745            ).unwrap();
2746
2747            let prefix: String = "p".repeat(prefix_len);
2748            let tag: String = "t".repeat(tag_len);
2749            let values = vec![prefix.as_str(), "middle content", tag.as_str()];
2750
2751            let result = format_sub_cells(&sub_cols, &values, parent_width);
2752            prop_assert_eq!(
2753                display_width(&result),
2754                parent_width,
2755                "three sub-cols output must fill parent width"
2756            );
2757        }
2758    }
2759}