1use console::measure_text_width;
2use crossterm::style::{Color, Stylize};
3use terminal_size::{terminal_size, Width};
4use unicode_segmentation::UnicodeSegmentation;
5
6#[derive(Clone, Copy)]
7pub enum Align {
8 Left,
9 Center,
10 Right,
11}
12
13#[cfg(feature = "table-presets")]
14pub fn render_table_preset_heavy_cyan_separators(
16 headers: &[&str],
17 rows: &[Vec<&str>],
18 mode: TableMode,
19 alignments: Option<&[Align]>,
20 trunc_modes: Option<&[TruncateMode]>,
21 row_separators: bool,
22) -> String {
23 render_table_with_opts_styled(
24 headers,
25 rows,
26 mode,
27 TableStyle::Heavy,
28 alignments,
29 trunc_modes,
30 true,
31 row_separators,
32 Some(crate::output::CYAN),
33 Some(crate::output::DARK_BLUE),
34 )
35}
36
37#[cfg(feature = "table-presets")]
38pub fn render_table_preset_minimal_magenta_grey_zebra(
40 headers: &[&str],
41 rows: &[Vec<&str>],
42 mode: TableMode,
43 alignments: Option<&[Align]>,
44 trunc_modes: Option<&[TruncateMode]>,
45 row_separators: bool,
46) -> String {
47 render_table_with_opts_styled(
48 headers,
49 rows,
50 mode,
51 TableStyle::Ascii,
52 alignments,
53 trunc_modes,
54 true,
55 row_separators,
56 Some(crate::output::MAGENTA),
57 Some(crate::output::LIGHT_GREY),
58 )
59}
60
61pub fn write_table_markdown(
63 path: &str,
64 headers: &[&str],
65 rows: &[Vec<&str>],
66) -> std::io::Result<()> {
67 std::fs::write(path, render_table_markdown(headers, rows))
68}
69
70pub fn write_table_csv(path: &str, headers: &[&str], rows: &[Vec<&str>]) -> std::io::Result<()> {
72 std::fs::write(path, render_table_csv(headers, rows))
73}
74
75#[allow(clippy::too_many_arguments)]
77pub fn render_table_with_opts_styled(
78 headers: &[&str],
79 rows: &[Vec<&str>],
80 mode: TableMode,
81 style: TableStyle,
82 alignments: Option<&[Align]>,
83 trunc_modes: Option<&[TruncateMode]>,
84 zebra: bool,
85 row_separators: bool,
86 header_fg: Option<Color>,
87 zebra_bg: Option<Color>,
88) -> String {
89 let term_width = terminal_size()
90 .map(|(Width(w), _)| w as usize)
91 .unwrap_or(80);
92 let col_count = headers.len().max(1);
93 let padding: usize = 1;
94 let total_padding = (col_count - 1) * padding;
95
96 let col_width = match mode {
97 TableMode::Fixed(width) => width,
98 TableMode::Full => {
99 let border_space = col_count + 1;
100 let usable = term_width.saturating_sub(border_space);
101 usable / col_count
102 }
103 TableMode::Flex => {
104 let content_max = headers
105 .iter()
106 .map(|h| measure_text_width(h))
107 .chain(
108 rows.iter()
109 .flat_map(|r| r.iter().map(|c| measure_text_width(c))),
110 )
111 .max()
112 .unwrap_or(10);
113 content_max.min((term_width.saturating_sub(total_padding)) / col_count)
114 }
115 };
116
117 let border = match style {
118 TableStyle::Ascii => BorderSet::ascii(),
119 TableStyle::Rounded => BorderSet::rounded(),
120 TableStyle::Heavy => BorderSet::heavy(),
121 };
122
123 let mut out = String::with_capacity(128);
124
125 out.push(border.top_left);
127 for i in 0..col_count {
128 out.push_str(&border.horizontal.to_string().repeat(col_width));
129 if i < col_count - 1 {
130 out.push(border.top_cross);
131 }
132 }
133 out.push(border.top_right);
134 out.push('\n');
135
136 out.push(border.vertical);
138 for h in headers.iter() {
139 let a = pick_align(0, alignments);
140 let t = pick_trunc(0, trunc_modes);
141 let mut cell = pad_cell_with(h, col_width, a, t);
142 if let Some(color) = header_fg {
143 cell = cell.with(color).bold().to_string();
144 }
145 out.push_str(&cell);
146 out.push(border.vertical);
147 }
148 out.push('\n');
149
150 out.push(border.mid_left);
152 for i in 0..col_count {
153 out.push_str(&border.inner_horizontal.to_string().repeat(col_width));
154 if i < col_count - 1 {
155 out.push(border.mid_cross);
156 }
157 }
158 out.push(border.mid_right);
159 out.push('\n');
160
161 for (ri, row) in rows.iter().enumerate() {
163 out.push(border.vertical);
164 for (ci, cell) in row.iter().enumerate() {
165 let a = pick_align(ci, alignments);
166 let t = pick_trunc(ci, trunc_modes);
167 let base = pad_cell_with(cell, col_width, a, t);
168 let styled = if zebra && (ri % 2 == 1) {
169 if let Some(bg) = zebra_bg {
170 base.on(bg).to_string()
171 } else {
172 base
173 }
174 } else {
175 base
176 };
177 out.push_str(&styled);
178 out.push(border.vertical);
179 }
180 out.push('\n');
181
182 if row_separators && ri < rows.len() - 1 {
183 out.push(border.mid_left);
184 for i in 0..col_count {
185 out.push_str(&border.inner_horizontal.to_string().repeat(col_width));
186 if i < col_count - 1 {
187 out.push(border.mid_cross);
188 }
189 }
190 out.push(border.mid_right);
191 out.push('\n');
192 }
193 }
194
195 out.push(border.bottom_left);
197 for i in 0..col_count {
198 out.push_str(&border.horizontal.to_string().repeat(col_width));
199 if i < col_count - 1 {
200 out.push(border.bottom_cross);
201 }
202 }
203 out.push(border.bottom_right);
204 out.push('\n');
205
206 out
207}
208
209pub fn render_table_markdown(headers: &[&str], rows: &[Vec<&str>]) -> String {
213 let mut out = String::new();
214 out.push('|');
216 for h in headers {
217 out.push(' ');
218 out.push_str(&escape_md(h));
219 out.push(' ');
220 out.push('|');
221 }
222 out.push('\n');
223 out.push('|');
225 for _ in headers {
226 out.push_str(" --- |");
227 }
228 out.push('\n');
229 for row in rows {
231 out.push('|');
232 for cell in row {
233 out.push(' ');
234 out.push_str(&escape_md(cell));
235 out.push(' ');
236 out.push('|');
237 }
238 out.push('\n');
239 }
240 out
241}
242
243pub fn render_table_csv(headers: &[&str], rows: &[Vec<&str>]) -> String {
245 let mut out = String::new();
246 out.push_str(&join_csv(headers.iter().copied()));
247 out.push('\n');
248 for row in rows {
249 out.push_str(&join_csv(row.iter().copied()));
250 out.push('\n');
251 }
252 out
253}
254
255pub fn render_table_json(headers: &[&str], rows: &[Vec<&str>]) -> String {
257 use std::fmt::Write as _;
258 let mut out = String::from("[");
259 for (ri, row) in rows.iter().enumerate() {
260 if ri > 0 {
261 out.push(',');
262 }
263 out.push('{');
264 for (ci, h) in headers.iter().enumerate() {
265 if ci > 0 {
266 out.push(',');
267 }
268 let _ = write!(
269 out,
270 "\"{}\":{}",
271 escape_json(h),
272 json_string(row.get(ci).copied().unwrap_or(""))
273 );
274 }
275 out.push('}');
276 }
277 out.push(']');
278 out
279}
280
281fn escape_md(s: &str) -> String {
282 s.replace('|', "\\|")
283}
284
285fn join_csv<'a, I: IntoIterator<Item = &'a str>>(iter: I) -> String {
286 let mut first = true;
287 let mut s = String::new();
288 for field in iter {
289 if !first {
290 s.push(',');
291 } else {
292 first = false;
293 }
294 s.push_str(&csv_field(field));
295 }
296 s
297}
298
299fn csv_field(s: &str) -> String {
300 let need_quotes = s.contains(',') || s.contains('"') || s.contains('\n');
301 if need_quotes {
302 let escaped = s.replace('"', "\"\"");
303 format!("\"{escaped}\"")
304 } else {
305 s.to_string()
306 }
307}
308
309fn escape_json(s: &str) -> String {
310 s.replace('"', "\\\"")
311}
312fn json_string(s: &str) -> String {
313 format!("\"{}\"", escape_json(s))
314}
315
316#[derive(Clone, Copy)]
317pub enum TruncateMode {
318 End,
319 Middle,
320 Start,
321}
322
323pub enum TableMode {
324 Flex,
325 Fixed(usize),
326 Full,
327}
328
329pub enum TableStyle {
330 Ascii,
331 Rounded,
332 Heavy,
333}
334
335#[cfg(feature = "table-presets")]
336impl TableStyle {
337 #[inline]
338 pub fn ascii_preset() -> Self {
339 TableStyle::Ascii
340 }
341 #[inline]
342 pub fn rounded_preset() -> Self {
343 TableStyle::Rounded
344 }
345 #[inline]
346 pub fn heavy_preset() -> Self {
347 TableStyle::Heavy
348 }
349}
350
351pub fn render_table(
352 headers: &[&str],
353 rows: &[Vec<&str>],
354 mode: TableMode,
355 style: TableStyle,
356) -> String {
357 render_table_with(headers, rows, mode, style, None, None)
358}
359
360pub fn render_table_with(
362 headers: &[&str],
363 rows: &[Vec<&str>],
364 mode: TableMode,
365 style: TableStyle,
366 alignments: Option<&[Align]>,
367 trunc_modes: Option<&[TruncateMode]>,
368) -> String {
369 render_table_with_opts(
370 headers,
371 rows,
372 mode,
373 style,
374 alignments,
375 trunc_modes,
376 false,
377 false,
378 )
379}
380
381#[allow(clippy::too_many_arguments)]
383pub fn render_table_with_opts(
384 headers: &[&str],
385 rows: &[Vec<&str>],
386 mode: TableMode,
387 style: TableStyle,
388 alignments: Option<&[Align]>,
389 trunc_modes: Option<&[TruncateMode]>,
390 zebra: bool,
391 row_separators: bool,
392) -> String {
393 let term_width = terminal_size()
394 .map(|(Width(w), _)| w as usize)
395 .unwrap_or(80);
396 let col_count = headers.len().max(1);
397 let padding: usize = 1;
398 let total_padding = (col_count - 1) * padding;
399
400 let col_width = match mode {
401 TableMode::Fixed(width) => width,
402 TableMode::Full => {
403 let border_space = col_count + 1; let usable = term_width.saturating_sub(border_space);
405 usable / col_count
406 }
407 TableMode::Flex => {
408 let content_max = headers
409 .iter()
410 .map(|h| measure_text_width(h))
411 .chain(
412 rows.iter()
413 .flat_map(|r| r.iter().map(|c| measure_text_width(c))),
414 )
415 .max()
416 .unwrap_or(10);
417 content_max.min((term_width.saturating_sub(total_padding)) / col_count)
418 }
419 };
420
421 let border = match style {
422 TableStyle::Ascii => BorderSet::ascii(),
423 TableStyle::Rounded => BorderSet::rounded(),
424 TableStyle::Heavy => BorderSet::heavy(),
425 };
426
427 let mut out = String::with_capacity(128);
428
429 out.push(border.top_left);
431 for i in 0..col_count {
432 out.push_str(&border.horizontal.to_string().repeat(col_width));
433 if i < col_count - 1 {
434 out.push(border.top_cross);
435 }
436 }
437 out.push(border.top_right);
438 out.push('\n');
439
440 out.push(border.vertical);
442 for h in headers.iter() {
443 let a = pick_align(0, alignments);
444 let t = pick_trunc(0, trunc_modes);
445 out.push_str(&pad_cell_with(h, col_width, a, t));
446 out.push(border.vertical);
447 }
448 out.push('\n');
449
450 out.push(border.mid_left);
452 for i in 0..col_count {
453 out.push_str(&border.inner_horizontal.to_string().repeat(col_width));
454 if i < col_count - 1 {
455 out.push(border.mid_cross);
456 }
457 }
458 out.push(border.mid_right);
459 out.push('\n');
460
461 for (ri, row) in rows.iter().enumerate() {
463 out.push(border.vertical);
464 for (ci, cell) in row.iter().enumerate() {
465 let a = pick_align(ci, alignments);
466 let t = pick_trunc(ci, trunc_modes);
467 let mut cell_s = pad_cell_with(cell, col_width, a, t);
468 if zebra && (ri % 2 == 1) {
469 cell_s = cell_s.replace(' ', "·");
472 }
473 out.push_str(&cell_s);
474 out.push(border.vertical);
475 }
476 out.push('\n');
477
478 if row_separators && ri < rows.len() - 1 {
479 out.push(border.mid_left);
481 for i in 0..col_count {
482 out.push_str(&border.inner_horizontal.to_string().repeat(col_width));
483 if i < col_count - 1 {
484 out.push(border.mid_cross);
485 }
486 }
487 out.push(border.mid_right);
488 out.push('\n');
489 }
490 }
491
492 out.push(border.bottom_left);
494 for i in 0..col_count {
495 out.push_str(&border.horizontal.to_string().repeat(col_width));
496 if i < col_count - 1 {
497 out.push(border.bottom_cross);
498 }
499 }
500 out.push(border.bottom_right);
501 out.push('\n');
502
503 out
504}
505
506#[derive(Clone, Copy)]
508pub enum ColWidth {
509 Fixed(usize),
510 Percent(u16),
511 Auto,
512}
513
514#[allow(clippy::too_many_arguments)]
516pub fn render_table_with_columns(
517 headers: &[&str],
518 rows: &[Vec<&str>],
519 style: TableStyle,
520 columns: &[ColWidth],
521 alignments: Option<&[Align]>,
522 trunc_modes: Option<&[TruncateMode]>,
523 zebra: bool,
524 row_separators: bool,
525) -> String {
526 let term_width = terminal_size()
527 .map(|(Width(w), _)| w as usize)
528 .unwrap_or(80);
529 let col_count = headers.len().max(1);
530 let padding: usize = 1;
531 let gaps_total = padding.saturating_mul(col_count.saturating_sub(1));
532
533 let mut widths = vec![0usize; col_count];
535 let mut fixed_total = 0usize;
536 let mut pct_total = 0u16;
537 let mut auto_count = 0usize;
538 for (i, spec) in columns.iter().enumerate().take(col_count) {
539 match spec {
540 ColWidth::Fixed(w) => {
541 widths[i] = *w;
542 fixed_total = fixed_total.saturating_add(*w);
543 }
544 ColWidth::Percent(p) => {
545 pct_total = pct_total.saturating_add(*p);
546 }
547 ColWidth::Auto => {
548 auto_count += 1;
549 }
550 }
551 }
552
553 let base_rem = term_width.saturating_sub(fixed_total + gaps_total);
554 for (i, spec) in columns.iter().enumerate().take(col_count) {
556 if let ColWidth::Percent(p) = spec {
557 let w = ((base_rem as u128) * (*p as u128) / 100u128) as usize;
558 widths[i] = w;
559 }
560 }
561 let used_except_auto: usize = widths.iter().sum();
563 let remaining = term_width.saturating_sub(used_except_auto + gaps_total);
564 let auto_share = if auto_count > 0 {
565 remaining / auto_count
566 } else {
567 0
568 };
569 for (i, spec) in columns.iter().enumerate().take(col_count) {
570 if matches!(spec, ColWidth::Auto) {
571 widths[i] = auto_share;
572 }
573 }
574
575 let border = match style {
576 TableStyle::Ascii => BorderSet::ascii(),
577 TableStyle::Rounded => BorderSet::rounded(),
578 TableStyle::Heavy => BorderSet::heavy(),
579 };
580 let mut out = String::with_capacity(128);
581
582 out.push(border.top_left);
584 for (i, w) in widths.iter().enumerate() {
585 out.push_str(&border.horizontal.to_string().repeat(*w));
586 if i < widths.len() - 1 {
587 out.push(border.top_cross);
588 }
589 }
590 out.push(border.top_right);
591 out.push('\n');
592
593 out.push(border.vertical);
595 for (ci, h) in headers.iter().enumerate() {
596 let a = pick_align(ci, alignments);
597 let t = pick_trunc(ci, trunc_modes);
598 out.push_str(&pad_cell_with(h, widths[ci].max(1), a, t));
599 out.push(border.vertical);
600 }
601 out.push('\n');
602
603 out.push(border.mid_left);
605 for (i, w) in widths.iter().enumerate() {
606 out.push_str(&border.inner_horizontal.to_string().repeat(*w));
607 if i < widths.len() - 1 {
608 out.push(border.mid_cross);
609 }
610 }
611 out.push(border.mid_right);
612 out.push('\n');
613
614 for (ri, row) in rows.iter().enumerate() {
616 out.push(border.vertical);
617 for (ci, cell) in row.iter().enumerate() {
618 let a = pick_align(ci, alignments);
619 let t = pick_trunc(ci, trunc_modes);
620 let mut cell_s = pad_cell_with(cell, widths[ci].max(1), a, t);
621 if zebra && (ri % 2 == 1) {
622 cell_s = cell_s.replace(' ', "·");
623 }
624 out.push_str(&cell_s);
625 out.push(border.vertical);
626 }
627 out.push('\n');
628
629 if row_separators && ri < rows.len() - 1 {
630 out.push(border.mid_left);
631 for (i, w) in widths.iter().enumerate() {
632 out.push_str(&border.inner_horizontal.to_string().repeat(*w));
633 if i < widths.len() - 1 {
634 out.push(border.mid_cross);
635 }
636 }
637 out.push(border.mid_right);
638 out.push('\n');
639 }
640 }
641
642 out.push(border.bottom_left);
644 for (i, w) in widths.iter().enumerate() {
645 out.push_str(&border.horizontal.to_string().repeat(*w));
646 if i < widths.len() - 1 {
647 out.push(border.bottom_cross);
648 }
649 }
650 out.push(border.bottom_right);
651 out.push('\n');
652
653 out
654}
655
656fn pick_align(idx: usize, aligns: Option<&[Align]>) -> Align {
658 aligns
659 .and_then(|arr| arr.get(idx).copied())
660 .unwrap_or(Align::Left)
661}
662
663fn pick_trunc(idx: usize, truncs: Option<&[TruncateMode]>) -> TruncateMode {
665 truncs
666 .and_then(|arr| arr.get(idx).copied())
667 .unwrap_or(TruncateMode::End)
668}
669
670fn pad_cell_with(cell: &str, width: usize, align: Align, trunc: TruncateMode) -> String {
672 let truncated = truncate_to_width_mode(cell, width, trunc);
673 let visual = measure_text_width(&truncated);
674 let pad = width.saturating_sub(visual);
675 match align {
676 Align::Left => format!("{truncated}{}", " ".repeat(pad)),
677 Align::Right => format!("{}{truncated}", " ".repeat(pad)),
678 Align::Center => {
679 let left = pad / 2;
680 let right = pad - left;
681 format!("{}{}{}", " ".repeat(left), truncated, " ".repeat(right))
682 }
683 }
684}
685
686fn truncate_to_width_mode(cell: &str, width: usize, mode: TruncateMode) -> String {
689 if width == 0 {
690 return String::new();
691 }
692 let visual = measure_text_width(cell);
693 if visual <= width {
694 return cell.to_string();
695 }
696
697 let target = width.saturating_sub(1);
699 let g = UnicodeSegmentation::graphemes(cell, true).collect::<Vec<&str>>();
701 match mode {
702 TruncateMode::End => {
703 let mut out = String::new();
704 for gr in &g {
705 let next = format!("{out}{gr}");
706 if measure_text_width(&next) > target {
707 break;
708 }
709 out.push_str(gr);
710 }
711 out.push('…');
712 out
713 }
714 TruncateMode::Start => {
715 let mut tail_rev: Vec<&str> = Vec::new();
716 for gr in g.iter().rev() {
717 let candidate = tail_rev
718 .iter()
719 .cloned()
720 .rev()
721 .chain(std::iter::once(*gr))
722 .collect::<String>();
723 if measure_text_width(&candidate) > target {
724 break;
725 }
726 tail_rev.push(gr);
727 }
728 let tail: String = tail_rev.into_iter().rev().collect();
729 format!("…{tail}")
730 }
731 TruncateMode::Middle => {
732 let mut head = String::new();
733 let mut tail_rev: Vec<&str> = Vec::new();
734 let mut left_i = 0usize;
735 let mut right_i = g.len();
736 loop {
737 let current = format!(
738 "{head}…{}",
739 tail_rev.iter().rev().cloned().collect::<String>()
740 );
741 if measure_text_width(¤t) > width {
742 break;
743 }
744 if left_i < right_i {
746 let next = format!("{head}{}", g[left_i]);
747 let cur2 = format!(
748 "{next}…{}",
749 tail_rev.iter().rev().cloned().collect::<String>()
750 );
751 if measure_text_width(&cur2) <= width {
752 head.push_str(g[left_i]);
753 left_i += 1;
754 continue;
755 }
756 }
757 if right_i > left_i {
759 let cand_tail = {
760 let mut tmp = tail_rev.clone();
761 if right_i > 0 {
762 tmp.push(g[right_i - 1]);
763 }
764 tmp
765 };
766 let cur2 = format!(
767 "{head}…{}",
768 cand_tail.iter().rev().cloned().collect::<String>()
769 );
770 if measure_text_width(&cur2) <= width {
771 tail_rev.push(g[right_i - 1]);
772 right_i -= 1;
773 continue;
774 }
775 }
776 break;
777 }
778 format!(
779 "{head}…{}",
780 tail_rev.iter().rev().cloned().collect::<String>()
781 )
782 }
783 }
784}
785
786struct BorderSet {
787 top_left: char,
788 top_right: char,
789 bottom_left: char,
790 bottom_right: char,
791 top_cross: char,
792 bottom_cross: char,
793 mid_cross: char,
794 mid_left: char,
795 mid_right: char,
796 horizontal: char,
797 inner_horizontal: char,
798 vertical: char,
799}
800
801impl BorderSet {
802 fn ascii() -> Self {
803 Self {
804 top_left: '+',
805 top_right: '+',
806 bottom_left: '+',
807 bottom_right: '+',
808 top_cross: '+',
809 bottom_cross: '+',
810 mid_cross: '+',
811 mid_left: '+',
812 mid_right: '+',
813 horizontal: '-',
814 inner_horizontal: '-',
815 vertical: '|',
816 }
817 }
818
819 fn rounded() -> Self {
820 Self {
821 top_left: '╭',
822 top_right: '╮',
823 bottom_left: '╰',
824 bottom_right: '╯',
825 top_cross: '┬',
826 bottom_cross: '┴',
827 mid_cross: '┼',
828 mid_left: '├',
829 mid_right: '┤',
830 horizontal: '─',
831 inner_horizontal: '─',
832 vertical: '│',
833 }
834 }
835
836 fn heavy() -> Self {
837 Self {
838 top_left: '┏',
839 top_right: '┓',
840 bottom_left: '┗',
841 bottom_right: '┛',
842 top_cross: '┳',
843 bottom_cross: '┻',
844 mid_cross: '╋',
845 mid_left: '┣',
846 mid_right: '┫',
847 horizontal: '━',
848 inner_horizontal: '━',
849 vertical: '┃',
850 }
851 }
852}