1use crate::types::Hash;
2use crate::utils::git;
3use crate::{Repository, Result};
4use std::fmt;
5use std::path::PathBuf;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum DiffStatus {
9 Added,
10 Modified,
11 Deleted,
12 Renamed,
13 Copied,
14}
15
16impl DiffStatus {
17 pub const fn from_char(c: char) -> Option<Self> {
18 match c {
19 'A' => Some(Self::Added),
20 'M' => Some(Self::Modified),
21 'D' => Some(Self::Deleted),
22 'R' => Some(Self::Renamed),
23 'C' => Some(Self::Copied),
24 _ => None,
25 }
26 }
27
28 pub const fn to_char(&self) -> char {
29 match self {
30 Self::Added => 'A',
31 Self::Modified => 'M',
32 Self::Deleted => 'D',
33 Self::Renamed => 'R',
34 Self::Copied => 'C',
35 }
36 }
37}
38
39impl fmt::Display for DiffStatus {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 let status_str = match self {
42 Self::Added => "added",
43 Self::Modified => "modified",
44 Self::Deleted => "deleted",
45 Self::Renamed => "renamed",
46 Self::Copied => "copied",
47 };
48 write!(f, "{}", status_str)
49 }
50}
51
52#[derive(Debug, Clone)]
53pub struct DiffChunk {
54 pub old_start: usize,
55 pub old_count: usize,
56 pub new_start: usize,
57 pub new_count: usize,
58 pub lines: Box<[DiffLine]>,
59}
60
61#[derive(Debug, Clone)]
62pub struct DiffLine {
63 pub line_type: DiffLineType,
64 pub content: String,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum DiffLineType {
69 Context,
70 Added,
71 Removed,
72}
73
74impl DiffLineType {
75 pub const fn from_char(c: char) -> Option<Self> {
76 match c {
77 ' ' => Some(Self::Context),
78 '+' => Some(Self::Added),
79 '-' => Some(Self::Removed),
80 _ => None,
81 }
82 }
83
84 pub const fn to_char(&self) -> char {
85 match self {
86 Self::Context => ' ',
87 Self::Added => '+',
88 Self::Removed => '-',
89 }
90 }
91}
92
93#[derive(Debug, Clone)]
94pub struct FileDiff {
95 pub path: PathBuf,
96 pub old_path: Option<PathBuf>,
97 pub status: DiffStatus,
98 pub chunks: Box<[DiffChunk]>,
99 pub additions: usize,
100 pub deletions: usize,
101}
102
103impl FileDiff {
104 pub fn new(path: PathBuf, status: DiffStatus) -> Self {
105 Self {
106 path,
107 old_path: None,
108 status,
109 chunks: Box::new([]),
110 additions: 0,
111 deletions: 0,
112 }
113 }
114
115 pub fn with_old_path(mut self, old_path: PathBuf) -> Self {
116 self.old_path = Some(old_path);
117 self
118 }
119
120 pub fn with_chunks(mut self, chunks: Vec<DiffChunk>) -> Self {
121 self.chunks = chunks.into_boxed_slice();
122 self
123 }
124
125 pub fn with_stats(mut self, additions: usize, deletions: usize) -> Self {
126 self.additions = additions;
127 self.deletions = deletions;
128 self
129 }
130
131 pub fn is_binary(&self) -> bool {
132 self.chunks.is_empty() && (self.additions > 0 || self.deletions > 0)
133 }
134}
135
136impl fmt::Display for FileDiff {
137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138 match &self.old_path {
139 Some(old_path) => write!(
140 f,
141 "{} {} -> {}",
142 self.status,
143 old_path.display(),
144 self.path.display()
145 ),
146 None => write!(f, "{} {}", self.status, self.path.display()),
147 }
148 }
149}
150
151#[derive(Debug, Clone)]
152pub struct DiffStats {
153 pub files_changed: usize,
154 pub insertions: usize,
155 pub deletions: usize,
156}
157
158impl DiffStats {
159 pub fn new() -> Self {
160 Self {
161 files_changed: 0,
162 insertions: 0,
163 deletions: 0,
164 }
165 }
166
167 pub fn add_file(&mut self, additions: usize, deletions: usize) {
168 self.files_changed += 1;
169 self.insertions += additions;
170 self.deletions += deletions;
171 }
172}
173
174impl Default for DiffStats {
175 fn default() -> Self {
176 Self::new()
177 }
178}
179
180impl fmt::Display for DiffStats {
181 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182 write!(
183 f,
184 "{} files changed, {} insertions(+), {} deletions(-)",
185 self.files_changed, self.insertions, self.deletions
186 )
187 }
188}
189
190#[derive(Debug, Clone)]
191pub struct DiffOutput {
192 pub files: Box<[FileDiff]>,
193 pub stats: DiffStats,
194}
195
196impl DiffOutput {
197 pub fn new(files: Vec<FileDiff>) -> Self {
198 let mut stats = DiffStats::new();
199 for file in &files {
200 stats.add_file(file.additions, file.deletions);
201 }
202
203 Self {
204 files: files.into_boxed_slice(),
205 stats,
206 }
207 }
208
209 pub fn is_empty(&self) -> bool {
210 self.files.is_empty()
211 }
212
213 pub fn len(&self) -> usize {
214 self.files.len()
215 }
216
217 pub fn iter(&self) -> std::slice::Iter<'_, FileDiff> {
218 self.files.iter()
219 }
220
221 pub fn files_with_status(&self, status: DiffStatus) -> impl Iterator<Item = &FileDiff> {
222 self.files.iter().filter(move |f| f.status == status)
223 }
224}
225
226impl fmt::Display for DiffOutput {
227 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228 if self.is_empty() {
229 return writeln!(f, "No differences found");
230 }
231
232 for file in &self.files {
233 writeln!(f, "{}", file)?;
234 }
235 writeln!(f, "{}", self.stats)
236 }
237}
238
239#[derive(Debug, Clone)]
240pub struct DiffOptions {
241 pub context_lines: Option<usize>,
242 pub ignore_whitespace: bool,
243 pub ignore_whitespace_change: bool,
244 pub ignore_blank_lines: bool,
245 pub paths: Option<Vec<PathBuf>>,
246 pub name_only: bool,
247 pub stat_only: bool,
248 pub numstat: bool,
249 pub cached: bool,
250 pub no_index: bool,
251}
252
253impl DiffOptions {
254 pub fn new() -> Self {
255 Self {
256 context_lines: None,
257 ignore_whitespace: false,
258 ignore_whitespace_change: false,
259 ignore_blank_lines: false,
260 paths: None,
261 name_only: false,
262 stat_only: false,
263 numstat: false,
264 cached: false,
265 no_index: false,
266 }
267 }
268
269 pub fn context_lines(mut self, lines: usize) -> Self {
270 self.context_lines = Some(lines);
271 self
272 }
273
274 pub fn ignore_whitespace(mut self) -> Self {
275 self.ignore_whitespace = true;
276 self
277 }
278
279 pub fn ignore_whitespace_change(mut self) -> Self {
280 self.ignore_whitespace_change = true;
281 self
282 }
283
284 pub fn ignore_blank_lines(mut self) -> Self {
285 self.ignore_blank_lines = true;
286 self
287 }
288
289 pub fn paths(mut self, paths: Vec<PathBuf>) -> Self {
290 self.paths = Some(paths);
291 self
292 }
293
294 pub fn name_only(mut self) -> Self {
295 self.name_only = true;
296 self
297 }
298
299 pub fn stat_only(mut self) -> Self {
300 self.stat_only = true;
301 self
302 }
303
304 pub fn numstat(mut self) -> Self {
305 self.numstat = true;
306 self
307 }
308
309 pub fn cached(mut self) -> Self {
310 self.cached = true;
311 self
312 }
313
314 pub fn no_index(mut self) -> Self {
315 self.no_index = true;
316 self
317 }
318}
319
320impl Default for DiffOptions {
321 fn default() -> Self {
322 Self::new()
323 }
324}
325
326impl Repository {
327 pub fn diff(&self) -> Result<DiffOutput> {
348 self.diff_with_options(&DiffOptions::new())
349 }
350
351 pub fn diff_staged(&self) -> Result<DiffOutput> {
372 self.diff_with_options(&DiffOptions::new().cached())
373 }
374
375 pub fn diff_head(&self) -> Result<DiffOutput> {
383 self.diff_commits_with_options(None, Some(&Hash::from("HEAD")), &DiffOptions::new())
384 }
385
386 pub fn diff_commits(&self, from: &Hash, to: &Hash) -> Result<DiffOutput> {
412 self.diff_commits_with_options(Some(from), Some(to), &DiffOptions::new())
413 }
414
415 pub fn diff_with_options(&self, options: &DiffOptions) -> Result<DiffOutput> {
441 self.diff_commits_with_options(None, None, options)
442 }
443
444 fn diff_commits_with_options(
446 &self,
447 from: Option<&Hash>,
448 to: Option<&Hash>,
449 options: &DiffOptions,
450 ) -> Result<DiffOutput> {
451 Self::ensure_git()?;
452
453 let mut args = vec!["diff".to_string()];
454
455 if let Some(lines) = options.context_lines {
457 args.push(format!("-U{}", lines));
458 }
459 if options.ignore_whitespace {
460 args.push("--ignore-all-space".to_string());
461 }
462 if options.ignore_whitespace_change {
463 args.push("--ignore-space-change".to_string());
464 }
465 if options.ignore_blank_lines {
466 args.push("--ignore-blank-lines".to_string());
467 }
468 if options.name_only {
469 args.push("--name-only".to_string());
470 }
471 if options.stat_only {
472 args.push("--stat".to_string());
473 }
474 if options.numstat {
475 args.push("--numstat".to_string());
476 }
477 if options.cached {
478 args.push("--cached".to_string());
479 }
480 if options.no_index {
481 args.push("--no-index".to_string());
482 }
483
484 match (from, to) {
486 (Some(from_hash), Some(to_hash)) => {
487 args.push(format!("{}..{}", from_hash.as_str(), to_hash.as_str()));
488 }
489 (None, Some(to_hash)) => {
490 args.push(to_hash.as_str().to_string());
491 }
492 (Some(from_hash), None) => {
493 args.push(format!("{}..HEAD", from_hash.as_str()));
494 }
495 (None, None) => {
496 }
498 }
499
500 if let Some(paths) = &options.paths {
502 args.push("--".to_string());
503 for path in paths {
504 args.push(path.to_string_lossy().to_string());
505 }
506 }
507
508 let args_str: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
509 let output = git(&args_str, Some(self.repo_path()))?;
510
511 if options.name_only {
512 parse_name_only_output(&output)
513 } else if options.stat_only {
514 parse_stat_output(&output)
515 } else if options.numstat {
516 parse_numstat_output(&output)
517 } else {
518 parse_diff_output(&output)
519 }
520 }
521}
522
523fn parse_name_only_output(output: &str) -> Result<DiffOutput> {
524 let files: Vec<FileDiff> = output
525 .lines()
526 .filter(|line| !line.is_empty())
527 .map(|line| FileDiff::new(PathBuf::from(line), DiffStatus::Modified))
528 .collect();
529
530 Ok(DiffOutput::new(files))
531}
532
533fn parse_stat_output(output: &str) -> Result<DiffOutput> {
534 let mut files = Vec::new();
535 let mut stats = DiffStats::new();
536
537 for line in output.lines() {
538 if line.contains(" | ") {
539 let parts: Vec<&str> = line.split(" | ").collect();
540 if parts.len() == 2 {
541 let path = PathBuf::from(parts[0].trim());
542 let file_diff = FileDiff::new(path, DiffStatus::Modified);
543 files.push(file_diff);
544 }
545 } else if line.contains("files changed") || line.contains("file changed") {
546 if let Some(files_part) = line.split(',').next()
548 && let Some(num_str) = files_part.split_whitespace().next()
549 && let Ok(num) = num_str.parse::<usize>()
550 {
551 stats.files_changed = num;
552 }
553 }
554 }
555
556 Ok(DiffOutput {
557 files: files.into_boxed_slice(),
558 stats,
559 })
560}
561
562fn parse_numstat_output(output: &str) -> Result<DiffOutput> {
563 let files: Vec<FileDiff> = output
564 .lines()
565 .filter(|line| !line.is_empty())
566 .filter_map(|line| {
567 let parts: Vec<&str> = line.split('\t').collect();
568 if parts.len() >= 3 {
569 let additions = parts[0].parse().unwrap_or(0);
570 let deletions = parts[1].parse().unwrap_or(0);
571 let path = PathBuf::from(parts[2]);
572
573 let status = if additions > 0 && deletions == 0 {
574 DiffStatus::Added
575 } else if additions == 0 && deletions > 0 {
576 DiffStatus::Deleted
577 } else {
578 DiffStatus::Modified
579 };
580
581 Some(FileDiff::new(path, status).with_stats(additions, deletions))
582 } else {
583 None
584 }
585 })
586 .collect();
587
588 Ok(DiffOutput::new(files))
589}
590
591fn parse_diff_output(output: &str) -> Result<DiffOutput> {
592 let files: Vec<FileDiff> = output
595 .lines()
596 .filter(|line| line.starts_with("diff --git"))
597 .filter_map(|line| {
598 let parts: Vec<&str> = line.split_whitespace().collect();
600 if parts.len() >= 4 {
601 let path_str = parts[3].strip_prefix("b/").unwrap_or(parts[3]);
602 Some(FileDiff::new(PathBuf::from(path_str), DiffStatus::Modified))
603 } else {
604 None
605 }
606 })
607 .collect();
608
609 Ok(DiffOutput::new(files))
610}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615 use std::env;
616
617 #[test]
618 fn test_diff_status_char_conversion() {
619 assert_eq!(DiffStatus::from_char('A'), Some(DiffStatus::Added));
620 assert_eq!(DiffStatus::from_char('M'), Some(DiffStatus::Modified));
621 assert_eq!(DiffStatus::from_char('D'), Some(DiffStatus::Deleted));
622 assert_eq!(DiffStatus::from_char('R'), Some(DiffStatus::Renamed));
623 assert_eq!(DiffStatus::from_char('C'), Some(DiffStatus::Copied));
624 assert_eq!(DiffStatus::from_char('X'), None);
625
626 assert_eq!(DiffStatus::Added.to_char(), 'A');
627 assert_eq!(DiffStatus::Modified.to_char(), 'M');
628 assert_eq!(DiffStatus::Deleted.to_char(), 'D');
629 assert_eq!(DiffStatus::Renamed.to_char(), 'R');
630 assert_eq!(DiffStatus::Copied.to_char(), 'C');
631 }
632
633 #[test]
634 fn test_diff_status_display() {
635 assert_eq!(DiffStatus::Added.to_string(), "added");
636 assert_eq!(DiffStatus::Modified.to_string(), "modified");
637 assert_eq!(DiffStatus::Deleted.to_string(), "deleted");
638 assert_eq!(DiffStatus::Renamed.to_string(), "renamed");
639 assert_eq!(DiffStatus::Copied.to_string(), "copied");
640 }
641
642 #[test]
643 fn test_diff_line_type_char_conversion() {
644 assert_eq!(DiffLineType::from_char(' '), Some(DiffLineType::Context));
645 assert_eq!(DiffLineType::from_char('+'), Some(DiffLineType::Added));
646 assert_eq!(DiffLineType::from_char('-'), Some(DiffLineType::Removed));
647 assert_eq!(DiffLineType::from_char('X'), None);
648
649 assert_eq!(DiffLineType::Context.to_char(), ' ');
650 assert_eq!(DiffLineType::Added.to_char(), '+');
651 assert_eq!(DiffLineType::Removed.to_char(), '-');
652 }
653
654 #[test]
655 fn test_file_diff_creation() {
656 let path = PathBuf::from("test.txt");
657 let file_diff = FileDiff::new(path.clone(), DiffStatus::Modified);
658
659 assert_eq!(file_diff.path, path);
660 assert_eq!(file_diff.status, DiffStatus::Modified);
661 assert_eq!(file_diff.old_path, None);
662 assert_eq!(file_diff.chunks.len(), 0);
663 assert_eq!(file_diff.additions, 0);
664 assert_eq!(file_diff.deletions, 0);
665 }
666
667 #[test]
668 fn test_file_diff_with_old_path() {
669 let old_path = PathBuf::from("old.txt");
670 let new_path = PathBuf::from("new.txt");
671 let file_diff =
672 FileDiff::new(new_path.clone(), DiffStatus::Renamed).with_old_path(old_path.clone());
673
674 assert_eq!(file_diff.path, new_path);
675 assert_eq!(file_diff.old_path, Some(old_path));
676 assert_eq!(file_diff.status, DiffStatus::Renamed);
677 }
678
679 #[test]
680 fn test_file_diff_with_stats() {
681 let path = PathBuf::from("test.txt");
682 let file_diff = FileDiff::new(path, DiffStatus::Modified).with_stats(10, 5);
683
684 assert_eq!(file_diff.additions, 10);
685 assert_eq!(file_diff.deletions, 5);
686 }
687
688 #[test]
689 fn test_diff_stats_creation() {
690 let mut stats = DiffStats::new();
691 assert_eq!(stats.files_changed, 0);
692 assert_eq!(stats.insertions, 0);
693 assert_eq!(stats.deletions, 0);
694
695 stats.add_file(10, 5);
696 assert_eq!(stats.files_changed, 1);
697 assert_eq!(stats.insertions, 10);
698 assert_eq!(stats.deletions, 5);
699
700 stats.add_file(3, 2);
701 assert_eq!(stats.files_changed, 2);
702 assert_eq!(stats.insertions, 13);
703 assert_eq!(stats.deletions, 7);
704 }
705
706 #[test]
707 fn test_diff_stats_display() {
708 let mut stats = DiffStats::new();
709 stats.add_file(10, 5);
710 stats.add_file(3, 2);
711
712 let display = stats.to_string();
713 assert!(display.contains("2 files changed"));
714 assert!(display.contains("13 insertions(+)"));
715 assert!(display.contains("7 deletions(-)"));
716 }
717
718 #[test]
719 fn test_diff_output_creation() {
720 let files = vec![
721 FileDiff::new(PathBuf::from("file1.txt"), DiffStatus::Added).with_stats(5, 0),
722 FileDiff::new(PathBuf::from("file2.txt"), DiffStatus::Modified).with_stats(3, 2),
723 ];
724
725 let diff_output = DiffOutput::new(files);
726
727 assert_eq!(diff_output.len(), 2);
728 assert!(!diff_output.is_empty());
729 assert_eq!(diff_output.stats.files_changed, 2);
730 assert_eq!(diff_output.stats.insertions, 8);
731 assert_eq!(diff_output.stats.deletions, 2);
732 }
733
734 #[test]
735 fn test_diff_output_empty() {
736 let diff_output = DiffOutput::new(vec![]);
737
738 assert_eq!(diff_output.len(), 0);
739 assert!(diff_output.is_empty());
740 assert_eq!(diff_output.stats.files_changed, 0);
741 }
742
743 #[test]
744 fn test_diff_output_files_with_status() {
745 let files = vec![
746 FileDiff::new(PathBuf::from("added.txt"), DiffStatus::Added),
747 FileDiff::new(PathBuf::from("modified.txt"), DiffStatus::Modified),
748 FileDiff::new(PathBuf::from("deleted.txt"), DiffStatus::Deleted),
749 ];
750
751 let diff_output = DiffOutput::new(files);
752
753 let added_files: Vec<_> = diff_output.files_with_status(DiffStatus::Added).collect();
754 assert_eq!(added_files.len(), 1);
755 assert_eq!(added_files[0].path, PathBuf::from("added.txt"));
756
757 let modified_files: Vec<_> = diff_output
758 .files_with_status(DiffStatus::Modified)
759 .collect();
760 assert_eq!(modified_files.len(), 1);
761 assert_eq!(modified_files[0].path, PathBuf::from("modified.txt"));
762 }
763
764 #[test]
765 fn test_diff_options_builder() {
766 let options = DiffOptions::new()
767 .context_lines(5)
768 .ignore_whitespace()
769 .ignore_whitespace_change()
770 .ignore_blank_lines()
771 .name_only()
772 .stat_only()
773 .numstat()
774 .cached()
775 .no_index();
776
777 assert_eq!(options.context_lines, Some(5));
778 assert!(options.ignore_whitespace);
779 assert!(options.ignore_whitespace_change);
780 assert!(options.ignore_blank_lines);
781 assert!(options.name_only);
782 assert!(options.stat_only);
783 assert!(options.numstat);
784 assert!(options.cached);
785 assert!(options.no_index);
786 }
787
788 #[test]
789 fn test_diff_options_with_paths() {
790 let paths = vec![PathBuf::from("src/"), PathBuf::from("tests/")];
791 let options = DiffOptions::new().paths(paths.clone());
792
793 assert_eq!(options.paths, Some(paths));
794 }
795
796 #[test]
797 fn test_parse_name_only_output() {
798 let output = "file1.txt\nfile2.rs\nsrc/lib.rs\n";
799 let result = parse_name_only_output(output).unwrap();
800
801 assert_eq!(result.len(), 3);
802 assert_eq!(result.files[0].path, PathBuf::from("file1.txt"));
803 assert_eq!(result.files[1].path, PathBuf::from("file2.rs"));
804 assert_eq!(result.files[2].path, PathBuf::from("src/lib.rs"));
805 }
806
807 #[test]
808 fn test_parse_numstat_output() {
809 let output = "5\t0\tfile1.txt\n3\t2\tfile2.rs\n0\t10\tfile3.py\n";
810 let result = parse_numstat_output(output).unwrap();
811
812 assert_eq!(result.len(), 3);
813
814 assert_eq!(result.files[0].path, PathBuf::from("file1.txt"));
815 assert_eq!(result.files[0].status, DiffStatus::Added);
816 assert_eq!(result.files[0].additions, 5);
817 assert_eq!(result.files[0].deletions, 0);
818
819 assert_eq!(result.files[1].path, PathBuf::from("file2.rs"));
820 assert_eq!(result.files[1].status, DiffStatus::Modified);
821 assert_eq!(result.files[1].additions, 3);
822 assert_eq!(result.files[1].deletions, 2);
823
824 assert_eq!(result.files[2].path, PathBuf::from("file3.py"));
825 assert_eq!(result.files[2].status, DiffStatus::Deleted);
826 assert_eq!(result.files[2].additions, 0);
827 assert_eq!(result.files[2].deletions, 10);
828 }
829
830 #[test]
831 fn test_repository_diff_basic() {
832 let repo_path = env::temp_dir().join("rustic_git_diff_test");
833 if repo_path.exists() {
835 std::fs::remove_dir_all(&repo_path).ok();
836 }
837
838 let repo = Repository::init(&repo_path, false).unwrap();
840
841 let result = repo.diff();
843 assert!(result.is_ok());
844
845 std::fs::remove_dir_all(&repo_path).ok();
847 }
848}