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))]
32#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
33#[cfg_attr(feature = "clap", clap(rename_all = "kebab-case"))]
34pub enum TableStyle {
35    /// Modern rounded corners (default).
36    #[default]
37    Modern,
38    /// No borders, space-separated.
39    Borderless,
40    /// Self-explanatory.
41    Markdown,
42    /// Sharp corners with box-drawing characters.
43    Sharp,
44    /// ASCII-only characters.
45    Ascii,
46    /// psql-style output.
47    Psql,
48    /// Uses dots for borders.
49    Dots,
50}
51
52impl TableStyle {
53    /// Applies this style to a table.
54    pub fn apply(self, table: &mut Table) {
55        match self {
56            TableStyle::Modern => {
57                table.with(Style::rounded());
58            }
59            TableStyle::Borderless => {
60                table.with(Style::blank());
61            }
62            TableStyle::Markdown => {
63                table.with(Style::markdown());
64            }
65            TableStyle::Sharp => {
66                table.with(Style::sharp());
67            }
68            TableStyle::Ascii => {
69                table.with(Style::ascii());
70            }
71            TableStyle::Psql => {
72                table.with(Style::psql());
73            }
74            TableStyle::Dots => {
75                table.with(Style::dots());
76            }
77        }
78    }
79}
80
81/// A builder for styled tables.
82pub struct StyledTable {
83    style: TableStyle,
84    header: Option<String>,
85    remove_header_row: bool,
86    padding: Option<Padding>,
87    newline_replacement: Option<String>,
88}
89
90impl Default for StyledTable {
91    fn default() -> Self {
92        Self::new()
93    }
94}
95
96impl StyledTable {
97    /// Creates a new table builder with the default style.
98    pub fn new() -> Self {
99        Self {
100            style: TableStyle::default(),
101            header: None,
102            remove_header_row: false,
103            padding: None,
104            newline_replacement: None,
105        }
106    }
107
108    /// Sets the table style.
109    pub fn style(mut self, style: TableStyle) -> Self {
110        self.style = style;
111        self
112    }
113
114    /// Sets a header panel above the table.
115    pub fn header(mut self, header: impl Into<String>) -> Self {
116        self.header = Some(header.into());
117        self
118    }
119
120    /// Removes the first row (column headers) from the table.
121    ///
122    /// Useful for non-interactive/scriptable output where headers are noise.
123    pub fn remove_header_row(mut self) -> Self {
124        self.remove_header_row = true;
125        self
126    }
127
128    /// Sets custom padding for table cells.
129    ///
130    /// Use `Padding::new(top, right, bottom, left)` to specify padding values.
131    pub fn padding(mut self, padding: Padding) -> Self {
132        self.padding = Some(padding);
133        self
134    }
135
136    /// Replaces newlines in cell content with the specified string.
137    ///
138    /// Useful for non-interactive output where newlines would break parsing.
139    /// Common replacement is `","` to turn multi-line values into comma-separated lists.
140    pub fn replace_newlines(mut self, replacement: impl Into<String>) -> Self {
141        self.newline_replacement = Some(replacement.into());
142        self
143    }
144
145    /// Builds the final table from the provided data.
146    pub fn build<T: tabled::Tabled>(self, data: Vec<T>) -> Table {
147        let mut table = Table::new(data);
148
149        self.style.apply(&mut table);
150
151        if let Some(padding) = self.padding {
152            table.with(padding);
153        }
154
155        // Remove column headers before adding panel header
156        if self.remove_header_row {
157            table.with(Remove::row(Rows::first()));
158        }
159
160        if let Some(header) = self.header {
161            table.with(Panel::header(header));
162        }
163
164        if let Some(replacement) = self.newline_replacement {
165            table.with(
166                Modify::new(Segment::all())
167                    .with(Format::content(move |s| s.replace('\n', &replacement))),
168            );
169        }
170
171        table
172    }
173}
174
175/// Formats an optional value for rendering in a table cell.
176///
177/// Returns an empty string for None, otherwise the Display representation.
178pub fn display_option<T: Display>(opt: &Option<T>) -> String {
179    opt.as_ref().map_or_else(String::new, |val| val.to_string())
180}
181
182/// Formats an optional value with a default.
183pub fn display_option_or<T: Display>(opt: &Option<T>, default: &str) -> String {
184    opt.as_ref()
185        .map_or_else(|| default.to_string(), |val| val.to_string())
186}