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