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 fn generate_proposed_message(
356 commit: &Commit,
357 commit_type: &str,
358 scope: &str,
359 file_changes: &FileChanges,
360 ) -> String {
361 let current_message = commit.message().unwrap_or("").lines().next().unwrap_or("");
362 Self::generate_proposed_message_from(current_message, commit_type, scope, file_changes)
363 }
364
365 fn generate_proposed_message_from(
371 current_message: &str,
372 commit_type: &str,
373 scope: &str,
374 file_changes: &FileChanges,
375 ) -> String {
376 if Self::extract_conventional_type(current_message).is_some() {
378 return current_message.to_string();
379 }
380
381 let description =
383 if !current_message.is_empty() && !current_message.eq_ignore_ascii_case("stuff") {
384 current_message.to_string()
385 } else {
386 Self::generate_description(commit_type, file_changes)
387 };
388
389 if scope.is_empty() {
391 format!("{commit_type}: {description}")
392 } else {
393 format!("{commit_type}({scope}): {description}")
394 }
395 }
396
397 fn generate_description(commit_type: &str, file_changes: &FileChanges) -> String {
399 match commit_type {
400 "feat" => {
401 if file_changes.total_files == 1 {
402 format!("add {}", file_changes.file_list[0].file)
403 } else {
404 format!("add {} new features", file_changes.total_files)
405 }
406 }
407 "fix" => "resolve issues".to_string(),
408 "docs" => "update documentation".to_string(),
409 "test" => "add tests".to_string(),
410 "refactor" => "improve code structure".to_string(),
411 "chore" => "update project files".to_string(),
412 _ => "update project".to_string(),
413 }
414 }
415
416 fn get_diff_summary(repo: &Repository, commit: &Commit) -> Result<String> {
418 let commit_tree = commit.tree().context("Failed to get commit tree")?;
419
420 let parent_tree = if commit.parent_count() > 0 {
421 Some(
422 commit
423 .parent(0)
424 .context("Failed to get parent commit")?
425 .tree()
426 .context("Failed to get parent tree")?,
427 )
428 } else {
429 None
430 };
431
432 let diff = if let Some(parent_tree) = parent_tree {
433 repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
434 .context("Failed to create diff")?
435 } else {
436 repo.diff_tree_to_tree(None, Some(&commit_tree), None)
437 .context("Failed to create diff for initial commit")?
438 };
439
440 let stats = diff.stats().context("Failed to get diff stats")?;
441
442 let mut summary = String::new();
443 for i in 0..stats.files_changed() {
444 if let Some(path) = diff
445 .get_delta(i)
446 .and_then(|d| d.new_file().path())
447 .and_then(|p| p.to_str())
448 {
449 let insertions = stats.insertions();
450 let deletions = stats.deletions();
451 summary.push_str(&format!(
452 " {} | {} +{} -{}\n",
453 path,
454 insertions + deletions,
455 insertions,
456 deletions
457 ));
458 }
459 }
460
461 Ok(summary)
462 }
463
464 fn write_diff_to_file(
466 repo: &Repository,
467 commit: &Commit,
468 ) -> Result<(String, Vec<FileDiffRef>)> {
469 let repo_root = repo.workdir().unwrap_or_else(|| repo.path());
474 let ai_scratch_path = crate::utils::ai_scratch::get_ai_scratch_dir_at(repo_root)
475 .context("Failed to determine AI scratch directory")?;
476
477 let diffs_dir = ai_scratch_path.join("diffs");
479 fs::create_dir_all(&diffs_dir).context("Failed to create diffs directory")?;
480
481 let commit_hash = commit.id().to_string();
483 let diff_filename = format!("{commit_hash}.diff");
484 let diff_path = diffs_dir.join(&diff_filename);
485
486 let commit_tree = commit.tree().context("Failed to get commit tree")?;
487
488 let parent_tree = if commit.parent_count() > 0 {
489 Some(
490 commit
491 .parent(0)
492 .context("Failed to get parent commit")?
493 .tree()
494 .context("Failed to get parent tree")?,
495 )
496 } else {
497 None
498 };
499
500 let diff = if let Some(parent_tree) = parent_tree {
501 repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
502 .context("Failed to create diff")?
503 } else {
504 repo.diff_tree_to_tree(None, Some(&commit_tree), None)
505 .context("Failed to create diff for initial commit")?
506 };
507
508 let mut diff_content = String::new();
509
510 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
511 let content = std::str::from_utf8(line.content()).unwrap_or("<binary>");
512 let prefix = match line.origin() {
513 '+' => "+",
514 '-' => "-",
515 ' ' => " ",
516 '@' => "@",
517 _ => "", };
519 diff_content.push_str(&format!("{prefix}{content}"));
520 true
521 })
522 .context("Failed to format diff")?;
523
524 if !diff_content.ends_with('\n') {
526 diff_content.push('\n');
527 }
528
529 fs::write(&diff_path, &diff_content).context("Failed to write diff file")?;
531
532 let per_file_diffs = split_by_file(&diff_content);
534 let mut file_diffs = Vec::with_capacity(per_file_diffs.len());
535
536 if !per_file_diffs.is_empty() {
537 let per_file_dir = diffs_dir.join(&commit_hash);
538 fs::create_dir_all(&per_file_dir)
539 .context("Failed to create per-file diffs directory")?;
540
541 for (index, file_diff) in per_file_diffs.iter().enumerate() {
542 let per_file_name = format!("{index:04}.diff");
543 let per_file_path = per_file_dir.join(&per_file_name);
544 fs::write(&per_file_path, &file_diff.content).with_context(|| {
545 format!("Failed to write per-file diff: {}", per_file_path.display())
546 })?;
547
548 file_diffs.push(FileDiffRef {
549 path: file_diff.path.clone(),
550 diff_file: per_file_path.to_string_lossy().to_string(),
551 byte_len: file_diff.byte_len,
552 });
553 }
554 }
555
556 Ok((diff_path.to_string_lossy().to_string(), file_diffs))
557 }
558}
559
560pub(crate) fn detect_commit_type_from_message(message: &str, file_changes: &FileChanges) -> String {
577 if let Some(existing_type) = CommitAnalysis::extract_conventional_type(message) {
579 return existing_type;
580 }
581
582 let files: Vec<&str> = file_changes
584 .file_list
585 .iter()
586 .map(|f| f.file.as_str())
587 .collect();
588
589 if files
591 .iter()
592 .any(|f| f.contains("test") || f.contains("spec"))
593 {
594 "test".to_string()
595 } else if files
596 .iter()
597 .any(|f| f.ends_with(".md") || f.contains("README") || f.contains("docs/"))
598 {
599 "docs".to_string()
600 } else if files
601 .iter()
602 .any(|f| f.contains("Cargo.toml") || f.contains("package.json") || f.contains("config"))
603 {
604 if file_changes.files_added > 0 {
605 "feat".to_string()
606 } else {
607 "chore".to_string()
608 }
609 } else if file_changes.files_added > 0
610 && files
611 .iter()
612 .any(|f| f.ends_with(".rs") || f.ends_with(".js") || f.ends_with(".py"))
613 {
614 "feat".to_string()
615 } else if message.to_lowercase().contains("fix") || message.to_lowercase().contains("bug") {
616 "fix".to_string()
617 } else if file_changes.files_deleted > file_changes.files_added {
618 "refactor".to_string()
619 } else {
620 "chore".to_string()
621 }
622}
623
624impl CommitInfoForAI {
625 pub fn from_commit_info(commit_info: CommitInfo) -> Result<Self> {
627 let analysis = CommitAnalysisForAI::from_commit_analysis(commit_info.analysis)?;
628
629 Ok(Self {
630 base: CommitInfo {
631 hash: commit_info.hash,
632 author: commit_info.author,
633 date: commit_info.date,
634 original_message: commit_info.original_message,
635 in_main_branches: commit_info.in_main_branches,
636 analysis,
637 },
638 pre_validated_checks: Vec::new(),
639 })
640 }
641
642 #[cfg(test)]
647 pub(crate) fn from_commit_info_partial(
648 commit_info: CommitInfo,
649 file_paths: &[String],
650 ) -> Result<Self> {
651 let overrides: Vec<Option<String>> = vec![None; file_paths.len()];
652 Self::from_commit_info_partial_with_overrides(commit_info, file_paths, &overrides)
653 }
654
655 pub(crate) fn from_commit_info_partial_with_overrides(
666 commit_info: CommitInfo,
667 file_paths: &[String],
668 diff_overrides: &[Option<String>],
669 ) -> Result<Self> {
670 let mut diff_parts = Vec::new();
671 let mut included_refs = Vec::new();
672 let mut loaded_disk_paths: std::collections::HashSet<String> =
673 std::collections::HashSet::new();
674
675 for (path, override_content) in file_paths.iter().zip(diff_overrides.iter()) {
676 if let Some(content) = override_content {
677 diff_parts.push(content.clone());
679 if let Some(file_ref) = commit_info
681 .analysis
682 .file_diffs
683 .iter()
684 .find(|r| r.path == *path)
685 {
686 if !included_refs.iter().any(|r: &FileDiffRef| r.path == *path) {
687 included_refs.push(file_ref.clone());
688 }
689 }
690 } else {
691 if loaded_disk_paths.insert(path.clone()) {
693 if let Some(file_ref) = commit_info
694 .analysis
695 .file_diffs
696 .iter()
697 .find(|r| r.path == *path)
698 {
699 let content =
700 fs::read_to_string(&file_ref.diff_file).with_context(|| {
701 format!("Failed to read per-file diff: {}", file_ref.diff_file)
702 })?;
703 diff_parts.push(content);
704 included_refs.push(file_ref.clone());
705 }
706 }
707 }
708 }
709
710 let diff_content = diff_parts.join("\n");
711
712 let partial_analysis = CommitAnalysisForAI {
713 base: CommitAnalysis {
714 file_diffs: included_refs,
715 ..commit_info.analysis
716 },
717 diff_content,
718 };
719
720 Ok(Self {
721 base: CommitInfo {
722 hash: commit_info.hash,
723 author: commit_info.author,
724 date: commit_info.date,
725 original_message: commit_info.original_message,
726 in_main_branches: commit_info.in_main_branches,
727 analysis: partial_analysis,
728 },
729 pre_validated_checks: Vec::new(),
730 })
731 }
732
733 pub fn run_pre_validation_checks(&mut self, valid_scopes: &[ScopeDefinition]) {
742 let first_line = self.base.original_message.lines().next().unwrap_or("");
743 let Some(scope) = lint::parse_subject(first_line).and_then(|p| p.scope) else {
744 return;
745 };
746
747 if scope.contains(',') && lint::scope_comma_format_ok(scope) {
748 self.pre_validated_checks.push(format!(
749 "Scope format verified: multi-scope '{scope}' uses commas with at most one trailing space"
750 ));
751 }
752
753 if !valid_scopes.is_empty() && lint::scope_parts_all_valid(scope, valid_scopes) {
755 self.pre_validated_checks.push(format!(
756 "Scope validity verified: '{scope}' is in the valid scopes list"
757 ));
758 }
759 }
760}
761
762pub fn resolve_scope(files: &[&str], scope_defs: &[ScopeDefinition]) -> Option<String> {
769 if scope_defs.is_empty() || files.is_empty() {
770 return None;
771 }
772
773 let mut matches: Vec<(&str, usize)> = Vec::new();
774 for scope_def in scope_defs {
775 if let Some(specificity) = scope_matches_files(files, &scope_def.file_patterns) {
776 matches.push((&scope_def.name, specificity));
777 }
778 }
779
780 if matches.is_empty() {
781 return None;
782 }
783
784 #[allow(clippy::expect_used)] let max_specificity = matches.iter().map(|(_, s)| *s).max().expect("non-empty");
787 let best: Vec<&str> = matches
788 .into_iter()
789 .filter(|(_, s)| *s == max_specificity)
790 .map(|(name, _)| name)
791 .collect();
792
793 Some(best.join(", "))
794}
795
796pub fn refine_message_scope(
802 message: &str,
803 files: &[&str],
804 scope_defs: &[ScopeDefinition],
805) -> String {
806 let Some(resolved) = resolve_scope(files, scope_defs) else {
807 return message.to_string();
808 };
809
810 let (first_line, rest) = message
812 .split_once('\n')
813 .map_or((message, ""), |(f, r)| (f, r));
814
815 let Some(existing_scope) = lint::parse_subject(first_line).and_then(|p| p.scope) else {
816 return message.to_string();
817 };
818
819 if existing_scope == resolved {
820 return message.to_string();
821 }
822
823 let new_first_line =
824 first_line.replacen(&format!("({existing_scope})"), &format!("({resolved})"), 1);
825
826 if rest.is_empty() {
827 new_first_line
828 } else {
829 format!("{new_first_line}\n{rest}")
830 }
831}
832
833pub(crate) fn scope_matches_files(files: &[&str], patterns: &[String]) -> Option<usize> {
842 let mut positive = Vec::new();
843 let mut negative = Vec::new();
844 for pat in patterns {
845 if let Some(stripped) = pat.strip_prefix('!') {
846 negative.push(stripped);
847 } else {
848 positive.push(pat.as_str());
849 }
850 }
851
852 let neg_matchers: Vec<_> = negative
854 .iter()
855 .filter_map(|p| Glob::new(p).ok().map(|g| g.compile_matcher()))
856 .collect();
857
858 let mut max_specificity: Option<usize> = None;
859 for pat in &positive {
860 let Ok(glob) = Glob::new(pat) else {
861 continue;
862 };
863 let matcher = glob.compile_matcher();
864 for file in files {
865 if matcher.is_match(file) && !neg_matchers.iter().any(|neg| neg.is_match(file)) {
866 let specificity = count_specificity(pat);
867 max_specificity =
868 Some(max_specificity.map_or(specificity, |cur| cur.max(specificity)));
869 }
870 }
871 }
872 max_specificity
873}
874
875fn count_specificity(pattern: &str) -> usize {
882 pattern
883 .split('/')
884 .filter(|segment| !segment.contains('*') && !segment.contains('?'))
885 .count()
886}
887
888#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
890pub struct ScopeCount {
891 pub name: String,
893 pub count: usize,
895}
896
897#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
900pub struct ScopeUsageReport {
901 pub total_commits: usize,
903 pub declared: Vec<ScopeCount>,
905 pub unknown: Vec<ScopeCount>,
907 pub unused: Vec<String>,
909 pub scope_less_count: usize,
911}
912
913pub fn tally_scope_usage(
927 subjects: &[&str],
928 known_scopes: &[ScopeDefinition],
929 unused_candidates: &[ScopeDefinition],
930) -> ScopeUsageReport {
931 use std::collections::HashMap;
932
933 let mut counts: HashMap<&str, usize> = HashMap::new();
934 let mut scope_less_count = 0;
935
936 for subject in subjects {
937 match lint::parse_subject(subject).and_then(|p| p.scope) {
938 Some(scope_text) => {
939 for part in scope_text.split(',').map(str::trim) {
940 if !part.is_empty() {
941 *counts.entry(part).or_default() += 1;
942 }
943 }
944 }
945 None => scope_less_count += 1,
946 }
947 }
948
949 let mut declared: Vec<ScopeCount> = counts
950 .iter()
951 .map(|(&name, &count)| ScopeCount {
952 name: name.to_string(),
953 count,
954 })
955 .collect();
956 declared.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
957
958 let known_names: std::collections::HashSet<&str> =
959 known_scopes.iter().map(|s| s.name.as_str()).collect();
960 let unknown: Vec<ScopeCount> = declared
961 .iter()
962 .filter(|sc| !known_names.contains(sc.name.as_str()))
963 .cloned()
964 .collect();
965
966 let mut unused: Vec<String> = unused_candidates
967 .iter()
968 .filter(|s| !counts.contains_key(s.name.as_str()))
969 .map(|s| s.name.clone())
970 .collect();
971 unused.sort();
972
973 ScopeUsageReport {
974 total_commits: subjects.len(),
975 declared,
976 unknown,
977 unused,
978 scope_less_count,
979 }
980}
981
982impl CommitAnalysisForAI {
983 pub fn from_commit_analysis(analysis: CommitAnalysis) -> Result<Self> {
985 let diff_content = fs::read_to_string(&analysis.diff_file)
987 .with_context(|| format!("Failed to read diff file: {}", analysis.diff_file))?;
988
989 Ok(Self {
990 base: analysis,
991 diff_content,
992 })
993 }
994}
995
996#[cfg(test)]
997#[allow(clippy::unwrap_used, clippy::expect_used)]
998mod tests {
999 use super::*;
1000 use crate::data::context::ScopeDefinition;
1001
1002 #[test]
1005 fn conventional_type_feat_with_scope() {
1006 assert_eq!(
1007 CommitAnalysis::extract_conventional_type("feat(cli): add flag"),
1008 Some("feat".to_string())
1009 );
1010 }
1011
1012 #[test]
1013 fn conventional_type_without_scope() {
1014 assert_eq!(
1015 CommitAnalysis::extract_conventional_type("fix: resolve bug"),
1016 Some("fix".to_string())
1017 );
1018 }
1019
1020 #[test]
1021 fn conventional_type_invalid_message() {
1022 assert_eq!(
1023 CommitAnalysis::extract_conventional_type("random message without colon"),
1024 None
1025 );
1026 }
1027
1028 #[test]
1029 fn conventional_type_unknown_type() {
1030 assert_eq!(
1031 CommitAnalysis::extract_conventional_type("yolo(scope): stuff"),
1032 None
1033 );
1034 }
1035
1036 #[test]
1037 fn conventional_type_all_valid_types() {
1038 let types = [
1039 "feat", "fix", "docs", "style", "refactor", "test", "chore", "build", "ci", "perf",
1040 ];
1041 for t in types {
1042 let msg = format!("{t}: description");
1043 assert_eq!(
1044 CommitAnalysis::extract_conventional_type(&msg),
1045 Some(t.to_string()),
1046 "expected Some for type '{t}'"
1047 );
1048 }
1049 }
1050
1051 #[test]
1054 fn valid_conventional_types() {
1055 for t in [
1056 "feat", "fix", "docs", "style", "refactor", "test", "chore", "build", "ci", "perf",
1057 ] {
1058 assert!(
1059 CommitAnalysis::is_valid_conventional_type(t),
1060 "'{t}' should be valid"
1061 );
1062 }
1063 }
1064
1065 #[test]
1066 fn invalid_conventional_types() {
1067 for t in ["yolo", "Feat", "", "FEAT", "feature", "bugfix"] {
1068 assert!(
1069 !CommitAnalysis::is_valid_conventional_type(t),
1070 "'{t}' should be invalid"
1071 );
1072 }
1073 }
1074
1075 fn make_file_changes(files: &[(&str, &str)]) -> FileChanges {
1078 FileChanges {
1079 total_files: files.len(),
1080 files_added: files.iter().filter(|(s, _)| *s == "A").count(),
1081 files_deleted: files.iter().filter(|(s, _)| *s == "D").count(),
1082 file_list: files
1083 .iter()
1084 .map(|(status, file)| FileChange {
1085 status: (*status).to_string(),
1086 file: (*file).to_string(),
1087 })
1088 .collect(),
1089 }
1090 }
1091
1092 #[test]
1093 fn scope_from_cli_files() {
1094 let changes = make_file_changes(&[("M", "src/cli/commands.rs")]);
1095 assert_eq!(CommitAnalysis::detect_scope(&changes), "cli");
1096 }
1097
1098 #[test]
1099 fn scope_from_git_files() {
1100 let changes = make_file_changes(&[("M", "src/git/remote.rs")]);
1101 assert_eq!(CommitAnalysis::detect_scope(&changes), "git");
1102 }
1103
1104 #[test]
1105 fn scope_from_docs_files() {
1106 let changes = make_file_changes(&[("M", "docs/README.md")]);
1107 assert_eq!(CommitAnalysis::detect_scope(&changes), "docs");
1108 }
1109
1110 #[test]
1111 fn scope_from_data_files() {
1112 let changes = make_file_changes(&[("M", "src/data/yaml.rs")]);
1113 assert_eq!(CommitAnalysis::detect_scope(&changes), "data");
1114 }
1115
1116 #[test]
1117 fn scope_from_test_files() {
1118 let changes = make_file_changes(&[("A", "tests/new_test.rs")]);
1119 assert_eq!(CommitAnalysis::detect_scope(&changes), "test");
1120 }
1121
1122 #[test]
1123 fn scope_from_deps_files() {
1124 let changes = make_file_changes(&[("M", "Cargo.toml")]);
1125 assert_eq!(CommitAnalysis::detect_scope(&changes), "deps");
1126 }
1127
1128 #[test]
1129 fn scope_unknown_files() {
1130 let changes = make_file_changes(&[("M", "random/path/file.txt")]);
1131 assert_eq!(CommitAnalysis::detect_scope(&changes), "");
1132 }
1133
1134 #[test]
1137 fn count_specificity_deep_path() {
1138 assert_eq!(super::count_specificity("src/main/scala/**"), 3);
1139 }
1140
1141 #[test]
1142 fn count_specificity_shallow() {
1143 assert_eq!(super::count_specificity("docs/**"), 1);
1144 }
1145
1146 #[test]
1147 fn count_specificity_wildcard_only() {
1148 assert_eq!(super::count_specificity("*.md"), 0);
1149 }
1150
1151 #[test]
1152 fn count_specificity_no_wildcards() {
1153 assert_eq!(super::count_specificity("src/lib.rs"), 2);
1154 }
1155
1156 #[test]
1159 fn scope_matches_positive_patterns() {
1160 let patterns = vec!["src/cli/**".to_string()];
1161 let files = &["src/cli/commands.rs"];
1162 assert!(super::scope_matches_files(files, &patterns).is_some());
1163 }
1164
1165 #[test]
1166 fn scope_matches_no_match() {
1167 let patterns = vec!["src/cli/**".to_string()];
1168 let files = &["src/git/remote.rs"];
1169 assert!(super::scope_matches_files(files, &patterns).is_none());
1170 }
1171
1172 #[test]
1173 fn scope_matches_with_negation() {
1174 let patterns = vec!["src/**".to_string(), "!src/test/**".to_string()];
1175 let files = &["src/lib.rs"];
1177 assert!(super::scope_matches_files(files, &patterns).is_some());
1178
1179 let test_files = &["src/test/helper.rs"];
1181 assert!(super::scope_matches_files(test_files, &patterns).is_none());
1182 }
1183
1184 fn make_scope_def(name: &str, patterns: &[&str]) -> ScopeDefinition {
1187 ScopeDefinition {
1188 name: name.to_string(),
1189 description: String::new(),
1190 examples: vec![],
1191 file_patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
1192 }
1193 }
1194
1195 #[test]
1196 fn refine_scope_empty_defs() {
1197 let mut analysis = CommitAnalysis {
1198 detected_type: "feat".to_string(),
1199 detected_scope: "original".to_string(),
1200 proposed_message: String::new(),
1201 file_changes: make_file_changes(&[("M", "src/cli/commands.rs")]),
1202 diff_summary: String::new(),
1203 diff_file: String::new(),
1204 file_diffs: Vec::new(),
1205 };
1206 analysis.refine_scope(&[]);
1207 assert_eq!(analysis.detected_scope, "original");
1208 }
1209
1210 #[test]
1211 fn refine_scope_most_specific_wins() {
1212 let scope_defs = vec![
1213 make_scope_def("lib", &["src/**"]),
1214 make_scope_def("cli", &["src/cli/**"]),
1215 ];
1216 let mut analysis = CommitAnalysis {
1217 detected_type: "feat".to_string(),
1218 detected_scope: String::new(),
1219 proposed_message: String::new(),
1220 file_changes: make_file_changes(&[("M", "src/cli/commands.rs")]),
1221 diff_summary: String::new(),
1222 diff_file: String::new(),
1223 file_diffs: Vec::new(),
1224 };
1225 analysis.refine_scope(&scope_defs);
1226 assert_eq!(analysis.detected_scope, "cli");
1227 }
1228
1229 #[test]
1230 fn resolve_scope_specific_pattern_beats_lib_catch_all() {
1231 let scope_defs = vec![
1235 make_scope_def("lib", &["src/lib.rs", "src/**"]),
1236 make_scope_def("worktrees", &["src/worktrees.rs"]),
1237 ];
1238 assert_eq!(
1239 resolve_scope(&["src/worktrees.rs"], &scope_defs),
1240 Some("worktrees".to_string())
1241 );
1242 }
1243
1244 #[test]
1245 fn refine_scope_no_matching_files() {
1246 let scope_defs = vec![make_scope_def("cli", &["src/cli/**"])];
1247 let mut analysis = CommitAnalysis {
1248 detected_type: "feat".to_string(),
1249 detected_scope: "original".to_string(),
1250 proposed_message: String::new(),
1251 file_changes: make_file_changes(&[("M", "README.md")]),
1252 diff_summary: String::new(),
1253 diff_file: String::new(),
1254 file_diffs: Vec::new(),
1255 };
1256 analysis.refine_scope(&scope_defs);
1257 assert_eq!(analysis.detected_scope, "original");
1259 }
1260
1261 #[test]
1262 fn refine_scope_equal_specificity_joins() {
1263 let scope_defs = vec![
1264 make_scope_def("cli", &["src/cli/**"]),
1265 make_scope_def("git", &["src/git/**"]),
1266 ];
1267 let mut analysis = CommitAnalysis {
1268 detected_type: "feat".to_string(),
1269 detected_scope: String::new(),
1270 proposed_message: String::new(),
1271 file_changes: make_file_changes(&[
1272 ("M", "src/cli/commands.rs"),
1273 ("M", "src/git/remote.rs"),
1274 ]),
1275 diff_summary: String::new(),
1276 diff_file: String::new(),
1277 file_diffs: Vec::new(),
1278 };
1279 analysis.refine_scope(&scope_defs);
1280 assert!(
1282 analysis.detected_scope == "cli, git" || analysis.detected_scope == "git, cli",
1283 "expected joined scopes, got: {}",
1284 analysis.detected_scope
1285 );
1286 }
1287
1288 #[test]
1291 fn refine_message_scope_replaces_less_specific() {
1292 let scope_defs = vec![
1293 make_scope_def("ci", &[".github/**"]),
1294 make_scope_def("workflows", &[".github/workflows/**"]),
1295 ];
1296 let files = &[".github/workflows/ci.yml"];
1297 let result = super::refine_message_scope(
1298 "chore(ci): bump EmbarkStudios/cargo-deny-action from 2.0.15 to 2.0.17",
1299 files,
1300 &scope_defs,
1301 );
1302 assert_eq!(
1303 result,
1304 "chore(workflows): bump EmbarkStudios/cargo-deny-action from 2.0.15 to 2.0.17"
1305 );
1306 }
1307
1308 #[test]
1309 fn refine_message_scope_keeps_already_correct() {
1310 let scope_defs = vec![
1311 make_scope_def("ci", &[".github/**"]),
1312 make_scope_def("workflows", &[".github/workflows/**"]),
1313 ];
1314 let files = &[".github/workflows/ci.yml"];
1315 let msg = "chore(workflows): bump something";
1316 assert_eq!(super::refine_message_scope(msg, files, &scope_defs), msg);
1317 }
1318
1319 #[test]
1320 fn refine_message_scope_no_scope_in_message() {
1321 let scope_defs = vec![make_scope_def("cli", &["src/cli/**"])];
1322 let files = &["src/cli/commands.rs"];
1323 let msg = "chore: do something";
1324 assert_eq!(super::refine_message_scope(msg, files, &scope_defs), msg);
1325 }
1326
1327 #[test]
1328 fn refine_message_scope_preserves_body() {
1329 let scope_defs = vec![
1330 make_scope_def("ci", &[".github/**"]),
1331 make_scope_def("workflows", &[".github/workflows/**"]),
1332 ];
1333 let files = &[".github/workflows/ci.yml"];
1334 let msg = "chore(ci): bump dep\n\nSome body text\nMore details";
1335 let result = super::refine_message_scope(msg, files, &scope_defs);
1336 assert_eq!(
1337 result,
1338 "chore(workflows): bump dep\n\nSome body text\nMore details"
1339 );
1340 }
1341
1342 #[test]
1343 fn refine_message_scope_breaking_change() {
1344 let scope_defs = vec![
1345 make_scope_def("ci", &[".github/**"]),
1346 make_scope_def("workflows", &[".github/workflows/**"]),
1347 ];
1348 let files = &[".github/workflows/ci.yml"];
1349 let result = super::refine_message_scope("feat!(ci): breaking change", files, &scope_defs);
1350 assert_eq!(result, "feat!(workflows): breaking change");
1351 }
1352
1353 #[test]
1357 fn refine_message_scope_breaking_change_documented_form() {
1358 let scope_defs = vec![
1359 make_scope_def("ci", &[".github/**"]),
1360 make_scope_def("workflows", &[".github/workflows/**"]),
1361 ];
1362 let files = &[".github/workflows/ci.yml"];
1363 let result = super::refine_message_scope("feat(ci)!: breaking change", files, &scope_defs);
1364 assert_eq!(result, "feat(workflows)!: breaking change");
1365 }
1366
1367 #[test]
1368 fn refine_message_scope_canonical_breaking_change_preserves_bang() {
1369 let scope_defs = vec![
1372 make_scope_def("ci", &[".github/**"]),
1373 make_scope_def("workflows", &[".github/workflows/**"]),
1374 ];
1375 let files = &[".github/workflows/ci.yml"];
1376 let result = super::refine_message_scope("feat(ci)!: breaking change", files, &scope_defs);
1377 assert_eq!(result, "feat(workflows)!: breaking change");
1378 }
1379
1380 #[test]
1381 fn refine_message_scope_preserves_breaking_marker() {
1382 let scope_defs = vec![
1383 make_scope_def("ci", &[".github/**"]),
1384 make_scope_def("workflows", &[".github/workflows/**"]),
1385 ];
1386 let files = &[".github/workflows/ci.yml"];
1387 let result = super::refine_message_scope("feat(ci)!: breaking change", files, &scope_defs);
1388 assert_eq!(
1389 result, "feat(workflows)!: breaking change",
1390 "the documented `type(scope)!:` form must be refined with its `!` intact"
1391 );
1392 }
1393
1394 #[test]
1395 fn refine_message_scope_no_matching_scope_defs() {
1396 let scope_defs = vec![make_scope_def("cli", &["src/cli/**"])];
1397 let files = &["README.md"];
1398 let msg = "docs(docs): update readme";
1399 assert_eq!(super::refine_message_scope(msg, files, &scope_defs), msg);
1400 }
1401
1402 fn make_commit_info_for_ai(message: &str) -> CommitInfoForAI {
1405 CommitInfoForAI {
1406 base: CommitInfo {
1407 hash: "a".repeat(40),
1408 author: "Test <test@example.com>".to_string(),
1409 date: chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:00+00:00").unwrap(),
1410 original_message: message.to_string(),
1411 in_main_branches: vec![],
1412 analysis: CommitAnalysisForAI {
1413 base: CommitAnalysis {
1414 detected_type: "feat".to_string(),
1415 detected_scope: String::new(),
1416 proposed_message: String::new(),
1417 file_changes: make_file_changes(&[]),
1418 diff_summary: String::new(),
1419 diff_file: String::new(),
1420 file_diffs: Vec::new(),
1421 },
1422 diff_content: String::new(),
1423 },
1424 },
1425 pre_validated_checks: vec![],
1426 }
1427 }
1428
1429 #[test]
1430 fn pre_validation_valid_single_scope() {
1431 let scopes = vec![make_scope_def("cli", &["src/cli/**"])];
1432 let mut info = make_commit_info_for_ai("feat(cli): add command");
1433 info.run_pre_validation_checks(&scopes);
1434 assert!(
1435 info.pre_validated_checks
1436 .iter()
1437 .any(|c| c.contains("Scope validity verified")),
1438 "expected scope validity check, got: {:?}",
1439 info.pre_validated_checks
1440 );
1441 }
1442
1443 #[test]
1446 fn pre_validation_breaking_change_documented_form() {
1447 let scopes = vec![make_scope_def("cli", &["src/cli/**"])];
1448 let mut info = make_commit_info_for_ai("feat(cli)!: change output format");
1449 info.run_pre_validation_checks(&scopes);
1450 assert!(
1451 info.pre_validated_checks
1452 .iter()
1453 .any(|c| c.contains("Scope validity verified")),
1454 "expected scope validity check for breaking-change form, got: {:?}",
1455 info.pre_validated_checks
1456 );
1457 }
1458
1459 #[test]
1460 fn pre_validation_multi_scope() {
1461 let scopes = vec![
1462 make_scope_def("cli", &["src/cli/**"]),
1463 make_scope_def("git", &["src/git/**"]),
1464 ];
1465 let mut info = make_commit_info_for_ai("feat(cli,git): cross-cutting change");
1466 info.run_pre_validation_checks(&scopes);
1467 assert!(info
1468 .pre_validated_checks
1469 .iter()
1470 .any(|c| c.contains("Scope validity verified")),);
1471 assert!(info
1472 .pre_validated_checks
1473 .iter()
1474 .any(|c| c.contains("multi-scope")),);
1475 }
1476
1477 #[test]
1478 fn pre_validation_breaking_change_single_scope() {
1479 let scopes = vec![make_scope_def("cli", &["src/cli/**"])];
1480 let mut info = make_commit_info_for_ai("feat(cli)!: change output format");
1481 info.run_pre_validation_checks(&scopes);
1482 assert!(
1483 info.pre_validated_checks
1484 .iter()
1485 .any(|c| c.contains("Scope validity verified")),
1486 "the documented `type(scope)!:` breaking-change form must be pre-validated \
1487 like any other scoped subject, got: {:?}",
1488 info.pre_validated_checks
1489 );
1490 }
1491
1492 #[test]
1493 fn pre_validation_breaking_change_multi_scope() {
1494 let scopes = vec![
1495 make_scope_def("cli", &["src/cli/**"]),
1496 make_scope_def("git", &["src/git/**"]),
1497 ];
1498 let mut info = make_commit_info_for_ai("feat(cli,git)!: cross-cutting breaking change");
1499 info.run_pre_validation_checks(&scopes);
1500 assert!(
1501 info.pre_validated_checks
1502 .iter()
1503 .any(|c| c.contains("Scope validity verified")),
1504 "expected scope validity check, got: {:?}",
1505 info.pre_validated_checks
1506 );
1507 assert!(
1508 info.pre_validated_checks
1509 .iter()
1510 .any(|c| c.contains("multi-scope")),
1511 "expected multi-scope format check, got: {:?}",
1512 info.pre_validated_checks
1513 );
1514 }
1515
1516 #[test]
1517 fn pre_validation_legacy_breaking_change_marker() {
1518 let scopes = vec![make_scope_def("cli", &["src/cli/**"])];
1519 let mut info = make_commit_info_for_ai("feat!(cli): change output format");
1520 info.run_pre_validation_checks(&scopes);
1521 assert!(
1522 info.pre_validated_checks
1523 .iter()
1524 .any(|c| c.contains("Scope validity verified")),
1525 "the undocumented `type!(scope):` form stays accepted as a lenient fallback, \
1526 got: {:?}",
1527 info.pre_validated_checks
1528 );
1529 }
1530
1531 #[test]
1532 fn pre_validation_multi_scope_with_spaces() {
1533 let scopes = vec![
1534 make_scope_def("cli", &["src/cli/**"]),
1535 make_scope_def("lib", &["src/lib/**"]),
1536 ];
1537 let mut info = make_commit_info_for_ai("feat(cli, lib): add something");
1538 info.run_pre_validation_checks(&scopes);
1539 assert!(
1540 info.pre_validated_checks
1541 .iter()
1542 .any(|c| c.contains("Scope validity verified")),
1543 "expected scope validity check for spaced multi-scope, got: {:?}",
1544 info.pre_validated_checks
1545 );
1546 assert!(
1547 info.pre_validated_checks
1548 .iter()
1549 .any(|c| c.contains("Scope format verified")),
1550 "single-space-after-comma multi-scope should pass the format check, got: {:?}",
1551 info.pre_validated_checks
1552 );
1553 }
1554
1555 #[test]
1556 fn pre_validation_multi_scope_double_space_not_format_verified() {
1557 let scopes = vec![
1558 make_scope_def("cli", &["src/cli/**"]),
1559 make_scope_def("lib", &["src/lib/**"]),
1560 ];
1561 let mut info = make_commit_info_for_ai("feat(cli, lib): add something");
1562 info.run_pre_validation_checks(&scopes);
1563 assert!(
1564 !info
1565 .pre_validated_checks
1566 .iter()
1567 .any(|c| c.contains("Scope format verified")),
1568 "double-space-after-comma must NOT be recorded as format-verified, got: {:?}",
1569 info.pre_validated_checks
1570 );
1571 }
1572
1573 #[test]
1574 fn pre_validation_multi_scope_space_before_comma_not_format_verified() {
1575 let scopes = vec![
1576 make_scope_def("cli", &["src/cli/**"]),
1577 make_scope_def("lib", &["src/lib/**"]),
1578 ];
1579 let mut info = make_commit_info_for_ai("feat(cli ,lib): add something");
1580 info.run_pre_validation_checks(&scopes);
1581 assert!(
1582 !info
1583 .pre_validated_checks
1584 .iter()
1585 .any(|c| c.contains("Scope format verified")),
1586 "space-before-comma must NOT be recorded as format-verified, got: {:?}",
1587 info.pre_validated_checks
1588 );
1589 }
1590
1591 #[test]
1592 fn pre_validation_invalid_scope_not_added() {
1593 let scopes = vec![make_scope_def("cli", &["src/cli/**"])];
1594 let mut info = make_commit_info_for_ai("feat(unknown): something");
1595 info.run_pre_validation_checks(&scopes);
1596 assert!(
1597 !info
1598 .pre_validated_checks
1599 .iter()
1600 .any(|c| c.contains("Scope validity verified")),
1601 "should not validate unknown scope"
1602 );
1603 }
1604
1605 #[test]
1606 fn pre_validation_no_scope_message() {
1607 let scopes = vec![make_scope_def("cli", &["src/cli/**"])];
1608 let mut info = make_commit_info_for_ai("feat: no scope here");
1609 info.run_pre_validation_checks(&scopes);
1610 assert!(info.pre_validated_checks.is_empty());
1611 }
1612
1613 #[test]
1618 fn pre_validation_canonical_breaking_change_single_scope() {
1619 let scopes = vec![make_scope_def("cli", &["src/cli/**"])];
1620 let mut info = make_commit_info_for_ai("feat(cli)!: change output format");
1621 info.run_pre_validation_checks(&scopes);
1622 assert!(
1623 info.pre_validated_checks
1624 .iter()
1625 .any(|c| c.contains("Scope validity verified")),
1626 "canonical breaking-change form must record scope validity, got: {:?}",
1627 info.pre_validated_checks
1628 );
1629 }
1630
1631 #[test]
1632 fn pre_validation_canonical_breaking_change_multi_scope() {
1633 let scopes = vec![
1634 make_scope_def("cli", &["src/cli/**"]),
1635 make_scope_def("claude", &["src/claude/**"]),
1636 ];
1637 let mut info = make_commit_info_for_ai("feat(cli,claude)!: add twiddle contextual options");
1638 info.run_pre_validation_checks(&scopes);
1639 assert!(info
1640 .pre_validated_checks
1641 .iter()
1642 .any(|c| c.contains("Scope validity verified")));
1643 assert!(info
1644 .pre_validated_checks
1645 .iter()
1646 .any(|c| c.contains("multi-scope")));
1647 }
1648
1649 #[test]
1650 fn pre_validation_lenient_legacy_breaking_change_still_records() {
1651 let scopes = vec![make_scope_def("cli", &["src/cli/**"])];
1652 let mut info = make_commit_info_for_ai("feat!(cli): add thing");
1653 info.run_pre_validation_checks(&scopes);
1654 assert!(
1655 info.pre_validated_checks
1656 .iter()
1657 .any(|c| c.contains("Scope validity verified")),
1658 "lenient legacy form must still record scope validity, got: {:?}",
1659 info.pre_validated_checks
1660 );
1661 }
1662
1663 #[test]
1664 fn pre_validation_non_breaking_unchanged() {
1665 let scopes = vec![make_scope_def("cli", &["src/cli/**"])];
1666 let mut info = make_commit_info_for_ai("feat(cli): add command");
1667 info.run_pre_validation_checks(&scopes);
1668 assert!(info
1669 .pre_validated_checks
1670 .iter()
1671 .any(|c| c.contains("Scope validity verified")));
1672 }
1673
1674 mod prop {
1677 use super::*;
1678 use proptest::prelude::*;
1679
1680 fn arb_conventional_type() -> impl Strategy<Value = &'static str> {
1681 prop_oneof![
1682 Just("feat"),
1683 Just("fix"),
1684 Just("docs"),
1685 Just("style"),
1686 Just("refactor"),
1687 Just("test"),
1688 Just("chore"),
1689 Just("build"),
1690 Just("ci"),
1691 Just("perf"),
1692 ]
1693 }
1694
1695 proptest! {
1696 #[test]
1697 fn valid_conventional_format_extracts_type(
1698 ctype in arb_conventional_type(),
1699 scope in "[a-z]{1,10}",
1700 desc in "[a-zA-Z ]{1,50}",
1701 ) {
1702 let message = format!("{ctype}({scope}): {desc}");
1703 let result = CommitAnalysis::extract_conventional_type(&message);
1704 prop_assert_eq!(result, Some(ctype.to_string()));
1705 }
1706
1707 #[test]
1708 fn no_colon_returns_none(s in "[^:]{0,100}") {
1709 let result = CommitAnalysis::extract_conventional_type(&s);
1710 prop_assert!(result.is_none());
1711 }
1712
1713 #[test]
1714 fn count_specificity_nonnegative(pattern in ".*") {
1715 let _ = super::count_specificity(&pattern);
1717 }
1718
1719 #[test]
1720 fn count_specificity_bounded_by_segments(
1721 segments in proptest::collection::vec("[a-z*?]{1,10}", 1..6),
1722 ) {
1723 let pattern = segments.join("/");
1724 let result = super::count_specificity(&pattern);
1725 prop_assert!(result <= segments.len());
1726 }
1727 }
1728 }
1729
1730 #[test]
1733 fn from_commit_analysis_loads_diff_content() {
1734 let dir = tempfile::tempdir().unwrap();
1735 let diff_path = dir.path().join("test.diff");
1736 std::fs::write(&diff_path, "+added line\n-removed line\n").unwrap();
1737
1738 let analysis = CommitAnalysis {
1739 detected_type: "feat".to_string(),
1740 detected_scope: "cli".to_string(),
1741 proposed_message: "feat(cli): test".to_string(),
1742 file_changes: make_file_changes(&[]),
1743 diff_summary: "file.rs | 2 +-".to_string(),
1744 diff_file: diff_path.to_string_lossy().to_string(),
1745 file_diffs: Vec::new(),
1746 };
1747
1748 let ai = CommitAnalysisForAI::from_commit_analysis(analysis.clone()).unwrap();
1749 assert_eq!(ai.diff_content, "+added line\n-removed line\n");
1750 assert_eq!(ai.base.detected_type, analysis.detected_type);
1751 assert_eq!(ai.base.diff_file, analysis.diff_file);
1752 }
1753
1754 #[test]
1755 fn from_commit_info_wraps_and_loads_diff() {
1756 let dir = tempfile::tempdir().unwrap();
1757 let diff_path = dir.path().join("test.diff");
1758 std::fs::write(&diff_path, "diff content").unwrap();
1759
1760 let info = CommitInfo {
1761 hash: "a".repeat(40),
1762 author: "Test <test@example.com>".to_string(),
1763 date: chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:00+00:00").unwrap(),
1764 original_message: "feat(cli): add flag".to_string(),
1765 in_main_branches: vec!["origin/main".to_string()],
1766 analysis: CommitAnalysis {
1767 detected_type: "feat".to_string(),
1768 detected_scope: "cli".to_string(),
1769 proposed_message: "feat(cli): add flag".to_string(),
1770 file_changes: make_file_changes(&[("M", "src/cli.rs")]),
1771 diff_summary: "cli.rs | 1 +".to_string(),
1772 diff_file: diff_path.to_string_lossy().to_string(),
1773 file_diffs: Vec::new(),
1774 },
1775 };
1776
1777 let ai = CommitInfoForAI::from_commit_info(info).unwrap();
1778 assert_eq!(ai.base.analysis.diff_content, "diff content");
1779 assert_eq!(ai.base.hash, "a".repeat(40));
1780 assert_eq!(ai.base.original_message, "feat(cli): add flag");
1781 assert!(ai.pre_validated_checks.is_empty());
1782 }
1783
1784 #[test]
1785 fn file_diffs_default_empty_on_deserialize() {
1786 let yaml = r#"
1787detected_type: feat
1788detected_scope: cli
1789proposed_message: "feat(cli): test"
1790file_changes:
1791 total_files: 0
1792 files_added: 0
1793 files_deleted: 0
1794 file_list: []
1795diff_summary: ""
1796diff_file: "/tmp/test.diff"
1797"#;
1798 let analysis: CommitAnalysis = serde_yaml::from_str(yaml).unwrap();
1799 assert!(analysis.file_diffs.is_empty());
1800 }
1801
1802 #[test]
1803 fn file_diffs_omitted_when_empty_on_serialize() {
1804 let analysis = CommitAnalysis {
1805 detected_type: "feat".to_string(),
1806 detected_scope: "cli".to_string(),
1807 proposed_message: "feat(cli): test".to_string(),
1808 file_changes: make_file_changes(&[]),
1809 diff_summary: String::new(),
1810 diff_file: String::new(),
1811 file_diffs: Vec::new(),
1812 };
1813 let yaml = serde_yaml::to_string(&analysis).unwrap();
1814 assert!(!yaml.contains("file_diffs"));
1815 }
1816
1817 #[test]
1818 fn file_diffs_included_when_populated() {
1819 let analysis = CommitAnalysis {
1820 detected_type: "feat".to_string(),
1821 detected_scope: "cli".to_string(),
1822 proposed_message: "feat(cli): test".to_string(),
1823 file_changes: make_file_changes(&[]),
1824 diff_summary: String::new(),
1825 diff_file: String::new(),
1826 file_diffs: vec![FileDiffRef {
1827 path: "src/main.rs".to_string(),
1828 diff_file: "/tmp/diffs/abc/0000.diff".to_string(),
1829 byte_len: 42,
1830 }],
1831 };
1832 let yaml = serde_yaml::to_string(&analysis).unwrap();
1833 assert!(yaml.contains("file_diffs"));
1834 assert!(yaml.contains("src/main.rs"));
1835 assert!(yaml.contains("byte_len: 42"));
1836 }
1837
1838 fn make_commit_with_file_diffs(
1842 dir: &tempfile::TempDir,
1843 files: &[(&str, &str)], ) -> CommitInfo {
1845 let file_diffs: Vec<FileDiffRef> = files
1846 .iter()
1847 .enumerate()
1848 .map(|(i, (path, content))| {
1849 let diff_path = dir.path().join(format!("{i:04}.diff"));
1850 fs::write(&diff_path, content).unwrap();
1851 FileDiffRef {
1852 path: (*path).to_string(),
1853 diff_file: diff_path.to_string_lossy().to_string(),
1854 byte_len: content.len(),
1855 }
1856 })
1857 .collect();
1858
1859 CommitInfo {
1860 hash: "abc123def456abc123def456abc123def456abc1".to_string(),
1861 author: "Test Author".to_string(),
1862 date: DateTime::parse_from_rfc3339("2025-01-01T00:00:00+00:00").unwrap(),
1863 original_message: "feat(cli): original message".to_string(),
1864 in_main_branches: vec!["main".to_string()],
1865 analysis: CommitAnalysis {
1866 detected_type: "feat".to_string(),
1867 detected_scope: "cli".to_string(),
1868 proposed_message: "feat(cli): proposed".to_string(),
1869 file_changes: make_file_changes(
1870 &files.iter().map(|(p, _)| ("M", *p)).collect::<Vec<_>>(),
1871 ),
1872 diff_summary: " src/main.rs | 10 ++++\n src/lib.rs | 5 ++\n".to_string(),
1873 diff_file: dir.path().join("full.diff").to_string_lossy().to_string(),
1874 file_diffs,
1875 },
1876 }
1877 }
1878
1879 #[test]
1880 fn from_commit_info_partial_loads_subset() -> Result<()> {
1881 let dir = tempfile::tempdir()?;
1882 let commit = make_commit_with_file_diffs(
1883 &dir,
1884 &[
1885 ("src/main.rs", "diff --git a/src/main.rs\n+main\n"),
1886 ("src/lib.rs", "diff --git a/src/lib.rs\n+lib\n"),
1887 ("src/utils.rs", "diff --git a/src/utils.rs\n+utils\n"),
1888 ],
1889 );
1890
1891 let paths = vec!["src/main.rs".to_string(), "src/utils.rs".to_string()];
1892 let partial = CommitInfoForAI::from_commit_info_partial(commit, &paths)?;
1893
1894 assert!(partial.base.analysis.diff_content.contains("+main"));
1896 assert!(partial.base.analysis.diff_content.contains("+utils"));
1897 assert!(!partial.base.analysis.diff_content.contains("+lib"));
1898
1899 let ref_paths: Vec<&str> = partial
1901 .base
1902 .analysis
1903 .base
1904 .file_diffs
1905 .iter()
1906 .map(|r| r.path.as_str())
1907 .collect();
1908 assert_eq!(ref_paths, &["src/main.rs", "src/utils.rs"]);
1909
1910 Ok(())
1911 }
1912
1913 #[test]
1914 fn from_commit_info_partial_deduplicates_paths() -> Result<()> {
1915 let dir = tempfile::tempdir()?;
1916 let commit = make_commit_with_file_diffs(
1917 &dir,
1918 &[("src/main.rs", "diff --git a/src/main.rs\n+main\n")],
1919 );
1920
1921 let paths = vec!["src/main.rs".to_string(), "src/main.rs".to_string()];
1923 let partial = CommitInfoForAI::from_commit_info_partial(commit, &paths)?;
1924
1925 assert_eq!(
1927 partial.base.analysis.diff_content.matches("+main").count(),
1928 1
1929 );
1930
1931 Ok(())
1932 }
1933
1934 #[test]
1935 fn from_commit_info_partial_preserves_metadata() -> Result<()> {
1936 let dir = tempfile::tempdir()?;
1937 let commit = make_commit_with_file_diffs(
1938 &dir,
1939 &[("src/main.rs", "diff --git a/src/main.rs\n+main\n")],
1940 );
1941
1942 let original_hash = commit.hash.clone();
1943 let original_author = commit.author.clone();
1944 let original_date = commit.date;
1945 let original_message = commit.original_message.clone();
1946 let original_summary = commit.analysis.diff_summary.clone();
1947
1948 let paths = vec!["src/main.rs".to_string()];
1949 let partial = CommitInfoForAI::from_commit_info_partial(commit, &paths)?;
1950
1951 assert_eq!(partial.base.hash, original_hash);
1952 assert_eq!(partial.base.author, original_author);
1953 assert_eq!(partial.base.date, original_date);
1954 assert_eq!(partial.base.original_message, original_message);
1955 assert_eq!(partial.base.analysis.base.diff_summary, original_summary);
1956
1957 Ok(())
1958 }
1959
1960 #[test]
1963 fn with_overrides_uses_override_content() -> Result<()> {
1964 let dir = tempfile::tempdir()?;
1965 let commit = make_commit_with_file_diffs(
1966 &dir,
1967 &[(
1968 "src/big.rs",
1969 "diff --git a/src/big.rs\n+full-file-content\n",
1970 )],
1971 );
1972
1973 let paths = vec!["src/big.rs".to_string(), "src/big.rs".to_string()];
1974 let overrides = vec![
1975 Some("diff --git a/src/big.rs\n@@ -1,3 +1,4 @@\n+hunk1\n".to_string()),
1976 Some("diff --git a/src/big.rs\n@@ -10,3 +10,4 @@\n+hunk2\n".to_string()),
1977 ];
1978 let partial =
1979 CommitInfoForAI::from_commit_info_partial_with_overrides(commit, &paths, &overrides)?;
1980
1981 assert!(partial.base.analysis.diff_content.contains("+hunk1"));
1983 assert!(partial.base.analysis.diff_content.contains("+hunk2"));
1984 assert!(
1985 !partial
1986 .base
1987 .analysis
1988 .diff_content
1989 .contains("+full-file-content"),
1990 "should not contain full file content"
1991 );
1992
1993 Ok(())
1994 }
1995
1996 #[test]
1997 fn with_overrides_mixed_override_and_disk() -> Result<()> {
1998 let dir = tempfile::tempdir()?;
1999 let commit = make_commit_with_file_diffs(
2000 &dir,
2001 &[
2002 ("src/big.rs", "diff --git a/src/big.rs\n+big-full\n"),
2003 ("src/small.rs", "diff --git a/src/small.rs\n+small-disk\n"),
2004 ],
2005 );
2006
2007 let paths = vec!["src/big.rs".to_string(), "src/small.rs".to_string()];
2008 let overrides = vec![
2009 Some("diff --git a/src/big.rs\n@@ -1,3 +1,4 @@\n+big-hunk\n".to_string()),
2010 None, ];
2012 let partial =
2013 CommitInfoForAI::from_commit_info_partial_with_overrides(commit, &paths, &overrides)?;
2014
2015 assert!(partial.base.analysis.diff_content.contains("+big-hunk"));
2017 assert!(!partial.base.analysis.diff_content.contains("+big-full"));
2018 assert!(partial.base.analysis.diff_content.contains("+small-disk"));
2020
2021 let ref_paths: Vec<&str> = partial
2023 .base
2024 .analysis
2025 .base
2026 .file_diffs
2027 .iter()
2028 .map(|r| r.path.as_str())
2029 .collect();
2030 assert!(ref_paths.contains(&"src/big.rs"));
2031 assert!(ref_paths.contains(&"src/small.rs"));
2032
2033 Ok(())
2034 }
2035
2036 #[test]
2037 fn with_overrides_deduplicates_disk_reads() -> Result<()> {
2038 let dir = tempfile::tempdir()?;
2039 let commit = make_commit_with_file_diffs(
2040 &dir,
2041 &[("src/main.rs", "diff --git a/src/main.rs\n+main\n")],
2042 );
2043
2044 let paths = vec!["src/main.rs".to_string(), "src/main.rs".to_string()];
2046 let overrides = vec![None, None];
2047 let partial =
2048 CommitInfoForAI::from_commit_info_partial_with_overrides(commit, &paths, &overrides)?;
2049
2050 assert_eq!(
2052 partial.base.analysis.diff_content.matches("+main").count(),
2053 1
2054 );
2055
2056 Ok(())
2057 }
2058
2059 #[test]
2060 fn with_overrides_preserves_metadata() -> Result<()> {
2061 let dir = tempfile::tempdir()?;
2062 let commit = make_commit_with_file_diffs(
2063 &dir,
2064 &[("src/main.rs", "diff --git a/src/main.rs\n+main\n")],
2065 );
2066
2067 let original_hash = commit.hash.clone();
2068 let original_author = commit.author.clone();
2069 let original_message = commit.original_message.clone();
2070
2071 let paths = vec!["src/main.rs".to_string()];
2072 let overrides = vec![Some("+override-content\n".to_string())];
2073 let partial =
2074 CommitInfoForAI::from_commit_info_partial_with_overrides(commit, &paths, &overrides)?;
2075
2076 assert_eq!(partial.base.hash, original_hash);
2077 assert_eq!(partial.base.author, original_author);
2078 assert_eq!(partial.base.original_message, original_message);
2079 assert!(partial.pre_validated_checks.is_empty());
2080
2081 Ok(())
2082 }
2083
2084 fn infer_type(message: &str, files: &[(&str, &str)]) -> String {
2090 detect_commit_type_from_message(message, &make_file_changes(files))
2091 }
2092
2093 #[test]
2094 fn commit_type_existing_conventional_wins() {
2095 assert_eq!(infer_type("feat(cli): add", &[("A", "src/x.rs")]), "feat");
2096 }
2097
2098 #[test]
2099 fn commit_type_test_files() {
2100 assert_eq!(infer_type("update", &[("A", "tests/foo_test.rs")]), "test");
2101 }
2102
2103 #[test]
2104 fn commit_type_docs_files() {
2105 assert_eq!(infer_type("update", &[("M", "README.md")]), "docs");
2106 }
2107
2108 #[test]
2109 fn commit_type_config_added_is_feat() {
2110 assert_eq!(infer_type("update", &[("A", "Cargo.toml")]), "feat");
2111 }
2112
2113 #[test]
2114 fn commit_type_config_modified_is_chore() {
2115 assert_eq!(infer_type("update", &[("M", "Cargo.toml")]), "chore");
2116 }
2117
2118 #[test]
2119 fn commit_type_added_source_is_feat() {
2120 assert_eq!(infer_type("add module", &[("A", "src/lib.rs")]), "feat");
2121 }
2122
2123 #[test]
2124 fn commit_type_fix_from_message() {
2125 assert_eq!(infer_type("fix the bug", &[("M", "src/lib.rs")]), "fix");
2127 }
2128
2129 #[test]
2130 fn commit_type_refactor_when_more_deletions() {
2131 assert_eq!(
2132 infer_type("cleanup", &[("D", "src/a.rs"), ("D", "src/b.rs")]),
2133 "refactor"
2134 );
2135 }
2136
2137 #[test]
2138 fn commit_type_default_chore() {
2139 assert_eq!(infer_type("update stuff", &[("M", "src/c.rs")]), "chore");
2140 }
2141
2142 #[test]
2145 fn proposed_message_with_scope() {
2146 let fc = make_file_changes(&[("A", "src/x.rs")]);
2147 let msg = CommitAnalysis::generate_proposed_message_from("do thing", "feat", "cli", &fc);
2148 assert_eq!(msg, "feat(cli): do thing");
2149 }
2150
2151 #[test]
2152 fn proposed_message_without_scope() {
2153 let fc = make_file_changes(&[("A", "src/x.rs")]);
2154 let msg = CommitAnalysis::generate_proposed_message_from("do thing", "feat", "", &fc);
2155 assert_eq!(msg, "feat: do thing");
2156 }
2157
2158 #[test]
2159 fn proposed_message_keeps_already_conventional() {
2160 let fc = make_file_changes(&[("A", "src/x.rs")]);
2161 let msg = CommitAnalysis::generate_proposed_message_from("fix(x): y", "feat", "cli", &fc);
2162 assert_eq!(msg, "fix(x): y");
2163 }
2164
2165 #[test]
2166 fn proposed_message_generates_description_when_empty() {
2167 let fc = make_file_changes(&[("A", "src/x.rs")]);
2168 let msg = CommitAnalysis::generate_proposed_message_from("", "chore", "", &fc);
2169 assert!(msg.starts_with("chore: "), "got: {msg}");
2170 }
2171
2172 #[test]
2179 fn analyze_file_changes_covers_delta_arms() -> Result<()> {
2180 let dir = tempfile::tempdir()?;
2181 let repo = git2::Repository::init(dir.path())?;
2182 let sig = git2::Signature::now("T", "t@e.com")?;
2183
2184 for (name, content) in [("a.txt", "a"), ("b.txt", "b")] {
2186 std::fs::write(dir.path().join(name), content)?;
2187 }
2188 let mut index = repo.index()?;
2189 index.add_path(std::path::Path::new("a.txt"))?;
2190 index.add_path(std::path::Path::new("b.txt"))?;
2191 index.write()?;
2192 let tree1 = repo.find_tree(index.write_tree()?)?;
2193 let c1 = repo.commit(Some("HEAD"), &sig, &sig, "init", &tree1, &[])?;
2194
2195 std::fs::remove_file(dir.path().join("a.txt"))?;
2197 std::fs::write(dir.path().join("b.txt"), "b2")?;
2198 std::fs::write(dir.path().join("c.txt"), "c")?;
2199 let mut index = repo.index()?;
2200 index.remove_path(std::path::Path::new("a.txt"))?;
2201 index.add_path(std::path::Path::new("b.txt"))?;
2202 index.add_path(std::path::Path::new("c.txt"))?;
2203 index.write()?;
2204 let tree2 = repo.find_tree(index.write_tree()?)?;
2205 let parent = repo.find_commit(c1)?;
2206 let c2 = repo.commit(Some("HEAD"), &sig, &sig, "change", &tree2, &[&parent])?;
2207
2208 let commit2 = repo.find_commit(c2)?;
2209 let changes = CommitAnalysis::analyze_file_changes(&repo, &commit2)?;
2210 assert_eq!(changes.files_added, 1, "c.txt added");
2211 assert_eq!(changes.files_deleted, 1, "a.txt deleted");
2212 Ok(())
2213 }
2214
2215 #[test]
2218 fn tally_multi_scope_counts_each_name_once() {
2219 let subjects = ["feat(cli,claude): cross-cutting change"];
2220 let report = super::tally_scope_usage(&subjects, &[], &[]);
2221 let names: Vec<&str> = report.declared.iter().map(|sc| sc.name.as_str()).collect();
2222 assert!(names.contains(&"cli"), "declared: {:?}", report.declared);
2223 assert!(names.contains(&"claude"), "declared: {:?}", report.declared);
2224 assert!(
2225 !names.contains(&"cli,claude"),
2226 "must not count the literal compound string, declared: {:?}",
2227 report.declared
2228 );
2229 assert_eq!(
2230 report
2231 .declared
2232 .iter()
2233 .find(|sc| sc.name == "cli")
2234 .map(|sc| sc.count),
2235 Some(1)
2236 );
2237 assert_eq!(report.scope_less_count, 0);
2238 }
2239
2240 #[test]
2241 fn tally_scope_less_commit_not_a_named_bucket() {
2242 let subjects = ["docs: update readme"];
2243 let report = super::tally_scope_usage(&subjects, &[], &[]);
2244 assert!(
2245 report.declared.is_empty(),
2246 "a scope-less subject must not create an empty-named declared entry: {:?}",
2247 report.declared
2248 );
2249 assert_eq!(report.scope_less_count, 1);
2250 assert_eq!(report.total_commits, 1);
2251 }
2252
2253 #[test]
2254 fn tally_breaking_change_counts_under_scope() {
2255 let subjects = ["feat(cli)!: change output format"];
2258 let report = super::tally_scope_usage(&subjects, &[], &[]);
2259 assert_eq!(report.scope_less_count, 0);
2260 assert_eq!(
2261 report
2262 .declared
2263 .iter()
2264 .find(|sc| sc.name == "cli")
2265 .map(|sc| sc.count),
2266 Some(1)
2267 );
2268 }
2269
2270 #[test]
2271 fn tally_unknown_excludes_known_scopes() {
2272 let subjects = ["feat(cli): add flag", "fix(lib): patch bug"];
2273 let known = vec![make_scope_def("cli", &[])];
2274 let report = super::tally_scope_usage(&subjects, &known, &[]);
2275 let unknown_names: Vec<&str> = report.unknown.iter().map(|sc| sc.name.as_str()).collect();
2276 assert_eq!(unknown_names, vec!["lib"]);
2277 }
2278
2279 #[test]
2280 fn tally_unused_lists_scope_never_declared() {
2281 let subjects = ["feat(cli): add flag"];
2282 let unused_candidates = vec![make_scope_def("cli", &[]), make_scope_def("workflows", &[])];
2283 let report = super::tally_scope_usage(&subjects, &unused_candidates, &unused_candidates);
2284 assert_eq!(report.unused, vec!["workflows".to_string()]);
2285 }
2286
2287 #[test]
2288 fn tally_empty_subjects_all_zero_report() {
2289 let report = super::tally_scope_usage(&[], &[], &[]);
2290 assert_eq!(report.total_commits, 0);
2291 assert!(report.declared.is_empty());
2292 assert!(report.unknown.is_empty());
2293 assert!(report.unused.is_empty());
2294 assert_eq!(report.scope_less_count, 0);
2295 }
2296
2297 #[test]
2298 fn tally_declared_sorted_desc_by_count_then_name() {
2299 let subjects = [
2300 "feat(cli): a",
2301 "feat(git): b",
2302 "feat(git): c",
2303 "feat(data): d",
2304 "feat(data): e",
2305 ];
2306 let report = super::tally_scope_usage(&subjects, &[], &[]);
2307 let names: Vec<&str> = report.declared.iter().map(|sc| sc.name.as_str()).collect();
2308 assert_eq!(names, vec!["data", "git", "cli"]);
2310 }
2311}