1use std::fmt::Write as _;
17
18use anstyle::Style;
19use tabled::builder::Builder;
20use tabled::settings::peaker::Priority;
21use tabled::settings::{Padding, Width};
22
23use crate::render::Context;
24use crate::render::style::{Painter, Palette};
25
26#[derive(Clone, Copy)]
33pub enum Paint {
34 Fixed(Style),
35 ByValue(fn(&str) -> Style),
36 ByOther {
43 source: usize,
45 pick: fn(&str) -> Style,
46 },
47}
48
49impl std::fmt::Debug for Paint {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 match self {
52 Self::Fixed(style) => f.debug_tuple("Fixed").field(style).finish(),
53 Self::ByValue(_) => f.write_str("ByValue(..)"),
54 Self::ByOther { source, .. } => write!(f, "ByOther({source})"),
55 }
56 }
57}
58
59impl Paint {
60 fn style(self, cell: &str, row: &[String]) -> Style {
61 match self {
62 Self::Fixed(style) => style,
63 Self::ByValue(pick) => pick(cell),
64 Self::ByOther { source, pick } => row.get(source).map_or_else(Style::new, |value| {
65 if value.is_empty() {
66 Style::new()
67 } else {
68 pick(value)
69 }
70 }),
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy)]
77pub struct Column {
78 pub header: &'static str,
79 pub width: usize,
81 pub truncate: bool,
84 pub paint: Paint,
85}
86
87impl Column {
88 #[must_use]
89 pub const fn new(header: &'static str, width: usize, style: Style) -> Self {
90 Self {
91 header,
92 width,
93 truncate: true,
94 paint: Paint::Fixed(style),
95 }
96 }
97
98 #[must_use]
100 pub const fn whole(header: &'static str, width: usize, style: Style) -> Self {
101 Self {
102 truncate: false,
103 ..Self::new(header, width, style)
104 }
105 }
106
107 #[must_use]
109 pub const fn by_value(header: &'static str, width: usize, pick: fn(&str) -> Style) -> Self {
110 Self {
111 header,
112 width,
113 truncate: true,
114 paint: Paint::ByValue(pick),
115 }
116 }
117
118 #[must_use]
124 pub const fn by_other(
125 header: &'static str,
126 width: usize,
127 source: usize,
128 pick: fn(&str) -> Style,
129 ) -> Self {
130 Self {
131 header,
132 width,
133 truncate: true,
134 paint: Paint::ByOther { source, pick },
135 }
136 }
137}
138
139#[must_use]
141pub fn render(columns: &[Column], rows: &[Vec<String>], ctx: &Context) -> String {
142 if ctx.is_human() {
143 human(columns, rows, ctx)
144 } else {
145 machine(columns, rows)
146 }
147}
148
149fn machine(columns: &[Column], rows: &[Vec<String>]) -> String {
154 let mut out = String::with_capacity(rows.len() * 80);
155
156 for row in rows {
157 let mut line = String::with_capacity(80);
158 let printed = row.len().min(columns.len());
159 for (index, cell) in row.iter().take(printed).enumerate() {
160 let Some(column) = columns.get(index) else {
161 continue;
162 };
163 let value = if column.truncate {
164 truncate(cell, column.width)
165 } else {
166 cell.clone()
167 };
168 if index + 1 == printed {
169 line.push_str(&value);
170 } else {
171 let _ = write!(
172 line,
173 "{value}{} ",
174 " ".repeat(column.width.saturating_sub(value.chars().count()))
175 );
176 }
177 }
178 let _ = writeln!(out, "{}", line.trim_end());
179 }
180
181 out
182}
183
184fn human(columns: &[Column], rows: &[Vec<String>], ctx: &Context) -> String {
186 if rows.is_empty() {
187 return String::new();
188 }
189 let paint = ctx.painter();
190
191 let mut builder = Builder::with_capacity(rows.len() + 1, columns.len());
192 builder.push_record(
193 columns
194 .iter()
195 .map(|column| paint.paint(column.header, Palette::label())),
196 );
197 for row in rows {
198 builder.push_record(paint_row(columns, row, paint));
199 }
200
201 let mut table = builder.build();
202 table
203 .with(tabled::settings::Style::blank())
204 .with(Padding::new(0, 2, 0, 0));
205
206 table.with(
209 Width::truncate(ctx.width)
210 .suffix("…")
211 .priority(Priority::max(true)),
212 );
213
214 let mut out = String::with_capacity(rows.len() * 96);
217 for line in table.to_string().lines() {
218 let _ = writeln!(out, "{}", line.trim_end());
219 }
220 out
221}
222
223fn paint_row(columns: &[Column], row: &[String], paint: Painter) -> Vec<String> {
224 row.iter()
225 .take(columns.len())
226 .enumerate()
227 .map(|(index, cell)| match columns.get(index) {
228 Some(column) => paint.paint(cell, column.paint.style(cell, row)),
229 None => cell.clone(),
230 })
231 .collect()
232}
233
234#[must_use]
241pub fn tally(shown: usize, total: Option<u64>, next_page: Option<u32>, ctx: &Context) -> String {
242 let paint = ctx.painter();
243 let counted = match total {
244 Some(total) => format!("shown {shown} of {total}"),
245 None => format!("shown {shown} of unknown total"),
246 };
247
248 let mut out = paint.paint(&counted, Palette::label());
249 if let Some(page) = next_page {
250 out.push_str(&paint.paint(&format!(" — next: --page {page}"), Palette::warn()));
251 }
252 out.push('\n');
253 out
254}
255
256pub(crate) fn truncate(value: &str, width: usize) -> String {
257 if value.chars().count() <= width {
258 return value.to_owned();
259 }
260 let mut kept: String = value.chars().take(width.saturating_sub(1)).collect();
261 kept.push('…');
262 kept
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use crate::render::{Audience, Format};
269
270 fn ctx(audience: Audience) -> Context {
271 Context {
272 format: Format::Text,
273 audience,
274 description_lines: None,
275 extra_fields: Vec::new(),
276 width: 80,
277 images: false,
278 inline: crate::render::image::Inline::default(),
279 }
280 }
281
282 fn columns() -> Vec<Column> {
283 vec![
284 Column::whole("KEY", 12, Palette::key()),
285 Column::new("SUMMARY", 40, Style::new()),
286 ]
287 }
288
289 fn rows() -> Vec<Vec<String>> {
290 vec![
291 vec!["PROJ-1".to_owned(), "short".to_owned()],
292 vec!["PROJ-22".to_owned(), "a longer summary".to_owned()],
293 ]
294 }
295
296 #[test]
299 fn a_pipe_puts_every_column_at_a_fixed_offset() {
300 let out = machine(&columns(), &rows());
301 for (line, row) in out.lines().zip(rows()) {
302 let summary: String = line.chars().skip(13).collect();
303 assert_eq!(summary, row[1], "the second column moved");
304 }
305 }
306
307 #[test]
308 fn a_pipe_gets_no_trailing_padding() {
309 let out = machine(&columns(), &rows());
310 assert!(out.lines().all(|line| !line.ends_with(' ')));
311 }
312
313 #[test]
316 fn both_forms_carry_the_same_values() {
317 let piped = machine(&columns(), &rows());
318 let terminal = human(&columns(), &rows(), &ctx(Audience::Human));
319
320 for row in rows() {
321 for cell in row {
322 assert!(piped.contains(&cell), "{cell} missing from the pipe form");
323 assert!(
324 terminal.contains(&cell),
325 "{cell} missing from the terminal form"
326 );
327 }
328 }
329 }
330
331 #[test]
334 fn a_key_is_never_cut() {
335 let long = vec![vec!["PROJECT-1234567890".to_owned(), "summary".to_owned()]];
336 assert!(machine(&columns(), &long).contains("PROJECT-1234567890"));
337 }
338
339 #[test]
340 fn an_over_long_value_is_cut_with_an_ellipsis() {
341 assert_eq!(truncate("abcdef", 4), "abc…");
342 assert_eq!(truncate("abc", 4), "abc");
343 }
344
345 #[test]
346 fn a_terminal_table_stays_inside_the_window() {
347 let wide = vec![vec!["PROJ-1".to_owned(), "x".repeat(400)]];
348 let narrow = Context {
349 width: 40,
350 ..ctx(Audience::Human)
351 };
352 let out = human(&columns(), &wide, &narrow);
353 assert!(
354 out.lines()
355 .all(|line| strip_ansi(line).chars().count() <= 40),
356 "a line ran past the window"
357 );
358 }
359
360 fn strip_ansi(text: &str) -> String {
361 let mut out = String::with_capacity(text.len());
362 let mut chars = text.chars();
363 while let Some(c) = chars.next() {
364 if c != '\u{1b}' {
365 out.push(c);
366 continue;
367 }
368 for c in chars.by_ref() {
369 if c.is_ascii_alphabetic() {
370 break;
371 }
372 }
373 }
374 out
375 }
376
377 #[test]
378 fn the_tally_names_the_next_page_when_there_is_one() {
379 let ctx = ctx(Audience::Machine);
380 assert_eq!(
381 tally(25, Some(340), Some(2), &ctx),
382 "shown 25 of 340 — next: --page 2\n"
383 );
384 assert_eq!(tally(1, Some(1), None, &ctx), "shown 1 of 1\n");
385 assert_eq!(tally(1, None, None, &ctx), "shown 1 of unknown total\n");
386 }
387}