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