1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct CommitInfo<A = CommitAnalysis> {
18 pub hash: String,
20 pub author: String,
22 pub date: DateTime<FixedOffset>,
24 pub original_message: String,
26 pub in_main_branches: Vec<String>,
28 pub analysis: A,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct CommitAnalysis {
35 pub detected_type: String,
37 pub detected_scope: String,
39 pub proposed_message: String,
41 pub file_changes: FileChanges,
43 pub diff_summary: String,
45 pub diff_file: String,
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
49 pub file_diffs: Vec<FileDiffRef>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct FileDiffRef {
59 pub path: String,
61 pub diff_file: String,
63 pub byte_len: usize,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct CommitAnalysisForAI {
70 #[serde(flatten)]
72 pub base: CommitAnalysis,
73 pub diff_content: String,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct CommitInfoForAI {
80 #[serde(flatten)]
82 pub base: CommitInfo<CommitAnalysisForAI>,
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 pub pre_validated_checks: Vec<String>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct FileChanges {
91 pub total_files: usize,
93 pub files_added: usize,
95 pub files_deleted: usize,
97 pub file_list: Vec<FileChange>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct FileChange {
104 pub status: String,
106 pub file: String,
108}
109
110impl CommitInfo {
111 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)] &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 pub fn analyze_commit(repo: &Repository, commit: &Commit) -> Result<Self> {
159 let file_changes = Self::analyze_file_changes(repo, commit)?;
161
162 let detected_type = Self::detect_commit_type(commit, &file_changes);
164
165 let detected_scope = Self::detect_scope(&file_changes);
167
168 let proposed_message =
170 Self::generate_proposed_message(commit, &detected_type, &detected_scope, &file_changes);
171
172 let diff_summary = Self::get_diff_summary(repo, commit)?;
174
175 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 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 let commit_tree = commit.tree().context("Failed to get commit tree")?;
197
198 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 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 repo.diff_tree_to_tree(None, Some(&commit_tree), None)
218 .context("Failed to create diff for initial commit")?
219 };
220
221 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 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 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 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 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 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 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 #[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 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 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 fn generate_proposed_message_from(
422 current_message: &str,
423 commit_type: &str,
424 scope: &str,
425 file_changes: &FileChanges,
426 ) -> String {
427 if Self::extract_conventional_type(current_message).is_some() {
429 return current_message.to_string();
430 }
431
432 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 if scope.is_empty() {
442 format!("{commit_type}: {description}")
443 } else {
444 format!("{commit_type}({scope}): {description}")
445 }
446 }
447
448 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 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 fn write_diff_to_file(
517 repo: &Repository,
518 commit: &Commit,
519 ) -> Result<(String, Vec<FileDiffRef>)> {
520 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 let diffs_dir = ai_scratch_path.join("diffs");
530 fs::create_dir_all(&diffs_dir).context("Failed to create diffs directory")?;
531
532 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 _ => "", };
570 diff_content.push_str(&format!("{prefix}{content}"));
571 true
572 })
573 .context("Failed to format diff")?;
574
575 if !diff_content.ends_with('\n') {
577 diff_content.push('\n');
578 }
579
580 fs::write(&diff_path, &diff_content).context("Failed to write diff file")?;
582
583 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
611pub(crate) fn detect_commit_type_from_message(message: &str, file_changes: &FileChanges) -> String {
628 if let Some(existing_type) = CommitAnalysis::extract_conventional_type(message) {
630 return existing_type;
631 }
632
633 let files: Vec<&str> = file_changes
635 .file_list
636 .iter()
637 .map(|f| f.file.as_str())
638 .collect();
639
640 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 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 #[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 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 diff_parts.push(content.clone());
730 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 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 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 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
813fn 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
822pub 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 #[allow(clippy::expect_used)] 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
856pub 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 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
893pub(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 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
935fn count_specificity(pattern: &str) -> usize {
942 pattern
943 .split('/')
944 .filter(|segment| !segment.contains('*') && !segment.contains('?'))
945 .count()
946}
947
948#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
950pub struct ScopeCount {
951 pub name: String,
953 pub count: usize,
955}
956
957#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
960pub struct ScopeUsageReport {
961 pub total_commits: usize,
963 pub declared: Vec<ScopeCount>,
965 pub unknown: Vec<ScopeCount>,
967 pub unused: Vec<String>,
969 pub scope_less_count: usize,
971}
972
973pub 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 pub fn from_commit_analysis(analysis: CommitAnalysis) -> Result<Self> {
1045 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 #[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 #[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 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 #[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 #[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 let files = &["src/lib.rs"];
1237 assert!(super::scope_matches_files(files, &patterns).is_some());
1238
1239 let test_files = &["src/test/helper.rs"];
1241 assert!(super::scope_matches_files(test_files, &patterns).is_none());
1242 }
1243
1244 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 #[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 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 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 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 #[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 #[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 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 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 #[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 #[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 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 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 #[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 fn make_commit_with_file_diffs(
2003 dir: &tempfile::TempDir,
2004 files: &[(&str, &str)], ) -> 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 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 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 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 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 #[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 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, ];
2173 let partial =
2174 CommitInfoForAI::from_commit_info_partial_with_overrides(commit, &paths, &overrides)?;
2175
2176 assert!(partial.base.analysis.diff_content.contains("+big-hunk"));
2178 assert!(!partial.base.analysis.diff_content.contains("+big-full"));
2179 assert!(partial.base.analysis.diff_content.contains("+small-disk"));
2181
2182 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 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 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 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 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 #[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 #[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 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 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 #[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 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 assert_eq!(names, vec!["data", "git", "cli"]);
2471 }
2472}