Skip to main content

standout_render/tabular/
types.rs

1//! Core types for tabular output configuration.
2//!
3//! This module defines the data structures used to specify table layout:
4//! column widths, alignment, truncation strategies, and decorations.
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9/// Text alignment within a column.
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum Align {
13    /// Left-align text (pad on the right).
14    #[default]
15    Left,
16    /// Right-align text (pad on the left).
17    Right,
18    /// Center text (pad on both sides).
19    Center,
20}
21
22/// Position where truncation occurs when content exceeds max width.
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum TruncateAt {
26    /// Truncate at the end, keeping the start visible.
27    /// Example: "Hello World" → "Hello W…"
28    #[default]
29    End,
30    /// Truncate at the start, keeping the end visible.
31    /// Example: "Hello World" → "…o World"
32    Start,
33    /// Truncate in the middle, keeping both start and end visible.
34    /// Example: "Hello World" → "Hel…orld"
35    Middle,
36}
37
38/// How a column handles content that exceeds its width.
39#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
40pub enum Overflow {
41    /// Truncate content with an ellipsis marker.
42    Truncate {
43        /// Where to truncate (start, middle, or end).
44        at: TruncateAt,
45        /// The marker to show when truncation occurs (default: "…").
46        marker: String,
47    },
48    /// Wrap content to multiple lines at word boundaries.
49    Wrap {
50        /// Number of spaces to indent continuation lines (default: 0).
51        indent: usize,
52    },
53    /// Hard cut without any marker.
54    Clip,
55    /// Allow content to overflow (ignore width limit).
56    Expand,
57}
58
59impl Default for Overflow {
60    fn default() -> Self {
61        Overflow::Truncate {
62            at: TruncateAt::End,
63            marker: "…".to_string(),
64        }
65    }
66}
67
68impl Overflow {
69    /// Create a truncate overflow with default marker.
70    pub fn truncate(at: TruncateAt) -> Self {
71        Overflow::Truncate {
72            at,
73            marker: "…".to_string(),
74        }
75    }
76
77    /// Create a truncate overflow with custom marker.
78    pub fn truncate_with_marker(at: TruncateAt, marker: impl Into<String>) -> Self {
79        Overflow::Truncate {
80            at,
81            marker: marker.into(),
82        }
83    }
84
85    /// Create a wrap overflow with no indent.
86    pub fn wrap() -> Self {
87        Overflow::Wrap { indent: 0 }
88    }
89
90    /// Create a wrap overflow with continuation indent.
91    pub fn wrap_with_indent(indent: usize) -> Self {
92        Overflow::Wrap { indent }
93    }
94}
95
96/// Column position anchor on the row.
97#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "lowercase")]
99pub enum Anchor {
100    /// Column flows left-to-right from the start (default).
101    #[default]
102    Left,
103    /// Column is positioned at the right edge.
104    Right,
105}
106
107/// Specifies how a column determines its width.
108#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(try_from = "WidthRaw", into = "WidthRaw")]
110pub enum Width {
111    /// Fixed width in display columns.
112    Fixed(usize),
113    /// Width calculated from content, constrained by optional min/max bounds.
114    Bounded {
115        /// Minimum width (defaults to 0 if not specified).
116        min: Option<usize>,
117        /// Maximum width (unlimited if not specified).
118        max: Option<usize>,
119    },
120    /// Expand to fill all remaining space.
121    /// Multiple Fill columns share remaining space equally.
122    Fill,
123    /// Proportional: takes n parts of the remaining space.
124    /// `Fraction(2)` gets twice the space of `Fraction(1)` or `Fill`.
125    Fraction(usize),
126}
127
128#[derive(Serialize, Deserialize)]
129#[serde(untagged)]
130enum WidthRaw {
131    Fixed(usize),
132    Bounded {
133        #[serde(default)]
134        min: Option<usize>,
135        #[serde(default)]
136        max: Option<usize>,
137    },
138    StringVariant(String),
139}
140
141impl From<Width> for WidthRaw {
142    fn from(width: Width) -> Self {
143        match width {
144            Width::Fixed(w) => WidthRaw::Fixed(w),
145            Width::Bounded { min, max } => WidthRaw::Bounded { min, max },
146            Width::Fill => WidthRaw::StringVariant("fill".to_string()),
147            Width::Fraction(n) => WidthRaw::StringVariant(format!("{}fr", n)),
148        }
149    }
150}
151
152impl TryFrom<WidthRaw> for Width {
153    type Error = String;
154
155    fn try_from(raw: WidthRaw) -> Result<Self, Self::Error> {
156        match raw {
157            WidthRaw::Fixed(w) => Ok(Width::Fixed(w)),
158            WidthRaw::Bounded { min, max } => Ok(Width::Bounded { min, max }),
159            WidthRaw::StringVariant(s) if s == "fill" => Ok(Width::Fill),
160            WidthRaw::StringVariant(s) if s.ends_with("fr") => {
161                let num_str = s.trim_end_matches("fr");
162                num_str
163                    .parse::<usize>()
164                    .map(Width::Fraction)
165                    .map_err(|_| format!("Invalid fraction: '{}'. Expected format like '2fr'.", s))
166            }
167            WidthRaw::StringVariant(s) => Err(format!(
168                "Invalid width string: '{}'. Expected 'fill' or '<n>fr'.",
169                s
170            )),
171        }
172    }
173}
174
175impl Default for Width {
176    fn default() -> Self {
177        Width::Bounded {
178            min: None,
179            max: None,
180        }
181    }
182}
183
184impl Width {
185    /// Create a fixed-width column.
186    pub fn fixed(width: usize) -> Self {
187        Width::Fixed(width)
188    }
189
190    /// Create a bounded-width column with both min and max.
191    pub fn bounded(min: usize, max: usize) -> Self {
192        Width::Bounded {
193            min: Some(min),
194            max: Some(max),
195        }
196    }
197
198    /// Create a column with only a minimum width.
199    pub fn min(min: usize) -> Self {
200        Width::Bounded {
201            min: Some(min),
202            max: None,
203        }
204    }
205
206    /// Create a column with only a maximum width.
207    pub fn max(max: usize) -> Self {
208        Width::Bounded {
209            min: None,
210            max: Some(max),
211        }
212    }
213
214    /// Create a fill column that expands to remaining space.
215    pub fn fill() -> Self {
216        Width::Fill
217    }
218
219    /// Create a fractional width column.
220    /// `Fraction(2)` gets twice the space of `Fraction(1)` or `Fill`.
221    pub fn fraction(n: usize) -> Self {
222        Width::Fraction(n)
223    }
224}
225
226/// Configuration for a single column in a table.
227#[derive(Clone, Debug, Serialize, Deserialize)]
228pub struct Column {
229    /// Optional column name/identifier.
230    pub name: Option<String>,
231    /// How the column determines its width.
232    pub width: Width,
233    /// Text alignment within the column.
234    pub align: Align,
235    /// Column position anchor (left or right edge).
236    pub anchor: Anchor,
237    /// How to handle content that exceeds width.
238    pub overflow: Overflow,
239    /// Representation for null/empty values.
240    pub null_repr: String,
241    /// Optional style name (resolved via theme).
242    pub style: Option<String>,
243    /// When true, use the cell value as the style name.
244    pub style_from_value: bool,
245    /// Optional key for data extraction (supports dot notation for nested fields).
246    pub key: Option<String>,
247    /// Optional header title (for table headers and CSV export).
248    pub header: Option<String>,
249}
250
251impl Default for Column {
252    fn default() -> Self {
253        Column {
254            name: None,
255            width: Width::default(),
256            align: Align::default(),
257            anchor: Anchor::default(),
258            overflow: Overflow::default(),
259            null_repr: "-".to_string(),
260            style: None,
261            style_from_value: false,
262            key: None,
263            header: None,
264        }
265    }
266}
267
268impl Column {
269    /// Create a new column with the specified width.
270    pub fn new(width: Width) -> Self {
271        Column {
272            width,
273            ..Default::default()
274        }
275    }
276
277    /// Create a column builder for fluent construction.
278    pub fn builder() -> ColumnBuilder {
279        ColumnBuilder::default()
280    }
281
282    /// Set the column name/identifier.
283    pub fn named(mut self, name: impl Into<String>) -> Self {
284        self.name = Some(name.into());
285        self
286    }
287
288    /// Set the text alignment.
289    pub fn align(mut self, align: Align) -> Self {
290        self.align = align;
291        self
292    }
293
294    /// Set alignment to right (shorthand for `.align(Align::Right)`).
295    pub fn right(self) -> Self {
296        self.align(Align::Right)
297    }
298
299    /// Set alignment to center (shorthand for `.align(Align::Center)`).
300    pub fn center(self) -> Self {
301        self.align(Align::Center)
302    }
303
304    /// Set the column anchor position.
305    pub fn anchor(mut self, anchor: Anchor) -> Self {
306        self.anchor = anchor;
307        self
308    }
309
310    /// Anchor column to the right edge (shorthand for `.anchor(Anchor::Right)`).
311    pub fn anchor_right(self) -> Self {
312        self.anchor(Anchor::Right)
313    }
314
315    /// Set the overflow behavior.
316    pub fn overflow(mut self, overflow: Overflow) -> Self {
317        self.overflow = overflow;
318        self
319    }
320
321    /// Set overflow to wrap (shorthand for `.overflow(Overflow::wrap())`).
322    pub fn wrap(self) -> Self {
323        self.overflow(Overflow::wrap())
324    }
325
326    /// Set overflow to wrap with indent.
327    pub fn wrap_indent(self, indent: usize) -> Self {
328        self.overflow(Overflow::wrap_with_indent(indent))
329    }
330
331    /// Set overflow to clip (shorthand for `.overflow(Overflow::Clip)`).
332    pub fn clip(self) -> Self {
333        self.overflow(Overflow::Clip)
334    }
335
336    /// Set truncation position (configures Overflow::Truncate).
337    pub fn truncate(mut self, at: TruncateAt) -> Self {
338        self.overflow = match self.overflow {
339            Overflow::Truncate { marker, .. } => Overflow::Truncate { at, marker },
340            _ => Overflow::truncate(at),
341        };
342        self
343    }
344
345    /// Set truncation to middle (shorthand for `.truncate(TruncateAt::Middle)`).
346    pub fn truncate_middle(self) -> Self {
347        self.truncate(TruncateAt::Middle)
348    }
349
350    /// Set truncation to start (shorthand for `.truncate(TruncateAt::Start)`).
351    pub fn truncate_start(self) -> Self {
352        self.truncate(TruncateAt::Start)
353    }
354
355    /// Set the ellipsis/marker for truncation.
356    pub fn ellipsis(mut self, ellipsis: impl Into<String>) -> Self {
357        self.overflow = match self.overflow {
358            Overflow::Truncate { at, .. } => Overflow::Truncate {
359                at,
360                marker: ellipsis.into(),
361            },
362            _ => Overflow::truncate_with_marker(TruncateAt::End, ellipsis),
363        };
364        self
365    }
366
367    /// Set the null/empty value representation.
368    pub fn null_repr(mut self, null_repr: impl Into<String>) -> Self {
369        self.null_repr = null_repr.into();
370        self
371    }
372
373    /// Set the style name for this column.
374    pub fn style(mut self, style: impl Into<String>) -> Self {
375        self.style = Some(style.into());
376        self
377    }
378
379    /// Use the cell value as the style name.
380    ///
381    /// When enabled, the cell content becomes the style tag.
382    /// For example, cell value "error" renders as `[error]error[/error]`.
383    pub fn style_from_value(mut self) -> Self {
384        self.style_from_value = true;
385        self
386    }
387
388    /// Set the data key for this column (e.g. "author.name").
389    pub fn key(mut self, key: impl Into<String>) -> Self {
390        self.key = Some(key.into());
391        self
392    }
393
394    /// Set the header title for this column.
395    pub fn header(mut self, header: impl Into<String>) -> Self {
396        self.header = Some(header.into());
397        self
398    }
399}
400
401/// Builder for constructing `Column` instances.
402#[derive(Clone, Debug, Default)]
403pub struct ColumnBuilder {
404    name: Option<String>,
405    width: Option<Width>,
406    align: Option<Align>,
407    anchor: Option<Anchor>,
408    overflow: Option<Overflow>,
409    null_repr: Option<String>,
410    style: Option<String>,
411    style_from_value: bool,
412    key: Option<String>,
413    header: Option<String>,
414}
415
416impl ColumnBuilder {
417    /// Set the column name/identifier.
418    pub fn named(mut self, name: impl Into<String>) -> Self {
419        self.name = Some(name.into());
420        self
421    }
422
423    /// Set the width strategy.
424    pub fn width(mut self, width: Width) -> Self {
425        self.width = Some(width);
426        self
427    }
428
429    /// Set a fixed width.
430    pub fn fixed(mut self, width: usize) -> Self {
431        self.width = Some(Width::Fixed(width));
432        self
433    }
434
435    /// Set the column to fill remaining space.
436    pub fn fill(mut self) -> Self {
437        self.width = Some(Width::Fill);
438        self
439    }
440
441    /// Set bounded width with min and max.
442    pub fn bounded(mut self, min: usize, max: usize) -> Self {
443        self.width = Some(Width::bounded(min, max));
444        self
445    }
446
447    /// Set fractional width.
448    pub fn fraction(mut self, n: usize) -> Self {
449        self.width = Some(Width::Fraction(n));
450        self
451    }
452
453    /// Set the text alignment.
454    pub fn align(mut self, align: Align) -> Self {
455        self.align = Some(align);
456        self
457    }
458
459    /// Set alignment to right.
460    pub fn right(self) -> Self {
461        self.align(Align::Right)
462    }
463
464    /// Set alignment to center.
465    pub fn center(self) -> Self {
466        self.align(Align::Center)
467    }
468
469    /// Set the column anchor position.
470    pub fn anchor(mut self, anchor: Anchor) -> Self {
471        self.anchor = Some(anchor);
472        self
473    }
474
475    /// Anchor column to the right edge.
476    pub fn anchor_right(self) -> Self {
477        self.anchor(Anchor::Right)
478    }
479
480    /// Set the overflow behavior.
481    pub fn overflow(mut self, overflow: Overflow) -> Self {
482        self.overflow = Some(overflow);
483        self
484    }
485
486    /// Set overflow to wrap.
487    pub fn wrap(self) -> Self {
488        self.overflow(Overflow::wrap())
489    }
490
491    /// Set overflow to clip.
492    pub fn clip(self) -> Self {
493        self.overflow(Overflow::Clip)
494    }
495
496    /// Set the truncation position (configures Overflow::Truncate).
497    pub fn truncate(mut self, at: TruncateAt) -> Self {
498        self.overflow = Some(match self.overflow {
499            Some(Overflow::Truncate { marker, .. }) => Overflow::Truncate { at, marker },
500            _ => Overflow::truncate(at),
501        });
502        self
503    }
504
505    /// Set the ellipsis string for truncation.
506    pub fn ellipsis(mut self, ellipsis: impl Into<String>) -> Self {
507        self.overflow = Some(match self.overflow {
508            Some(Overflow::Truncate { at, .. }) => Overflow::Truncate {
509                at,
510                marker: ellipsis.into(),
511            },
512            _ => Overflow::truncate_with_marker(TruncateAt::End, ellipsis),
513        });
514        self
515    }
516
517    /// Set the null/empty value representation.
518    pub fn null_repr(mut self, null_repr: impl Into<String>) -> Self {
519        self.null_repr = Some(null_repr.into());
520        self
521    }
522
523    /// Set the style name.
524    pub fn style(mut self, style: impl Into<String>) -> Self {
525        self.style = Some(style.into());
526        self
527    }
528
529    /// Use cell value as the style name.
530    pub fn style_from_value(mut self) -> Self {
531        self.style_from_value = true;
532        self
533    }
534
535    /// Set the data key.
536    pub fn key(mut self, key: impl Into<String>) -> Self {
537        self.key = Some(key.into());
538        self
539    }
540
541    /// Set the header title.
542    pub fn header(mut self, header: impl Into<String>) -> Self {
543        self.header = Some(header.into());
544        self
545    }
546
547    /// Build the `Column` instance.
548    pub fn build(self) -> Column {
549        let default = Column::default();
550        Column {
551            name: self.name,
552            width: self.width.unwrap_or(default.width),
553            align: self.align.unwrap_or(default.align),
554            anchor: self.anchor.unwrap_or(default.anchor),
555            overflow: self.overflow.unwrap_or(default.overflow),
556            null_repr: self.null_repr.unwrap_or(default.null_repr),
557            style: self.style,
558            style_from_value: self.style_from_value,
559            key: self.key,
560            header: self.header,
561        }
562    }
563}
564
565/// Shorthand constructors for creating columns.
566///
567/// Provides a concise API for common column configurations:
568///
569/// ```rust
570/// use standout::tabular::Col;
571///
572/// let col = Col::fixed(10);           // Fixed width 10
573/// let col = Col::min(5);              // At least 5, grows to fit
574/// let col = Col::bounded(5, 20);      // Between 5 and 20
575/// let col = Col::fill();              // Fill remaining space
576/// let col = Col::fraction(2);         // 2 parts of remaining space
577///
578/// // Chain with fluent methods
579/// let col = Col::fixed(10).right().style("header");
580/// ```
581pub struct Col;
582
583impl Col {
584    /// Create a fixed-width column.
585    pub fn fixed(width: usize) -> Column {
586        Column::new(Width::Fixed(width))
587    }
588
589    /// Create a column with minimum width that grows to fit content.
590    pub fn min(min: usize) -> Column {
591        Column::new(Width::min(min))
592    }
593
594    /// Create a column with maximum width that shrinks to fit content.
595    pub fn max(max: usize) -> Column {
596        Column::new(Width::max(max))
597    }
598
599    /// Create a bounded-width column (between min and max).
600    pub fn bounded(min: usize, max: usize) -> Column {
601        Column::new(Width::bounded(min, max))
602    }
603
604    /// Create a fill column that expands to remaining space.
605    pub fn fill() -> Column {
606        Column::new(Width::Fill)
607    }
608
609    /// Create a fractional width column.
610    /// `Col::fraction(2)` gets twice the space of `Col::fraction(1)` or `Col::fill()`.
611    pub fn fraction(n: usize) -> Column {
612        Column::new(Width::Fraction(n))
613    }
614}
615
616/// Decorations for table rows (separators, prefixes, suffixes).
617#[derive(Clone, Debug, Default, Serialize, Deserialize)]
618pub struct Decorations {
619    /// Separator between columns (e.g., "  " or " │ ").
620    pub column_sep: String,
621    /// Prefix at the start of each row.
622    pub row_prefix: String,
623    /// Suffix at the end of each row.
624    pub row_suffix: String,
625}
626
627impl Decorations {
628    /// Create decorations with just a column separator.
629    pub fn with_separator(sep: impl Into<String>) -> Self {
630        Decorations {
631            column_sep: sep.into(),
632            row_prefix: String::new(),
633            row_suffix: String::new(),
634        }
635    }
636
637    /// Set the column separator.
638    pub fn separator(mut self, sep: impl Into<String>) -> Self {
639        self.column_sep = sep.into();
640        self
641    }
642
643    /// Set the row prefix.
644    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
645        self.row_prefix = prefix.into();
646        self
647    }
648
649    /// Set the row suffix.
650    pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
651        self.row_suffix = suffix.into();
652        self
653    }
654
655    /// Calculate the total overhead (prefix + suffix + separators between n columns).
656    pub fn overhead(&self, num_columns: usize) -> usize {
657        use crate::tabular::display_width;
658        let prefix_width = display_width(&self.row_prefix);
659        let suffix_width = display_width(&self.row_suffix);
660        let sep_width = display_width(&self.column_sep);
661        let sep_count = num_columns.saturating_sub(1);
662        prefix_width + suffix_width + (sep_width * sep_count)
663    }
664}
665
666/// Complete specification for a flat data layout (Table or CSV).
667#[derive(Clone, Debug, Serialize, Deserialize)]
668pub struct FlatDataSpec {
669    /// Column specifications.
670    pub columns: Vec<Column>,
671    /// Row decorations (separators, prefix, suffix).
672    pub decorations: Decorations,
673}
674
675impl FlatDataSpec {
676    /// Create a new spec with the given columns and default decorations.
677    pub fn new(columns: Vec<Column>) -> Self {
678        FlatDataSpec {
679            columns,
680            decorations: Decorations::default(),
681        }
682    }
683
684    /// Create a spec builder.
685    pub fn builder() -> FlatDataSpecBuilder {
686        FlatDataSpecBuilder::default()
687    }
688
689    /// Get the number of columns.
690    pub fn num_columns(&self) -> usize {
691        self.columns.len()
692    }
693
694    /// Check if any column uses Fill width.
695    pub fn has_fill_column(&self) -> bool {
696        self.columns.iter().any(|c| matches!(c.width, Width::Fill))
697    }
698
699    /// Extract a header row from the spec.
700    ///
701    /// Uses column `header` if present, otherwise `key`, otherwise empty string.
702    pub fn extract_header(&self) -> Vec<String> {
703        self.columns
704            .iter()
705            .map(|col| {
706                col.header
707                    .as_deref()
708                    .or(col.key.as_deref())
709                    .unwrap_or("")
710                    .to_string()
711            })
712            .collect()
713    }
714
715    /// Extract a data row from a JSON value using the spec.
716    ///
717    /// For each column:
718    /// - If `key` is set, traverses the JSON to find the value.
719    /// - If `key` is unset/missing, uses `null_repr`.
720    /// - Handles nested objects via dot notation (e.g. "author.name").
721    pub fn extract_row(&self, data: &Value) -> Vec<String> {
722        self.columns
723            .iter()
724            .map(|col| {
725                if let Some(key) = &col.key {
726                    extract_value(data, key).unwrap_or(col.null_repr.clone())
727                } else {
728                    col.null_repr.clone()
729                }
730            })
731            .collect()
732    }
733}
734
735/// Helper to extract a value from nested JSON using dot notation.
736fn extract_value(data: &Value, path: &str) -> Option<String> {
737    let mut current = data;
738    for part in path.split('.') {
739        match current {
740            Value::Object(map) => {
741                current = map.get(part)?;
742            }
743            _ => return None,
744        }
745    }
746
747    match current {
748        Value::String(s) => Some(s.clone()),
749        Value::Null => None,
750        // For structured types, just jsonify them effectively
751        v => Some(v.to_string()),
752    }
753}
754
755/// Builder for constructing `FlatDataSpec` instances.
756#[derive(Clone, Debug, Default)]
757pub struct FlatDataSpecBuilder {
758    columns: Vec<Column>,
759    decorations: Decorations,
760}
761
762impl FlatDataSpecBuilder {
763    /// Add a column to the table.
764    pub fn column(mut self, column: Column) -> Self {
765        self.columns.push(column);
766        self
767    }
768
769    /// Add multiple columns from an iterator.
770    pub fn columns(mut self, columns: impl IntoIterator<Item = Column>) -> Self {
771        self.columns.extend(columns);
772        self
773    }
774
775    /// Set the column separator.
776    pub fn separator(mut self, sep: impl Into<String>) -> Self {
777        self.decorations.column_sep = sep.into();
778        self
779    }
780
781    /// Set the row prefix.
782    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
783        self.decorations.row_prefix = prefix.into();
784        self
785    }
786
787    /// Set the row suffix.
788    pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
789        self.decorations.row_suffix = suffix.into();
790        self
791    }
792
793    /// Set all decorations at once.
794    pub fn decorations(mut self, decorations: Decorations) -> Self {
795        self.decorations = decorations;
796        self
797    }
798
799    /// Build the `FlatDataSpec` instance.
800    pub fn build(self) -> FlatDataSpec {
801        FlatDataSpec {
802            columns: self.columns,
803            decorations: self.decorations,
804        }
805    }
806}
807
808/// Type alias: TabularSpec is the preferred name for FlatDataSpec.
809pub type TabularSpec = FlatDataSpec;
810/// Type alias for the builder.
811pub type TabularSpecBuilder = FlatDataSpecBuilder;
812
813#[cfg(test)]
814mod tests {
815    use super::*;
816
817    // --- Align tests ---
818
819    #[test]
820    fn align_default_is_left() {
821        assert_eq!(Align::default(), Align::Left);
822    }
823
824    #[test]
825    fn align_serde_roundtrip() {
826        let values = [Align::Left, Align::Right, Align::Center];
827        for align in values {
828            let json = serde_json::to_string(&align).unwrap();
829            let parsed: Align = serde_json::from_str(&json).unwrap();
830            assert_eq!(parsed, align);
831        }
832    }
833
834    // --- TruncateAt tests ---
835
836    #[test]
837    fn truncate_at_default_is_end() {
838        assert_eq!(TruncateAt::default(), TruncateAt::End);
839    }
840
841    #[test]
842    fn truncate_at_serde_roundtrip() {
843        let values = [TruncateAt::End, TruncateAt::Start, TruncateAt::Middle];
844        for truncate in values {
845            let json = serde_json::to_string(&truncate).unwrap();
846            let parsed: TruncateAt = serde_json::from_str(&json).unwrap();
847            assert_eq!(parsed, truncate);
848        }
849    }
850
851    // --- Width tests ---
852
853    #[test]
854    fn width_constructors() {
855        assert_eq!(Width::fixed(10), Width::Fixed(10));
856        assert_eq!(
857            Width::bounded(5, 20),
858            Width::Bounded {
859                min: Some(5),
860                max: Some(20)
861            }
862        );
863        assert_eq!(
864            Width::min(5),
865            Width::Bounded {
866                min: Some(5),
867                max: None
868            }
869        );
870        assert_eq!(
871            Width::max(20),
872            Width::Bounded {
873                min: None,
874                max: Some(20)
875            }
876        );
877        assert_eq!(Width::fill(), Width::Fill);
878    }
879
880    #[test]
881    fn width_serde_fixed() {
882        let width = Width::Fixed(10);
883        let json = serde_json::to_string(&width).unwrap();
884        assert_eq!(json, "10");
885        let parsed: Width = serde_json::from_str(&json).unwrap();
886        assert_eq!(parsed, width);
887    }
888
889    #[test]
890    fn width_serde_bounded() {
891        let width = Width::Bounded {
892            min: Some(5),
893            max: Some(20),
894        };
895        let json = serde_json::to_string(&width).unwrap();
896        let parsed: Width = serde_json::from_str(&json).unwrap();
897        assert_eq!(parsed, width);
898    }
899
900    #[test]
901    fn width_serde_fill() {
902        let width = Width::Fill;
903        let json = serde_json::to_string(&width).unwrap();
904        // Now serializes to "fill"
905        assert_eq!(json, "\"fill\"");
906
907        let parsed: Width = serde_json::from_str("\"fill\"").unwrap();
908        assert_eq!(parsed, width);
909    }
910
911    #[test]
912    fn width_serde_fraction() {
913        let width = Width::Fraction(2);
914        let json = serde_json::to_string(&width).unwrap();
915        assert_eq!(json, "\"2fr\"");
916
917        let parsed: Width = serde_json::from_str("\"2fr\"").unwrap();
918        assert_eq!(parsed, width);
919
920        // Also test 1fr
921        let parsed_1: Width = serde_json::from_str("\"1fr\"").unwrap();
922        assert_eq!(parsed_1, Width::Fraction(1));
923    }
924
925    #[test]
926    fn width_fraction_constructor() {
927        assert_eq!(Width::fraction(3), Width::Fraction(3));
928    }
929
930    // --- Overflow tests ---
931
932    #[test]
933    fn overflow_default() {
934        let overflow = Overflow::default();
935        assert!(matches!(
936            overflow,
937            Overflow::Truncate {
938                at: TruncateAt::End,
939                ..
940            }
941        ));
942    }
943
944    #[test]
945    fn overflow_constructors() {
946        let truncate = Overflow::truncate(TruncateAt::Middle);
947        assert!(matches!(
948            truncate,
949            Overflow::Truncate {
950                at: TruncateAt::Middle,
951                ref marker
952            } if marker == "…"
953        ));
954
955        let truncate_custom = Overflow::truncate_with_marker(TruncateAt::Start, "...");
956        assert!(matches!(
957            truncate_custom,
958            Overflow::Truncate {
959                at: TruncateAt::Start,
960                ref marker
961            } if marker == "..."
962        ));
963
964        let wrap = Overflow::wrap();
965        assert!(matches!(wrap, Overflow::Wrap { indent: 0 }));
966
967        let wrap_indent = Overflow::wrap_with_indent(4);
968        assert!(matches!(wrap_indent, Overflow::Wrap { indent: 4 }));
969    }
970
971    // --- Anchor tests ---
972
973    #[test]
974    fn anchor_default() {
975        assert_eq!(Anchor::default(), Anchor::Left);
976    }
977
978    #[test]
979    fn anchor_serde_roundtrip() {
980        let values = [Anchor::Left, Anchor::Right];
981        for anchor in values {
982            let json = serde_json::to_string(&anchor).unwrap();
983            let parsed: Anchor = serde_json::from_str(&json).unwrap();
984            assert_eq!(parsed, anchor);
985        }
986    }
987
988    // --- Col shorthand tests ---
989
990    #[test]
991    fn col_shorthand_constructors() {
992        let fixed = Col::fixed(10);
993        assert_eq!(fixed.width, Width::Fixed(10));
994
995        let min = Col::min(5);
996        assert_eq!(
997            min.width,
998            Width::Bounded {
999                min: Some(5),
1000                max: None
1001            }
1002        );
1003
1004        let bounded = Col::bounded(5, 20);
1005        assert_eq!(
1006            bounded.width,
1007            Width::Bounded {
1008                min: Some(5),
1009                max: Some(20)
1010            }
1011        );
1012
1013        let fill = Col::fill();
1014        assert_eq!(fill.width, Width::Fill);
1015
1016        let fraction = Col::fraction(3);
1017        assert_eq!(fraction.width, Width::Fraction(3));
1018    }
1019
1020    #[test]
1021    fn col_shorthand_chaining() {
1022        let col = Col::fixed(10).right().anchor_right().style("header");
1023        assert_eq!(col.width, Width::Fixed(10));
1024        assert_eq!(col.align, Align::Right);
1025        assert_eq!(col.anchor, Anchor::Right);
1026        assert_eq!(col.style, Some("header".to_string()));
1027    }
1028
1029    #[test]
1030    fn column_wrap_shorthand() {
1031        let col = Col::fill().wrap();
1032        assert!(matches!(col.overflow, Overflow::Wrap { indent: 0 }));
1033
1034        let col_indent = Col::fill().wrap_indent(2);
1035        assert!(matches!(col_indent.overflow, Overflow::Wrap { indent: 2 }));
1036    }
1037
1038    #[test]
1039    fn column_clip_shorthand() {
1040        let col = Col::fixed(10).clip();
1041        assert!(matches!(col.overflow, Overflow::Clip));
1042    }
1043
1044    #[test]
1045    fn column_named() {
1046        let col = Col::fixed(10).named("author");
1047        assert_eq!(col.name, Some("author".to_string()));
1048    }
1049
1050    // --- Column tests ---
1051
1052    #[test]
1053    fn column_defaults() {
1054        let col = Column::default();
1055        assert!(matches!(
1056            col.width,
1057            Width::Bounded {
1058                min: None,
1059                max: None
1060            }
1061        ));
1062        assert_eq!(col.align, Align::Left);
1063        assert_eq!(col.anchor, Anchor::Left);
1064        assert!(matches!(
1065            col.overflow,
1066            Overflow::Truncate {
1067                at: TruncateAt::End,
1068                ..
1069            }
1070        ));
1071        assert_eq!(col.null_repr, "-");
1072        assert!(col.style.is_none());
1073    }
1074
1075    #[test]
1076    fn column_fluent_api() {
1077        let col = Column::new(Width::Fixed(10))
1078            .align(Align::Right)
1079            .truncate(TruncateAt::Middle)
1080            .ellipsis("...")
1081            .null_repr("N/A")
1082            .style("header");
1083
1084        assert_eq!(col.width, Width::Fixed(10));
1085        assert_eq!(col.align, Align::Right);
1086        assert!(matches!(
1087            col.overflow,
1088            Overflow::Truncate {
1089                at: TruncateAt::Middle,
1090                ref marker
1091            } if marker == "..."
1092        ));
1093        assert_eq!(col.null_repr, "N/A");
1094        assert_eq!(col.style, Some("header".to_string()));
1095    }
1096
1097    #[test]
1098    fn column_builder() {
1099        let col = Column::builder()
1100            .fixed(15)
1101            .align(Align::Center)
1102            .truncate(TruncateAt::Start)
1103            .build();
1104
1105        assert_eq!(col.width, Width::Fixed(15));
1106        assert_eq!(col.align, Align::Center);
1107        assert!(matches!(
1108            col.overflow,
1109            Overflow::Truncate {
1110                at: TruncateAt::Start,
1111                ..
1112            }
1113        ));
1114    }
1115
1116    #[test]
1117    fn column_builder_fill() {
1118        let col = Column::builder().fill().build();
1119        assert_eq!(col.width, Width::Fill);
1120    }
1121
1122    // --- Decorations tests ---
1123
1124    #[test]
1125    fn decorations_default() {
1126        let dec = Decorations::default();
1127        assert_eq!(dec.column_sep, "");
1128        assert_eq!(dec.row_prefix, "");
1129        assert_eq!(dec.row_suffix, "");
1130    }
1131
1132    #[test]
1133    fn decorations_with_separator() {
1134        let dec = Decorations::with_separator("  ");
1135        assert_eq!(dec.column_sep, "  ");
1136    }
1137
1138    #[test]
1139    fn decorations_overhead() {
1140        let dec = Decorations::default()
1141            .separator("  ")
1142            .prefix("│ ")
1143            .suffix(" │");
1144
1145        // 3 columns: prefix(2) + suffix(2) + 2 separators(4) = 8
1146        assert_eq!(dec.overhead(3), 8);
1147        // 1 column: prefix(2) + suffix(2) + 0 separators = 4
1148        assert_eq!(dec.overhead(1), 4);
1149        // 0 columns: just prefix + suffix
1150        assert_eq!(dec.overhead(0), 4);
1151    }
1152
1153    // --- FlatDataSpec tests ---
1154
1155    #[test]
1156    fn flat_data_spec_builder() {
1157        let spec = FlatDataSpec::builder()
1158            .column(Column::new(Width::Fixed(8)))
1159            .column(Column::new(Width::Fill))
1160            .column(Column::new(Width::Fixed(10)))
1161            .separator("  ")
1162            .build();
1163
1164        assert_eq!(spec.num_columns(), 3);
1165        assert!(spec.has_fill_column());
1166        assert_eq!(spec.decorations.column_sep, "  ");
1167    }
1168
1169    #[test]
1170    fn table_spec_no_fill() {
1171        let spec = TabularSpec::builder()
1172            .column(Column::new(Width::Fixed(8)))
1173            .column(Column::new(Width::Fixed(10)))
1174            .build();
1175
1176        assert!(!spec.has_fill_column());
1177    }
1178
1179    #[test]
1180    fn extract_fields_from_json() {
1181        let json = serde_json::json!({
1182            "name": "Alice",
1183            "meta": {
1184                "age": 30,
1185                "role": "admin"
1186            }
1187        });
1188
1189        let spec = FlatDataSpec::builder()
1190            .column(Column::new(Width::Fixed(10)).key("name"))
1191            .column(Column::new(Width::Fixed(5)).key("meta.age"))
1192            .column(Column::new(Width::Fixed(10)).key("meta.role"))
1193            .column(Column::new(Width::Fixed(10)).key("missing.field")) // Should use null_repr
1194            .build();
1195
1196        let row = spec.extract_row(&json);
1197        assert_eq!(row[0], "Alice");
1198        assert_eq!(row[1], "30"); // Numbers coerced to string
1199        assert_eq!(row[2], "admin");
1200        assert_eq!(row[3], "-"); // Default null_repr
1201    }
1202
1203    #[test]
1204    fn extract_header_row() {
1205        let spec = FlatDataSpec::builder()
1206            .column(Column::new(Width::Fixed(10)).header("Name").key("name"))
1207            .column(Column::new(Width::Fixed(5)).key("age")) // Fallback to key
1208            .column(Column::new(Width::Fixed(10))) // Empty
1209            .build();
1210
1211        let header = spec.extract_header();
1212        assert_eq!(header[0], "Name");
1213        assert_eq!(header[1], "age");
1214        assert_eq!(header[2], "");
1215    }
1216}