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