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 similarity: Option<u32>,
218 pub lines: LineStat,
220}
221
222impl From<&FileDiff> for FileStat {
223 fn from(file: &FileDiff) -> Self {
224 Self {
225 path: file.path.clone(),
226 old_path: file.old_path.clone(),
227 status: file.status,
228 binary: file.binary,
229 similarity: file.similarity,
230 lines: file.line_stat(),
231 }
232 }
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, Default)]
237pub struct DiffStat {
238 files: Vec<FileStat>,
239 totals: DiffTotals,
240}
241
242impl DiffStat {
243 #[must_use]
249 pub fn new(files: Vec<FileStat>) -> Self {
250 let lines: LineStat = files.iter().map(|file| file.lines).sum();
251 let totals = DiffTotals {
252 files_changed: files.len(),
253 insertions: lines.insertions,
254 deletions: lines.deletions,
255 };
256 Self { files, totals }
257 }
258
259 #[must_use]
267 pub fn from_files(files: &[FileDiff]) -> Self {
268 files.iter().map(FileStat::from).collect()
269 }
270
271 #[must_use]
273 pub fn files(&self) -> &[FileStat] {
274 &self.files
275 }
276
277 #[must_use]
279 pub fn totals(&self) -> DiffTotals {
280 self.totals
281 }
282
283 #[must_use]
285 pub fn is_empty(&self) -> bool {
286 self.files.is_empty()
287 }
288
289 #[must_use]
291 pub fn max_changes(&self) -> usize {
292 self.files
293 .iter()
294 .map(|file| file.lines.changes())
295 .max()
296 .unwrap_or(0)
297 }
298
299 #[must_use]
301 pub fn into_files(self) -> Vec<FileStat> {
302 self.files
303 }
304}
305
306impl FromIterator<FileStat> for DiffStat {
307 fn from_iter<I: IntoIterator<Item = FileStat>>(iter: I) -> Self {
308 Self::new(iter.into_iter().collect())
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use crate::combined_diff_patch::{
316 CombinedStructuredHunk, CombinedStructuredLine, CombinedStructuredLineKind,
317 };
318 use crate::diff::structured::StructuredLine;
319
320 fn lines(insertions: usize, deletions: usize) -> LineStat {
321 LineStat {
322 insertions,
323 deletions,
324 }
325 }
326
327 fn line(kind: StructuredLineKind) -> StructuredLine {
328 StructuredLine {
329 kind,
330 old: None,
331 new: None,
332 segments: Vec::new(),
333 no_newline_at_eof: false,
334 }
335 }
336
337 fn hunk(kinds: &[StructuredLineKind]) -> StructuredHunk {
338 StructuredHunk {
339 old_start: 1,
340 new_start: 1,
341 old_lines: 0,
342 new_lines: 0,
343 context: None,
344 lines: kinds.iter().copied().map(line).collect(),
345 }
346 }
347
348 fn row(path: &str, binary: bool, stat: LineStat) -> FileStat {
349 FileStat {
350 path: path.to_owned(),
351 old_path: None,
352 status: DiffStatus::Modified,
353 binary,
354 similarity: None,
355 lines: stat,
356 }
357 }
358
359 fn listed(stat: Option<LineStat>, structured: Option<StructuredFileDiff>) -> FileDiff {
360 FileDiff {
361 path: "f".to_owned(),
362 old_path: None,
363 status: DiffStatus::Modified,
364 old_mode: None,
365 new_mode: None,
366 binary: false,
367 similarity: None,
368 stat,
369 structured,
370 combined: None,
371 combined_status: None,
372 }
373 }
374
375 fn float_bar(ins: usize, del: usize, max: usize, width: usize) -> (usize, usize) {
378 let total = ins + del;
379 if total == 0 {
380 return (0, 0);
381 }
382 let scaled = if max > width {
383 ((total * width).div_ceil(max)).max(1)
384 } else {
385 total
386 };
387 let mut plus = ((ins * scaled) as f64 / total as f64).round() as usize;
388 let mut minus = scaled.saturating_sub(plus);
389 if ins > 0 && plus == 0 {
390 plus = 1;
391 minus = minus.saturating_sub(1);
392 }
393 if del > 0 && minus == 0 {
394 minus = 1;
395 plus = plus.saturating_sub(1);
396 }
397 (plus, minus)
398 }
399
400 #[test]
401 fn line_stat_arithmetic() {
402 assert_eq!(lines(2, 3).changes(), 5);
403 assert_eq!(lines(1, 2) + lines(3, 4), lines(4, 6));
404 let mut sum = lines(1, 0);
405 sum += lines(0, 1);
406 assert_eq!(sum, lines(1, 1));
407 assert_eq!(
408 [lines(1, 2), lines(3, 0)].into_iter().sum::<LineStat>(),
409 lines(4, 2)
410 );
411 assert_eq!(
412 std::iter::empty::<LineStat>().sum::<LineStat>(),
413 LineStat::default()
414 );
415 }
416
417 #[test]
418 fn counts_structured_hunk_lines() {
419 use StructuredLineKind::{Add, Context, Del};
420 let hunks = [hunk(&[Context, Add, Add, Del, Context]), hunk(&[Del, Add])];
421 assert_eq!(LineStat::from_hunks(&hunks), lines(3, 2));
422 assert_eq!(LineStat::from_hunks(&[]), LineStat::default());
423 let structured = StructuredFileDiff {
424 old_mode: None,
425 new_mode: None,
426 old_encoding_lossy: false,
427 new_encoding_lossy: false,
428 hunks: hunks.to_vec(),
429 };
430 assert_eq!(structured.line_stat(), lines(3, 2));
431 }
432
433 #[test]
434 fn counts_combined_lines_by_parent_columns() {
435 let line = |parents: &[char], text: &str| CombinedStructuredLine {
436 parents: parents.to_vec(),
437 text: text.to_owned(),
438 kind: CombinedStructuredLineKind::from_parent_markers(parents),
439 };
440 let body = CombinedStructuredTextDiff {
441 hunks: vec![CombinedStructuredHunk {
442 header: "@@@ -1 -1 +1 @@@".to_owned(),
443 lines: vec![
444 line(&['+', ' '], "added"),
445 line(&['+', '+'], ""),
446 line(&['-', ' '], "gone"),
447 line(&[' ', ' '], "same"),
448 ],
449 }],
450 };
451 assert_eq!(body.line_stat(), lines(1, 1));
452 }
453
454 #[test]
455 fn listed_file_prefers_stat_then_hunks() {
456 let structured = StructuredFileDiff {
457 old_mode: None,
458 new_mode: None,
459 old_encoding_lossy: false,
460 new_encoding_lossy: false,
461 hunks: vec![hunk(&[StructuredLineKind::Add])],
462 };
463 assert_eq!(
464 listed(Some(lines(7, 8)), Some(structured.clone())).line_stat(),
465 lines(7, 8)
466 );
467 assert_eq!(listed(None, Some(structured)).line_stat(), lines(1, 0));
468 assert_eq!(listed(None, None).line_stat(), LineStat::default());
469 }
470
471 #[test]
472 fn changes_all_ignored_only_for_text_modifications_with_no_lines_left() {
473 let ws = WhitespaceMode::ignore_space_change();
474 let empty = listed(Some(LineStat::default()), None);
475 assert!(empty.changes_all_ignored(ws));
476 assert!(!empty.changes_all_ignored(WhitespaceMode::default()));
477 assert!(!listed(Some(lines(1, 1)), None).changes_all_ignored(ws));
478
479 let mut mode_change = empty.clone();
480 mode_change.old_mode = Some("100644".to_owned());
481 mode_change.new_mode = Some("100755".to_owned());
482 assert!(!mode_change.changes_all_ignored(ws));
483
484 let mut binary = empty.clone();
485 binary.binary = true;
486 assert!(!binary.changes_all_ignored(ws));
487
488 for status in [DiffStatus::Added, DiffStatus::Deleted, DiffStatus::Renamed] {
489 let mut other = empty.clone();
490 other.status = status;
491 assert!(!other.changes_all_ignored(ws), "{status:?}");
492 }
493 }
494
495 #[test]
496 fn diffstat_totals_and_scale() {
497 let stat: DiffStat = [
498 row("a", false, lines(3, 1)),
499 row("bin", true, LineStat::default()),
500 row("b", false, lines(0, 9)),
501 ]
502 .into_iter()
503 .collect();
504 assert_eq!(
505 stat.totals(),
506 DiffTotals {
507 files_changed: 3,
508 insertions: 3,
509 deletions: 10,
510 }
511 );
512 assert_eq!(stat.max_changes(), 9);
513 assert!(!stat.is_empty());
514 assert_eq!(stat.files()[1].path, "bin");
515 assert_eq!(stat.into_files().len(), 3);
516
517 let empty = DiffStat::new(Vec::new());
518 assert!(empty.is_empty());
519 assert_eq!(empty.max_changes(), 0);
520 assert_eq!(empty.totals(), DiffTotals::default());
521 }
522
523 #[test]
524 fn diffstat_from_listed_files() {
525 let mut renamed = listed(Some(lines(1, 1)), None);
526 renamed.status = DiffStatus::Renamed;
527 renamed.old_path = Some("old".to_owned());
528 renamed.similarity = Some(90);
529 let stat = DiffStat::from_files(&[renamed, listed(Some(lines(2, 0)), None)]);
530 assert_eq!(stat.totals().insertions, 3);
531 assert_eq!(stat.totals().deletions, 1);
532 let first = &stat.files()[0];
533 assert_eq!(first.status, DiffStatus::Renamed);
534 assert_eq!(first.old_path.as_deref(), Some("old"));
535 assert_eq!(first.similarity, Some(90));
536 assert!(!first.binary);
537 }
538
539 #[test]
540 fn bar_is_one_column_per_line_within_width() {
541 assert_eq!(lines(3, 2).bar(5, 40), StatBar { plus: 3, minus: 2 });
542 assert_eq!(lines(0, 0).bar(5, 40), StatBar::default());
543 assert_eq!(lines(1, 0).bar(40, 40), StatBar { plus: 1, minus: 0 });
544 }
545
546 #[test]
547 fn bar_scales_and_keeps_both_sides_visible() {
548 assert_eq!(lines(80, 0).bar(80, 40), StatBar { plus: 40, minus: 0 });
549 assert_eq!(
550 lines(40, 40).bar(80, 40),
551 StatBar {
552 plus: 20,
553 minus: 20
554 }
555 );
556 assert_eq!(lines(1, 0).bar(10_000, 40), StatBar { plus: 1, minus: 0 });
558 assert_eq!(lines(999, 1).bar(1000, 40), StatBar { plus: 39, minus: 1 });
560 assert_eq!(lines(1, 999).bar(1000, 40), StatBar { plus: 1, minus: 39 });
561 }
562
563 #[test]
564 fn bar_matches_float_rounding() {
565 for width in [1, 2, 7, 40] {
566 for max in 0..=120 {
567 for ins in 0..=max {
568 for del in 0..=(max - ins) {
569 let bar = lines(ins, del).bar(max, width);
570 assert_eq!(
571 (bar.plus, bar.minus),
572 float_bar(ins, del, max, width),
573 "ins={ins} del={del} max={max} width={width}"
574 );
575 }
576 }
577 }
578 }
579 }
580}