Skip to main content

omni_dev/git/
commit.rs

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