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