bel7_cli/
tables.rs

1// Copyright (C) 2025-2026 Michael S. Klishin and Contributors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Table styling utilities for CLI output.
16
17use std::fmt::Display;
18use tabled::{
19    Table,
20    settings::{
21        Format, Modify, Panel, Remove,
22        object::{Rows, Segment},
23        style::Style,
24    },
25};
26
27pub use tabled::settings::Padding;
28
29/// Available table styles for CLI output.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
32pub enum TableStyle {
33    /// Modern rounded corners (default).
34    #[default]
35    Modern,
36    /// No borders, space-separated.
37    Borderless,
38    /// Self-explanatory.
39    Markdown,
40    /// Sharp corners with box-drawing characters.
41    Sharp,
42    /// ASCII-only characters.
43    Ascii,
44    /// psql-style output.
45    Psql,
46    /// Uses dots for borders.
47    Dots,
48}
49
50#[cfg(feature = "clap")]
51impl clap::ValueEnum for TableStyle {
52    fn value_variants<'a>() -> &'a [Self] {
53        &[
54            Self::Modern,
55            Self::Borderless,
56            Self::Markdown,
57            Self::Sharp,
58            Self::Ascii,
59            Self::Psql,
60            Self::Dots,
61        ]
62    }
63
64    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
65        Some(clap::builder::PossibleValue::new(match self {
66            Self::Modern => "modern",
67            Self::Borderless => "borderless",
68            Self::Markdown => "markdown",
69            Self::Sharp => "sharp",
70            Self::Ascii => "ascii",
71            Self::Psql => "psql",
72            Self::Dots => "dots",
73        }))
74    }
75}
76
77impl TableStyle {
78    /// Applies this style to a table.
79    pub fn apply(self, table: &mut Table) {
80        match self {
81            TableStyle::Modern => {
82                table.with(Style::rounded());
83            }
84            TableStyle::Borderless => {
85                table.with(Style::blank());
86            }
87            TableStyle::Markdown => {
88                table.with(Style::markdown());
89            }
90            TableStyle::Sharp => {
91                table.with(Style::sharp());
92            }
93            TableStyle::Ascii => {
94                table.with(Style::ascii());
95            }
96            TableStyle::Psql => {
97                table.with(Style::psql());
98            }
99            TableStyle::Dots => {
100                table.with(Style::dots());
101            }
102        }
103    }
104}
105
106/// A builder for styled tables.
107pub struct StyledTable {
108    style: TableStyle,
109    header: Option<String>,
110    remove_header_row: bool,
111    padding: Option<Padding>,
112    newline_replacement: Option<String>,
113}
114
115impl Default for StyledTable {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121impl StyledTable {
122    /// Creates a new table builder with the default style.
123    pub fn new() -> Self {
124        Self {
125            style: TableStyle::default(),
126            header: None,
127            remove_header_row: false,
128            padding: None,
129            newline_replacement: None,
130        }
131    }
132
133    /// Sets the table style.
134    pub fn style(mut self, style: TableStyle) -> Self {
135        self.style = style;
136        self
137    }
138
139    /// Sets a header panel above the table.
140    pub fn header(mut self, header: impl Into<String>) -> Self {
141        self.header = Some(header.into());
142        self
143    }
144
145    /// Removes the first row (column headers) from the table.
146    ///
147    /// Useful for non-interactive/scriptable output where headers are noise.
148    pub fn remove_header_row(mut self) -> Self {
149        self.remove_header_row = true;
150        self
151    }
152
153    /// Sets custom padding for table cells.
154    ///
155    /// Use `Padding::new(top, right, bottom, left)` to specify padding values.
156    pub fn padding(mut self, padding: Padding) -> Self {
157        self.padding = Some(padding);
158        self
159    }
160
161    /// Replaces newlines in cell content with the specified string.
162    ///
163    /// Useful for non-interactive output where newlines would break parsing.
164    /// Common replacement is `","` to turn multi-line values into comma-separated lists.
165    pub fn replace_newlines(mut self, replacement: impl Into<String>) -> Self {
166        self.newline_replacement = Some(replacement.into());
167        self
168    }
169
170    /// Builds the final table from the provided data.
171    pub fn build<T: tabled::Tabled>(self, data: Vec<T>) -> Table {
172        let mut table = Table::new(data);
173
174        self.style.apply(&mut table);
175
176        if let Some(padding) = self.padding {
177            table.with(padding);
178        }
179
180        // Remove column headers before adding panel header
181        if self.remove_header_row {
182            table.with(Remove::row(Rows::first()));
183        }
184
185        if let Some(header) = self.header {
186            table.with(Panel::header(header));
187        }
188
189        if let Some(replacement) = self.newline_replacement {
190            table.with(
191                Modify::new(Segment::all())
192                    .with(Format::content(move |s| s.replace('\n', &replacement))),
193            );
194        }
195
196        table
197    }
198}
199
200/// Formats an optional value for rendering in a table cell.
201///
202/// Returns an empty string for None, otherwise the Display representation.
203pub fn display_option<T: Display>(opt: &Option<T>) -> String {
204    opt.as_ref().map_or_else(String::new, |val| val.to_string())
205}
206
207/// Formats an optional value with a default.
208pub fn display_option_or<T: Display>(opt: &Option<T>, default: &str) -> String {
209    opt.as_ref()
210        .map_or_else(|| default.to_string(), |val| val.to_string())
211}