cfgd_core/output/renderer/
table.rs1use 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 #[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 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 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 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 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 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 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}