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