1use std::io::{Result as IoResult, Write};
6
7use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
8
9#[must_use]
11pub fn display_width_minus_ansi(s: &str) -> usize {
12 let mut w = 0usize;
13 let mut chars = s.chars().peekable();
14 while let Some(ch) = chars.next() {
15 if ch == '\x1b' {
16 if chars.peek() == Some(&'[') {
17 chars.next();
18 for c in chars.by_ref() {
19 if c.is_ascii_alphabetic() {
20 break;
21 }
22 }
23 }
24 continue;
25 }
26 w = w.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0));
27 }
28 w
29}
30
31#[must_use]
33pub fn terminal_columns() -> usize {
34 if let Ok(cols) = std::env::var("COLUMNS") {
36 if let Some(w) = parse_columns_env(Some(cols.as_str())) {
37 return w;
38 }
39 }
40 static TERMINAL_COLS: std::sync::OnceLock<Option<usize>> = std::sync::OnceLock::new();
45 if let Some(w) =
46 *TERMINAL_COLS.get_or_init(|| terminal_size::terminal_size().map(|(w, _)| w.0 as usize))
47 {
48 return w;
49 }
50 80
51}
52
53fn parse_columns_env(cols: Option<&str>) -> Option<usize> {
54 let cols = cols?;
55 let w = cols.parse::<usize>().ok()?;
56 (w > 0).then_some(w)
57}
58
59pub const FORMAT_PATCH_STAT_WIDTH: usize = 72;
61
62#[derive(Debug, Clone)]
63pub struct FileStatInput {
65 pub path_display: String,
67 pub insertions: usize,
69 pub deletions: usize,
71 pub is_binary: bool,
73 pub is_unmerged: bool,
76}
77
78#[derive(Debug, Clone)]
80pub struct DiffstatOptions<'a> {
81 pub total_width: usize,
83 pub line_prefix: &'a str,
86 pub width_prefix: &'a str,
92 pub subtract_prefix_from_terminal: bool,
96 pub terminal_width: usize,
98 pub stat_name_width: Option<usize>,
100 pub stat_graph_width: Option<usize>,
102 pub stat_count: Option<usize>,
104 pub color_add: &'a str,
106 pub color_del: &'a str,
108 pub color_reset: &'a str,
110 pub graph_bar_slack: usize,
112 pub graph_prefix_budget_slack: usize,
114}
115
116fn scale_linear(it: usize, width: usize, max_change: usize) -> usize {
117 if it == 0 || max_change == 0 {
118 return 0;
119 }
120 if width <= 1 {
121 return if it > 0 { 1 } else { 0 };
122 }
123 1 + (it * (width - 1) / max_change)
124}
125
126fn decimal_width(n: usize) -> usize {
127 if n == 0 {
128 1
129 } else {
130 format!("{n}").len()
131 }
132}
133
134fn pad_name_to_display_width(s: &str, min_cols: usize) -> String {
140 let w = s.width();
141 if w >= min_cols {
142 return s.to_string();
143 }
144 let pad = min_cols - w;
145 let mut out = String::with_capacity(s.len() + pad);
146 out.push_str(s);
147 out.push_str(&" ".repeat(pad));
148 out
149}
150
151fn truncate_path_for_name_area(path: &str, area_width: usize) -> (String, usize) {
152 let full_w = path.width();
153 if full_w <= area_width {
154 return (path.to_string(), full_w);
155 }
156 let mut len = area_width;
157 len = len.saturating_sub(3);
158 let mut byte_start = 0usize;
159 let mut name_w = full_w;
160 while name_w > len {
161 let ch = path[byte_start..].chars().next().unwrap_or('\u{fffd}');
162 let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
163 name_w = name_w.saturating_sub(cw);
164 byte_start += ch.len_utf8();
165 }
166 let rest = &path[byte_start..];
167 if let Some(slash_idx) = rest.find('/') {
168 let after = &rest[slash_idx..];
169 let after_w = after.width();
170 if after_w <= area_width {
171 return (format!("...{}", after), after_w);
172 }
173 }
174 let s = format!("...{}", rest);
175 (s.clone(), s.width())
176}
177
178pub fn format_single_stat_line(
180 path: &str,
181 insertions: usize,
182 deletions: usize,
183 total_width: usize,
184) -> IoResult<String> {
185 let mut buf = Vec::new();
186 let files = [FileStatInput {
187 path_display: path.to_string(),
188 insertions,
189 deletions,
190 is_binary: false,
191 is_unmerged: false,
192 }];
193 let opts = DiffstatOptions {
194 terminal_width: total_width,
195 total_width,
196 stat_name_width: None,
197 stat_graph_width: None,
198 stat_count: Some(1),
199 line_prefix: "",
200 width_prefix: "",
201 subtract_prefix_from_terminal: false,
202 graph_prefix_budget_slack: 0,
203 color_add: "",
204 color_del: "",
205 color_reset: "",
206 graph_bar_slack: 0,
207 };
208 write_diffstat_block(&mut buf, &files, &opts)?;
209 let text = String::from_utf8(buf)
210 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
211 Ok(text.lines().next().unwrap_or("").trim_end().to_string())
212}
213
214pub fn write_diffstat_block(
216 out: &mut impl Write,
217 files: &[FileStatInput],
218 opts: &DiffstatOptions<'_>,
219) -> IoResult<()> {
220 if files.is_empty() {
221 return Ok(());
222 }
223
224 let limit = opts.stat_count.unwrap_or(files.len()).min(files.len());
225 let shown = &files[..limit];
226
227 let mut max_len = 0usize;
228 let mut max_change = 0usize;
229 let mut number_width = 0usize;
230 let mut bin_width = 0usize;
231
232 for f in shown {
233 let w = f.path_display.width();
234 if max_len < w {
235 max_len = w;
236 }
237 if f.is_unmerged {
238 if bin_width < 8 {
240 bin_width = 8;
241 }
242 continue;
243 }
244 if f.is_binary {
245 let w = if f.insertions == 0 && f.deletions == 0 {
246 3
247 } else {
248 14 + decimal_width(f.insertions) + decimal_width(f.deletions)
249 };
250 if bin_width < w {
251 bin_width = w;
252 }
253 number_width = number_width.max(3);
254 continue;
255 }
256 let ch = f.insertions + f.deletions;
257 if max_change < ch {
258 max_change = ch;
259 }
260 }
261
262 let width_prefix = if opts.width_prefix.is_empty() {
263 opts.line_prefix
264 } else {
265 opts.width_prefix
266 };
267 let mut width = if opts.subtract_prefix_from_terminal {
268 opts.terminal_width
269 .saturating_sub(display_width_minus_ansi(width_prefix))
270 .saturating_add(opts.graph_prefix_budget_slack)
271 } else {
272 opts.total_width
273 };
274
275 number_width = number_width.max(decimal_width(max_change));
276
277 if width < 16 + 6 + number_width {
278 width = 16 + 6 + number_width;
279 }
280
281 let mut graph_width = if max_change + 4 > bin_width {
282 max_change
283 } else {
284 bin_width.saturating_sub(4)
285 };
286 if let Some(cap) = opts.stat_graph_width {
287 if cap > 0 && cap < graph_width {
288 graph_width = cap;
289 }
290 }
291
292 let mut name_width = match opts.stat_name_width {
293 Some(nw) if nw > 0 && nw < max_len => nw,
294 _ => max_len,
295 };
296
297 if name_width + number_width + 6 + graph_width > width {
298 let mut gw = graph_width;
299 let target_gw = width * 3 / 8;
300 if gw > target_gw.saturating_sub(number_width).saturating_sub(6) {
301 gw = target_gw.saturating_sub(number_width).saturating_sub(6);
302 if gw < 6 {
303 gw = 6;
304 }
305 }
306 graph_width = gw;
307 if let Some(cap) = opts.stat_graph_width {
308 if graph_width > cap {
309 graph_width = cap;
310 }
311 }
312 if name_width
313 > width
314 .saturating_sub(number_width)
315 .saturating_sub(6)
316 .saturating_sub(graph_width)
317 {
318 name_width = width
319 .saturating_sub(number_width)
320 .saturating_sub(6)
321 .saturating_sub(graph_width);
322 } else {
323 graph_width = width
324 .saturating_sub(number_width)
325 .saturating_sub(6)
326 .saturating_sub(name_width);
327 }
328 }
329
330 graph_width = graph_width.saturating_add(opts.graph_bar_slack);
331
332 let mut total_ins = 0usize;
333 let mut total_del = 0usize;
334
335 for f in shown {
336 let prefix = opts.line_prefix;
337 if f.is_unmerged {
338 let (display_name, _) = truncate_path_for_name_area(&f.path_display, name_width);
339 let name_col = pad_name_to_display_width(&display_name, name_width);
340 if prefix.is_empty() {
343 writeln!(
344 out,
345 " {} | {:>nw$}",
346 name_col,
347 "Unmerged",
348 nw = number_width
349 )?;
350 } else {
351 writeln!(
352 out,
353 "{prefix}{} | {:>nw$}",
354 name_col,
355 "Unmerged",
356 nw = number_width
357 )?;
358 }
359 continue;
360 }
361 if f.is_binary {
362 let (display_name, _) = truncate_path_for_name_area(&f.path_display, name_width);
363 let name_col = pad_name_to_display_width(&display_name, name_width);
364 if f.insertions == 0 && f.deletions == 0 {
365 if prefix.is_empty() {
366 writeln!(out, " {} | {:>nw$}", name_col, "Bin", nw = number_width)?;
367 } else {
368 writeln!(
369 out,
370 "{prefix}{} | {:>nw$}",
371 name_col,
372 "Bin",
373 nw = number_width
374 )?;
375 }
376 } else if prefix.is_empty() {
377 writeln!(
378 out,
379 " {} | {:>nw$} {} -> {} bytes",
380 name_col,
381 "Bin",
382 f.deletions,
383 f.insertions,
384 nw = number_width
385 )?;
386 } else {
387 writeln!(
388 out,
389 "{prefix}{} | {:>nw$} {} -> {} bytes",
390 name_col,
391 "Bin",
392 f.deletions,
393 f.insertions,
394 nw = number_width
395 )?;
396 }
397 continue;
398 }
399
400 let added = f.insertions;
401 let deleted = f.deletions;
402 let (display_name, _) = truncate_path_for_name_area(&f.path_display, name_width);
403 let name_col = pad_name_to_display_width(&display_name, name_width);
404
405 let mut add = added;
406 let mut del = deleted;
407 if graph_width <= max_change && max_change > 0 {
408 let total_scaled = scale_linear(added + del, graph_width, max_change);
409 let mut total = total_scaled;
410 if total < 2 && add > 0 && del > 0 {
411 total = 2;
412 }
413 if add < del {
414 add = scale_linear(add, graph_width, max_change);
415 del = total.saturating_sub(add);
416 } else {
417 del = scale_linear(del, graph_width, max_change);
418 add = total.saturating_sub(del);
419 }
420 }
421
422 total_ins = total_ins.saturating_add(added);
423 total_del = total_del.saturating_add(deleted);
424
425 let total = added + del;
426 if prefix.is_empty() {
427 write!(out, " {} | {:>nw$}", name_col, total, nw = number_width)?;
428 } else {
429 write!(
430 out,
431 "{prefix}{} | {:>nw$}",
432 name_col,
433 total,
434 nw = number_width
435 )?;
436 }
437 if total > 0 {
438 write!(out, " ")?;
439 }
440 if add > 0 {
441 if !opts.color_add.is_empty() {
442 write!(out, "{}", opts.color_add)?;
443 }
444 write!(out, "{}", "+".repeat(add))?;
445 if !opts.color_add.is_empty() && !opts.color_reset.is_empty() {
446 write!(out, "{}", opts.color_reset)?;
447 }
448 }
449 if del > 0 {
450 if !opts.color_del.is_empty() {
451 write!(out, "{}", opts.color_del)?;
452 }
453 write!(out, "{}", "-".repeat(del))?;
454 if !opts.color_del.is_empty() && !opts.color_reset.is_empty() {
455 write!(out, "{}", opts.color_reset)?;
456 }
457 }
458 writeln!(out)?;
459 }
460
461 if files.len() > limit {
462 if opts.line_prefix.is_empty() {
463 writeln!(out, " ...")?;
464 } else {
465 writeln!(out, "{}...", opts.line_prefix)?;
466 }
467 }
468
469 for f in &files[limit..] {
472 if f.is_binary {
473 continue;
474 }
475 total_ins = total_ins.saturating_add(f.insertions);
476 total_del = total_del.saturating_add(f.deletions);
477 }
478
479 let files_changed = files.iter().filter(|f| !f.is_unmerged).count();
481 let mut summary = if opts.line_prefix.is_empty() {
482 format!(
483 " {} file{} changed",
484 files_changed,
485 if files_changed == 1 { "" } else { "s" }
486 )
487 } else {
488 format!(
489 "{}{} file{} changed",
490 opts.line_prefix,
491 files_changed,
492 if files_changed == 1 { "" } else { "s" }
493 )
494 };
495 if files_changed > 0 {
498 if total_ins > 0 {
499 summary.push_str(&format!(
500 ", {} insertion{}(+)",
501 total_ins,
502 if total_ins == 1 { "" } else { "s" }
503 ));
504 }
505 if total_del > 0 {
506 summary.push_str(&format!(
507 ", {} deletion{}(-)",
508 total_del,
509 if total_del == 1 { "" } else { "s" }
510 ));
511 }
512 if total_ins == 0 && total_del == 0 {
513 summary.push_str(", 0 insertions(+), 0 deletions(-)");
514 }
515 }
516 writeln!(out, "{summary}")?;
517
518 Ok(())
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 #[test]
526 fn pad_name_leaves_string_when_already_wide_enough() {
527 assert_eq!(pad_name_to_display_width("hello", 3), "hello");
528 }
529
530 #[test]
531 fn pad_name_matches_git_display_columns_for_wide_chars() {
532 let truncated = ".../f再见";
534 assert_eq!(truncated.width(), 9);
535 let padded = pad_name_to_display_width(truncated, 10);
536 assert_eq!(padded.width(), 10);
537 assert_eq!(padded, ".../f再见 ");
538 }
539
540 #[test]
541 fn display_width_minus_ansi_skips_csi_sequences() {
542 let s = "\x1b[31mhello\x1b[m";
543 assert_eq!(display_width_minus_ansi(s), "hello".width());
544 }
545
546 #[test]
547 fn write_diffstat_unmerged_binary_and_truncation() {
548 let files = vec![
549 FileStatInput {
550 path_display: "data.bin".into(),
551 insertions: 100,
552 deletions: 50,
553 is_binary: true,
554 is_unmerged: false,
555 },
556 FileStatInput {
557 path_display: "src/very/long/path/that/exceeds/the/name/area/width/file.rs".into(),
558 insertions: 3,
559 deletions: 1,
560 is_binary: false,
561 is_unmerged: false,
562 },
563 FileStatInput {
564 path_display: "conflict.txt".into(),
565 insertions: 0,
566 deletions: 0,
567 is_binary: false,
568 is_unmerged: true,
569 },
570 FileStatInput {
571 path_display: "empty.bin".into(),
572 insertions: 0,
573 deletions: 0,
574 is_binary: true,
575 is_unmerged: false,
576 },
577 ];
578 let opts = DiffstatOptions {
579 total_width: 60,
580 line_prefix: "| ",
581 width_prefix: "",
582 subtract_prefix_from_terminal: false,
583 terminal_width: 80,
584 stat_name_width: Some(20),
585 stat_graph_width: Some(8),
586 stat_count: Some(3),
587 color_add: "\x1b[32m",
588 color_del: "\x1b[31m",
589 color_reset: "\x1b[m",
590 graph_bar_slack: 1,
591 graph_prefix_budget_slack: 0,
592 };
593 let mut buf = Vec::new();
594 write_diffstat_block(&mut buf, &files, &opts).unwrap();
595 let s = String::from_utf8(buf).unwrap();
596 assert!(s.contains("Unmerged"));
597 assert!(s.contains("..."));
598 assert!(s.contains("bytes") || s.contains("Bin"));
599 assert!(s.contains("file"));
600 assert!(s.contains("changed"));
601 }
602
603 #[test]
604 fn write_diffstat_subtract_prefix_and_zero_change_summary() {
605 let files = vec![FileStatInput {
606 path_display: "only-unmerged".into(),
607 insertions: 0,
608 deletions: 0,
609 is_binary: false,
610 is_unmerged: true,
611 }];
612 let opts = DiffstatOptions {
613 total_width: 80,
614 line_prefix: "",
615 width_prefix: "graph ",
616 subtract_prefix_from_terminal: true,
617 terminal_width: 40,
618 stat_name_width: None,
619 stat_graph_width: None,
620 stat_count: None,
621 color_add: "",
622 color_del: "",
623 color_reset: "",
624 graph_bar_slack: 0,
625 graph_prefix_budget_slack: 2,
626 };
627 let mut buf = Vec::new();
628 write_diffstat_block(&mut buf, &files, &opts).unwrap();
629 let s = String::from_utf8(buf).unwrap();
630 assert!(s.contains("0 files changed"));
631 }
632
633 #[test]
634 fn write_diffstat_mixed_add_del_scales_graph() {
635 let files = vec![FileStatInput {
636 path_display: "x.txt".into(),
637 insertions: 40,
638 deletions: 10,
639 is_binary: false,
640 is_unmerged: false,
641 }];
642 let opts = DiffstatOptions {
643 total_width: 80,
644 line_prefix: "",
645 width_prefix: "",
646 subtract_prefix_from_terminal: false,
647 terminal_width: 80,
648 stat_name_width: None,
649 stat_graph_width: Some(6),
650 stat_count: None,
651 color_add: "",
652 color_del: "",
653 color_reset: "",
654 graph_bar_slack: 0,
655 graph_prefix_budget_slack: 0,
656 };
657 let mut buf = Vec::new();
658 write_diffstat_block(&mut buf, &files, &opts).unwrap();
659 let s = String::from_utf8(buf).unwrap();
660 let line = s.lines().next().expect("stat line");
661 assert!(line.contains('+'));
662 }
663
664 #[test]
665 fn diffstat_name_width_10_matches_git_padding() {
666 let files = vec![FileStatInput {
667 path_display: "d你好/f再见".to_string(),
668 insertions: 0,
669 deletions: 0,
670 is_binary: false,
671 is_unmerged: false,
672 }];
673 let opts = DiffstatOptions {
674 total_width: 80,
675 line_prefix: "",
676 width_prefix: "",
677 subtract_prefix_from_terminal: false,
678 terminal_width: 80,
679 stat_name_width: Some(10),
680 stat_graph_width: None,
681 stat_count: None,
682 color_add: "",
683 color_del: "",
684 color_reset: "",
685 graph_bar_slack: 0,
686 graph_prefix_budget_slack: 0,
687 };
688 let mut buf = Vec::new();
689 write_diffstat_block(&mut buf, &files, &opts).unwrap();
690 let s = String::from_utf8(buf).unwrap();
691 let line = s.lines().next().unwrap();
692 assert!(
693 line.contains(".../f再见 |"),
694 "expected two spaces before pipe like git, got {line:?}"
695 );
696 }
697
698 #[test]
699 fn write_diffstat_empty_files_is_noop() {
700 let opts = DiffstatOptions {
701 total_width: 80,
702 line_prefix: "",
703 width_prefix: "",
704 subtract_prefix_from_terminal: false,
705 terminal_width: 80,
706 stat_name_width: None,
707 stat_graph_width: None,
708 stat_count: None,
709 color_add: "",
710 color_del: "",
711 color_reset: "",
712 graph_bar_slack: 0,
713 graph_prefix_budget_slack: 0,
714 };
715 let mut buf = Vec::new();
716 write_diffstat_block(&mut buf, &[], &opts).unwrap();
717 assert!(buf.is_empty());
718 }
719
720 #[test]
721 fn terminal_columns_returns_positive_width() {
722 assert!(terminal_columns() >= 80);
723 }
724
725 #[test]
726 fn parse_columns_env_rejects_zero_and_invalid() {
727 assert_eq!(parse_columns_env(Some("0")), None);
728 assert_eq!(parse_columns_env(Some("nope")), None);
729 assert_eq!(parse_columns_env(Some("40")), Some(40));
730 }
731
732 #[test]
733 fn truncate_path_prefers_suffix_after_slash() {
734 let long = "aaaa/bbbb/cccc/dddd/eeee/ffff/gggg/hhhh/iiii/jjjj/kkkk/llll/mmmm/nnnn/oooo/pppp/qqqq/rrrr/ssss/tttt/uuuu/vvvv/wwww/xxxx/yyyy/zzzz/final.rs";
735 let (display, w) = truncate_path_for_name_area(long, 24);
736 assert!(display.starts_with("..."));
737 assert!(display.contains("final.rs") || display.contains('/'));
738 assert!(w <= 24);
739 }
740
741 #[test]
742 fn write_diffstat_deletions_heavier_than_insertions() {
743 let files = vec![FileStatInput {
744 path_display: "y.txt".into(),
745 insertions: 2,
746 deletions: 50,
747 is_binary: false,
748 is_unmerged: false,
749 }];
750 let opts = DiffstatOptions {
751 total_width: 80,
752 line_prefix: "",
753 width_prefix: "",
754 subtract_prefix_from_terminal: false,
755 terminal_width: 80,
756 stat_name_width: None,
757 stat_graph_width: Some(10),
758 stat_count: None,
759 color_add: "",
760 color_del: "",
761 color_reset: "",
762 graph_bar_slack: 0,
763 graph_prefix_budget_slack: 0,
764 };
765 let mut buf = Vec::new();
766 write_diffstat_block(&mut buf, &files, &opts).unwrap();
767 let s = String::from_utf8(buf).unwrap();
768 assert!(s.contains('-'));
769 assert!(s.contains("deletion"));
770 }
771
772 #[test]
773 fn write_diffstat_zero_line_changes_non_binary() {
774 let files = vec![FileStatInput {
775 path_display: "touch.txt".into(),
776 insertions: 0,
777 deletions: 0,
778 is_binary: false,
779 is_unmerged: false,
780 }];
781 let opts = DiffstatOptions {
782 total_width: 80,
783 line_prefix: "",
784 width_prefix: "",
785 subtract_prefix_from_terminal: false,
786 terminal_width: 80,
787 stat_name_width: None,
788 stat_graph_width: None,
789 stat_count: None,
790 color_add: "",
791 color_del: "",
792 color_reset: "",
793 graph_bar_slack: 0,
794 graph_prefix_budget_slack: 0,
795 };
796 let mut buf = Vec::new();
797 write_diffstat_block(&mut buf, &files, &opts).unwrap();
798 let s = String::from_utf8(buf).unwrap();
799 assert!(s.contains("0 insertions(+), 0 deletions(-)"));
800 }
801
802 #[test]
803 fn write_diffstat_stat_count_hides_lines_but_sums_summary() {
804 let files = vec![
805 FileStatInput {
806 path_display: "a.txt".into(),
807 insertions: 5,
808 deletions: 0,
809 is_binary: false,
810 is_unmerged: false,
811 },
812 FileStatInput {
813 path_display: "b.txt".into(),
814 insertions: 0,
815 deletions: 7,
816 is_binary: false,
817 is_unmerged: false,
818 },
819 ];
820 let opts = DiffstatOptions {
821 total_width: 80,
822 line_prefix: "P",
823 width_prefix: "",
824 subtract_prefix_from_terminal: false,
825 terminal_width: 80,
826 stat_name_width: None,
827 stat_graph_width: None,
828 stat_count: Some(1),
829 color_add: "",
830 color_del: "",
831 color_reset: "",
832 graph_bar_slack: 0,
833 graph_prefix_budget_slack: 0,
834 };
835 let mut buf = Vec::new();
836 write_diffstat_block(&mut buf, &files, &opts).unwrap();
837 let s = String::from_utf8(buf).unwrap();
838 assert!(s.contains("P..."));
839 assert!(s.contains("2 files changed"));
840 assert!(s.contains("5 insertion"));
841 assert!(s.contains("7 deletion"));
842 }
843
844 #[test]
845 fn display_width_minus_ansi_bare_escape_is_visible() {
846 assert_eq!(display_width_minus_ansi("\x1bX"), 1);
847 }
848
849 #[test]
850 fn write_diffstat_narrow_total_width_still_renders() {
851 let files = vec![FileStatInput {
852 path_display: "big-change.txt".into(),
853 insertions: 500,
854 deletions: 500,
855 is_binary: false,
856 is_unmerged: false,
857 }];
858 let opts = DiffstatOptions {
859 total_width: 28,
860 line_prefix: "",
861 width_prefix: "",
862 subtract_prefix_from_terminal: false,
863 terminal_width: 80,
864 stat_name_width: None,
865 stat_graph_width: None,
866 stat_count: None,
867 color_add: "\x1b[32m",
868 color_del: "\x1b[31m",
869 color_reset: "\x1b[m",
870 graph_bar_slack: 0,
871 graph_prefix_budget_slack: 0,
872 };
873 let mut buf = Vec::new();
874 write_diffstat_block(&mut buf, &files, &opts).unwrap();
875 let s = String::from_utf8(buf).unwrap();
876 assert!(s.contains("file changed"));
877 assert!(s.contains("insertion"));
878 assert!(s.contains("deletion"));
879 }
880
881 #[test]
882 fn write_diffstat_summary_uses_line_prefix() {
883 let files = vec![FileStatInput {
884 path_display: "a.txt".into(),
885 insertions: 2,
886 deletions: 0,
887 is_binary: false,
888 is_unmerged: false,
889 }];
890 let opts = DiffstatOptions {
891 total_width: 80,
892 line_prefix: ">> ",
893 width_prefix: "",
894 subtract_prefix_from_terminal: false,
895 terminal_width: 80,
896 stat_name_width: None,
897 stat_graph_width: None,
898 stat_count: None,
899 color_add: "",
900 color_del: "",
901 color_reset: "",
902 graph_bar_slack: 0,
903 graph_prefix_budget_slack: 0,
904 };
905 let mut buf = Vec::new();
906 write_diffstat_block(&mut buf, &files, &opts).unwrap();
907 let s = String::from_utf8(buf).unwrap();
908 assert!(s.contains(">> 1 file changed"));
909 assert!(s.contains("2 insertion"));
910 }
911
912 #[test]
913 fn write_diffstat_binary_bin_only_with_prefix() {
914 let files = vec![FileStatInput {
915 path_display: "x.bin".into(),
916 insertions: 0,
917 deletions: 0,
918 is_binary: true,
919 is_unmerged: false,
920 }];
921 let opts = DiffstatOptions {
922 total_width: 60,
923 line_prefix: "> ",
924 width_prefix: "",
925 subtract_prefix_from_terminal: false,
926 terminal_width: 80,
927 stat_name_width: None,
928 stat_graph_width: None,
929 stat_count: None,
930 color_add: "",
931 color_del: "",
932 color_reset: "",
933 graph_bar_slack: 0,
934 graph_prefix_budget_slack: 0,
935 };
936 let mut buf = Vec::new();
937 write_diffstat_block(&mut buf, &files, &opts).unwrap();
938 let s = String::from_utf8(buf).unwrap();
939 assert!(s.contains("> "));
940 assert!(s.contains("Bin"));
941 }
942}