infiniloom_engine/
git.rs

1//! Git integration for diff/log analysis
2//!
3//! Provides integration with Git for:
4//! - Getting changed files between commits
5//! - Extracting commit history
6//! - Blame information for file importance
7
8use std::path::Path;
9use std::process::Command;
10use thiserror::Error;
11
12/// Git repository wrapper
13pub struct GitRepo {
14    path: String,
15}
16
17/// A git commit entry
18#[derive(Debug, Clone)]
19pub struct Commit {
20    pub hash: String,
21    pub short_hash: String,
22    pub author: String,
23    pub email: String,
24    pub date: String,
25    pub message: String,
26}
27
28/// A file changed in a commit
29#[derive(Debug, Clone)]
30pub struct ChangedFile {
31    /// Current path (or new path for renames)
32    pub path: String,
33    /// Original path for renamed/copied files (None for add/modify/delete)
34    pub old_path: Option<String>,
35    pub status: FileStatus,
36    pub additions: u32,
37    pub deletions: u32,
38}
39
40/// File change status
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum FileStatus {
43    Added,
44    Modified,
45    Deleted,
46    Renamed,
47    Copied,
48    Unknown,
49}
50
51impl FileStatus {
52    fn from_char(c: char) -> Self {
53        match c {
54            'A' => Self::Added,
55            'M' => Self::Modified,
56            'D' => Self::Deleted,
57            'R' => Self::Renamed,
58            'C' => Self::Copied,
59            _ => Self::Unknown,
60        }
61    }
62}
63
64/// Blame entry for a line
65#[derive(Debug, Clone)]
66pub struct BlameLine {
67    pub commit: String,
68    pub author: String,
69    pub date: String,
70    pub line_number: u32,
71}
72
73/// Type of line change in a diff
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum DiffLineType {
76    /// Line was added
77    Add,
78    /// Line was removed
79    Remove,
80    /// Context line (unchanged)
81    Context,
82}
83
84impl DiffLineType {
85    /// Get string representation
86    pub fn as_str(&self) -> &'static str {
87        match self {
88            Self::Add => "add",
89            Self::Remove => "remove",
90            Self::Context => "context",
91        }
92    }
93}
94
95/// A single line change within a diff hunk
96#[derive(Debug, Clone)]
97pub struct DiffLine {
98    /// Type of change: add, remove, or context
99    pub change_type: DiffLineType,
100    /// Line number in the old file (None for additions)
101    pub old_line: Option<u32>,
102    /// Line number in the new file (None for deletions)
103    pub new_line: Option<u32>,
104    /// The actual line content (without +/- prefix)
105    pub content: String,
106}
107
108/// A diff hunk representing a contiguous block of changes
109#[derive(Debug, Clone)]
110pub struct DiffHunk {
111    /// File path this hunk belongs to (relative to repo root)
112    pub file: String,
113    /// Starting line in the old file
114    pub old_start: u32,
115    /// Number of lines in the old file section
116    pub old_count: u32,
117    /// Starting line in the new file
118    pub new_start: u32,
119    /// Number of lines in the new file section
120    pub new_count: u32,
121    /// Header line (e.g., "@@ -1,5 +1,7 @@ function name")
122    pub header: String,
123    /// Individual line changes within this hunk
124    pub lines: Vec<DiffLine>,
125}
126
127/// Git errors
128#[derive(Debug, Error)]
129pub enum GitError {
130    #[error("Not a git repository")]
131    NotAGitRepo,
132    #[error("Git command failed: {0}")]
133    CommandFailed(String),
134    #[error("Parse error: {0}")]
135    ParseError(String),
136}
137
138impl GitRepo {
139    /// Open a git repository
140    pub fn open(path: &Path) -> Result<Self, GitError> {
141        let git_dir = path.join(".git");
142        if !git_dir.exists() {
143            return Err(GitError::NotAGitRepo);
144        }
145
146        Ok(Self { path: path.to_string_lossy().to_string() })
147    }
148
149    /// Check if path is a git repository
150    pub fn is_git_repo(path: &Path) -> bool {
151        path.join(".git").exists()
152    }
153
154    /// Get current branch name
155    pub fn current_branch(&self) -> Result<String, GitError> {
156        let output = self.run_git(&["rev-parse", "--abbrev-ref", "HEAD"])?;
157        Ok(output.trim().to_owned())
158    }
159
160    /// Get current commit hash
161    pub fn current_commit(&self) -> Result<String, GitError> {
162        let output = self.run_git(&["rev-parse", "HEAD"])?;
163        Ok(output.trim().to_owned())
164    }
165
166    /// Get short commit hash
167    pub fn short_hash(&self, commit: &str) -> Result<String, GitError> {
168        let output = self.run_git(&["rev-parse", "--short", commit])?;
169        Ok(output.trim().to_owned())
170    }
171
172    /// Get files changed between two commits
173    pub fn diff_files(&self, from: &str, to: &str) -> Result<Vec<ChangedFile>, GitError> {
174        // First get file status with --name-status (shows A/M/D/R/C status)
175        let status_output = self.run_git(&["diff", "--name-status", from, to])?;
176
177        // Then get line counts with --numstat (shows additions/deletions)
178        let numstat_output = self.run_git(&["diff", "--numstat", from, to])?;
179
180        // Build a map of path -> (additions, deletions) from numstat
181        let mut stats: std::collections::HashMap<String, (u32, u32)> =
182            std::collections::HashMap::new();
183        for line in numstat_output.lines() {
184            if line.is_empty() {
185                continue;
186            }
187            let parts: Vec<&str> = line.split('\t').collect();
188            if parts.len() >= 3 {
189                // numstat format: additions<TAB>deletions<TAB>path
190                // Binary files show "-" for additions/deletions
191                let add = parts[0].parse::<u32>().unwrap_or(0);
192                let del = parts[1].parse::<u32>().unwrap_or(0);
193                let path = parts[2..].join("\t");
194                stats.insert(path, (add, del));
195            }
196        }
197
198        let mut files = Vec::new();
199
200        // Parse name-status output
201        for line in status_output.lines() {
202            if line.is_empty() {
203                continue;
204            }
205
206            let parts: Vec<&str> = line.split('\t').collect();
207            if parts.is_empty() {
208                continue;
209            }
210
211            let status_str = parts[0];
212            let first_char = status_str.chars().next().unwrap_or(' ');
213            let status = FileStatus::from_char(first_char);
214
215            // Handle renamed/copied files: R100 or C100 followed by old_path and new_path
216            let (path, old_path) = if (first_char == 'R' || first_char == 'C') && parts.len() >= 3 {
217                // For renames: parts[1] = old_path, parts[2] = new_path
218                (parts[2].to_owned(), Some(parts[1].to_owned()))
219            } else if parts.len() >= 2 {
220                // For other statuses: parts[1] is the path
221                (parts[1].to_owned(), None)
222            } else {
223                continue;
224            };
225
226            // Look up line statistics
227            let (additions, deletions) = stats.get(&path).copied().unwrap_or((0, 0));
228
229            files.push(ChangedFile { path, old_path, status, additions, deletions });
230        }
231
232        Ok(files)
233    }
234
235    /// Get files changed in working tree
236    ///
237    /// Returns both staged and unstaged changes. For renames, the `old_path`
238    /// field contains the original filename.
239    pub fn status(&self) -> Result<Vec<ChangedFile>, GitError> {
240        let output = self.run_git(&["status", "--porcelain"])?;
241
242        let mut files = Vec::new();
243
244        for line in output.lines() {
245            if line.len() < 3 {
246                continue;
247            }
248
249            // Git status --porcelain format: XY filename
250            // X = staged status, Y = unstaged status
251            let staged_char = line.chars().next().unwrap_or(' ');
252            let unstaged_char = line.chars().nth(1).unwrap_or(' ');
253            let path_part = &line[3..];
254
255            // Determine the effective status (prefer staged, then unstaged)
256            let (status, status_char) = if staged_char != ' ' && staged_char != '?' {
257                // Has staged changes
258                (
259                    match staged_char {
260                        'A' => FileStatus::Added,
261                        'M' => FileStatus::Modified,
262                        'D' => FileStatus::Deleted,
263                        'R' => FileStatus::Renamed,
264                        'C' => FileStatus::Copied,
265                        _ => FileStatus::Unknown,
266                    },
267                    staged_char,
268                )
269            } else {
270                // Only unstaged changes
271                (
272                    match unstaged_char {
273                        '?' | 'A' => FileStatus::Added,
274                        'M' => FileStatus::Modified,
275                        'D' => FileStatus::Deleted,
276                        'R' => FileStatus::Renamed,
277                        _ => FileStatus::Unknown,
278                    },
279                    unstaged_char,
280                )
281            };
282
283            // Handle renames: format is "old_path -> new_path"
284            let (path, old_path) = if status_char == 'R' || status_char == 'C' {
285                if let Some(arrow_pos) = path_part.find(" -> ") {
286                    let old = path_part[..arrow_pos].to_owned();
287                    let new = path_part[arrow_pos + 4..].to_owned();
288                    (new, Some(old))
289                } else {
290                    (path_part.to_owned(), None)
291                }
292            } else {
293                (path_part.to_owned(), None)
294            };
295
296            files.push(ChangedFile { path, old_path, status, additions: 0, deletions: 0 });
297        }
298
299        Ok(files)
300    }
301
302    /// Get recent commits
303    pub fn log(&self, count: usize) -> Result<Vec<Commit>, GitError> {
304        let output = self.run_git(&[
305            "log",
306            &format!("-{}", count),
307            "--format=%H%n%h%n%an%n%ae%n%ad%n%s%n---COMMIT---",
308            "--date=short",
309        ])?;
310
311        let mut commits = Vec::new();
312        let mut lines = output.lines().peekable();
313
314        while lines.peek().is_some() {
315            let hash = lines.next().unwrap_or("").to_owned();
316            if hash.is_empty() {
317                continue;
318            }
319
320            let short_hash = lines.next().unwrap_or("").to_owned();
321            let author = lines.next().unwrap_or("").to_owned();
322            let email = lines.next().unwrap_or("").to_owned();
323            let date = lines.next().unwrap_or("").to_owned();
324            let message = lines.next().unwrap_or("").to_owned();
325
326            // Skip separator
327            while lines.peek().map(|l| *l != "---COMMIT---").unwrap_or(false) {
328                lines.next();
329            }
330            lines.next(); // Skip the separator
331
332            commits.push(Commit { hash, short_hash, author, email, date, message });
333        }
334
335        Ok(commits)
336    }
337
338    /// Get commits that modified a specific file
339    pub fn file_log(&self, path: &str, count: usize) -> Result<Vec<Commit>, GitError> {
340        let output = self.run_git(&[
341            "log",
342            &format!("-{}", count),
343            "--format=%H%n%h%n%an%n%ae%n%ad%n%s%n---COMMIT---",
344            "--date=short",
345            "--follow",
346            "--",
347            path,
348        ])?;
349
350        let mut commits = Vec::new();
351        let commit_blocks: Vec<&str> = output.split("---COMMIT---").collect();
352
353        for block in commit_blocks {
354            let lines: Vec<&str> = block.lines().filter(|l| !l.is_empty()).collect();
355            if lines.len() < 6 {
356                continue;
357            }
358
359            commits.push(Commit {
360                hash: lines[0].to_owned(),
361                short_hash: lines[1].to_owned(),
362                author: lines[2].to_owned(),
363                email: lines[3].to_owned(),
364                date: lines[4].to_owned(),
365                message: lines[5].to_owned(),
366            });
367        }
368
369        Ok(commits)
370    }
371
372    /// Get blame information for a file
373    pub fn blame(&self, path: &str) -> Result<Vec<BlameLine>, GitError> {
374        let output = self.run_git(&["blame", "--porcelain", path])?;
375
376        let mut lines = Vec::new();
377        let mut current_commit = String::new();
378        let mut current_author = String::new();
379        let mut current_date = String::new();
380        let mut line_number = 0u32;
381
382        for line in output.lines() {
383            if line.starts_with('\t') {
384                // This is the actual line content, create blame entry
385                lines.push(BlameLine {
386                    commit: current_commit.clone(),
387                    author: current_author.clone(),
388                    date: current_date.clone(),
389                    line_number,
390                });
391            } else if line.len() >= 40 && line.chars().take(40).all(|c| c.is_ascii_hexdigit()) {
392                // New commit hash line
393                let parts: Vec<&str> = line.split_whitespace().collect();
394                if !parts.is_empty() {
395                    current_commit = parts[0][..8.min(parts[0].len())].to_string();
396                    if parts.len() >= 3 {
397                        line_number = parts[2].parse().unwrap_or(0);
398                    }
399                }
400            } else if let Some(author) = line.strip_prefix("author ") {
401                current_author = author.to_owned();
402            } else if let Some(time) = line.strip_prefix("author-time ") {
403                // Convert Unix timestamp to date
404                if let Ok(ts) = time.parse::<i64>() {
405                    current_date = format_timestamp(ts);
406                }
407            }
408        }
409
410        Ok(lines)
411    }
412
413    /// Get list of files tracked by git
414    pub fn ls_files(&self) -> Result<Vec<String>, GitError> {
415        let output = self.run_git(&["ls-files"])?;
416        Ok(output.lines().map(String::from).collect())
417    }
418
419    /// Get diff content between two commits for a file
420    pub fn diff_content(&self, from: &str, to: &str, path: &str) -> Result<String, GitError> {
421        self.run_git(&["diff", from, to, "--", path])
422    }
423
424    /// Get diff content for uncommitted changes (working tree vs HEAD)
425    /// Includes both staged and unstaged changes.
426    pub fn uncommitted_diff(&self, path: &str) -> Result<String, GitError> {
427        // Get both staged and unstaged changes combined
428        self.run_git(&["diff", "HEAD", "--", path])
429    }
430
431    /// Get diff content for all uncommitted changes
432    /// Returns combined diff for all changed files.
433    pub fn all_uncommitted_diffs(&self) -> Result<String, GitError> {
434        self.run_git(&["diff", "HEAD"])
435    }
436
437    /// Check if a file has uncommitted changes
438    pub fn has_changes(&self, path: &str) -> Result<bool, GitError> {
439        let output = self.run_git(&["status", "--porcelain", "--", path])?;
440        Ok(!output.trim().is_empty())
441    }
442
443    /// Get the commit where a file was last modified
444    pub fn last_modified_commit(&self, path: &str) -> Result<Commit, GitError> {
445        let commits = self.file_log(path, 1)?;
446        commits
447            .into_iter()
448            .next()
449            .ok_or_else(|| GitError::ParseError("No commits found".to_owned()))
450    }
451
452    /// Calculate file importance based on recent changes
453    pub fn file_change_frequency(&self, path: &str, days: u32) -> Result<u32, GitError> {
454        let output = self.run_git(&[
455            "log",
456            &format!("--since={} days ago", days),
457            "--oneline",
458            "--follow",
459            "--",
460            path,
461        ])?;
462
463        Ok(output.lines().count() as u32)
464    }
465
466    /// Get file content at a specific git ref (commit, branch, tag)
467    ///
468    /// Uses `git show <ref>:<path>` to retrieve file content at that revision.
469    ///
470    /// # Arguments
471    /// * `path` - File path relative to repository root
472    /// * `git_ref` - Git ref (commit hash, branch name, tag, HEAD~n, etc.)
473    ///
474    /// # Returns
475    /// File content as string, or error if file doesn't exist at that ref
476    ///
477    /// # Example
478    /// ```ignore
479    /// let repo = GitRepo::open(Path::new("."))?;
480    /// let content = repo.file_at_ref("src/main.rs", "HEAD~5")?;
481    /// ```
482    pub fn file_at_ref(&self, path: &str, git_ref: &str) -> Result<String, GitError> {
483        self.run_git(&["show", &format!("{}:{}", git_ref, path)])
484    }
485
486    /// Parse diff between two refs into structured hunks
487    ///
488    /// Returns detailed hunk information including line numbers for each change.
489    ///
490    /// # Arguments
491    /// * `from_ref` - Starting ref (e.g., "main", "HEAD~5", commit hash)
492    /// * `to_ref` - Ending ref (e.g., "HEAD", "feature-branch")
493    /// * `path` - Optional file path to filter to a single file
494    ///
495    /// # Returns
496    /// Vec of DiffHunk with structured line-level information
497    pub fn diff_hunks(
498        &self,
499        from_ref: &str,
500        to_ref: &str,
501        path: Option<&str>,
502    ) -> Result<Vec<DiffHunk>, GitError> {
503        let output = match path {
504            Some(p) => self.run_git(&["diff", "-U3", from_ref, to_ref, "--", p])?,
505            None => self.run_git(&["diff", "-U3", from_ref, to_ref])?,
506        };
507
508        parse_diff_hunks(&output)
509    }
510
511    /// Parse uncommitted changes (working tree vs HEAD) into structured hunks
512    ///
513    /// # Arguments
514    /// * `path` - Optional file path to filter to a single file
515    ///
516    /// # Returns
517    /// Vec of DiffHunk for uncommitted changes
518    pub fn uncommitted_hunks(&self, path: Option<&str>) -> Result<Vec<DiffHunk>, GitError> {
519        let output = match path {
520            Some(p) => self.run_git(&["diff", "-U3", "HEAD", "--", p])?,
521            None => self.run_git(&["diff", "-U3", "HEAD"])?,
522        };
523
524        parse_diff_hunks(&output)
525    }
526
527    /// Parse staged changes into structured hunks
528    ///
529    /// # Arguments
530    /// * `path` - Optional file path to filter to a single file
531    ///
532    /// # Returns
533    /// Vec of DiffHunk for staged changes only
534    pub fn staged_hunks(&self, path: Option<&str>) -> Result<Vec<DiffHunk>, GitError> {
535        let output = match path {
536            Some(p) => self.run_git(&["diff", "-U3", "--staged", "--", p])?,
537            None => self.run_git(&["diff", "-U3", "--staged"])?,
538        };
539
540        parse_diff_hunks(&output)
541    }
542
543    /// Run a git command and return output
544    fn run_git(&self, args: &[&str]) -> Result<String, GitError> {
545        let output = Command::new("git")
546            .current_dir(&self.path)
547            .args(args)
548            .output()
549            .map_err(|e| GitError::CommandFailed(e.to_string()))?;
550
551        if !output.status.success() {
552            let stderr = String::from_utf8_lossy(&output.stderr);
553            return Err(GitError::CommandFailed(stderr.to_string()));
554        }
555
556        String::from_utf8(output.stdout).map_err(|e| GitError::ParseError(e.to_string()))
557    }
558}
559
560/// Format Unix timestamp as YYYY-MM-DD
561fn format_timestamp(ts: i64) -> String {
562    // Simple formatting without chrono
563    let secs_per_day = 86400;
564    let days_since_epoch = ts / secs_per_day;
565
566    // Approximate calculation (doesn't account for leap seconds)
567    let mut year = 1970;
568    let mut remaining_days = days_since_epoch;
569
570    loop {
571        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
572        if remaining_days < days_in_year {
573            break;
574        }
575        remaining_days -= days_in_year;
576        year += 1;
577    }
578
579    let days_in_months = if is_leap_year(year) {
580        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
581    } else {
582        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
583    };
584
585    let mut month = 1;
586    for days in days_in_months {
587        if remaining_days < days {
588            break;
589        }
590        remaining_days -= days;
591        month += 1;
592    }
593
594    let day = remaining_days + 1;
595
596    format!("{:04}-{:02}-{:02}", year, month, day)
597}
598
599fn is_leap_year(year: i64) -> bool {
600    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
601}
602
603/// Parse unified diff output into structured hunks
604///
605/// Handles the standard unified diff format with hunk headers like:
606/// `@@ -start,count +start,count @@ optional context`
607fn parse_diff_hunks(diff_output: &str) -> Result<Vec<DiffHunk>, GitError> {
608    let mut hunks = Vec::new();
609    let mut current_hunk: Option<DiffHunk> = None;
610    let mut current_file = String::new();
611    let mut old_line = 0u32;
612    let mut new_line = 0u32;
613
614    for line in diff_output.lines() {
615        // Reset file tracking when we see a new diff header
616        if line.starts_with("diff --git") {
617            // Save previous hunk if exists before starting new file
618            if let Some(hunk) = current_hunk.take() {
619                hunks.push(hunk);
620            }
621            current_file = String::new();
622            continue;
623        }
624        // Track file from "--- a/path" lines (old file path)
625        if let Some(path) = line.strip_prefix("--- a/") {
626            current_file = path.to_owned();
627            continue;
628        }
629        // Track file from "+++ b/path" lines (new file path - prefer this)
630        if let Some(path) = line.strip_prefix("+++ b/") {
631            current_file = path.to_owned();
632            continue;
633        }
634        // Handle /dev/null for new or deleted files
635        if line.starts_with("--- /dev/null") || line.starts_with("+++ /dev/null") {
636            continue;
637        }
638
639        // Check for hunk header: @@ -old_start,old_count +new_start,new_count @@ context
640        if line.starts_with("@@") {
641            // Save previous hunk if exists
642            if let Some(hunk) = current_hunk.take() {
643                hunks.push(hunk);
644            }
645
646            // Parse hunk header
647            if let Some((old_start, old_count, new_start, new_count)) = parse_hunk_header(line) {
648                old_line = old_start;
649                new_line = new_start;
650
651                current_hunk = Some(DiffHunk {
652                    file: current_file.clone(),
653                    old_start,
654                    old_count,
655                    new_start,
656                    new_count,
657                    header: line.to_owned(),
658                    lines: Vec::new(),
659                });
660            }
661        } else if let Some(ref mut hunk) = current_hunk {
662            // Parse line within a hunk
663            if let Some(first_char) = line.chars().next() {
664                let (change_type, content) = match first_char {
665                    '+' => (DiffLineType::Add, line[1..].to_owned()),
666                    '-' => (DiffLineType::Remove, line[1..].to_owned()),
667                    ' ' => (DiffLineType::Context, line[1..].to_owned()),
668                    '\\' => continue, // "\ No newline at end of file"
669                    _ => continue,    // Skip diff headers (diff --git, index, ---, +++)
670                };
671
672                let (old_ln, new_ln) = match change_type {
673                    DiffLineType::Add => {
674                        let nl = new_line;
675                        new_line += 1;
676                        (None, Some(nl))
677                    },
678                    DiffLineType::Remove => {
679                        let ol = old_line;
680                        old_line += 1;
681                        (Some(ol), None)
682                    },
683                    DiffLineType::Context => {
684                        let ol = old_line;
685                        let nl = new_line;
686                        old_line += 1;
687                        new_line += 1;
688                        (Some(ol), Some(nl))
689                    },
690                };
691
692                hunk.lines.push(DiffLine {
693                    change_type,
694                    old_line: old_ln,
695                    new_line: new_ln,
696                    content,
697                });
698            }
699        }
700    }
701
702    // Push final hunk
703    if let Some(hunk) = current_hunk {
704        hunks.push(hunk);
705    }
706
707    Ok(hunks)
708}
709
710/// Parse a hunk header line into (old_start, old_count, new_start, new_count)
711///
712/// Format: @@ -old_start,old_count +new_start,new_count @@ optional_context
713/// Note: count defaults to 1 if omitted (e.g., @@ -5 +5,2 @@)
714fn parse_hunk_header(header: &str) -> Option<(u32, u32, u32, u32)> {
715    // Find the range specifications between @@ markers
716    let header = header.strip_prefix("@@")?;
717    let end_idx = header.find("@@")?;
718    let range_part = header[..end_idx].trim();
719
720    let parts: Vec<&str> = range_part.split_whitespace().collect();
721    if parts.len() < 2 {
722        return None;
723    }
724
725    // Parse old range: -start,count or -start
726    let old_part = parts[0].strip_prefix('-')?;
727    let (old_start, old_count) = parse_range(old_part)?;
728
729    // Parse new range: +start,count or +start
730    let new_part = parts[1].strip_prefix('+')?;
731    let (new_start, new_count) = parse_range(new_part)?;
732
733    Some((old_start, old_count, new_start, new_count))
734}
735
736/// Parse a range specification like "5,3" or "5" into (start, count)
737fn parse_range(range: &str) -> Option<(u32, u32)> {
738    if let Some((start_str, count_str)) = range.split_once(',') {
739        let start = start_str.parse().ok()?;
740        let count = count_str.parse().ok()?;
741        Some((start, count))
742    } else {
743        let start = range.parse().ok()?;
744        Some((start, 1)) // Default count is 1
745    }
746}
747
748#[cfg(test)]
749#[allow(clippy::str_to_string)]
750mod tests {
751    use super::*;
752    use std::process::Command;
753    use tempfile::TempDir;
754
755    fn init_test_repo() -> TempDir {
756        let temp = TempDir::new().unwrap();
757
758        // Initialize git repo
759        Command::new("git")
760            .current_dir(temp.path())
761            .args(["init"])
762            .output()
763            .unwrap();
764
765        // Configure git
766        Command::new("git")
767            .current_dir(temp.path())
768            .args(["config", "user.email", "test@test.com"])
769            .output()
770            .unwrap();
771
772        Command::new("git")
773            .current_dir(temp.path())
774            .args(["config", "user.name", "Test"])
775            .output()
776            .unwrap();
777
778        // Create a file and commit
779        std::fs::write(temp.path().join("test.txt"), "hello").unwrap();
780
781        Command::new("git")
782            .current_dir(temp.path())
783            .args(["add", "."])
784            .output()
785            .unwrap();
786
787        Command::new("git")
788            .current_dir(temp.path())
789            .args(["commit", "-m", "Initial commit"])
790            .output()
791            .unwrap();
792
793        temp
794    }
795
796    #[test]
797    fn test_open_repo() {
798        let temp = init_test_repo();
799        let repo = GitRepo::open(temp.path());
800        assert!(repo.is_ok());
801    }
802
803    #[test]
804    fn test_not_a_repo() {
805        let temp = TempDir::new().unwrap();
806        let repo = GitRepo::open(temp.path());
807        assert!(matches!(repo, Err(GitError::NotAGitRepo)));
808    }
809
810    #[test]
811    fn test_current_branch() {
812        let temp = init_test_repo();
813        let repo = GitRepo::open(temp.path()).unwrap();
814        let branch = repo.current_branch().unwrap();
815        // Branch could be "main" or "master" depending on git config
816        assert!(!branch.is_empty());
817    }
818
819    #[test]
820    fn test_log() {
821        let temp = init_test_repo();
822        let repo = GitRepo::open(temp.path()).unwrap();
823        let commits = repo.log(10).unwrap();
824        assert!(!commits.is_empty());
825        assert_eq!(commits[0].message, "Initial commit");
826    }
827
828    #[test]
829    fn test_ls_files() {
830        let temp = init_test_repo();
831        let repo = GitRepo::open(temp.path()).unwrap();
832        let files = repo.ls_files().unwrap();
833        assert!(files.contains(&"test.txt".to_string()));
834    }
835
836    #[test]
837    fn test_format_timestamp() {
838        // 2024-01-01 00:00:00 UTC
839        let ts = 1704067200;
840        let date = format_timestamp(ts);
841        assert_eq!(date, "2024-01-01");
842    }
843
844    #[test]
845    fn test_file_at_ref() {
846        let temp = init_test_repo();
847        let repo = GitRepo::open(temp.path()).unwrap();
848
849        // Get file content at HEAD
850        let content = repo.file_at_ref("test.txt", "HEAD").unwrap();
851        assert_eq!(content.trim(), "hello");
852
853        // Modify the file and commit
854        std::fs::write(temp.path().join("test.txt"), "world").unwrap();
855        Command::new("git")
856            .current_dir(temp.path())
857            .args(["add", "."])
858            .output()
859            .unwrap();
860        Command::new("git")
861            .current_dir(temp.path())
862            .args(["commit", "-m", "Update"])
863            .output()
864            .unwrap();
865
866        // Check current HEAD has new content
867        let new_content = repo.file_at_ref("test.txt", "HEAD").unwrap();
868        assert_eq!(new_content.trim(), "world");
869
870        // Check HEAD~1 still has old content
871        let old_content = repo.file_at_ref("test.txt", "HEAD~1").unwrap();
872        assert_eq!(old_content.trim(), "hello");
873    }
874
875    #[test]
876    fn test_parse_hunk_header() {
877        // Standard case
878        let result = parse_hunk_header("@@ -1,5 +1,7 @@ fn main()");
879        assert_eq!(result, Some((1, 5, 1, 7)));
880
881        // No count (defaults to 1)
882        let result = parse_hunk_header("@@ -1 +1 @@");
883        assert_eq!(result, Some((1, 1, 1, 1)));
884
885        // Mixed
886        let result = parse_hunk_header("@@ -10,3 +15 @@");
887        assert_eq!(result, Some((10, 3, 15, 1)));
888
889        // Invalid
890        let result = parse_hunk_header("not a header");
891        assert_eq!(result, None);
892    }
893
894    #[test]
895    fn test_parse_diff_hunks() {
896        let diff = r#"diff --git a/test.txt b/test.txt
897index abc123..def456 100644
898--- a/test.txt
899+++ b/test.txt
900@@ -1,3 +1,4 @@
901 line 1
902-old line 2
903+new line 2
904+added line
905 line 3
906"#;
907
908        let hunks = parse_diff_hunks(diff).unwrap();
909        assert_eq!(hunks.len(), 1);
910
911        let hunk = &hunks[0];
912        assert_eq!(hunk.old_start, 1);
913        assert_eq!(hunk.old_count, 3);
914        assert_eq!(hunk.new_start, 1);
915        assert_eq!(hunk.new_count, 4);
916        assert_eq!(hunk.lines.len(), 5);
917
918        // Check line types
919        assert_eq!(hunk.lines[0].change_type, DiffLineType::Context);
920        assert_eq!(hunk.lines[1].change_type, DiffLineType::Remove);
921        assert_eq!(hunk.lines[2].change_type, DiffLineType::Add);
922        assert_eq!(hunk.lines[3].change_type, DiffLineType::Add);
923        assert_eq!(hunk.lines[4].change_type, DiffLineType::Context);
924
925        // Check line numbers
926        assert_eq!(hunk.lines[0].old_line, Some(1));
927        assert_eq!(hunk.lines[0].new_line, Some(1));
928        assert_eq!(hunk.lines[1].old_line, Some(2));
929        assert_eq!(hunk.lines[1].new_line, None);
930        assert_eq!(hunk.lines[2].old_line, None);
931        assert_eq!(hunk.lines[2].new_line, Some(2));
932    }
933
934    #[test]
935    fn test_diff_hunks() {
936        let temp = init_test_repo();
937        let repo = GitRepo::open(temp.path()).unwrap();
938
939        // Modify file and commit
940        std::fs::write(temp.path().join("test.txt"), "hello\nworld\n").unwrap();
941        Command::new("git")
942            .current_dir(temp.path())
943            .args(["add", "."])
944            .output()
945            .unwrap();
946        Command::new("git")
947            .current_dir(temp.path())
948            .args(["commit", "-m", "Add world"])
949            .output()
950            .unwrap();
951
952        // Get hunks between commits
953        let hunks = repo.diff_hunks("HEAD~1", "HEAD", Some("test.txt")).unwrap();
954        assert!(!hunks.is_empty());
955
956        // Verify we got structured data
957        let hunk = &hunks[0];
958        assert!(hunk.old_start > 0);
959        assert!(!hunk.header.is_empty());
960    }
961
962    #[test]
963    fn test_uncommitted_hunks() {
964        let temp = init_test_repo();
965        let repo = GitRepo::open(temp.path()).unwrap();
966
967        // Make uncommitted change
968        std::fs::write(temp.path().join("test.txt"), "modified content").unwrap();
969
970        let hunks = repo.uncommitted_hunks(Some("test.txt")).unwrap();
971        assert!(!hunks.is_empty());
972
973        // Should have some changes
974        let total_changes: usize = hunks.iter().map(|h| h.lines.len()).sum();
975        assert!(total_changes > 0);
976    }
977}