Skip to main content

omni_dev/git/
commit.rs

1//! Git commit operations and analysis.
2
3use std::fs;
4use std::sync::LazyLock;
5
6use anyhow::{Context, Result};
7use chrono::{DateTime, FixedOffset};
8use git2::{Commit, Repository};
9use globset::Glob;
10use regex::Regex;
11use serde::{Deserialize, Serialize};
12
13use crate::data::context::ScopeDefinition;
14use crate::git::diff_split::split_by_file;
15
16/// Matches conventional commit scope patterns including breaking-change syntax.
17#[allow(clippy::unwrap_used)] // Compile-time constant regex pattern
18static SCOPE_RE: LazyLock<Regex> =
19    LazyLock::new(|| Regex::new(r"^[a-z]+!\(([^)]+)\):|^[a-z]+\(([^)]+)\):").unwrap());
20
21/// Commit information structure, generic over analysis type.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct CommitInfo<A = CommitAnalysis> {
24    /// Full SHA-1 hash of the commit.
25    pub hash: String,
26    /// Commit author name and email address.
27    pub author: String,
28    /// Commit date in ISO format with timezone.
29    pub date: DateTime<FixedOffset>,
30    /// The original commit message as written by the author.
31    pub original_message: String,
32    /// Array of remote main branches that contain this commit.
33    pub in_main_branches: Vec<String>,
34    /// Automated analysis of the commit including type detection and proposed message.
35    pub analysis: A,
36}
37
38/// Commit analysis information.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct CommitAnalysis {
41    /// Automatically detected conventional commit type (feat, fix, docs, test, chore, etc.).
42    pub detected_type: String,
43    /// Automatically detected scope based on file paths (cli, git, data, etc.).
44    pub detected_scope: String,
45    /// AI-generated conventional commit message based on file changes.
46    pub proposed_message: String,
47    /// Detailed statistics about file changes in this commit.
48    pub file_changes: FileChanges,
49    /// Git diff --stat output showing lines changed per file.
50    pub diff_summary: String,
51    /// Path to diff file showing line-by-line changes.
52    pub diff_file: String,
53    /// Per-file diff references for individual file changes.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub file_diffs: Vec<FileDiffRef>,
56}
57
58/// Reference to a per-file diff stored on disk.
59///
60/// Tracks the repository-relative file path, the absolute path to the
61/// diff file on disk, and the byte length of that diff. Gives consumers
62/// per-file size information without loading diff content into memory.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct FileDiffRef {
65    /// Repository-relative path of the changed file.
66    pub path: String,
67    /// Absolute path to the per-file diff file on disk.
68    pub diff_file: String,
69    /// Byte length of the per-file diff content.
70    pub byte_len: usize,
71}
72
73/// Enhanced commit analysis for AI processing with full diff content.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct CommitAnalysisForAI {
76    /// Base commit analysis fields.
77    #[serde(flatten)]
78    pub base: CommitAnalysis,
79    /// Full diff content for AI analysis.
80    pub diff_content: String,
81}
82
83/// Commit information with enhanced analysis for AI processing.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct CommitInfoForAI {
86    /// Base commit information with AI-enhanced analysis.
87    #[serde(flatten)]
88    pub base: CommitInfo<CommitAnalysisForAI>,
89    /// Deterministic checks already performed; the LLM should treat these as authoritative.
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    pub pre_validated_checks: Vec<String>,
92}
93
94/// File changes statistics.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct FileChanges {
97    /// Total number of files modified in this commit.
98    pub total_files: usize,
99    /// Number of new files added in this commit.
100    pub files_added: usize,
101    /// Number of files deleted in this commit.
102    pub files_deleted: usize,
103    /// Array of files changed with their git status (M=modified, A=added, D=deleted).
104    pub file_list: Vec<FileChange>,
105}
106
107/// Individual file change.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct FileChange {
110    /// Git status code (A=added, M=modified, D=deleted, R=renamed).
111    pub status: String,
112    /// Path to the file relative to repository root.
113    pub file: String,
114}
115
116impl CommitInfo {
117    /// Creates a `CommitInfo` from a `git2::Commit`.
118    ///
119    /// `main_tips` is the precomputed set of remote main-branch tips (see
120    /// [`crate::git::main_branches::detect_main_branch_tips`]); callers resolve
121    /// it once per invocation rather than per commit.
122    pub fn from_git_commit(
123        repo: &Repository,
124        commit: &Commit,
125        main_tips: &[crate::git::main_branches::MainBranchTip],
126    ) -> Result<Self> {
127        let hash = commit.id().to_string();
128
129        let author = format!(
130            "{} <{}>",
131            commit.author().name().unwrap_or("Unknown"),
132            commit.author().email().unwrap_or("unknown@example.com")
133        );
134
135        let timestamp = commit.author().when();
136        let date = DateTime::from_timestamp(timestamp.seconds(), 0)
137            .context("Invalid commit timestamp")?
138            .with_timezone(
139                #[allow(clippy::unwrap_used)] // Offset 0 is always valid
140                &FixedOffset::east_opt(timestamp.offset_minutes() * 60)
141                    .unwrap_or_else(|| FixedOffset::east_opt(0).unwrap()),
142            );
143
144        let original_message = commit.message().unwrap_or("").to_string();
145
146        let in_main_branches =
147            crate::git::main_branches::branches_containing(repo, main_tips, commit.id())?;
148
149        let analysis = CommitAnalysis::analyze_commit(repo, commit)?;
150
151        Ok(Self {
152            hash,
153            author,
154            date,
155            original_message,
156            in_main_branches,
157            analysis,
158        })
159    }
160}
161
162impl CommitAnalysis {
163    /// Analyzes a commit and generates analysis information.
164    pub fn analyze_commit(repo: &Repository, commit: &Commit) -> Result<Self> {
165        // Get file changes
166        let file_changes = Self::analyze_file_changes(repo, commit)?;
167
168        // Detect conventional commit type based on files and message
169        let detected_type = Self::detect_commit_type(commit, &file_changes);
170
171        // Detect scope based on file paths
172        let detected_scope = Self::detect_scope(&file_changes);
173
174        // Generate proposed conventional commit message
175        let proposed_message =
176            Self::generate_proposed_message(commit, &detected_type, &detected_scope, &file_changes);
177
178        // Get diff summary
179        let diff_summary = Self::get_diff_summary(repo, commit)?;
180
181        // Write diff to file and get path
182        let (diff_file, file_diffs) = Self::write_diff_to_file(repo, commit)?;
183
184        Ok(Self {
185            detected_type,
186            detected_scope,
187            proposed_message,
188            file_changes,
189            diff_summary,
190            diff_file,
191            file_diffs,
192        })
193    }
194
195    /// Analyzes file changes in the commit.
196    fn analyze_file_changes(repo: &Repository, commit: &Commit) -> Result<FileChanges> {
197        let mut file_list = Vec::new();
198        let mut files_added = 0;
199        let mut files_deleted = 0;
200
201        // Get the tree for this commit
202        let commit_tree = commit.tree().context("Failed to get commit tree")?;
203
204        // Get parent tree if available
205        let parent_tree = if commit.parent_count() > 0 {
206            Some(
207                commit
208                    .parent(0)
209                    .context("Failed to get parent commit")?
210                    .tree()
211                    .context("Failed to get parent tree")?,
212            )
213        } else {
214            None
215        };
216
217        // Create diff between parent and commit
218        let diff = if let Some(parent_tree) = parent_tree {
219            repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
220                .context("Failed to create diff")?
221        } else {
222            // Initial commit - diff against empty tree
223            repo.diff_tree_to_tree(None, Some(&commit_tree), None)
224                .context("Failed to create diff for initial commit")?
225        };
226
227        // Process each diff delta
228        diff.foreach(
229            &mut |delta, _progress| {
230                let status = match delta.status() {
231                    git2::Delta::Added => {
232                        files_added += 1;
233                        "A"
234                    }
235                    git2::Delta::Deleted => {
236                        files_deleted += 1;
237                        "D"
238                    }
239                    git2::Delta::Modified => "M",
240                    git2::Delta::Renamed => "R",
241                    git2::Delta::Copied => "C",
242                    git2::Delta::Typechange => "T",
243                    _ => "?",
244                };
245
246                if let Some(path) = delta.new_file().path() {
247                    if let Some(path_str) = path.to_str() {
248                        file_list.push(FileChange {
249                            status: status.to_string(),
250                            file: path_str.to_string(),
251                        });
252                    }
253                }
254
255                true
256            },
257            None,
258            None,
259            None,
260        )
261        .context("Failed to process diff")?;
262
263        let total_files = file_list.len();
264
265        Ok(FileChanges {
266            total_files,
267            files_added,
268            files_deleted,
269            file_list,
270        })
271    }
272
273    /// Detects conventional commit type based on files and existing message.
274    fn detect_commit_type(commit: &Commit, file_changes: &FileChanges) -> String {
275        Self::detect_commit_type_from_message(commit.message().unwrap_or(""), file_changes)
276    }
277
278    /// Pure type inference from a commit `message` and its `file_changes`.
279    ///
280    /// Separated from [`Self::detect_commit_type`] so the branch logic can be exercised
281    /// deterministically by unit tests; the `&Commit`-taking wrapper requires a
282    /// live repository whose state varies run-to-run (which makes coverage of
283    /// these branches flicker — see the conventional-type tests below).
284    fn detect_commit_type_from_message(message: &str, file_changes: &FileChanges) -> String {
285        // Check if message already has conventional commit format
286        if let Some(existing_type) = Self::extract_conventional_type(message) {
287            return existing_type;
288        }
289
290        // Analyze file patterns
291        let files: Vec<&str> = file_changes
292            .file_list
293            .iter()
294            .map(|f| f.file.as_str())
295            .collect();
296
297        // Check for specific patterns
298        if files
299            .iter()
300            .any(|f| f.contains("test") || f.contains("spec"))
301        {
302            "test".to_string()
303        } else if files
304            .iter()
305            .any(|f| f.ends_with(".md") || f.contains("README") || f.contains("docs/"))
306        {
307            "docs".to_string()
308        } else if files
309            .iter()
310            .any(|f| f.contains("Cargo.toml") || f.contains("package.json") || f.contains("config"))
311        {
312            if file_changes.files_added > 0 {
313                "feat".to_string()
314            } else {
315                "chore".to_string()
316            }
317        } else if file_changes.files_added > 0
318            && files
319                .iter()
320                .any(|f| f.ends_with(".rs") || f.ends_with(".js") || f.ends_with(".py"))
321        {
322            "feat".to_string()
323        } else if message.to_lowercase().contains("fix") || message.to_lowercase().contains("bug") {
324            "fix".to_string()
325        } else if file_changes.files_deleted > file_changes.files_added {
326            "refactor".to_string()
327        } else {
328            "chore".to_string()
329        }
330    }
331
332    /// Extracts conventional commit type from an existing message.
333    fn extract_conventional_type(message: &str) -> Option<String> {
334        let first_line = message.lines().next().unwrap_or("");
335        if let Some(colon_pos) = first_line.find(':') {
336            let prefix = &first_line[..colon_pos];
337            if let Some(paren_pos) = prefix.find('(') {
338                let type_part = &prefix[..paren_pos];
339                if Self::is_valid_conventional_type(type_part) {
340                    return Some(type_part.to_string());
341                }
342            } else if Self::is_valid_conventional_type(prefix) {
343                return Some(prefix.to_string());
344            }
345        }
346        None
347    }
348
349    /// Checks if a string is a valid conventional commit type.
350    fn is_valid_conventional_type(s: &str) -> bool {
351        matches!(
352            s,
353            "feat"
354                | "fix"
355                | "docs"
356                | "style"
357                | "refactor"
358                | "test"
359                | "chore"
360                | "build"
361                | "ci"
362                | "perf"
363        )
364    }
365
366    /// Detects scope from file paths.
367    fn detect_scope(file_changes: &FileChanges) -> String {
368        let files: Vec<&str> = file_changes
369            .file_list
370            .iter()
371            .map(|f| f.file.as_str())
372            .collect();
373
374        // Analyze common path patterns
375        if files.iter().any(|f| f.starts_with("src/cli/")) {
376            "cli".to_string()
377        } else if files.iter().any(|f| f.starts_with("src/git/")) {
378            "git".to_string()
379        } else if files.iter().any(|f| f.starts_with("src/data/")) {
380            "data".to_string()
381        } else if files.iter().any(|f| f.starts_with("tests/")) {
382            "test".to_string()
383        } else if files.iter().any(|f| f.starts_with("docs/")) {
384            "docs".to_string()
385        } else if files
386            .iter()
387            .any(|f| f.contains("Cargo.toml") || f.contains("deny.toml"))
388        {
389            "deps".to_string()
390        } else {
391            String::new()
392        }
393    }
394
395    /// Re-detects scope using file_patterns from scope definitions.
396    ///
397    /// More specific patterns (more literal path components) win regardless of
398    /// definition order in scopes.yaml. Equally specific matches are joined
399    /// with ", ". If no scope definitions match, the existing detected_scope
400    /// is kept as a fallback.
401    pub fn refine_scope(&mut self, scope_defs: &[ScopeDefinition]) {
402        let files: Vec<&str> = self
403            .file_changes
404            .file_list
405            .iter()
406            .map(|f| f.file.as_str())
407            .collect();
408
409        if let Some(resolved) = resolve_scope(&files, scope_defs) {
410            self.detected_scope = resolved;
411        }
412    }
413
414    /// Generates a proposed conventional commit message.
415    fn generate_proposed_message(
416        commit: &Commit,
417        commit_type: &str,
418        scope: &str,
419        file_changes: &FileChanges,
420    ) -> String {
421        let current_message = commit.message().unwrap_or("").lines().next().unwrap_or("");
422        Self::generate_proposed_message_from(current_message, commit_type, scope, file_changes)
423    }
424
425    /// Pure message generation from the commit's first line and its analysis.
426    ///
427    /// Separated from [`Self::generate_proposed_message`] so the scope/format branches
428    /// can be unit-tested deterministically (the `&Commit` wrapper needs a live
429    /// repository).
430    fn generate_proposed_message_from(
431        current_message: &str,
432        commit_type: &str,
433        scope: &str,
434        file_changes: &FileChanges,
435    ) -> String {
436        // If already properly formatted, return as-is
437        if Self::extract_conventional_type(current_message).is_some() {
438            return current_message.to_string();
439        }
440
441        // Generate description based on changes
442        let description =
443            if !current_message.is_empty() && !current_message.eq_ignore_ascii_case("stuff") {
444                current_message.to_string()
445            } else {
446                Self::generate_description(commit_type, file_changes)
447            };
448
449        // Format with scope if available
450        if scope.is_empty() {
451            format!("{commit_type}: {description}")
452        } else {
453            format!("{commit_type}({scope}): {description}")
454        }
455    }
456
457    /// Generates a description based on commit type and changes.
458    fn generate_description(commit_type: &str, file_changes: &FileChanges) -> String {
459        match commit_type {
460            "feat" => {
461                if file_changes.total_files == 1 {
462                    format!("add {}", file_changes.file_list[0].file)
463                } else {
464                    format!("add {} new features", file_changes.total_files)
465                }
466            }
467            "fix" => "resolve issues".to_string(),
468            "docs" => "update documentation".to_string(),
469            "test" => "add tests".to_string(),
470            "refactor" => "improve code structure".to_string(),
471            "chore" => "update project files".to_string(),
472            _ => "update project".to_string(),
473        }
474    }
475
476    /// Returns diff summary statistics.
477    fn get_diff_summary(repo: &Repository, commit: &Commit) -> Result<String> {
478        let commit_tree = commit.tree().context("Failed to get commit tree")?;
479
480        let parent_tree = if commit.parent_count() > 0 {
481            Some(
482                commit
483                    .parent(0)
484                    .context("Failed to get parent commit")?
485                    .tree()
486                    .context("Failed to get parent tree")?,
487            )
488        } else {
489            None
490        };
491
492        let diff = if let Some(parent_tree) = parent_tree {
493            repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
494                .context("Failed to create diff")?
495        } else {
496            repo.diff_tree_to_tree(None, Some(&commit_tree), None)
497                .context("Failed to create diff for initial commit")?
498        };
499
500        let stats = diff.stats().context("Failed to get diff stats")?;
501
502        let mut summary = String::new();
503        for i in 0..stats.files_changed() {
504            if let Some(path) = diff
505                .get_delta(i)
506                .and_then(|d| d.new_file().path())
507                .and_then(|p| p.to_str())
508            {
509                let insertions = stats.insertions();
510                let deletions = stats.deletions();
511                summary.push_str(&format!(
512                    " {} | {} +{} -{}\n",
513                    path,
514                    insertions + deletions,
515                    insertions,
516                    deletions
517                ));
518            }
519        }
520
521        Ok(summary)
522    }
523
524    /// Writes full diff content to a file and returns the path and per-file refs.
525    fn write_diff_to_file(
526        repo: &Repository,
527        commit: &Commit,
528    ) -> Result<(String, Vec<FileDiffRef>)> {
529        // Get AI scratch directory, anchored to the opened repository's workdir
530        // (#967) so the per-commit diff files land under the same repo the rest
531        // of the view reports, rather than the ambient process CWD. `repo` is
532        // the already-opened (possibly `--repo`-injected) git2 handle.
533        let repo_root = repo.workdir().unwrap_or_else(|| repo.path());
534        let ai_scratch_path = crate::utils::ai_scratch::get_ai_scratch_dir_at(repo_root)
535            .context("Failed to determine AI scratch directory")?;
536
537        // Create diffs subdirectory
538        let diffs_dir = ai_scratch_path.join("diffs");
539        fs::create_dir_all(&diffs_dir).context("Failed to create diffs directory")?;
540
541        // Create filename with commit hash
542        let commit_hash = commit.id().to_string();
543        let diff_filename = format!("{commit_hash}.diff");
544        let diff_path = diffs_dir.join(&diff_filename);
545
546        let commit_tree = commit.tree().context("Failed to get commit tree")?;
547
548        let parent_tree = if commit.parent_count() > 0 {
549            Some(
550                commit
551                    .parent(0)
552                    .context("Failed to get parent commit")?
553                    .tree()
554                    .context("Failed to get parent tree")?,
555            )
556        } else {
557            None
558        };
559
560        let diff = if let Some(parent_tree) = parent_tree {
561            repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
562                .context("Failed to create diff")?
563        } else {
564            repo.diff_tree_to_tree(None, Some(&commit_tree), None)
565                .context("Failed to create diff for initial commit")?
566        };
567
568        let mut diff_content = String::new();
569
570        diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
571            let content = std::str::from_utf8(line.content()).unwrap_or("<binary>");
572            let prefix = match line.origin() {
573                '+' => "+",
574                '-' => "-",
575                ' ' => " ",
576                '@' => "@",
577                _ => "", // Header, file header, and other origins
578            };
579            diff_content.push_str(&format!("{prefix}{content}"));
580            true
581        })
582        .context("Failed to format diff")?;
583
584        // Ensure the diff content ends with a newline to encourage literal block style
585        if !diff_content.ends_with('\n') {
586            diff_content.push('\n');
587        }
588
589        // Write flat diff content to file
590        fs::write(&diff_path, &diff_content).context("Failed to write diff file")?;
591
592        // Split into per-file diffs and write each to disk
593        let per_file_diffs = split_by_file(&diff_content);
594        let mut file_diffs = Vec::with_capacity(per_file_diffs.len());
595
596        if !per_file_diffs.is_empty() {
597            let per_file_dir = diffs_dir.join(&commit_hash);
598            fs::create_dir_all(&per_file_dir)
599                .context("Failed to create per-file diffs directory")?;
600
601            for (index, file_diff) in per_file_diffs.iter().enumerate() {
602                let per_file_name = format!("{index:04}.diff");
603                let per_file_path = per_file_dir.join(&per_file_name);
604                fs::write(&per_file_path, &file_diff.content).with_context(|| {
605                    format!("Failed to write per-file diff: {}", per_file_path.display())
606                })?;
607
608                file_diffs.push(FileDiffRef {
609                    path: file_diff.path.clone(),
610                    diff_file: per_file_path.to_string_lossy().to_string(),
611                    byte_len: file_diff.byte_len,
612                });
613            }
614        }
615
616        Ok((diff_path.to_string_lossy().to_string(), file_diffs))
617    }
618}
619
620impl CommitInfoForAI {
621    /// Converts from a basic `CommitInfo` by loading diff content.
622    pub fn from_commit_info(commit_info: CommitInfo) -> Result<Self> {
623        let analysis = CommitAnalysisForAI::from_commit_analysis(commit_info.analysis)?;
624
625        Ok(Self {
626            base: CommitInfo {
627                hash: commit_info.hash,
628                author: commit_info.author,
629                date: commit_info.date,
630                original_message: commit_info.original_message,
631                in_main_branches: commit_info.in_main_branches,
632                analysis,
633            },
634            pre_validated_checks: Vec::new(),
635        })
636    }
637
638    /// Creates a partial view of a commit containing only the specified file diffs.
639    ///
640    /// Convenience wrapper around [`Self::from_commit_info_partial_with_overrides`]
641    /// with all-`None` overrides (every file loaded from disk).
642    #[cfg(test)]
643    pub(crate) fn from_commit_info_partial(
644        commit_info: CommitInfo,
645        file_paths: &[String],
646    ) -> Result<Self> {
647        let overrides: Vec<Option<String>> = vec![None; file_paths.len()];
648        Self::from_commit_info_partial_with_overrides(commit_info, file_paths, &overrides)
649    }
650
651    /// Creates a partial view using pre-sliced diff content where available.
652    ///
653    /// `file_paths` and `diff_overrides` must be parallel slices. When
654    /// `diff_overrides[i]` is `Some(content)`, that content is used directly
655    /// instead of reading the full per-file diff from disk. This enables
656    /// per-hunk partial views where each chunk receives only its assigned
657    /// hunk slices rather than the entire file.
658    ///
659    /// Entries with `None` overrides fall back to loading from disk via
660    /// [`FileDiffRef::diff_file`], deduplicated by path.
661    pub(crate) fn from_commit_info_partial_with_overrides(
662        commit_info: CommitInfo,
663        file_paths: &[String],
664        diff_overrides: &[Option<String>],
665    ) -> Result<Self> {
666        let mut diff_parts = Vec::new();
667        let mut included_refs = Vec::new();
668        let mut loaded_disk_paths: std::collections::HashSet<String> =
669            std::collections::HashSet::new();
670
671        for (path, override_content) in file_paths.iter().zip(diff_overrides.iter()) {
672            if let Some(content) = override_content {
673                // Pre-sliced hunk content — use directly.
674                diff_parts.push(content.clone());
675                // Include the FileDiffRef for metadata (deduplicated).
676                if let Some(file_ref) = commit_info
677                    .analysis
678                    .file_diffs
679                    .iter()
680                    .find(|r| r.path == *path)
681                {
682                    if !included_refs.iter().any(|r: &FileDiffRef| r.path == *path) {
683                        included_refs.push(file_ref.clone());
684                    }
685                }
686            } else {
687                // Whole-file item — load from disk (deduplicated).
688                if loaded_disk_paths.insert(path.clone()) {
689                    if let Some(file_ref) = commit_info
690                        .analysis
691                        .file_diffs
692                        .iter()
693                        .find(|r| r.path == *path)
694                    {
695                        let content =
696                            fs::read_to_string(&file_ref.diff_file).with_context(|| {
697                                format!("Failed to read per-file diff: {}", file_ref.diff_file)
698                            })?;
699                        diff_parts.push(content);
700                        included_refs.push(file_ref.clone());
701                    }
702                }
703            }
704        }
705
706        let diff_content = diff_parts.join("\n");
707
708        let partial_analysis = CommitAnalysisForAI {
709            base: CommitAnalysis {
710                file_diffs: included_refs,
711                ..commit_info.analysis
712            },
713            diff_content,
714        };
715
716        Ok(Self {
717            base: CommitInfo {
718                hash: commit_info.hash,
719                author: commit_info.author,
720                date: commit_info.date,
721                original_message: commit_info.original_message,
722                in_main_branches: commit_info.in_main_branches,
723                analysis: partial_analysis,
724            },
725            pre_validated_checks: Vec::new(),
726        })
727    }
728
729    /// Runs deterministic pre-validation checks on the commit message.
730    /// Passing checks are recorded in pre_validated_checks so the LLM
731    /// can skip re-checking them. Failing checks are not recorded.
732    pub fn run_pre_validation_checks(&mut self, valid_scopes: &[ScopeDefinition]) {
733        if let Some(caps) = SCOPE_RE.captures(&self.base.original_message) {
734            let scope = caps.get(1).or_else(|| caps.get(2)).map(|m| m.as_str());
735            if let Some(scope) = scope {
736                if scope.contains(',') && !scope.contains(",  ") && !scope.contains(" ,") {
737                    self.pre_validated_checks.push(format!(
738                        "Scope format verified: multi-scope '{scope}' uses commas with at most one trailing space"
739                    ));
740                }
741
742                // Deterministic scope validity check
743                if !valid_scopes.is_empty() {
744                    let scope_parts: Vec<&str> = scope.split(',').map(str::trim).collect();
745                    let all_valid = scope_parts
746                        .iter()
747                        .all(|part| valid_scopes.iter().any(|s| s.name == *part));
748                    if all_valid {
749                        self.pre_validated_checks.push(format!(
750                            "Scope validity verified: '{scope}' is in the valid scopes list"
751                        ));
752                    }
753                }
754            }
755        }
756    }
757}
758
759/// Resolves the best scope for a set of files using scope definition file patterns.
760///
761/// More specific patterns (more literal path components) win regardless of
762/// definition order in `scopes.yaml`. Equally specific matches are joined
763/// with ", ". Returns `None` when `scope_defs` or `files` is empty, or no
764/// scope definition matches.
765pub fn resolve_scope(files: &[&str], scope_defs: &[ScopeDefinition]) -> Option<String> {
766    if scope_defs.is_empty() || files.is_empty() {
767        return None;
768    }
769
770    let mut matches: Vec<(&str, usize)> = Vec::new();
771    for scope_def in scope_defs {
772        if let Some(specificity) = scope_matches_files(files, &scope_def.file_patterns) {
773            matches.push((&scope_def.name, specificity));
774        }
775    }
776
777    if matches.is_empty() {
778        return None;
779    }
780
781    // SAFETY: matches is non-empty (guarded by early return above)
782    #[allow(clippy::expect_used)] // Guarded by is_empty() check above
783    let max_specificity = matches.iter().map(|(_, s)| *s).max().expect("non-empty");
784    let best: Vec<&str> = matches
785        .into_iter()
786        .filter(|(_, s)| *s == max_specificity)
787        .map(|(name, _)| name)
788        .collect();
789
790    Some(best.join(", "))
791}
792
793/// Replaces the scope in a conventional commit message with the deterministically
794/// resolved scope based on the given files and scope definitions.
795///
796/// If the message does not contain a conventional commit scope, or if no scope
797/// can be resolved from the files, the message is returned unchanged.
798pub fn refine_message_scope(
799    message: &str,
800    files: &[&str],
801    scope_defs: &[ScopeDefinition],
802) -> String {
803    let Some(resolved) = resolve_scope(files, scope_defs) else {
804        return message.to_string();
805    };
806
807    // Split into first line and rest
808    let (first_line, rest) = message
809        .split_once('\n')
810        .map_or((message, ""), |(f, r)| (f, r));
811
812    let Some(caps) = SCOPE_RE.captures(first_line) else {
813        return message.to_string();
814    };
815
816    // Determine which capture group matched (group 1 = breaking, group 2 = normal)
817    let existing_scope = caps
818        .get(1)
819        .or_else(|| caps.get(2))
820        .map_or("", |m| m.as_str());
821
822    if existing_scope == resolved {
823        return message.to_string();
824    }
825
826    let new_first_line =
827        first_line.replacen(&format!("({existing_scope})"), &format!("({resolved})"), 1);
828
829    if rest.is_empty() {
830        new_first_line
831    } else {
832        format!("{new_first_line}\n{rest}")
833    }
834}
835
836/// Checks if a scope's file patterns match any of the given files.
837///
838/// Returns `Some(max_specificity)` if at least one file matches the scope
839/// (after applying negation patterns), or `None` if no file matches.
840fn scope_matches_files(files: &[&str], patterns: &[String]) -> Option<usize> {
841    let mut positive = Vec::new();
842    let mut negative = Vec::new();
843    for pat in patterns {
844        if let Some(stripped) = pat.strip_prefix('!') {
845            negative.push(stripped);
846        } else {
847            positive.push(pat.as_str());
848        }
849    }
850
851    // Build negative matchers
852    let neg_matchers: Vec<_> = negative
853        .iter()
854        .filter_map(|p| Glob::new(p).ok().map(|g| g.compile_matcher()))
855        .collect();
856
857    let mut max_specificity: Option<usize> = None;
858    for pat in &positive {
859        let Ok(glob) = Glob::new(pat) else {
860            continue;
861        };
862        let matcher = glob.compile_matcher();
863        for file in files {
864            if matcher.is_match(file) && !neg_matchers.iter().any(|neg| neg.is_match(file)) {
865                let specificity = count_specificity(pat);
866                max_specificity =
867                    Some(max_specificity.map_or(specificity, |cur| cur.max(specificity)));
868            }
869        }
870    }
871    max_specificity
872}
873
874/// Counts the number of literal (non-wildcard) path segments in a glob pattern.
875///
876/// - `docs/adrs/**` → 2 (`docs`, `adrs`)
877/// - `docs/**` → 1 (`docs`)
878/// - `*.md` → 0
879/// - `src/main/scala/**` → 3
880fn count_specificity(pattern: &str) -> usize {
881    pattern
882        .split('/')
883        .filter(|segment| !segment.contains('*') && !segment.contains('?'))
884        .count()
885}
886
887impl CommitAnalysisForAI {
888    /// Converts from a basic `CommitAnalysis` by loading diff content from file.
889    pub fn from_commit_analysis(analysis: CommitAnalysis) -> Result<Self> {
890        // Read the actual diff content from the file
891        let diff_content = fs::read_to_string(&analysis.diff_file)
892            .with_context(|| format!("Failed to read diff file: {}", analysis.diff_file))?;
893
894        Ok(Self {
895            base: analysis,
896            diff_content,
897        })
898    }
899}
900
901#[cfg(test)]
902#[allow(clippy::unwrap_used, clippy::expect_used)]
903mod tests {
904    use super::*;
905    use crate::data::context::ScopeDefinition;
906
907    // ── extract_conventional_type ────────────────────────────────────
908
909    #[test]
910    fn conventional_type_feat_with_scope() {
911        assert_eq!(
912            CommitAnalysis::extract_conventional_type("feat(cli): add flag"),
913            Some("feat".to_string())
914        );
915    }
916
917    #[test]
918    fn conventional_type_without_scope() {
919        assert_eq!(
920            CommitAnalysis::extract_conventional_type("fix: resolve bug"),
921            Some("fix".to_string())
922        );
923    }
924
925    #[test]
926    fn conventional_type_invalid_message() {
927        assert_eq!(
928            CommitAnalysis::extract_conventional_type("random message without colon"),
929            None
930        );
931    }
932
933    #[test]
934    fn conventional_type_unknown_type() {
935        assert_eq!(
936            CommitAnalysis::extract_conventional_type("yolo(scope): stuff"),
937            None
938        );
939    }
940
941    #[test]
942    fn conventional_type_all_valid_types() {
943        let types = [
944            "feat", "fix", "docs", "style", "refactor", "test", "chore", "build", "ci", "perf",
945        ];
946        for t in types {
947            let msg = format!("{t}: description");
948            assert_eq!(
949                CommitAnalysis::extract_conventional_type(&msg),
950                Some(t.to_string()),
951                "expected Some for type '{t}'"
952            );
953        }
954    }
955
956    // ── is_valid_conventional_type ───────────────────────────────────
957
958    #[test]
959    fn valid_conventional_types() {
960        for t in [
961            "feat", "fix", "docs", "style", "refactor", "test", "chore", "build", "ci", "perf",
962        ] {
963            assert!(
964                CommitAnalysis::is_valid_conventional_type(t),
965                "'{t}' should be valid"
966            );
967        }
968    }
969
970    #[test]
971    fn invalid_conventional_types() {
972        for t in ["yolo", "Feat", "", "FEAT", "feature", "bugfix"] {
973            assert!(
974                !CommitAnalysis::is_valid_conventional_type(t),
975                "'{t}' should be invalid"
976            );
977        }
978    }
979
980    // ── detect_scope ─────────────────────────────────────────────────
981
982    fn make_file_changes(files: &[(&str, &str)]) -> FileChanges {
983        FileChanges {
984            total_files: files.len(),
985            files_added: files.iter().filter(|(s, _)| *s == "A").count(),
986            files_deleted: files.iter().filter(|(s, _)| *s == "D").count(),
987            file_list: files
988                .iter()
989                .map(|(status, file)| FileChange {
990                    status: (*status).to_string(),
991                    file: (*file).to_string(),
992                })
993                .collect(),
994        }
995    }
996
997    #[test]
998    fn scope_from_cli_files() {
999        let changes = make_file_changes(&[("M", "src/cli/commands.rs")]);
1000        assert_eq!(CommitAnalysis::detect_scope(&changes), "cli");
1001    }
1002
1003    #[test]
1004    fn scope_from_git_files() {
1005        let changes = make_file_changes(&[("M", "src/git/remote.rs")]);
1006        assert_eq!(CommitAnalysis::detect_scope(&changes), "git");
1007    }
1008
1009    #[test]
1010    fn scope_from_docs_files() {
1011        let changes = make_file_changes(&[("M", "docs/README.md")]);
1012        assert_eq!(CommitAnalysis::detect_scope(&changes), "docs");
1013    }
1014
1015    #[test]
1016    fn scope_from_data_files() {
1017        let changes = make_file_changes(&[("M", "src/data/yaml.rs")]);
1018        assert_eq!(CommitAnalysis::detect_scope(&changes), "data");
1019    }
1020
1021    #[test]
1022    fn scope_from_test_files() {
1023        let changes = make_file_changes(&[("A", "tests/new_test.rs")]);
1024        assert_eq!(CommitAnalysis::detect_scope(&changes), "test");
1025    }
1026
1027    #[test]
1028    fn scope_from_deps_files() {
1029        let changes = make_file_changes(&[("M", "Cargo.toml")]);
1030        assert_eq!(CommitAnalysis::detect_scope(&changes), "deps");
1031    }
1032
1033    #[test]
1034    fn scope_unknown_files() {
1035        let changes = make_file_changes(&[("M", "random/path/file.txt")]);
1036        assert_eq!(CommitAnalysis::detect_scope(&changes), "");
1037    }
1038
1039    // ── count_specificity ────────────────────────────────────────────
1040
1041    #[test]
1042    fn count_specificity_deep_path() {
1043        assert_eq!(super::count_specificity("src/main/scala/**"), 3);
1044    }
1045
1046    #[test]
1047    fn count_specificity_shallow() {
1048        assert_eq!(super::count_specificity("docs/**"), 1);
1049    }
1050
1051    #[test]
1052    fn count_specificity_wildcard_only() {
1053        assert_eq!(super::count_specificity("*.md"), 0);
1054    }
1055
1056    #[test]
1057    fn count_specificity_no_wildcards() {
1058        assert_eq!(super::count_specificity("src/lib.rs"), 2);
1059    }
1060
1061    // ── scope_matches_files ──────────────────────────────────────────
1062
1063    #[test]
1064    fn scope_matches_positive_patterns() {
1065        let patterns = vec!["src/cli/**".to_string()];
1066        let files = &["src/cli/commands.rs"];
1067        assert!(super::scope_matches_files(files, &patterns).is_some());
1068    }
1069
1070    #[test]
1071    fn scope_matches_no_match() {
1072        let patterns = vec!["src/cli/**".to_string()];
1073        let files = &["src/git/remote.rs"];
1074        assert!(super::scope_matches_files(files, &patterns).is_none());
1075    }
1076
1077    #[test]
1078    fn scope_matches_with_negation() {
1079        let patterns = vec!["src/**".to_string(), "!src/test/**".to_string()];
1080        // File in src/ but not in src/test/ should match
1081        let files = &["src/lib.rs"];
1082        assert!(super::scope_matches_files(files, &patterns).is_some());
1083
1084        // File in src/test/ should be excluded
1085        let test_files = &["src/test/helper.rs"];
1086        assert!(super::scope_matches_files(test_files, &patterns).is_none());
1087    }
1088
1089    // ── refine_scope ─────────────────────────────────────────────────
1090
1091    fn make_scope_def(name: &str, patterns: &[&str]) -> ScopeDefinition {
1092        ScopeDefinition {
1093            name: name.to_string(),
1094            description: String::new(),
1095            examples: vec![],
1096            file_patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
1097        }
1098    }
1099
1100    #[test]
1101    fn refine_scope_empty_defs() {
1102        let mut analysis = CommitAnalysis {
1103            detected_type: "feat".to_string(),
1104            detected_scope: "original".to_string(),
1105            proposed_message: String::new(),
1106            file_changes: make_file_changes(&[("M", "src/cli/commands.rs")]),
1107            diff_summary: String::new(),
1108            diff_file: String::new(),
1109            file_diffs: Vec::new(),
1110        };
1111        analysis.refine_scope(&[]);
1112        assert_eq!(analysis.detected_scope, "original");
1113    }
1114
1115    #[test]
1116    fn refine_scope_most_specific_wins() {
1117        let scope_defs = vec![
1118            make_scope_def("lib", &["src/**"]),
1119            make_scope_def("cli", &["src/cli/**"]),
1120        ];
1121        let mut analysis = CommitAnalysis {
1122            detected_type: "feat".to_string(),
1123            detected_scope: String::new(),
1124            proposed_message: String::new(),
1125            file_changes: make_file_changes(&[("M", "src/cli/commands.rs")]),
1126            diff_summary: String::new(),
1127            diff_file: String::new(),
1128            file_diffs: Vec::new(),
1129        };
1130        analysis.refine_scope(&scope_defs);
1131        assert_eq!(analysis.detected_scope, "cli");
1132    }
1133
1134    #[test]
1135    fn refine_scope_no_matching_files() {
1136        let scope_defs = vec![make_scope_def("cli", &["src/cli/**"])];
1137        let mut analysis = CommitAnalysis {
1138            detected_type: "feat".to_string(),
1139            detected_scope: "original".to_string(),
1140            proposed_message: String::new(),
1141            file_changes: make_file_changes(&[("M", "README.md")]),
1142            diff_summary: String::new(),
1143            diff_file: String::new(),
1144            file_diffs: Vec::new(),
1145        };
1146        analysis.refine_scope(&scope_defs);
1147        // No match → keeps original
1148        assert_eq!(analysis.detected_scope, "original");
1149    }
1150
1151    #[test]
1152    fn refine_scope_equal_specificity_joins() {
1153        let scope_defs = vec![
1154            make_scope_def("cli", &["src/cli/**"]),
1155            make_scope_def("git", &["src/git/**"]),
1156        ];
1157        let mut analysis = CommitAnalysis {
1158            detected_type: "feat".to_string(),
1159            detected_scope: String::new(),
1160            proposed_message: String::new(),
1161            file_changes: make_file_changes(&[
1162                ("M", "src/cli/commands.rs"),
1163                ("M", "src/git/remote.rs"),
1164            ]),
1165            diff_summary: String::new(),
1166            diff_file: String::new(),
1167            file_diffs: Vec::new(),
1168        };
1169        analysis.refine_scope(&scope_defs);
1170        // Both have specificity 2 and both match → joined
1171        assert!(
1172            analysis.detected_scope == "cli, git" || analysis.detected_scope == "git, cli",
1173            "expected joined scopes, got: {}",
1174            analysis.detected_scope
1175        );
1176    }
1177
1178    // ── refine_message_scope ───────────────────────────────────────────
1179
1180    #[test]
1181    fn refine_message_scope_replaces_less_specific() {
1182        let scope_defs = vec![
1183            make_scope_def("ci", &[".github/**"]),
1184            make_scope_def("workflows", &[".github/workflows/**"]),
1185        ];
1186        let files = &[".github/workflows/ci.yml"];
1187        let result = super::refine_message_scope(
1188            "chore(ci): bump EmbarkStudios/cargo-deny-action from 2.0.15 to 2.0.17",
1189            files,
1190            &scope_defs,
1191        );
1192        assert_eq!(
1193            result,
1194            "chore(workflows): bump EmbarkStudios/cargo-deny-action from 2.0.15 to 2.0.17"
1195        );
1196    }
1197
1198    #[test]
1199    fn refine_message_scope_keeps_already_correct() {
1200        let scope_defs = vec![
1201            make_scope_def("ci", &[".github/**"]),
1202            make_scope_def("workflows", &[".github/workflows/**"]),
1203        ];
1204        let files = &[".github/workflows/ci.yml"];
1205        let msg = "chore(workflows): bump something";
1206        assert_eq!(super::refine_message_scope(msg, files, &scope_defs), msg);
1207    }
1208
1209    #[test]
1210    fn refine_message_scope_no_scope_in_message() {
1211        let scope_defs = vec![make_scope_def("cli", &["src/cli/**"])];
1212        let files = &["src/cli/commands.rs"];
1213        let msg = "chore: do something";
1214        assert_eq!(super::refine_message_scope(msg, files, &scope_defs), msg);
1215    }
1216
1217    #[test]
1218    fn refine_message_scope_preserves_body() {
1219        let scope_defs = vec![
1220            make_scope_def("ci", &[".github/**"]),
1221            make_scope_def("workflows", &[".github/workflows/**"]),
1222        ];
1223        let files = &[".github/workflows/ci.yml"];
1224        let msg = "chore(ci): bump dep\n\nSome body text\nMore details";
1225        let result = super::refine_message_scope(msg, files, &scope_defs);
1226        assert_eq!(
1227            result,
1228            "chore(workflows): bump dep\n\nSome body text\nMore details"
1229        );
1230    }
1231
1232    #[test]
1233    fn refine_message_scope_breaking_change() {
1234        let scope_defs = vec![
1235            make_scope_def("ci", &[".github/**"]),
1236            make_scope_def("workflows", &[".github/workflows/**"]),
1237        ];
1238        let files = &[".github/workflows/ci.yml"];
1239        let result = super::refine_message_scope("feat!(ci): breaking change", files, &scope_defs);
1240        assert_eq!(result, "feat!(workflows): breaking change");
1241    }
1242
1243    #[test]
1244    fn refine_message_scope_no_matching_scope_defs() {
1245        let scope_defs = vec![make_scope_def("cli", &["src/cli/**"])];
1246        let files = &["README.md"];
1247        let msg = "docs(docs): update readme";
1248        assert_eq!(super::refine_message_scope(msg, files, &scope_defs), msg);
1249    }
1250
1251    // ── run_pre_validation_checks ────────────────────────────────────
1252
1253    fn make_commit_info_for_ai(message: &str) -> CommitInfoForAI {
1254        CommitInfoForAI {
1255            base: CommitInfo {
1256                hash: "a".repeat(40),
1257                author: "Test <test@example.com>".to_string(),
1258                date: chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:00+00:00").unwrap(),
1259                original_message: message.to_string(),
1260                in_main_branches: vec![],
1261                analysis: CommitAnalysisForAI {
1262                    base: CommitAnalysis {
1263                        detected_type: "feat".to_string(),
1264                        detected_scope: String::new(),
1265                        proposed_message: String::new(),
1266                        file_changes: make_file_changes(&[]),
1267                        diff_summary: String::new(),
1268                        diff_file: String::new(),
1269                        file_diffs: Vec::new(),
1270                    },
1271                    diff_content: String::new(),
1272                },
1273            },
1274            pre_validated_checks: vec![],
1275        }
1276    }
1277
1278    #[test]
1279    fn pre_validation_valid_single_scope() {
1280        let scopes = vec![make_scope_def("cli", &["src/cli/**"])];
1281        let mut info = make_commit_info_for_ai("feat(cli): add command");
1282        info.run_pre_validation_checks(&scopes);
1283        assert!(
1284            info.pre_validated_checks
1285                .iter()
1286                .any(|c| c.contains("Scope validity verified")),
1287            "expected scope validity check, got: {:?}",
1288            info.pre_validated_checks
1289        );
1290    }
1291
1292    #[test]
1293    fn pre_validation_multi_scope() {
1294        let scopes = vec![
1295            make_scope_def("cli", &["src/cli/**"]),
1296            make_scope_def("git", &["src/git/**"]),
1297        ];
1298        let mut info = make_commit_info_for_ai("feat(cli,git): cross-cutting change");
1299        info.run_pre_validation_checks(&scopes);
1300        assert!(info
1301            .pre_validated_checks
1302            .iter()
1303            .any(|c| c.contains("Scope validity verified")),);
1304        assert!(info
1305            .pre_validated_checks
1306            .iter()
1307            .any(|c| c.contains("multi-scope")),);
1308    }
1309
1310    #[test]
1311    fn pre_validation_multi_scope_with_spaces() {
1312        let scopes = vec![
1313            make_scope_def("cli", &["src/cli/**"]),
1314            make_scope_def("lib", &["src/lib/**"]),
1315        ];
1316        let mut info = make_commit_info_for_ai("feat(cli, lib): add something");
1317        info.run_pre_validation_checks(&scopes);
1318        assert!(
1319            info.pre_validated_checks
1320                .iter()
1321                .any(|c| c.contains("Scope validity verified")),
1322            "expected scope validity check for spaced multi-scope, got: {:?}",
1323            info.pre_validated_checks
1324        );
1325        assert!(
1326            info.pre_validated_checks
1327                .iter()
1328                .any(|c| c.contains("Scope format verified")),
1329            "single-space-after-comma multi-scope should pass the format check, got: {:?}",
1330            info.pre_validated_checks
1331        );
1332    }
1333
1334    #[test]
1335    fn pre_validation_multi_scope_double_space_not_format_verified() {
1336        let scopes = vec![
1337            make_scope_def("cli", &["src/cli/**"]),
1338            make_scope_def("lib", &["src/lib/**"]),
1339        ];
1340        let mut info = make_commit_info_for_ai("feat(cli,  lib): add something");
1341        info.run_pre_validation_checks(&scopes);
1342        assert!(
1343            !info
1344                .pre_validated_checks
1345                .iter()
1346                .any(|c| c.contains("Scope format verified")),
1347            "double-space-after-comma must NOT be recorded as format-verified, got: {:?}",
1348            info.pre_validated_checks
1349        );
1350    }
1351
1352    #[test]
1353    fn pre_validation_multi_scope_space_before_comma_not_format_verified() {
1354        let scopes = vec![
1355            make_scope_def("cli", &["src/cli/**"]),
1356            make_scope_def("lib", &["src/lib/**"]),
1357        ];
1358        let mut info = make_commit_info_for_ai("feat(cli ,lib): add something");
1359        info.run_pre_validation_checks(&scopes);
1360        assert!(
1361            !info
1362                .pre_validated_checks
1363                .iter()
1364                .any(|c| c.contains("Scope format verified")),
1365            "space-before-comma must NOT be recorded as format-verified, got: {:?}",
1366            info.pre_validated_checks
1367        );
1368    }
1369
1370    #[test]
1371    fn pre_validation_invalid_scope_not_added() {
1372        let scopes = vec![make_scope_def("cli", &["src/cli/**"])];
1373        let mut info = make_commit_info_for_ai("feat(unknown): something");
1374        info.run_pre_validation_checks(&scopes);
1375        assert!(
1376            !info
1377                .pre_validated_checks
1378                .iter()
1379                .any(|c| c.contains("Scope validity verified")),
1380            "should not validate unknown scope"
1381        );
1382    }
1383
1384    #[test]
1385    fn pre_validation_no_scope_message() {
1386        let scopes = vec![make_scope_def("cli", &["src/cli/**"])];
1387        let mut info = make_commit_info_for_ai("feat: no scope here");
1388        info.run_pre_validation_checks(&scopes);
1389        assert!(info.pre_validated_checks.is_empty());
1390    }
1391
1392    // ── property tests ────────────────────────────────────────────
1393
1394    mod prop {
1395        use super::*;
1396        use proptest::prelude::*;
1397
1398        fn arb_conventional_type() -> impl Strategy<Value = &'static str> {
1399            prop_oneof![
1400                Just("feat"),
1401                Just("fix"),
1402                Just("docs"),
1403                Just("style"),
1404                Just("refactor"),
1405                Just("test"),
1406                Just("chore"),
1407                Just("build"),
1408                Just("ci"),
1409                Just("perf"),
1410            ]
1411        }
1412
1413        proptest! {
1414            #[test]
1415            fn valid_conventional_format_extracts_type(
1416                ctype in arb_conventional_type(),
1417                scope in "[a-z]{1,10}",
1418                desc in "[a-zA-Z ]{1,50}",
1419            ) {
1420                let message = format!("{ctype}({scope}): {desc}");
1421                let result = CommitAnalysis::extract_conventional_type(&message);
1422                prop_assert_eq!(result, Some(ctype.to_string()));
1423            }
1424
1425            #[test]
1426            fn no_colon_returns_none(s in "[^:]{0,100}") {
1427                let result = CommitAnalysis::extract_conventional_type(&s);
1428                prop_assert!(result.is_none());
1429            }
1430
1431            #[test]
1432            fn count_specificity_nonnegative(pattern in ".*") {
1433                // usize is always >= 0; this test catches panics on arbitrary input
1434                let _ = super::count_specificity(&pattern);
1435            }
1436
1437            #[test]
1438            fn count_specificity_bounded_by_segments(
1439                segments in proptest::collection::vec("[a-z*?]{1,10}", 1..6),
1440            ) {
1441                let pattern = segments.join("/");
1442                let result = super::count_specificity(&pattern);
1443                prop_assert!(result <= segments.len());
1444            }
1445        }
1446    }
1447
1448    // ── conversion tests ────────────────────────────────────────────
1449
1450    #[test]
1451    fn from_commit_analysis_loads_diff_content() {
1452        let dir = tempfile::tempdir().unwrap();
1453        let diff_path = dir.path().join("test.diff");
1454        std::fs::write(&diff_path, "+added line\n-removed line\n").unwrap();
1455
1456        let analysis = CommitAnalysis {
1457            detected_type: "feat".to_string(),
1458            detected_scope: "cli".to_string(),
1459            proposed_message: "feat(cli): test".to_string(),
1460            file_changes: make_file_changes(&[]),
1461            diff_summary: "file.rs | 2 +-".to_string(),
1462            diff_file: diff_path.to_string_lossy().to_string(),
1463            file_diffs: Vec::new(),
1464        };
1465
1466        let ai = CommitAnalysisForAI::from_commit_analysis(analysis.clone()).unwrap();
1467        assert_eq!(ai.diff_content, "+added line\n-removed line\n");
1468        assert_eq!(ai.base.detected_type, analysis.detected_type);
1469        assert_eq!(ai.base.diff_file, analysis.diff_file);
1470    }
1471
1472    #[test]
1473    fn from_commit_info_wraps_and_loads_diff() {
1474        let dir = tempfile::tempdir().unwrap();
1475        let diff_path = dir.path().join("test.diff");
1476        std::fs::write(&diff_path, "diff content").unwrap();
1477
1478        let info = CommitInfo {
1479            hash: "a".repeat(40),
1480            author: "Test <test@example.com>".to_string(),
1481            date: chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:00+00:00").unwrap(),
1482            original_message: "feat(cli): add flag".to_string(),
1483            in_main_branches: vec!["origin/main".to_string()],
1484            analysis: CommitAnalysis {
1485                detected_type: "feat".to_string(),
1486                detected_scope: "cli".to_string(),
1487                proposed_message: "feat(cli): add flag".to_string(),
1488                file_changes: make_file_changes(&[("M", "src/cli.rs")]),
1489                diff_summary: "cli.rs | 1 +".to_string(),
1490                diff_file: diff_path.to_string_lossy().to_string(),
1491                file_diffs: Vec::new(),
1492            },
1493        };
1494
1495        let ai = CommitInfoForAI::from_commit_info(info).unwrap();
1496        assert_eq!(ai.base.analysis.diff_content, "diff content");
1497        assert_eq!(ai.base.hash, "a".repeat(40));
1498        assert_eq!(ai.base.original_message, "feat(cli): add flag");
1499        assert!(ai.pre_validated_checks.is_empty());
1500    }
1501
1502    #[test]
1503    fn file_diffs_default_empty_on_deserialize() {
1504        let yaml = r#"
1505detected_type: feat
1506detected_scope: cli
1507proposed_message: "feat(cli): test"
1508file_changes:
1509  total_files: 0
1510  files_added: 0
1511  files_deleted: 0
1512  file_list: []
1513diff_summary: ""
1514diff_file: "/tmp/test.diff"
1515"#;
1516        let analysis: CommitAnalysis = serde_yaml::from_str(yaml).unwrap();
1517        assert!(analysis.file_diffs.is_empty());
1518    }
1519
1520    #[test]
1521    fn file_diffs_omitted_when_empty_on_serialize() {
1522        let analysis = CommitAnalysis {
1523            detected_type: "feat".to_string(),
1524            detected_scope: "cli".to_string(),
1525            proposed_message: "feat(cli): test".to_string(),
1526            file_changes: make_file_changes(&[]),
1527            diff_summary: String::new(),
1528            diff_file: String::new(),
1529            file_diffs: Vec::new(),
1530        };
1531        let yaml = serde_yaml::to_string(&analysis).unwrap();
1532        assert!(!yaml.contains("file_diffs"));
1533    }
1534
1535    #[test]
1536    fn file_diffs_included_when_populated() {
1537        let analysis = CommitAnalysis {
1538            detected_type: "feat".to_string(),
1539            detected_scope: "cli".to_string(),
1540            proposed_message: "feat(cli): test".to_string(),
1541            file_changes: make_file_changes(&[]),
1542            diff_summary: String::new(),
1543            diff_file: String::new(),
1544            file_diffs: vec![FileDiffRef {
1545                path: "src/main.rs".to_string(),
1546                diff_file: "/tmp/diffs/abc/0000.diff".to_string(),
1547                byte_len: 42,
1548            }],
1549        };
1550        let yaml = serde_yaml::to_string(&analysis).unwrap();
1551        assert!(yaml.contains("file_diffs"));
1552        assert!(yaml.contains("src/main.rs"));
1553        assert!(yaml.contains("byte_len: 42"));
1554    }
1555
1556    // ── from_commit_info_partial ────────────────────────────────────
1557
1558    /// Helper: creates a `CommitInfo` with N file diffs backed by temp files.
1559    fn make_commit_with_file_diffs(
1560        dir: &tempfile::TempDir,
1561        files: &[(&str, &str)], // (path, diff_content)
1562    ) -> CommitInfo {
1563        let file_diffs: Vec<FileDiffRef> = files
1564            .iter()
1565            .enumerate()
1566            .map(|(i, (path, content))| {
1567                let diff_path = dir.path().join(format!("{i:04}.diff"));
1568                fs::write(&diff_path, content).unwrap();
1569                FileDiffRef {
1570                    path: (*path).to_string(),
1571                    diff_file: diff_path.to_string_lossy().to_string(),
1572                    byte_len: content.len(),
1573                }
1574            })
1575            .collect();
1576
1577        CommitInfo {
1578            hash: "abc123def456abc123def456abc123def456abc1".to_string(),
1579            author: "Test Author".to_string(),
1580            date: DateTime::parse_from_rfc3339("2025-01-01T00:00:00+00:00").unwrap(),
1581            original_message: "feat(cli): original message".to_string(),
1582            in_main_branches: vec!["main".to_string()],
1583            analysis: CommitAnalysis {
1584                detected_type: "feat".to_string(),
1585                detected_scope: "cli".to_string(),
1586                proposed_message: "feat(cli): proposed".to_string(),
1587                file_changes: make_file_changes(
1588                    &files.iter().map(|(p, _)| ("M", *p)).collect::<Vec<_>>(),
1589                ),
1590                diff_summary: " src/main.rs | 10 ++++\n src/lib.rs | 5 ++\n".to_string(),
1591                diff_file: dir.path().join("full.diff").to_string_lossy().to_string(),
1592                file_diffs,
1593            },
1594        }
1595    }
1596
1597    #[test]
1598    fn from_commit_info_partial_loads_subset() -> Result<()> {
1599        let dir = tempfile::tempdir()?;
1600        let commit = make_commit_with_file_diffs(
1601            &dir,
1602            &[
1603                ("src/main.rs", "diff --git a/src/main.rs\n+main\n"),
1604                ("src/lib.rs", "diff --git a/src/lib.rs\n+lib\n"),
1605                ("src/utils.rs", "diff --git a/src/utils.rs\n+utils\n"),
1606            ],
1607        );
1608
1609        let paths = vec!["src/main.rs".to_string(), "src/utils.rs".to_string()];
1610        let partial = CommitInfoForAI::from_commit_info_partial(commit, &paths)?;
1611
1612        // Only requested files in diff_content
1613        assert!(partial.base.analysis.diff_content.contains("+main"));
1614        assert!(partial.base.analysis.diff_content.contains("+utils"));
1615        assert!(!partial.base.analysis.diff_content.contains("+lib"));
1616
1617        // file_diffs filtered to requested paths
1618        let ref_paths: Vec<&str> = partial
1619            .base
1620            .analysis
1621            .base
1622            .file_diffs
1623            .iter()
1624            .map(|r| r.path.as_str())
1625            .collect();
1626        assert_eq!(ref_paths, &["src/main.rs", "src/utils.rs"]);
1627
1628        Ok(())
1629    }
1630
1631    #[test]
1632    fn from_commit_info_partial_deduplicates_paths() -> Result<()> {
1633        let dir = tempfile::tempdir()?;
1634        let commit = make_commit_with_file_diffs(
1635            &dir,
1636            &[("src/main.rs", "diff --git a/src/main.rs\n+main\n")],
1637        );
1638
1639        // Duplicate path (simulates hunk-split scenario)
1640        let paths = vec!["src/main.rs".to_string(), "src/main.rs".to_string()];
1641        let partial = CommitInfoForAI::from_commit_info_partial(commit, &paths)?;
1642
1643        // Content loaded only once (no duplicate)
1644        assert_eq!(
1645            partial.base.analysis.diff_content.matches("+main").count(),
1646            1
1647        );
1648
1649        Ok(())
1650    }
1651
1652    #[test]
1653    fn from_commit_info_partial_preserves_metadata() -> Result<()> {
1654        let dir = tempfile::tempdir()?;
1655        let commit = make_commit_with_file_diffs(
1656            &dir,
1657            &[("src/main.rs", "diff --git a/src/main.rs\n+main\n")],
1658        );
1659
1660        let original_hash = commit.hash.clone();
1661        let original_author = commit.author.clone();
1662        let original_date = commit.date;
1663        let original_message = commit.original_message.clone();
1664        let original_summary = commit.analysis.diff_summary.clone();
1665
1666        let paths = vec!["src/main.rs".to_string()];
1667        let partial = CommitInfoForAI::from_commit_info_partial(commit, &paths)?;
1668
1669        assert_eq!(partial.base.hash, original_hash);
1670        assert_eq!(partial.base.author, original_author);
1671        assert_eq!(partial.base.date, original_date);
1672        assert_eq!(partial.base.original_message, original_message);
1673        assert_eq!(partial.base.analysis.base.diff_summary, original_summary);
1674
1675        Ok(())
1676    }
1677
1678    // ── from_commit_info_partial_with_overrides ─────────────────────
1679
1680    #[test]
1681    fn with_overrides_uses_override_content() -> Result<()> {
1682        let dir = tempfile::tempdir()?;
1683        let commit = make_commit_with_file_diffs(
1684            &dir,
1685            &[(
1686                "src/big.rs",
1687                "diff --git a/src/big.rs\n+full-file-content\n",
1688            )],
1689        );
1690
1691        let paths = vec!["src/big.rs".to_string(), "src/big.rs".to_string()];
1692        let overrides = vec![
1693            Some("diff --git a/src/big.rs\n@@ -1,3 +1,4 @@\n+hunk1\n".to_string()),
1694            Some("diff --git a/src/big.rs\n@@ -10,3 +10,4 @@\n+hunk2\n".to_string()),
1695        ];
1696        let partial =
1697            CommitInfoForAI::from_commit_info_partial_with_overrides(commit, &paths, &overrides)?;
1698
1699        // Should contain hunk content, NOT full file content.
1700        assert!(partial.base.analysis.diff_content.contains("+hunk1"));
1701        assert!(partial.base.analysis.diff_content.contains("+hunk2"));
1702        assert!(
1703            !partial
1704                .base
1705                .analysis
1706                .diff_content
1707                .contains("+full-file-content"),
1708            "should not contain full file content"
1709        );
1710
1711        Ok(())
1712    }
1713
1714    #[test]
1715    fn with_overrides_mixed_override_and_disk() -> Result<()> {
1716        let dir = tempfile::tempdir()?;
1717        let commit = make_commit_with_file_diffs(
1718            &dir,
1719            &[
1720                ("src/big.rs", "diff --git a/src/big.rs\n+big-full\n"),
1721                ("src/small.rs", "diff --git a/src/small.rs\n+small-disk\n"),
1722            ],
1723        );
1724
1725        let paths = vec!["src/big.rs".to_string(), "src/small.rs".to_string()];
1726        let overrides = vec![
1727            Some("diff --git a/src/big.rs\n@@ -1,3 +1,4 @@\n+big-hunk\n".to_string()),
1728            None, // load from disk
1729        ];
1730        let partial =
1731            CommitInfoForAI::from_commit_info_partial_with_overrides(commit, &paths, &overrides)?;
1732
1733        // big.rs: override content
1734        assert!(partial.base.analysis.diff_content.contains("+big-hunk"));
1735        assert!(!partial.base.analysis.diff_content.contains("+big-full"));
1736        // small.rs: loaded from disk
1737        assert!(partial.base.analysis.diff_content.contains("+small-disk"));
1738
1739        // Both files should appear in file_diffs metadata.
1740        let ref_paths: Vec<&str> = partial
1741            .base
1742            .analysis
1743            .base
1744            .file_diffs
1745            .iter()
1746            .map(|r| r.path.as_str())
1747            .collect();
1748        assert!(ref_paths.contains(&"src/big.rs"));
1749        assert!(ref_paths.contains(&"src/small.rs"));
1750
1751        Ok(())
1752    }
1753
1754    #[test]
1755    fn with_overrides_deduplicates_disk_reads() -> Result<()> {
1756        let dir = tempfile::tempdir()?;
1757        let commit = make_commit_with_file_diffs(
1758            &dir,
1759            &[("src/main.rs", "diff --git a/src/main.rs\n+main\n")],
1760        );
1761
1762        // Two None entries for same path (simulates duplicate whole-file items).
1763        let paths = vec!["src/main.rs".to_string(), "src/main.rs".to_string()];
1764        let overrides = vec![None, None];
1765        let partial =
1766            CommitInfoForAI::from_commit_info_partial_with_overrides(commit, &paths, &overrides)?;
1767
1768        // Content loaded only once despite two None entries.
1769        assert_eq!(
1770            partial.base.analysis.diff_content.matches("+main").count(),
1771            1
1772        );
1773
1774        Ok(())
1775    }
1776
1777    #[test]
1778    fn with_overrides_preserves_metadata() -> Result<()> {
1779        let dir = tempfile::tempdir()?;
1780        let commit = make_commit_with_file_diffs(
1781            &dir,
1782            &[("src/main.rs", "diff --git a/src/main.rs\n+main\n")],
1783        );
1784
1785        let original_hash = commit.hash.clone();
1786        let original_author = commit.author.clone();
1787        let original_message = commit.original_message.clone();
1788
1789        let paths = vec!["src/main.rs".to_string()];
1790        let overrides = vec![Some("+override-content\n".to_string())];
1791        let partial =
1792            CommitInfoForAI::from_commit_info_partial_with_overrides(commit, &paths, &overrides)?;
1793
1794        assert_eq!(partial.base.hash, original_hash);
1795        assert_eq!(partial.base.author, original_author);
1796        assert_eq!(partial.base.original_message, original_message);
1797        assert!(partial.pre_validated_checks.is_empty());
1798
1799        Ok(())
1800    }
1801
1802    // ── detect_commit_type_from_message (deterministic type inference) ──
1803    //
1804    // These pin every branch of the type-inference chain so its coverage no
1805    // longer depends on whatever commit the live-repo dispatch tests analyze.
1806
1807    fn infer_type(message: &str, files: &[(&str, &str)]) -> String {
1808        CommitAnalysis::detect_commit_type_from_message(message, &make_file_changes(files))
1809    }
1810
1811    #[test]
1812    fn commit_type_existing_conventional_wins() {
1813        assert_eq!(infer_type("feat(cli): add", &[("A", "src/x.rs")]), "feat");
1814    }
1815
1816    #[test]
1817    fn commit_type_test_files() {
1818        assert_eq!(infer_type("update", &[("A", "tests/foo_test.rs")]), "test");
1819    }
1820
1821    #[test]
1822    fn commit_type_docs_files() {
1823        assert_eq!(infer_type("update", &[("M", "README.md")]), "docs");
1824    }
1825
1826    #[test]
1827    fn commit_type_config_added_is_feat() {
1828        assert_eq!(infer_type("update", &[("A", "Cargo.toml")]), "feat");
1829    }
1830
1831    #[test]
1832    fn commit_type_config_modified_is_chore() {
1833        assert_eq!(infer_type("update", &[("M", "Cargo.toml")]), "chore");
1834    }
1835
1836    #[test]
1837    fn commit_type_added_source_is_feat() {
1838        assert_eq!(infer_type("add module", &[("A", "src/lib.rs")]), "feat");
1839    }
1840
1841    #[test]
1842    fn commit_type_fix_from_message() {
1843        // Modified (not added) .rs file ⇒ falls through to the message check.
1844        assert_eq!(infer_type("fix the bug", &[("M", "src/lib.rs")]), "fix");
1845    }
1846
1847    #[test]
1848    fn commit_type_refactor_when_more_deletions() {
1849        assert_eq!(
1850            infer_type("cleanup", &[("D", "src/a.rs"), ("D", "src/b.rs")]),
1851            "refactor"
1852        );
1853    }
1854
1855    #[test]
1856    fn commit_type_default_chore() {
1857        assert_eq!(infer_type("update stuff", &[("M", "src/c.rs")]), "chore");
1858    }
1859
1860    // ── generate_proposed_message_from (scope/format branches) ──
1861
1862    #[test]
1863    fn proposed_message_with_scope() {
1864        let fc = make_file_changes(&[("A", "src/x.rs")]);
1865        let msg = CommitAnalysis::generate_proposed_message_from("do thing", "feat", "cli", &fc);
1866        assert_eq!(msg, "feat(cli): do thing");
1867    }
1868
1869    #[test]
1870    fn proposed_message_without_scope() {
1871        let fc = make_file_changes(&[("A", "src/x.rs")]);
1872        let msg = CommitAnalysis::generate_proposed_message_from("do thing", "feat", "", &fc);
1873        assert_eq!(msg, "feat: do thing");
1874    }
1875
1876    #[test]
1877    fn proposed_message_keeps_already_conventional() {
1878        let fc = make_file_changes(&[("A", "src/x.rs")]);
1879        let msg = CommitAnalysis::generate_proposed_message_from("fix(x): y", "feat", "cli", &fc);
1880        assert_eq!(msg, "fix(x): y");
1881    }
1882
1883    #[test]
1884    fn proposed_message_generates_description_when_empty() {
1885        let fc = make_file_changes(&[("A", "src/x.rs")]);
1886        let msg = CommitAnalysis::generate_proposed_message_from("", "chore", "", &fc);
1887        assert!(msg.starts_with("chore: "), "got: {msg}");
1888    }
1889
1890    // ── analyze_file_changes (Delta arms) ──
1891    //
1892    // Exercises the Added/Deleted/Modified arms against a constructed repo, so
1893    // their coverage is deterministic rather than dependent on the live HEAD
1894    // commit (which is what made `Delta::Deleted` flicker run-to-run).
1895
1896    #[test]
1897    fn analyze_file_changes_covers_delta_arms() -> Result<()> {
1898        let dir = tempfile::tempdir()?;
1899        let repo = git2::Repository::init(dir.path())?;
1900        let sig = git2::Signature::now("T", "t@e.com")?;
1901
1902        // Commit 1: add a.txt and b.txt.
1903        for (name, content) in [("a.txt", "a"), ("b.txt", "b")] {
1904            std::fs::write(dir.path().join(name), content)?;
1905        }
1906        let mut index = repo.index()?;
1907        index.add_path(std::path::Path::new("a.txt"))?;
1908        index.add_path(std::path::Path::new("b.txt"))?;
1909        index.write()?;
1910        let tree1 = repo.find_tree(index.write_tree()?)?;
1911        let c1 = repo.commit(Some("HEAD"), &sig, &sig, "init", &tree1, &[])?;
1912
1913        // Commit 2: delete a.txt (Deleted), modify b.txt (Modified), add c.txt (Added).
1914        std::fs::remove_file(dir.path().join("a.txt"))?;
1915        std::fs::write(dir.path().join("b.txt"), "b2")?;
1916        std::fs::write(dir.path().join("c.txt"), "c")?;
1917        let mut index = repo.index()?;
1918        index.remove_path(std::path::Path::new("a.txt"))?;
1919        index.add_path(std::path::Path::new("b.txt"))?;
1920        index.add_path(std::path::Path::new("c.txt"))?;
1921        index.write()?;
1922        let tree2 = repo.find_tree(index.write_tree()?)?;
1923        let parent = repo.find_commit(c1)?;
1924        let c2 = repo.commit(Some("HEAD"), &sig, &sig, "change", &tree2, &[&parent])?;
1925
1926        let commit2 = repo.find_commit(c2)?;
1927        let changes = CommitAnalysis::analyze_file_changes(&repo, &commit2)?;
1928        assert_eq!(changes.files_added, 1, "c.txt added");
1929        assert_eq!(changes.files_deleted, 1, "a.txt deleted");
1930        Ok(())
1931    }
1932}