rustic_git/commands/
diff.rs

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    /// Get diff between working directory and index (staged changes)
328    ///
329    /// Shows changes that are not yet staged for commit.
330    ///
331    /// # Returns
332    ///
333    /// A `Result` containing the `DiffOutput` or a `GitError`.
334    ///
335    /// # Example
336    ///
337    /// ```rust
338    /// use rustic_git::Repository;
339    ///
340    /// # fn main() -> rustic_git::Result<()> {
341    /// let repo = Repository::open(".")?;
342    /// let diff = repo.diff()?;
343    /// println!("Unstaged changes: {}", diff);
344    /// # Ok(())
345    /// # }
346    /// ```
347    pub fn diff(&self) -> Result<DiffOutput> {
348        self.diff_with_options(&DiffOptions::new())
349    }
350
351    /// Get diff between index and HEAD (staged changes)
352    ///
353    /// Shows changes that are staged for commit.
354    ///
355    /// # Returns
356    ///
357    /// A `Result` containing the `DiffOutput` or a `GitError`.
358    ///
359    /// # Example
360    ///
361    /// ```rust
362    /// use rustic_git::Repository;
363    ///
364    /// # fn main() -> rustic_git::Result<()> {
365    /// let repo = Repository::open(".")?;
366    /// let diff = repo.diff_staged()?;
367    /// println!("Staged changes: {}", diff);
368    /// # Ok(())
369    /// # }
370    /// ```
371    pub fn diff_staged(&self) -> Result<DiffOutput> {
372        self.diff_with_options(&DiffOptions::new().cached())
373    }
374
375    /// Get diff between working directory and HEAD
376    ///
377    /// Shows all changes (both staged and unstaged) compared to the last commit.
378    ///
379    /// # Returns
380    ///
381    /// A `Result` containing the `DiffOutput` or a `GitError`.
382    pub fn diff_head(&self) -> Result<DiffOutput> {
383        self.diff_commits_with_options(None, Some(&Hash::from("HEAD")), &DiffOptions::new())
384    }
385
386    /// Get diff between two commits
387    ///
388    /// # Arguments
389    ///
390    /// * `from` - The starting commit hash
391    /// * `to` - The ending commit hash
392    ///
393    /// # Returns
394    ///
395    /// A `Result` containing the `DiffOutput` or a `GitError`.
396    ///
397    /// # Example
398    ///
399    /// ```rust,no_run
400    /// use rustic_git::{Repository, Hash};
401    ///
402    /// # fn main() -> rustic_git::Result<()> {
403    /// let repo = Repository::open(".")?;
404    /// let from = Hash::from("abc123");
405    /// let to = Hash::from("def456");
406    /// let diff = repo.diff_commits(&from, &to)?;
407    /// println!("Changes between commits: {}", diff);
408    /// # Ok(())
409    /// # }
410    /// ```
411    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    /// Get diff with custom options
416    ///
417    /// # Arguments
418    ///
419    /// * `options` - The diff options to use
420    ///
421    /// # Returns
422    ///
423    /// A `Result` containing the `DiffOutput` or a `GitError`.
424    ///
425    /// # Example
426    ///
427    /// ```rust
428    /// use rustic_git::{Repository, DiffOptions};
429    ///
430    /// # fn main() -> rustic_git::Result<()> {
431    /// let repo = Repository::open(".")?;
432    /// let options = DiffOptions::new()
433    ///     .ignore_whitespace()
434    ///     .context_lines(5);
435    /// let diff = repo.diff_with_options(&options)?;
436    /// println!("Diff with options: {}", diff);
437    /// # Ok(())
438    /// # }
439    /// ```
440    pub fn diff_with_options(&self, options: &DiffOptions) -> Result<DiffOutput> {
441        self.diff_commits_with_options(None, None, options)
442    }
443
444    /// Internal method to handle all diff operations
445    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        // Add options
456        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        // Add commit range if specified
485        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                // Default diff behavior
497            }
498        }
499
500        // Add paths if specified
501        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            // Parse summary line like "3 files changed, 15 insertions(+), 5 deletions(-)"
547            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    // For now, return a simplified parser
593    // In a full implementation, this would parse the complete diff format
594    let files: Vec<FileDiff> = output
595        .lines()
596        .filter(|line| line.starts_with("diff --git"))
597        .filter_map(|line| {
598            // Extract file paths from "diff --git a/file b/file"
599            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        // Clean up any previous run
834        if repo_path.exists() {
835            std::fs::remove_dir_all(&repo_path).ok();
836        }
837
838        // Initialize repository
839        let repo = Repository::init(&repo_path, false).unwrap();
840
841        // Test diff on empty repository (should not fail)
842        let result = repo.diff();
843        assert!(result.is_ok());
844
845        // Clean up
846        std::fs::remove_dir_all(&repo_path).ok();
847    }
848}