1use std::iter::Sum;
9use std::ops::{Add, AddAssign};
10
11use super::detail::{DiffTotals, FileDiff, LineStat};
12use super::structured::{StructuredFileDiff, StructuredHunk, StructuredLineKind};
13use super::{DiffStatus, WhitespaceMode};
14use crate::combined_diff_patch::CombinedStructuredTextDiff;
15
16impl LineStat {
17 #[must_use]
19 pub fn changes(self) -> usize {
20 self.insertions + self.deletions
21 }
22
23 #[must_use]
34 pub fn from_hunks(hunks: &[StructuredHunk]) -> Self {
35 hunks
36 .iter()
37 .flat_map(|hunk| &hunk.lines)
38 .map(|line| line.kind)
39 .collect()
40 }
41
42 #[must_use]
61 pub fn bar(self, max_changes: usize, width: usize) -> StatBar {
62 let total = self.changes();
63 if total == 0 {
64 return StatBar::default();
65 }
66 let columns = if max_changes > width {
67 (total * width).div_ceil(max_changes).max(1)
68 } else {
69 total
70 };
71 let mut plus = (2 * self.insertions * columns + total) / (2 * total);
73 let mut minus = columns.saturating_sub(plus);
74 if self.insertions > 0 && plus == 0 {
75 plus = 1;
76 minus = minus.saturating_sub(1);
77 }
78 if self.deletions > 0 && minus == 0 {
79 minus = 1;
80 plus = plus.saturating_sub(1);
81 }
82 StatBar { plus, minus }
83 }
84}
85
86impl Add for LineStat {
87 type Output = Self;
88
89 fn add(self, rhs: Self) -> Self {
90 Self {
91 insertions: self.insertions + rhs.insertions,
92 deletions: self.deletions + rhs.deletions,
93 }
94 }
95}
96
97impl AddAssign for LineStat {
98 fn add_assign(&mut self, rhs: Self) {
99 *self = *self + rhs;
100 }
101}
102
103impl Sum for LineStat {
104 fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
105 iter.fold(Self::default(), Add::add)
106 }
107}
108
109impl FromIterator<StructuredLineKind> for LineStat {
110 fn from_iter<I: IntoIterator<Item = StructuredLineKind>>(iter: I) -> Self {
113 let mut stat = Self::default();
114 for kind in iter {
115 match kind {
116 StructuredLineKind::Add => stat.insertions += 1,
117 StructuredLineKind::Del => stat.deletions += 1,
118 StructuredLineKind::Context => {}
119 }
120 }
121 stat
122 }
123}
124
125impl StructuredFileDiff {
126 #[must_use]
128 pub fn line_stat(&self) -> LineStat {
129 LineStat::from_hunks(&self.hunks)
130 }
131}
132
133impl CombinedStructuredTextDiff {
134 #[must_use]
139 pub fn line_stat(&self) -> LineStat {
140 let mut stat = LineStat::default();
141 for line in self.hunks.iter().flat_map(|hunk| &hunk.lines) {
142 if line.parents.contains(&'+') && !line.text.is_empty() {
143 stat.insertions += 1;
144 }
145 if line.parents.contains(&'-') {
146 stat.deletions += 1;
147 }
148 }
149 stat
150 }
151}
152
153impl FileDiff {
154 #[must_use]
162 pub fn line_stat(&self) -> LineStat {
163 if let Some(stat) = self.stat {
164 return stat;
165 }
166 if let Some(structured) = &self.structured {
167 return structured.line_stat();
168 }
169 self.combined
170 .as_ref()
171 .map(CombinedStructuredTextDiff::line_stat)
172 .unwrap_or_default()
173 }
174
175 #[must_use]
187 pub fn changes_all_ignored(&self, whitespace: WhitespaceMode) -> bool {
188 whitespace.is_active()
189 && self.status == DiffStatus::Modified
190 && !self.binary
191 && self.old_mode == self.new_mode
192 && self.line_stat().changes() == 0
193 }
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
198pub struct StatBar {
199 pub plus: usize,
201 pub minus: usize,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct FileStat {
208 pub path: String,
210 pub old_path: Option<String>,
212 pub status: DiffStatus,
214 pub binary: bool,
216 pub old_size: u64,
218 pub new_size: u64,
220 pub similarity: Option<u32>,
222 pub lines: LineStat,
224}
225
226impl From<&FileDiff> for FileStat {
227 fn from(file: &FileDiff) -> Self {
228 Self {
229 path: file.path.clone(),
230 old_path: file.old_path.clone(),
231 status: file.status,
232 binary: file.binary,
233 old_size: file.old_size,
234 new_size: file.new_size,
235 similarity: file.similarity,
236 lines: file.line_stat(),
237 }
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Default)]
243pub struct DiffStat {
244 files: Vec<FileStat>,
245 totals: DiffTotals,
246}
247
248impl DiffStat {
249 #[must_use]
255 pub fn new(files: Vec<FileStat>) -> Self {
256 let lines: LineStat = files.iter().map(|file| file.lines).sum();
257 let totals = DiffTotals {
258 files_changed: files.len(),
259 insertions: lines.insertions,
260 deletions: lines.deletions,
261 };
262 Self { files, totals }
263 }
264
265 #[must_use]
273 pub fn from_files(files: &[FileDiff]) -> Self {
274 files.iter().map(FileStat::from).collect()
275 }
276
277 #[must_use]
279 pub fn files(&self) -> &[FileStat] {
280 &self.files
281 }
282
283 #[must_use]
285 pub fn totals(&self) -> DiffTotals {
286 self.totals
287 }
288
289 #[must_use]
291 pub fn is_empty(&self) -> bool {
292 self.files.is_empty()
293 }
294
295 #[must_use]
297 pub fn max_changes(&self) -> usize {
298 self.files
299 .iter()
300 .map(|file| file.lines.changes())
301 .max()
302 .unwrap_or(0)
303 }
304
305 #[must_use]
307 pub fn into_files(self) -> Vec<FileStat> {
308 self.files
309 }
310}
311
312impl FromIterator<FileStat> for DiffStat {
313 fn from_iter<I: IntoIterator<Item = FileStat>>(iter: I) -> Self {
314 Self::new(iter.into_iter().collect())
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use crate::combined_diff_patch::{
322 CombinedStructuredHunk, CombinedStructuredLine, CombinedStructuredLineKind,
323 };
324 use crate::diff::structured::StructuredLine;
325
326 fn lines(insertions: usize, deletions: usize) -> LineStat {
327 LineStat {
328 insertions,
329 deletions,
330 }
331 }
332
333 fn line(kind: StructuredLineKind) -> StructuredLine {
334 StructuredLine {
335 kind,
336 old: None,
337 new: None,
338 segments: Vec::new(),
339 no_newline_at_eof: false,
340 }
341 }
342
343 fn hunk(kinds: &[StructuredLineKind]) -> StructuredHunk {
344 StructuredHunk {
345 old_start: 1,
346 new_start: 1,
347 old_lines: 0,
348 new_lines: 0,
349 context: None,
350 lines: kinds.iter().copied().map(line).collect(),
351 }
352 }
353
354 fn row(path: &str, binary: bool, stat: LineStat) -> FileStat {
355 FileStat {
356 path: path.to_owned(),
357 old_path: None,
358 status: DiffStatus::Modified,
359 binary,
360 old_size: 0,
361 new_size: 0,
362 similarity: None,
363 lines: stat,
364 }
365 }
366
367 fn listed(stat: Option<LineStat>, structured: Option<StructuredFileDiff>) -> FileDiff {
368 FileDiff {
369 path: "f".to_owned(),
370 old_path: None,
371 status: DiffStatus::Modified,
372 old_mode: None,
373 new_mode: None,
374 binary: false,
375 old_size: 0,
376 new_size: 0,
377 similarity: None,
378 stat,
379 structured,
380 combined: None,
381 combined_status: None,
382 }
383 }
384
385 fn float_bar(ins: usize, del: usize, max: usize, width: usize) -> (usize, usize) {
388 let total = ins + del;
389 if total == 0 {
390 return (0, 0);
391 }
392 let scaled = if max > width {
393 ((total * width).div_ceil(max)).max(1)
394 } else {
395 total
396 };
397 let mut plus = ((ins * scaled) as f64 / total as f64).round() as usize;
398 let mut minus = scaled.saturating_sub(plus);
399 if ins > 0 && plus == 0 {
400 plus = 1;
401 minus = minus.saturating_sub(1);
402 }
403 if del > 0 && minus == 0 {
404 minus = 1;
405 plus = plus.saturating_sub(1);
406 }
407 (plus, minus)
408 }
409
410 #[test]
411 fn line_stat_arithmetic() {
412 assert_eq!(lines(2, 3).changes(), 5);
413 assert_eq!(lines(1, 2) + lines(3, 4), lines(4, 6));
414 let mut sum = lines(1, 0);
415 sum += lines(0, 1);
416 assert_eq!(sum, lines(1, 1));
417 assert_eq!(
418 [lines(1, 2), lines(3, 0)].into_iter().sum::<LineStat>(),
419 lines(4, 2)
420 );
421 assert_eq!(
422 std::iter::empty::<LineStat>().sum::<LineStat>(),
423 LineStat::default()
424 );
425 }
426
427 #[test]
428 fn counts_structured_hunk_lines() {
429 use StructuredLineKind::{Add, Context, Del};
430 let hunks = [hunk(&[Context, Add, Add, Del, Context]), hunk(&[Del, Add])];
431 assert_eq!(LineStat::from_hunks(&hunks), lines(3, 2));
432 assert_eq!(LineStat::from_hunks(&[]), LineStat::default());
433 let structured = StructuredFileDiff {
434 old_mode: None,
435 new_mode: None,
436 old_encoding_lossy: false,
437 new_encoding_lossy: false,
438 hunks: hunks.to_vec(),
439 };
440 assert_eq!(structured.line_stat(), lines(3, 2));
441 }
442
443 #[test]
444 fn counts_combined_lines_by_parent_columns() {
445 let line = |parents: &[char], text: &str| CombinedStructuredLine {
446 parents: parents.to_vec(),
447 text: text.to_owned(),
448 kind: CombinedStructuredLineKind::from_parent_markers(parents),
449 };
450 let body = CombinedStructuredTextDiff {
451 hunks: vec![CombinedStructuredHunk {
452 header: "@@@ -1 -1 +1 @@@".to_owned(),
453 lines: vec![
454 line(&['+', ' '], "added"),
455 line(&['+', '+'], ""),
456 line(&['-', ' '], "gone"),
457 line(&[' ', ' '], "same"),
458 ],
459 }],
460 };
461 assert_eq!(body.line_stat(), lines(1, 1));
462 }
463
464 #[test]
465 fn listed_file_prefers_stat_then_hunks() {
466 let structured = StructuredFileDiff {
467 old_mode: None,
468 new_mode: None,
469 old_encoding_lossy: false,
470 new_encoding_lossy: false,
471 hunks: vec![hunk(&[StructuredLineKind::Add])],
472 };
473 assert_eq!(
474 listed(Some(lines(7, 8)), Some(structured.clone())).line_stat(),
475 lines(7, 8)
476 );
477 assert_eq!(listed(None, Some(structured)).line_stat(), lines(1, 0));
478 assert_eq!(listed(None, None).line_stat(), LineStat::default());
479 }
480
481 #[test]
482 fn changes_all_ignored_only_for_text_modifications_with_no_lines_left() {
483 let ws = WhitespaceMode::ignore_space_change();
484 let empty = listed(Some(LineStat::default()), None);
485 assert!(empty.changes_all_ignored(ws));
486 assert!(!empty.changes_all_ignored(WhitespaceMode::default()));
487 assert!(!listed(Some(lines(1, 1)), None).changes_all_ignored(ws));
488
489 let mut mode_change = empty.clone();
490 mode_change.old_mode = Some("100644".to_owned());
491 mode_change.new_mode = Some("100755".to_owned());
492 assert!(!mode_change.changes_all_ignored(ws));
493
494 let mut binary = empty.clone();
495 binary.binary = true;
496 assert!(!binary.changes_all_ignored(ws));
497
498 for status in [DiffStatus::Added, DiffStatus::Deleted, DiffStatus::Renamed] {
499 let mut other = empty.clone();
500 other.status = status;
501 assert!(!other.changes_all_ignored(ws), "{status:?}");
502 }
503 }
504
505 #[test]
506 fn diffstat_totals_and_scale() {
507 let stat: DiffStat = [
508 row("a", false, lines(3, 1)),
509 row("bin", true, LineStat::default()),
510 row("b", false, lines(0, 9)),
511 ]
512 .into_iter()
513 .collect();
514 assert_eq!(
515 stat.totals(),
516 DiffTotals {
517 files_changed: 3,
518 insertions: 3,
519 deletions: 10,
520 }
521 );
522 assert_eq!(stat.max_changes(), 9);
523 assert!(!stat.is_empty());
524 assert_eq!(stat.files()[1].path, "bin");
525 assert_eq!(stat.into_files().len(), 3);
526
527 let empty = DiffStat::new(Vec::new());
528 assert!(empty.is_empty());
529 assert_eq!(empty.max_changes(), 0);
530 assert_eq!(empty.totals(), DiffTotals::default());
531 }
532
533 #[test]
534 fn diffstat_from_listed_files() {
535 let mut renamed = listed(Some(lines(1, 1)), None);
536 renamed.status = DiffStatus::Renamed;
537 renamed.old_path = Some("old".to_owned());
538 renamed.similarity = Some(90);
539 let stat = DiffStat::from_files(&[renamed, listed(Some(lines(2, 0)), None)]);
540 assert_eq!(stat.totals().insertions, 3);
541 assert_eq!(stat.totals().deletions, 1);
542 let first = &stat.files()[0];
543 assert_eq!(first.status, DiffStatus::Renamed);
544 assert_eq!(first.old_path.as_deref(), Some("old"));
545 assert_eq!(first.similarity, Some(90));
546 assert!(!first.binary);
547 }
548
549 #[test]
550 fn bar_is_one_column_per_line_within_width() {
551 assert_eq!(lines(3, 2).bar(5, 40), StatBar { plus: 3, minus: 2 });
552 assert_eq!(lines(0, 0).bar(5, 40), StatBar::default());
553 assert_eq!(lines(1, 0).bar(40, 40), StatBar { plus: 1, minus: 0 });
554 }
555
556 #[test]
557 fn bar_scales_and_keeps_both_sides_visible() {
558 assert_eq!(lines(80, 0).bar(80, 40), StatBar { plus: 40, minus: 0 });
559 assert_eq!(
560 lines(40, 40).bar(80, 40),
561 StatBar {
562 plus: 20,
563 minus: 20
564 }
565 );
566 assert_eq!(lines(1, 0).bar(10_000, 40), StatBar { plus: 1, minus: 0 });
568 assert_eq!(lines(999, 1).bar(1000, 40), StatBar { plus: 39, minus: 1 });
570 assert_eq!(lines(1, 999).bar(1000, 40), StatBar { plus: 1, minus: 39 });
571 }
572
573 #[test]
574 fn bar_matches_float_rounding() {
575 for width in [1, 2, 7, 40] {
576 for max in 0..=120 {
577 for ins in 0..=max {
578 for del in 0..=(max - ins) {
579 let bar = lines(ins, del).bar(max, width);
580 assert_eq!(
581 (bar.plus, bar.minus),
582 float_bar(ins, del, max, width),
583 "ins={ins} del={del} max={max} width={width}"
584 );
585 }
586 }
587 }
588 }
589 }
590}