Skip to main content

cfgd_core/output/renderer/
table.rs

1use serde::Serialize;
2use unicode_width::UnicodeWidthStr;
3
4use super::{Renderer, Writer, role_glyph};
5use crate::output::{Role, Verbosity};
6
7#[derive(Debug, Clone, Serialize)]
8pub struct Table {
9    pub(crate) headers: Vec<String>,
10    pub(crate) rows: Vec<Vec<String>>,
11    /// Per-cell role tags, parallel to `rows`. Each inner vec is the same
12    /// length as its row's cell count, with `Some(role)` for cells that
13    /// should be re-styled by the renderer after width-aware padding.
14    /// Width calculation still uses the plain string in `rows`, so ANSI
15    /// escapes from styling never inflate column widths.
16    #[serde(skip_serializing_if = "Vec::is_empty")]
17    pub(crate) row_roles: Vec<Vec<Option<Role>>>,
18}
19
20impl Table {
21    pub fn new(headers: impl IntoIterator<Item = impl Into<String>>) -> Self {
22        Self {
23            headers: headers.into_iter().map(Into::into).collect(),
24            rows: Vec::new(),
25            row_roles: Vec::new(),
26        }
27    }
28
29    pub fn row(mut self, row: impl IntoIterator<Item = impl Into<String>>) -> Self {
30        let cells: Vec<String> = row.into_iter().map(Into::into).collect();
31        self.row_roles.push(vec![None; cells.len()]);
32        self.rows.push(cells);
33        self
34    }
35
36    /// Like `row` but each cell carries an optional `Role` whose style is
37    /// applied after the row is padded to the column widths. Use this for
38    /// columns where the value (`"pending"`, `"remote"`, `"merged"`) carries
39    /// the semantic, and color amplifies it rather than replacing the text.
40    pub fn row_styled<I, S>(mut self, row: I) -> Self
41    where
42        I: IntoIterator<Item = (S, Option<Role>)>,
43        S: Into<String>,
44    {
45        let mut cells = Vec::new();
46        let mut roles = Vec::new();
47        for (value, role) in row {
48            cells.push(value.into());
49            roles.push(role);
50        }
51        self.rows.push(cells);
52        self.row_roles.push(roles);
53        self
54    }
55}
56
57impl Renderer {
58    pub fn render_table(&self, w: &dyn Writer, depth: usize, t: &Table) {
59        if self.verbosity == Verbosity::Quiet || t.headers.is_empty() {
60            return;
61        }
62        self.flush_pending_section_headers(w);
63        let cols = t.headers.len();
64        let mut widths = vec![0usize; cols];
65        for (i, h) in t.headers.iter().enumerate() {
66            widths[i] = UnicodeWidthStr::width(h.as_str());
67        }
68        for row in &t.rows {
69            for (i, cell) in row.iter().enumerate().take(cols) {
70                widths[i] = widths[i].max(UnicodeWidthStr::width(cell.as_str()));
71            }
72        }
73        // Header row. Pad by the display-width deficit so CJK / emoji / accented
74        // cells line up with ASCII neighbours — `format!("{:<w$}", ...)` pads by
75        // char count, which over-pads multi-byte and under-pads zero-width.
76        let header_line: String = t
77            .headers
78            .iter()
79            .enumerate()
80            .map(|(i, h)| {
81                let pad = widths[i].saturating_sub(UnicodeWidthStr::width(h.as_str()));
82                format!("{h}{}", " ".repeat(pad))
83            })
84            .collect::<Vec<_>>()
85            .join("  ");
86        let styled = self.theme.header.apply_to(&header_line).to_string();
87        self.write_line(w, depth, &styled);
88        // Separator
89        let sep: String = widths
90            .iter()
91            .map(|w| "─".repeat(*w))
92            .collect::<Vec<_>>()
93            .join("──");
94        let dim = self.theme.muted.apply_to(&sep).to_string();
95        self.write_line(w, depth, &dim);
96        // Data rows. Padding runs against the plain string so widths stay
97        // honest; per-cell roles re-style the padded cell post-hoc.
98        let empty_roles: Vec<Option<Role>> = Vec::new();
99        for (row_idx, row) in t.rows.iter().enumerate() {
100            let roles_for_row = t.row_roles.get(row_idx).unwrap_or(&empty_roles);
101            let line: String = row
102                .iter()
103                .enumerate()
104                .take(cols)
105                .map(|(i, cell)| {
106                    let pad = widths[i].saturating_sub(UnicodeWidthStr::width(cell.as_str()));
107                    let padded = format!("{cell}{}", " ".repeat(pad));
108                    match roles_for_row.get(i).and_then(|r| *r) {
109                        Some(role) => {
110                            let (_icon, style) = role_glyph(&self.theme, role);
111                            style.apply_to(padded).to_string()
112                        }
113                        None => padded,
114                    }
115                })
116                .collect::<Vec<_>>()
117                .join("  ");
118            self.write_line(w, depth, &line);
119        }
120        self.mark_top_level_blank_if_at_root();
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use std::sync::{Arc, Mutex};
127
128    use super::super::StringSink;
129    use super::*;
130    use crate::output::Theme;
131
132    #[test]
133    fn empty_table_emits_nothing() {
134        let buf = Arc::new(Mutex::new(String::new()));
135        let sink = StringSink(buf.clone());
136        let r = Renderer::new(Theme::default(), Verbosity::Normal);
137        let t = Table::new(std::iter::empty::<String>());
138        r.render_table(&sink, 0, &t);
139        assert!(buf.lock().unwrap().is_empty());
140    }
141
142    #[test]
143    fn header_then_separator_then_rows() {
144        let buf = Arc::new(Mutex::new(String::new()));
145        let sink = StringSink(buf.clone());
146        let r = Renderer::new(Theme::default(), Verbosity::Normal);
147        let t = Table::new(["Name", "Age"])
148            .row(["alice", "30"])
149            .row(["bob", "25"]);
150        r.render_table(&sink, 0, &t);
151        let out = buf.lock().unwrap();
152        assert!(out.contains("Name"));
153        assert!(out.contains("Age"));
154        assert!(out.contains("─"));
155        assert!(out.contains("alice"));
156        assert!(out.contains("bob"));
157    }
158
159    #[test]
160    fn row_appends_keep_rows_and_roles_in_lockstep() {
161        let t = Table::new(["a", "b"]).row(["1", "2"]).row(["3", "4"]);
162        assert_eq!(t.rows.len(), t.row_roles.len());
163        assert_eq!(t.rows[0].len(), t.row_roles[0].len());
164        assert_eq!(t.rows[1].len(), t.row_roles[1].len());
165    }
166
167    #[test]
168    fn row_styled_appends_keep_rows_and_roles_in_lockstep() {
169        let t = Table::new(["a", "b"])
170            .row(["plain", "plain"])
171            .row_styled([("styled", Some(Role::Ok)), ("nostyle", None)]);
172        assert_eq!(t.rows.len(), t.row_roles.len());
173        assert_eq!(t.row_roles[1], vec![Some(Role::Ok), None]);
174    }
175
176    /// Returns the display-width prefix of `line` ending at the first byte
177    /// occurrence of `needle`. Drives the column-alignment assertions for
178    /// CJK / emoji rows where byte index ≠ display column.
179    fn prefix_display_width(line: &str, needle: &str) -> usize {
180        let idx = line
181            .find(needle)
182            .unwrap_or_else(|| panic!("needle {needle:?} missing in line {line:?}"));
183        UnicodeWidthStr::width(&line[..idx])
184    }
185
186    #[test]
187    fn table_aligns_cjk_cells_by_display_width() {
188        let buf = Arc::new(Mutex::new(String::new()));
189        let sink = StringSink(buf.clone());
190        let r = Renderer::new(Theme::default(), Verbosity::Normal);
191        let t = Table::new(["Name", "Score"])
192            .row(["京都", "100"])
193            .row(["Tokyo", "200"]);
194        r.render_table(&sink, 0, &t);
195        let out = crate::output::strip_ansi(&buf.lock().unwrap().clone());
196        let lines: Vec<&str> = out.lines().collect();
197        let kyoto = lines
198            .iter()
199            .find(|l| l.contains("京都"))
200            .expect("kyoto row missing");
201        let tokyo = lines
202            .iter()
203            .find(|l| l.contains("Tokyo"))
204            .expect("tokyo row missing");
205        assert_eq!(
206            prefix_display_width(kyoto, "100"),
207            prefix_display_width(tokyo, "200"),
208            "CJK and ASCII rows must align by display width.\nout:\n{out}"
209        );
210    }
211
212    #[test]
213    fn table_aligns_emoji_cells_by_display_width() {
214        let buf = Arc::new(Mutex::new(String::new()));
215        let sink = StringSink(buf.clone());
216        let r = Renderer::new(Theme::default(), Verbosity::Normal);
217        let t = Table::new(["Status", "Note"])
218            .row(["✓", "ok"])
219            .row(["⚠️", "warn"])
220            .row(["complete", "yep"]);
221        r.render_table(&sink, 0, &t);
222        let out = crate::output::strip_ansi(&buf.lock().unwrap().clone());
223        let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
224        let ok_line = lines
225            .iter()
226            .find(|l| l.contains(" ok"))
227            .expect("ok row missing");
228        let warn_line = lines
229            .iter()
230            .find(|l| l.contains("warn"))
231            .expect("warn row missing");
232        let yep_line = lines
233            .iter()
234            .find(|l| l.contains("yep"))
235            .expect("yep row missing");
236        let positions = [
237            prefix_display_width(ok_line, "ok"),
238            prefix_display_width(warn_line, "warn"),
239            prefix_display_width(yep_line, "yep"),
240        ];
241        let first = positions[0];
242        for (i, p) in positions.iter().enumerate() {
243            assert_eq!(
244                *p, first,
245                "row {i} note column misaligned, got width {p}, expected {first}\nout:\n{out}"
246            );
247        }
248    }
249
250    #[test]
251    fn table_aligns_cyrillic_cells_by_display_width() {
252        let buf = Arc::new(Mutex::new(String::new()));
253        let sink = StringSink(buf.clone());
254        let r = Renderer::new(Theme::default(), Verbosity::Normal);
255        let t = Table::new(["Name", "City"])
256            .row(["Москва", "ru"])
257            .row(["Paris", "fr"]);
258        r.render_table(&sink, 0, &t);
259        let out = crate::output::strip_ansi(&buf.lock().unwrap().clone());
260        let lines: Vec<&str> = out.lines().collect();
261        let ru = lines
262            .iter()
263            .find(|l| l.contains("Москва"))
264            .expect("ru row missing");
265        let fr = lines
266            .iter()
267            .find(|l| l.contains("Paris"))
268            .expect("fr row missing");
269        assert_eq!(
270            prefix_display_width(ru, "ru"),
271            prefix_display_width(fr, "fr"),
272            "Cyrillic and ASCII rows must align by display width.\nout:\n{out}"
273        );
274    }
275
276    #[test]
277    fn table_does_not_panic_on_tab_or_control_chars_in_cells() {
278        // unicode_width::width treats \t and other C0 control codes as 0
279        // display cells. Lock in the current "tab/control = 0 width" outcome:
280        // the renderer must complete without panic or NaN widths, both rows
281        // must appear in the output, and the second-column value columns must
282        // align by display width across rows (the tab-bearing row's "0-width
283        // tab" + "b" column ends up shorter than "plain", padding makes up
284        // the difference).
285        let buf = Arc::new(Mutex::new(String::new()));
286        let sink = StringSink(buf.clone());
287        let r = Renderer::new(Theme::default(), Verbosity::Normal);
288        let t = Table::new(["Key", "Value"])
289            .row(["a\tb\x0Bc", "ok"])
290            .row(["plain", "yep"]);
291        r.render_table(&sink, 0, &t);
292        let out = crate::output::strip_ansi(&buf.lock().unwrap().clone());
293        assert!(out.contains("ok"), "missing ok row: {out:?}");
294        assert!(out.contains("yep"), "missing yep row: {out:?}");
295        let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
296        let ok_line = lines
297            .iter()
298            .find(|l| l.contains("ok"))
299            .expect("ok row missing");
300        let yep_line = lines
301            .iter()
302            .find(|l| l.contains("yep"))
303            .expect("yep row missing");
304        assert_eq!(
305            prefix_display_width(ok_line, "ok"),
306            prefix_display_width(yep_line, "yep"),
307            "tab/control-bearing row and plain row should align to the same display column"
308        );
309    }
310}