Skip to main content

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;
18
19use tabled::Table;
20use tabled::builder::Builder;
21use tabled::settings::Format;
22use tabled::settings::Modify;
23use tabled::settings::Panel;
24use tabled::settings::Remove;
25use tabled::settings::Width;
26use tabled::settings::object::{Columns, Rows, Segment};
27use tabled::settings::style::Style;
28use terminal_size::Width as TermWidth;
29use terminal_size::terminal_size;
30
31pub use tabled::settings::Padding;
32
33/// Default terminal width when detection fails.
34pub const DEFAULT_TERMINAL_WIDTH: usize = 120;
35
36/// Returns the current terminal width in columns.
37///
38/// Falls back to `DEFAULT_TERMINAL_WIDTH` (120) if detection fails.
39#[must_use]
40pub fn terminal_width() -> usize {
41    terminal_size()
42        .map(|(TermWidth(w), _)| w as usize)
43        .unwrap_or(DEFAULT_TERMINAL_WIDTH)
44}
45
46/// Returns a target width for tables based on terminal size.
47///
48/// Uses a utilization factor (0.0-1.0) to leave some margin.
49/// Common value is 0.85 (85% of terminal width).
50#[must_use]
51pub fn responsive_width(utilization: f64) -> usize {
52    let width = terminal_width();
53    (width as f64 * utilization.clamp(0.0, 1.0)) as usize
54}
55
56/// Available table styles for CLI output.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
59pub enum TableStyle {
60    /// Modern rounded corners (default).
61    #[default]
62    Modern,
63    /// No borders, space-separated.
64    Borderless,
65    /// Self-explanatory.
66    Markdown,
67    /// Sharp corners with box-drawing characters.
68    Sharp,
69    /// ASCII-only characters.
70    Ascii,
71    /// psql-style output.
72    Psql,
73    /// Uses dots for borders.
74    Dots,
75}
76
77#[cfg(feature = "clap")]
78impl clap::ValueEnum for TableStyle {
79    fn value_variants<'a>() -> &'a [Self] {
80        &[
81            Self::Modern,
82            Self::Borderless,
83            Self::Markdown,
84            Self::Sharp,
85            Self::Ascii,
86            Self::Psql,
87            Self::Dots,
88        ]
89    }
90
91    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
92        Some(clap::builder::PossibleValue::new(match self {
93            Self::Modern => "modern",
94            Self::Borderless => "borderless",
95            Self::Markdown => "markdown",
96            Self::Sharp => "sharp",
97            Self::Ascii => "ascii",
98            Self::Psql => "psql",
99            Self::Dots => "dots",
100        }))
101    }
102}
103
104impl TableStyle {
105    /// Applies this style to a table.
106    pub fn apply(self, table: &mut Table) {
107        match self {
108            TableStyle::Modern => {
109                table.with(Style::rounded());
110            }
111            TableStyle::Borderless => {
112                table.with(Style::blank());
113            }
114            TableStyle::Markdown => {
115                table.with(Style::markdown());
116            }
117            TableStyle::Sharp => {
118                table.with(Style::sharp());
119            }
120            TableStyle::Ascii => {
121                table.with(Style::ascii());
122            }
123            TableStyle::Psql => {
124                table.with(Style::psql());
125            }
126            TableStyle::Dots => {
127                table.with(Style::dots());
128            }
129        }
130    }
131}
132
133/// A builder for styled tables.
134pub struct StyledTable {
135    style: TableStyle,
136    header: Option<String>,
137    remove_header_row: bool,
138    padding: Option<Padding>,
139    newline_replacement: Option<String>,
140    max_width: Option<usize>,
141    wrap_column: Option<(usize, usize)>,
142}
143
144impl Default for StyledTable {
145    fn default() -> Self {
146        Self::new()
147    }
148}
149
150impl StyledTable {
151    /// Creates a new table builder with the default style.
152    #[must_use]
153    pub fn new() -> Self {
154        Self {
155            style: TableStyle::default(),
156            header: None,
157            remove_header_row: false,
158            padding: None,
159            newline_replacement: None,
160            max_width: None,
161            wrap_column: None,
162        }
163    }
164
165    /// Sets maximum width for the table (enables responsive layout).
166    #[must_use]
167    pub fn max_width(mut self, width: usize) -> Self {
168        self.max_width = Some(width);
169        self
170    }
171
172    /// Sets a column to wrap at a specific width.
173    ///
174    /// Column index is 0-based.
175    #[must_use]
176    pub fn wrap_column(mut self, column_index: usize, width: usize) -> Self {
177        self.wrap_column = Some((column_index, width));
178        self
179    }
180
181    /// Sets the table style.
182    pub fn style(mut self, style: TableStyle) -> Self {
183        self.style = style;
184        self
185    }
186
187    /// Sets a header panel above the table.
188    pub fn header(mut self, header: impl Into<String>) -> Self {
189        self.header = Some(header.into());
190        self
191    }
192
193    /// Removes the first row (column headers) from the table.
194    ///
195    /// Useful for non-interactive/scriptable output where headers are noise.
196    pub fn remove_header_row(mut self) -> Self {
197        self.remove_header_row = true;
198        self
199    }
200
201    /// Sets custom padding for table cells.
202    ///
203    /// Use `Padding::new(top, right, bottom, left)` to specify padding values.
204    pub fn padding(mut self, padding: Padding) -> Self {
205        self.padding = Some(padding);
206        self
207    }
208
209    /// Replaces newlines in cell content with the specified string.
210    ///
211    /// Useful for non-interactive output where newlines would break parsing.
212    /// Common replacement is `","` to turn multi-line values into comma-separated lists.
213    pub fn replace_newlines(mut self, replacement: impl Into<String>) -> Self {
214        self.newline_replacement = Some(replacement.into());
215        self
216    }
217
218    /// Builds the final table from the provided data.
219    pub fn build<T: tabled::Tabled>(self, data: Vec<T>) -> Table {
220        let mut table = Table::new(data);
221
222        self.style.apply(&mut table);
223
224        if let Some(padding) = self.padding {
225            table.with(padding);
226        }
227
228        // Remove column headers before adding panel header
229        if self.remove_header_row {
230            table.with(Remove::row(Rows::first()));
231        }
232
233        if let Some(header) = self.header {
234            table.with(Panel::header(header));
235        }
236
237        if let Some(replacement) = self.newline_replacement {
238            table.with(
239                Modify::new(Segment::all())
240                    .with(Format::content(move |s| s.replace('\n', &replacement))),
241            );
242        }
243
244        if let Some((col_idx, width)) = self.wrap_column {
245            table.with(Modify::new(Columns::new(col_idx..=col_idx)).with(Width::wrap(width)));
246        }
247
248        if let Some(width) = self.max_width {
249            table.with(Width::truncate(width));
250        }
251
252        table
253    }
254}
255
256/// Formats an optional value for rendering in a table cell.
257///
258/// Returns an empty string for None, otherwise the Display representation.
259#[must_use]
260pub fn display_option<T: Display>(opt: &Option<T>) -> String {
261    opt.as_ref().map_or_else(String::new, |val| val.to_string())
262}
263
264/// Formats an optional value with a default.
265#[must_use]
266pub fn display_option_or<T: Display>(opt: &Option<T>, default: &str) -> String {
267    opt.as_ref()
268        .map_or_else(|| default.to_string(), |val| val.to_string())
269}
270
271/// Parses a comma-separated column list into a vector of lowercase column names.
272///
273/// Trims whitespace and filters empty entries.
274#[must_use]
275pub fn parse_columns(columns_arg: &str) -> Vec<String> {
276    columns_arg
277        .split(',')
278        .map(|s| s.trim().to_lowercase())
279        .filter(|s| !s.is_empty())
280        .collect()
281}
282
283/// Builds a table with only the specified columns.
284///
285/// Columns are matched case-insensitively. Unknown columns are ignored.
286#[must_use]
287pub fn build_table_with_columns<T: tabled::Tabled>(data: &[T], columns: &[String]) -> Table {
288    let mut builder = Builder::default();
289
290    let headers: Vec<String> = T::headers()
291        .into_iter()
292        .map(|c| c.to_string().to_lowercase())
293        .collect();
294
295    let valid_columns: Vec<(usize, &String)> = columns
296        .iter()
297        .filter_map(|col| headers.iter().position(|h| h == col).map(|idx| (idx, col)))
298        .collect();
299
300    builder.push_record(valid_columns.iter().map(|(_, col)| col.as_str()));
301
302    for item in data {
303        let fields: Vec<String> = item.fields().into_iter().map(|c| c.to_string()).collect();
304
305        let row: Vec<&str> = valid_columns
306            .iter()
307            .map(|(idx, _)| fields[*idx].as_str())
308            .collect();
309        builder.push_record(row);
310    }
311
312    builder.build()
313}