1use std::fs;
4use std::sync::LazyLock;
5
6use anyhow::{Context, Result};
7use chrono::{DateTime, FixedOffset};
8use git2::{Commit, Repository};
9use globset::Glob;
10use regex::Regex;
11use serde::{Deserialize, Serialize};
12
13use crate::data::context::ScopeDefinition;
14use crate::git::diff_split::split_by_file;
15
16#[allow(clippy::unwrap_used)] static SCOPE_RE: LazyLock<Regex> =
19 LazyLock::new(|| Regex::new(r"^[a-z]+!\(([^)]+)\):|^[a-z]+\(([^)]+)\):").unwrap());
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct CommitInfo<A = CommitAnalysis> {
24 pub hash: String,
26 pub author: String,
28 pub date: DateTime<FixedOffset>,
30 pub original_message: String,
32 pub in_main_branches: Vec<String>,
34 pub analysis: A,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct CommitAnalysis {
41 pub detected_type: String,
43 pub detected_scope: String,
45 pub proposed_message: String,
47 pub file_changes: FileChanges,
49 pub diff_summary: String,
51 pub diff_file: String,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub file_diffs: Vec<FileDiffRef>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct FileDiffRef {
65 pub path: String,
67 pub diff_file: String,
69 pub byte_len: usize,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct CommitAnalysisForAI {
76 #[serde(flatten)]
78 pub base: CommitAnalysis,
79 pub diff_content: String,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct CommitInfoForAI {
86 #[serde(flatten)]
88 pub base: CommitInfo<CommitAnalysisForAI>,
89 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub pre_validated_checks: Vec<String>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct FileChanges {
97 pub total_files: usize,
99 pub files_added: usize,
101 pub files_deleted: usize,
103 pub file_list: Vec<FileChange>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct FileChange {
110 pub status: String,
112 pub file: String,
114}
115
116impl CommitInfo {
117 pub fn from_git_commit(repo: &Repository, commit: &Commit) -> Result<Self> {
119 let hash = commit.id().to_string();
120
121 let author = format!(
122 "{} <{}>",
123 commit.author().name().unwrap_or("Unknown"),
124 commit.author().email().unwrap_or("unknown@example.com")
125 );
126
127 let timestamp = commit.author().when();
128 let date = DateTime::from_timestamp(timestamp.seconds(), 0)
129 .context("Invalid commit timestamp")?
130 .with_timezone(
131 #[allow(clippy::unwrap_used)] &FixedOffset::east_opt(timestamp.offset_minutes() * 60)
133 .unwrap_or_else(|| FixedOffset::east_opt(0).unwrap()),
134 );
135
136 let original_message = commit.message().unwrap_or("").to_string();
137
138 let in_main_branches = Vec::new();
140
141 let analysis = CommitAnalysis::analyze_commit(repo, commit)?;
143
144 Ok(Self {
145 hash,
146 author,
147 date,
148 original_message,
149 in_main_branches,
150 analysis,
151 })
152 }
153}
154
155impl CommitAnalysis {
156 pub fn analyze_commit(repo: &Repository, commit: &Commit) -> Result<Self> {
158 let file_changes = Self::analyze_file_changes(repo, commit)?;
160
161 let detected_type = Self::detect_commit_type(commit, &file_changes);
163
164 let detected_scope = Self::detect_scope(&file_changes);
166
167 let proposed_message =
169 Self::generate_proposed_message(commit, &detected_type, &detected_scope, &file_changes);
170
171 let diff_summary = Self::get_diff_summary(repo, commit)?;
173
174 let (diff_file, file_diffs) = Self::write_diff_to_file(repo, commit)?;
176
177 Ok(Self {
178 detected_type,
179 detected_scope,
180 proposed_message,
181 file_changes,
182 diff_summary,
183 diff_file,
184 file_diffs,
185 })
186 }
187
188 fn analyze_file_changes(repo: &Repository, commit: &Commit) -> Result<FileChanges> {
190 let mut file_list = Vec::new();
191 let mut files_added = 0;
192 let mut files_deleted = 0;
193
194 let commit_tree = commit.tree().context("Failed to get commit tree")?;
196
197 let parent_tree = if commit.parent_count() > 0 {
199 Some(
200 commit
201 .parent(0)
202 .context("Failed to get parent commit")?
203 .tree()
204 .context("Failed to get parent tree")?,
205 )
206 } else {
207 None
208 };
209
210 let diff = if let Some(parent_tree) = parent_tree {
212 repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
213 .context("Failed to create diff")?
214 } else {
215 repo.diff_tree_to_tree(None, Some(&commit_tree), None)
217 .context("Failed to create diff for initial commit")?
218 };
219
220 diff.foreach(
222 &mut |delta, _progress| {
223 let status = match delta.status() {
224 git2::Delta::Added => {
225 files_added += 1;
226 "A"
227 }
228 git2::Delta::Deleted => {
229 files_deleted += 1;
230 "D"
231 }
232 git2::Delta::Modified => "M",
233 git2::Delta::Renamed => "R",
234 git2::Delta::Copied => "C",
235 git2::Delta::Typechange => "T",
236 _ => "?",
237 };
238
239 if let Some(path) = delta.new_file().path() {
240 if let Some(path_str) = path.to_str() {
241 file_list.push(FileChange {
242 status: status.to_string(),
243 file: path_str.to_string(),
244 });
245 }
246 }
247
248 true
249 },
250 None,
251 None,
252 None,
253 )
254 .context("Failed to process diff")?;
255
256 let total_files = file_list.len();
257
258 Ok(FileChanges {
259 total_files,
260 files_added,
261 files_deleted,
262 file_list,
263 })
264 }
265
266 fn detect_commit_type(commit: &Commit, file_changes: &FileChanges) -> String {
268 Self::detect_commit_type_from_message(commit.message().unwrap_or(""), file_changes)
269 }
270
271 fn detect_commit_type_from_message(message: &str, file_changes: &FileChanges) -> String {
278 if let Some(existing_type) = Self::extract_conventional_type(message) {
280 return existing_type;
281 }
282
283 let files: Vec<&str> = file_changes
285 .file_list
286 .iter()
287 .map(|f| f.file.as_str())
288 .collect();
289
290 if files
292 .iter()
293 .any(|f| f.contains("test") || f.contains("spec"))
294 {
295 "test".to_string()
296 } else if files
297 .iter()
298 .any(|f| f.ends_with(".md") || f.contains("README") || f.contains("docs/"))
299 {
300 "docs".to_string()
301 } else if files
302 .iter()
303 .any(|f| f.contains("Cargo.toml") || f.contains("package.json") || f.contains("config"))
304 {
305 if file_changes.files_added > 0 {
306 "feat".to_string()
307 } else {
308 "chore".to_string()
309 }
310 } else if file_changes.files_added > 0
311 && files
312 .iter()
313 .any(|f| f.ends_with(".rs") || f.ends_with(".js") || f.ends_with(".py"))
314 {
315 "feat".to_string()
316 } else if message.to_lowercase().contains("fix") || message.to_lowercase().contains("bug") {
317 "fix".to_string()
318 } else if file_changes.files_deleted > file_changes.files_added {
319 "refactor".to_string()
320 } else {
321 "chore".to_string()
322 }
323 }
324
325 fn extract_conventional_type(message: &str) -> Option<String> {
327 let first_line = message.lines().next().unwrap_or("");
328 if let Some(colon_pos) = first_line.find(':') {
329 let prefix = &first_line[..colon_pos];
330 if let Some(paren_pos) = prefix.find('(') {
331 let type_part = &prefix[..paren_pos];
332 if Self::is_valid_conventional_type(type_part) {
333 return Some(type_part.to_string());
334 }
335 } else if Self::is_valid_conventional_type(prefix) {
336 return Some(prefix.to_string());
337 }
338 }
339 None
340 }
341
342 fn is_valid_conventional_type(s: &str) -> bool {
344 matches!(
345 s,
346 "feat"
347 | "fix"
348 | "docs"
349 | "style"
350 | "refactor"
351 | "test"
352 | "chore"
353 | "build"
354 | "ci"
355 | "perf"
356 )
357 }
358
359 fn detect_scope(file_changes: &FileChanges) -> String {
361 let files: Vec<&str> = file_changes
362 .file_list
363 .iter()
364 .map(|f| f.file.as_str())
365 .collect();
366
367 if files.iter().any(|f| f.starts_with("src/cli/")) {
369 "cli".to_string()
370 } else if files.iter().any(|f| f.starts_with("src/git/")) {
371 "git".to_string()
372 } else if files.iter().any(|f| f.starts_with("src/data/")) {
373 "data".to_string()
374 } else if files.iter().any(|f| f.starts_with("tests/")) {
375 "test".to_string()
376 } else if files.iter().any(|f| f.starts_with("docs/")) {
377 "docs".to_string()
378 } else if files
379 .iter()
380 .any(|f| f.contains("Cargo.toml") || f.contains("deny.toml"))
381 {
382 "deps".to_string()
383 } else {
384 String::new()
385 }
386 }
387
388 pub fn refine_scope(&mut self, scope_defs: &[ScopeDefinition]) {
395 let files: Vec<&str> = self
396 .file_changes
397 .file_list
398 .iter()
399 .map(|f| f.file.as_str())
400 .collect();
401
402 if let Some(resolved) = resolve_scope(&files, scope_defs) {
403 self.detected_scope = resolved;
404 }
405 }
406
407 fn generate_proposed_message(
409 commit: &Commit,
410 commit_type: &str,
411 scope: &str,
412 file_changes: &FileChanges,
413 ) -> String {
414 let current_message = commit.message().unwrap_or("").lines().next().unwrap_or("");
415 Self::generate_proposed_message_from(current_message, commit_type, scope, file_changes)
416 }
417
418 fn generate_proposed_message_from(
424 current_message: &str,
425 commit_type: &str,
426 scope: &str,
427 file_changes: &FileChanges,
428 ) -> String {
429 if Self::extract_conventional_type(current_message).is_some() {
431 return current_message.to_string();
432 }
433
434 let description =
436 if !current_message.is_empty() && !current_message.eq_ignore_ascii_case("stuff") {
437 current_message.to_string()
438 } else {
439 Self::generate_description(commit_type, file_changes)
440 };
441
442 if scope.is_empty() {
444 format!("{commit_type}: {description}")
445 } else {
446 format!("{commit_type}({scope}): {description}")
447 }
448 }
449
450 fn generate_description(commit_type: &str, file_changes: &FileChanges) -> String {
452 match commit_type {
453 "feat" => {
454 if file_changes.total_files == 1 {
455 format!("add {}", file_changes.file_list[0].file)
456 } else {
457 format!("add {} new features", file_changes.total_files)
458 }
459 }
460 "fix" => "resolve issues".to_string(),
461 "docs" => "update documentation".to_string(),
462 "test" => "add tests".to_string(),
463 "refactor" => "improve code structure".to_string(),
464 "chore" => "update project files".to_string(),
465 _ => "update project".to_string(),
466 }
467 }
468
469 fn get_diff_summary(repo: &Repository, commit: &Commit) -> Result<String> {
471 let commit_tree = commit.tree().context("Failed to get commit tree")?;
472
473 let parent_tree = if commit.parent_count() > 0 {
474 Some(
475 commit
476 .parent(0)
477 .context("Failed to get parent commit")?
478 .tree()
479 .context("Failed to get parent tree")?,
480 )
481 } else {
482 None
483 };
484
485 let diff = if let Some(parent_tree) = parent_tree {
486 repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
487 .context("Failed to create diff")?
488 } else {
489 repo.diff_tree_to_tree(None, Some(&commit_tree), None)
490 .context("Failed to create diff for initial commit")?
491 };
492
493 let stats = diff.stats().context("Failed to get diff stats")?;
494
495 let mut summary = String::new();
496 for i in 0..stats.files_changed() {
497 if let Some(path) = diff
498 .get_delta(i)
499 .and_then(|d| d.new_file().path())
500 .and_then(|p| p.to_str())
501 {
502 let insertions = stats.insertions();
503 let deletions = stats.deletions();
504 summary.push_str(&format!(
505 " {} | {} +{} -{}\n",
506 path,
507 insertions + deletions,
508 insertions,
509 deletions
510 ));
511 }
512 }
513
514 Ok(summary)
515 }
516
517 fn write_diff_to_file(
519 repo: &Repository,
520 commit: &Commit,
521 ) -> Result<(String, Vec<FileDiffRef>)> {
522 let repo_root = repo.workdir().unwrap_or_else(|| repo.path());
527 let ai_scratch_path = crate::utils::ai_scratch::get_ai_scratch_dir_at(repo_root)
528 .context("Failed to determine AI scratch directory")?;
529
530 let diffs_dir = ai_scratch_path.join("diffs");
532 fs::create_dir_all(&diffs_dir).context("Failed to create diffs directory")?;
533
534 let commit_hash = commit.id().to_string();
536 let diff_filename = format!("{commit_hash}.diff");
537 let diff_path = diffs_dir.join(&diff_filename);
538
539 let commit_tree = commit.tree().context("Failed to get commit tree")?;
540
541 let parent_tree = if commit.parent_count() > 0 {
542 Some(
543 commit
544 .parent(0)
545 .context("Failed to get parent commit")?
546 .tree()
547 .context("Failed to get parent tree")?,
548 )
549 } else {
550 None
551 };
552
553 let diff = if let Some(parent_tree) = parent_tree {
554 repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
555 .context("Failed to create diff")?
556 } else {
557 repo.diff_tree_to_tree(None, Some(&commit_tree), None)
558 .context("Failed to create diff for initial commit")?
559 };
560
561 let mut diff_content = String::new();
562
563 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
564 let content = std::str::from_utf8(line.content()).unwrap_or("<binary>");
565 let prefix = match line.origin() {
566 '+' => "+",
567 '-' => "-",
568 ' ' => " ",
569 '@' => "@",
570 _ => "", };
572 diff_content.push_str(&format!("{prefix}{content}"));
573 true
574 })
575 .context("Failed to format diff")?;
576
577 if !diff_content.ends_with('\n') {
579 diff_content.push('\n');
580 }
581
582 fs::write(&diff_path, &diff_content).context("Failed to write diff file")?;
584
585 let per_file_diffs = split_by_file(&diff_content);
587 let mut file_diffs = Vec::with_capacity(per_file_diffs.len());
588
589 if !per_file_diffs.is_empty() {
590 let per_file_dir = diffs_dir.join(&commit_hash);
591 fs::create_dir_all(&per_file_dir)
592 .context("Failed to create per-file diffs directory")?;
593
594 for (index, file_diff) in per_file_diffs.iter().enumerate() {
595 let per_file_name = format!("{index:04}.diff");
596 let per_file_path = per_file_dir.join(&per_file_name);
597 fs::write(&per_file_path, &file_diff.content).with_context(|| {
598 format!("Failed to write per-file diff: {}", per_file_path.display())
599 })?;
600
601 file_diffs.push(FileDiffRef {
602 path: file_diff.path.clone(),
603 diff_file: per_file_path.to_string_lossy().to_string(),
604 byte_len: file_diff.byte_len,
605 });
606 }
607 }
608
609 Ok((diff_path.to_string_lossy().to_string(), file_diffs))
610 }
611}
612
613impl CommitInfoForAI {
614 pub fn from_commit_info(commit_info: CommitInfo) -> Result<Self> {
616 let analysis = CommitAnalysisForAI::from_commit_analysis(commit_info.analysis)?;
617
618 Ok(Self {
619 base: CommitInfo {
620 hash: commit_info.hash,
621 author: commit_info.author,
622 date: commit_info.date,
623 original_message: commit_info.original_message,
624 in_main_branches: commit_info.in_main_branches,
625 analysis,
626 },
627 pre_validated_checks: Vec::new(),
628 })
629 }
630
631 #[cfg(test)]
636 pub(crate) fn from_commit_info_partial(
637 commit_info: CommitInfo,
638 file_paths: &[String],
639 ) -> Result<Self> {
640 let overrides: Vec<Option<String>> = vec![None; file_paths.len()];
641 Self::from_commit_info_partial_with_overrides(commit_info, file_paths, &overrides)
642 }
643
644 pub(crate) fn from_commit_info_partial_with_overrides(
655 commit_info: CommitInfo,
656 file_paths: &[String],
657 diff_overrides: &[Option<String>],
658 ) -> Result<Self> {
659 let mut diff_parts = Vec::new();
660 let mut included_refs = Vec::new();
661 let mut loaded_disk_paths: std::collections::HashSet<String> =
662 std::collections::HashSet::new();
663
664 for (path, override_content) in file_paths.iter().zip(diff_overrides.iter()) {
665 if let Some(content) = override_content {
666 diff_parts.push(content.clone());
668 if let Some(file_ref) = commit_info
670 .analysis
671 .file_diffs
672 .iter()
673 .find(|r| r.path == *path)
674 {
675 if !included_refs.iter().any(|r: &FileDiffRef| r.path == *path) {
676 included_refs.push(file_ref.clone());
677 }
678 }
679 } else {
680 if loaded_disk_paths.insert(path.clone()) {
682 if let Some(file_ref) = commit_info
683 .analysis
684 .file_diffs
685 .iter()
686 .find(|r| r.path == *path)
687 {
688 let content =
689 fs::read_to_string(&file_ref.diff_file).with_context(|| {
690 format!("Failed to read per-file diff: {}", file_ref.diff_file)
691 })?;
692 diff_parts.push(content);
693 included_refs.push(file_ref.clone());
694 }
695 }
696 }
697 }
698
699 let diff_content = diff_parts.join("\n");
700
701 let partial_analysis = CommitAnalysisForAI {
702 base: CommitAnalysis {
703 file_diffs: included_refs,
704 ..commit_info.analysis
705 },
706 diff_content,
707 };
708
709 Ok(Self {
710 base: CommitInfo {
711 hash: commit_info.hash,
712 author: commit_info.author,
713 date: commit_info.date,
714 original_message: commit_info.original_message,
715 in_main_branches: commit_info.in_main_branches,
716 analysis: partial_analysis,
717 },
718 pre_validated_checks: Vec::new(),
719 })
720 }
721
722 pub fn run_pre_validation_checks(&mut self, valid_scopes: &[ScopeDefinition]) {
726 if let Some(caps) = SCOPE_RE.captures(&self.base.original_message) {
727 let scope = caps.get(1).or_else(|| caps.get(2)).map(|m| m.as_str());
728 if let Some(scope) = scope {
729 if scope.contains(',') && !scope.contains(", ") && !scope.contains(" ,") {
730 self.pre_validated_checks.push(format!(
731 "Scope format verified: multi-scope '{scope}' uses commas with at most one trailing space"
732 ));
733 }
734
735 if !valid_scopes.is_empty() {
737 let scope_parts: Vec<&str> = scope.split(',').map(str::trim).collect();
738 let all_valid = scope_parts
739 .iter()
740 .all(|part| valid_scopes.iter().any(|s| s.name == *part));
741 if all_valid {
742 self.pre_validated_checks.push(format!(
743 "Scope validity verified: '{scope}' is in the valid scopes list"
744 ));
745 }
746 }
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(caps) = SCOPE_RE.captures(first_line) else {
806 return message.to_string();
807 };
808
809 let existing_scope = caps
811 .get(1)
812 .or_else(|| caps.get(2))
813 .map_or("", |m| m.as_str());
814
815 if existing_scope == resolved {
816 return message.to_string();
817 }
818
819 let new_first_line =
820 first_line.replacen(&format!("({existing_scope})"), &format!("({resolved})"), 1);
821
822 if rest.is_empty() {
823 new_first_line
824 } else {
825 format!("{new_first_line}\n{rest}")
826 }
827}
828
829fn scope_matches_files(files: &[&str], patterns: &[String]) -> Option<usize> {
834 let mut positive = Vec::new();
835 let mut negative = Vec::new();
836 for pat in patterns {
837 if let Some(stripped) = pat.strip_prefix('!') {
838 negative.push(stripped);
839 } else {
840 positive.push(pat.as_str());
841 }
842 }
843
844 let neg_matchers: Vec<_> = negative
846 .iter()
847 .filter_map(|p| Glob::new(p).ok().map(|g| g.compile_matcher()))
848 .collect();
849
850 let mut max_specificity: Option<usize> = None;
851 for pat in &positive {
852 let Ok(glob) = Glob::new(pat) else {
853 continue;
854 };
855 let matcher = glob.compile_matcher();
856 for file in files {
857 if matcher.is_match(file) && !neg_matchers.iter().any(|neg| neg.is_match(file)) {
858 let specificity = count_specificity(pat);
859 max_specificity =
860 Some(max_specificity.map_or(specificity, |cur| cur.max(specificity)));
861 }
862 }
863 }
864 max_specificity
865}
866
867fn count_specificity(pattern: &str) -> usize {
874 pattern
875 .split('/')
876 .filter(|segment| !segment.contains('*') && !segment.contains('?'))
877 .count()
878}
879
880impl CommitAnalysisForAI {
881 pub fn from_commit_analysis(analysis: CommitAnalysis) -> Result<Self> {
883 let diff_content = fs::read_to_string(&analysis.diff_file)
885 .with_context(|| format!("Failed to read diff file: {}", analysis.diff_file))?;
886
887 Ok(Self {
888 base: analysis,
889 diff_content,
890 })
891 }
892}
893
894#[cfg(test)]
895#[allow(clippy::unwrap_used, clippy::expect_used)]
896mod tests {
897 use super::*;
898 use crate::data::context::ScopeDefinition;
899
900 #[test]
903 fn conventional_type_feat_with_scope() {
904 assert_eq!(
905 CommitAnalysis::extract_conventional_type("feat(cli): add flag"),
906 Some("feat".to_string())
907 );
908 }
909
910 #[test]
911 fn conventional_type_without_scope() {
912 assert_eq!(
913 CommitAnalysis::extract_conventional_type("fix: resolve bug"),
914 Some("fix".to_string())
915 );
916 }
917
918 #[test]
919 fn conventional_type_invalid_message() {
920 assert_eq!(
921 CommitAnalysis::extract_conventional_type("random message without colon"),
922 None
923 );
924 }
925
926 #[test]
927 fn conventional_type_unknown_type() {
928 assert_eq!(
929 CommitAnalysis::extract_conventional_type("yolo(scope): stuff"),
930 None
931 );
932 }
933
934 #[test]
935 fn conventional_type_all_valid_types() {
936 let types = [
937 "feat", "fix", "docs", "style", "refactor", "test", "chore", "build", "ci", "perf",
938 ];
939 for t in types {
940 let msg = format!("{t}: description");
941 assert_eq!(
942 CommitAnalysis::extract_conventional_type(&msg),
943 Some(t.to_string()),
944 "expected Some for type '{t}'"
945 );
946 }
947 }
948
949 #[test]
952 fn valid_conventional_types() {
953 for t in [
954 "feat", "fix", "docs", "style", "refactor", "test", "chore", "build", "ci", "perf",
955 ] {
956 assert!(
957 CommitAnalysis::is_valid_conventional_type(t),
958 "'{t}' should be valid"
959 );
960 }
961 }
962
963 #[test]
964 fn invalid_conventional_types() {
965 for t in ["yolo", "Feat", "", "FEAT", "feature", "bugfix"] {
966 assert!(
967 !CommitAnalysis::is_valid_conventional_type(t),
968 "'{t}' should be invalid"
969 );
970 }
971 }
972
973 fn make_file_changes(files: &[(&str, &str)]) -> FileChanges {
976 FileChanges {
977 total_files: files.len(),
978 files_added: files.iter().filter(|(s, _)| *s == "A").count(),
979 files_deleted: files.iter().filter(|(s, _)| *s == "D").count(),
980 file_list: files
981 .iter()
982 .map(|(status, file)| FileChange {
983 status: (*status).to_string(),
984 file: (*file).to_string(),
985 })
986 .collect(),
987 }
988 }
989
990 #[test]
991 fn scope_from_cli_files() {
992 let changes = make_file_changes(&[("M", "src/cli/commands.rs")]);
993 assert_eq!(CommitAnalysis::detect_scope(&changes), "cli");
994 }
995
996 #[test]
997 fn scope_from_git_files() {
998 let changes = make_file_changes(&[("M", "src/git/remote.rs")]);
999 assert_eq!(CommitAnalysis::detect_scope(&changes), "git");
1000 }
1001
1002 #[test]
1003 fn scope_from_docs_files() {
1004 let changes = make_file_changes(&[("M", "docs/README.md")]);
1005 assert_eq!(CommitAnalysis::detect_scope(&changes), "docs");
1006 }
1007
1008 #[test]
1009 fn scope_from_data_files() {
1010 let changes = make_file_changes(&[("M", "src/data/yaml.rs")]);
1011 assert_eq!(CommitAnalysis::detect_scope(&changes), "data");
1012 }
1013
1014 #[test]
1015 fn scope_from_test_files() {
1016 let changes = make_file_changes(&[("A", "tests/new_test.rs")]);
1017 assert_eq!(CommitAnalysis::detect_scope(&changes), "test");
1018 }
1019
1020 #[test]
1021 fn scope_from_deps_files() {
1022 let changes = make_file_changes(&[("M", "Cargo.toml")]);
1023 assert_eq!(CommitAnalysis::detect_scope(&changes), "deps");
1024 }
1025
1026 #[test]
1027 fn scope_unknown_files() {
1028 let changes = make_file_changes(&[("M", "random/path/file.txt")]);
1029 assert_eq!(CommitAnalysis::detect_scope(&changes), "");
1030 }
1031
1032 #[test]
1035 fn count_specificity_deep_path() {
1036 assert_eq!(super::count_specificity("src/main/scala/**"), 3);
1037 }
1038
1039 #[test]
1040 fn count_specificity_shallow() {
1041 assert_eq!(super::count_specificity("docs/**"), 1);
1042 }
1043
1044 #[test]
1045 fn count_specificity_wildcard_only() {
1046 assert_eq!(super::count_specificity("*.md"), 0);
1047 }
1048
1049 #[test]
1050 fn count_specificity_no_wildcards() {
1051 assert_eq!(super::count_specificity("src/lib.rs"), 2);
1052 }
1053
1054 #[test]
1057 fn scope_matches_positive_patterns() {
1058 let patterns = vec!["src/cli/**".to_string()];
1059 let files = &["src/cli/commands.rs"];
1060 assert!(super::scope_matches_files(files, &patterns).is_some());
1061 }
1062
1063 #[test]
1064 fn scope_matches_no_match() {
1065 let patterns = vec!["src/cli/**".to_string()];
1066 let files = &["src/git/remote.rs"];
1067 assert!(super::scope_matches_files(files, &patterns).is_none());
1068 }
1069
1070 #[test]
1071 fn scope_matches_with_negation() {
1072 let patterns = vec!["src/**".to_string(), "!src/test/**".to_string()];
1073 let files = &["src/lib.rs"];
1075 assert!(super::scope_matches_files(files, &patterns).is_some());
1076
1077 let test_files = &["src/test/helper.rs"];
1079 assert!(super::scope_matches_files(test_files, &patterns).is_none());
1080 }
1081
1082 fn make_scope_def(name: &str, patterns: &[&str]) -> ScopeDefinition {
1085 ScopeDefinition {
1086 name: name.to_string(),
1087 description: String::new(),
1088 examples: vec![],
1089 file_patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
1090 }
1091 }
1092
1093 #[test]
1094 fn refine_scope_empty_defs() {
1095 let mut analysis = CommitAnalysis {
1096 detected_type: "feat".to_string(),
1097 detected_scope: "original".to_string(),
1098 proposed_message: String::new(),
1099 file_changes: make_file_changes(&[("M", "src/cli/commands.rs")]),
1100 diff_summary: String::new(),
1101 diff_file: String::new(),
1102 file_diffs: Vec::new(),
1103 };
1104 analysis.refine_scope(&[]);
1105 assert_eq!(analysis.detected_scope, "original");
1106 }
1107
1108 #[test]
1109 fn refine_scope_most_specific_wins() {
1110 let scope_defs = vec![
1111 make_scope_def("lib", &["src/**"]),
1112 make_scope_def("cli", &["src/cli/**"]),
1113 ];
1114 let mut analysis = CommitAnalysis {
1115 detected_type: "feat".to_string(),
1116 detected_scope: String::new(),
1117 proposed_message: String::new(),
1118 file_changes: make_file_changes(&[("M", "src/cli/commands.rs")]),
1119 diff_summary: String::new(),
1120 diff_file: String::new(),
1121 file_diffs: Vec::new(),
1122 };
1123 analysis.refine_scope(&scope_defs);
1124 assert_eq!(analysis.detected_scope, "cli");
1125 }
1126
1127 #[test]
1128 fn refine_scope_no_matching_files() {
1129 let scope_defs = vec![make_scope_def("cli", &["src/cli/**"])];
1130 let mut analysis = CommitAnalysis {
1131 detected_type: "feat".to_string(),
1132 detected_scope: "original".to_string(),
1133 proposed_message: String::new(),
1134 file_changes: make_file_changes(&[("M", "README.md")]),
1135 diff_summary: String::new(),
1136 diff_file: String::new(),
1137 file_diffs: Vec::new(),
1138 };
1139 analysis.refine_scope(&scope_defs);
1140 assert_eq!(analysis.detected_scope, "original");
1142 }
1143
1144 #[test]
1145 fn refine_scope_equal_specificity_joins() {
1146 let scope_defs = vec![
1147 make_scope_def("cli", &["src/cli/**"]),
1148 make_scope_def("git", &["src/git/**"]),
1149 ];
1150 let mut analysis = CommitAnalysis {
1151 detected_type: "feat".to_string(),
1152 detected_scope: String::new(),
1153 proposed_message: String::new(),
1154 file_changes: make_file_changes(&[
1155 ("M", "src/cli/commands.rs"),
1156 ("M", "src/git/remote.rs"),
1157 ]),
1158 diff_summary: String::new(),
1159 diff_file: String::new(),
1160 file_diffs: Vec::new(),
1161 };
1162 analysis.refine_scope(&scope_defs);
1163 assert!(
1165 analysis.detected_scope == "cli, git" || analysis.detected_scope == "git, cli",
1166 "expected joined scopes, got: {}",
1167 analysis.detected_scope
1168 );
1169 }
1170
1171 #[test]
1174 fn refine_message_scope_replaces_less_specific() {
1175 let scope_defs = vec![
1176 make_scope_def("ci", &[".github/**"]),
1177 make_scope_def("workflows", &[".github/workflows/**"]),
1178 ];
1179 let files = &[".github/workflows/ci.yml"];
1180 let result = super::refine_message_scope(
1181 "chore(ci): bump EmbarkStudios/cargo-deny-action from 2.0.15 to 2.0.17",
1182 files,
1183 &scope_defs,
1184 );
1185 assert_eq!(
1186 result,
1187 "chore(workflows): bump EmbarkStudios/cargo-deny-action from 2.0.15 to 2.0.17"
1188 );
1189 }
1190
1191 #[test]
1192 fn refine_message_scope_keeps_already_correct() {
1193 let scope_defs = vec![
1194 make_scope_def("ci", &[".github/**"]),
1195 make_scope_def("workflows", &[".github/workflows/**"]),
1196 ];
1197 let files = &[".github/workflows/ci.yml"];
1198 let msg = "chore(workflows): bump something";
1199 assert_eq!(super::refine_message_scope(msg, files, &scope_defs), msg);
1200 }
1201
1202 #[test]
1203 fn refine_message_scope_no_scope_in_message() {
1204 let scope_defs = vec![make_scope_def("cli", &["src/cli/**"])];
1205 let files = &["src/cli/commands.rs"];
1206 let msg = "chore: do something";
1207 assert_eq!(super::refine_message_scope(msg, files, &scope_defs), msg);
1208 }
1209
1210 #[test]
1211 fn refine_message_scope_preserves_body() {
1212 let scope_defs = vec![
1213 make_scope_def("ci", &[".github/**"]),
1214 make_scope_def("workflows", &[".github/workflows/**"]),
1215 ];
1216 let files = &[".github/workflows/ci.yml"];
1217 let msg = "chore(ci): bump dep\n\nSome body text\nMore details";
1218 let result = super::refine_message_scope(msg, files, &scope_defs);
1219 assert_eq!(
1220 result,
1221 "chore(workflows): bump dep\n\nSome body text\nMore details"
1222 );
1223 }
1224
1225 #[test]
1226 fn refine_message_scope_breaking_change() {
1227 let scope_defs = vec![
1228 make_scope_def("ci", &[".github/**"]),
1229 make_scope_def("workflows", &[".github/workflows/**"]),
1230 ];
1231 let files = &[".github/workflows/ci.yml"];
1232 let result = super::refine_message_scope("feat!(ci): breaking change", files, &scope_defs);
1233 assert_eq!(result, "feat!(workflows): breaking change");
1234 }
1235
1236 #[test]
1237 fn refine_message_scope_no_matching_scope_defs() {
1238 let scope_defs = vec![make_scope_def("cli", &["src/cli/**"])];
1239 let files = &["README.md"];
1240 let msg = "docs(docs): update readme";
1241 assert_eq!(super::refine_message_scope(msg, files, &scope_defs), msg);
1242 }
1243
1244 fn make_commit_info_for_ai(message: &str) -> CommitInfoForAI {
1247 CommitInfoForAI {
1248 base: CommitInfo {
1249 hash: "a".repeat(40),
1250 author: "Test <test@example.com>".to_string(),
1251 date: chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:00+00:00").unwrap(),
1252 original_message: message.to_string(),
1253 in_main_branches: vec![],
1254 analysis: CommitAnalysisForAI {
1255 base: CommitAnalysis {
1256 detected_type: "feat".to_string(),
1257 detected_scope: String::new(),
1258 proposed_message: String::new(),
1259 file_changes: make_file_changes(&[]),
1260 diff_summary: String::new(),
1261 diff_file: String::new(),
1262 file_diffs: Vec::new(),
1263 },
1264 diff_content: String::new(),
1265 },
1266 },
1267 pre_validated_checks: vec![],
1268 }
1269 }
1270
1271 #[test]
1272 fn pre_validation_valid_single_scope() {
1273 let scopes = vec![make_scope_def("cli", &["src/cli/**"])];
1274 let mut info = make_commit_info_for_ai("feat(cli): add command");
1275 info.run_pre_validation_checks(&scopes);
1276 assert!(
1277 info.pre_validated_checks
1278 .iter()
1279 .any(|c| c.contains("Scope validity verified")),
1280 "expected scope validity check, got: {:?}",
1281 info.pre_validated_checks
1282 );
1283 }
1284
1285 #[test]
1286 fn pre_validation_multi_scope() {
1287 let scopes = vec![
1288 make_scope_def("cli", &["src/cli/**"]),
1289 make_scope_def("git", &["src/git/**"]),
1290 ];
1291 let mut info = make_commit_info_for_ai("feat(cli,git): cross-cutting change");
1292 info.run_pre_validation_checks(&scopes);
1293 assert!(info
1294 .pre_validated_checks
1295 .iter()
1296 .any(|c| c.contains("Scope validity verified")),);
1297 assert!(info
1298 .pre_validated_checks
1299 .iter()
1300 .any(|c| c.contains("multi-scope")),);
1301 }
1302
1303 #[test]
1304 fn pre_validation_multi_scope_with_spaces() {
1305 let scopes = vec![
1306 make_scope_def("cli", &["src/cli/**"]),
1307 make_scope_def("lib", &["src/lib/**"]),
1308 ];
1309 let mut info = make_commit_info_for_ai("feat(cli, lib): add something");
1310 info.run_pre_validation_checks(&scopes);
1311 assert!(
1312 info.pre_validated_checks
1313 .iter()
1314 .any(|c| c.contains("Scope validity verified")),
1315 "expected scope validity check for spaced multi-scope, got: {:?}",
1316 info.pre_validated_checks
1317 );
1318 assert!(
1319 info.pre_validated_checks
1320 .iter()
1321 .any(|c| c.contains("Scope format verified")),
1322 "single-space-after-comma multi-scope should pass the format check, got: {:?}",
1323 info.pre_validated_checks
1324 );
1325 }
1326
1327 #[test]
1328 fn pre_validation_multi_scope_double_space_not_format_verified() {
1329 let scopes = vec![
1330 make_scope_def("cli", &["src/cli/**"]),
1331 make_scope_def("lib", &["src/lib/**"]),
1332 ];
1333 let mut info = make_commit_info_for_ai("feat(cli, lib): add something");
1334 info.run_pre_validation_checks(&scopes);
1335 assert!(
1336 !info
1337 .pre_validated_checks
1338 .iter()
1339 .any(|c| c.contains("Scope format verified")),
1340 "double-space-after-comma must NOT be recorded as format-verified, got: {:?}",
1341 info.pre_validated_checks
1342 );
1343 }
1344
1345 #[test]
1346 fn pre_validation_multi_scope_space_before_comma_not_format_verified() {
1347 let scopes = vec![
1348 make_scope_def("cli", &["src/cli/**"]),
1349 make_scope_def("lib", &["src/lib/**"]),
1350 ];
1351 let mut info = make_commit_info_for_ai("feat(cli ,lib): add something");
1352 info.run_pre_validation_checks(&scopes);
1353 assert!(
1354 !info
1355 .pre_validated_checks
1356 .iter()
1357 .any(|c| c.contains("Scope format verified")),
1358 "space-before-comma must NOT be recorded as format-verified, got: {:?}",
1359 info.pre_validated_checks
1360 );
1361 }
1362
1363 #[test]
1364 fn pre_validation_invalid_scope_not_added() {
1365 let scopes = vec![make_scope_def("cli", &["src/cli/**"])];
1366 let mut info = make_commit_info_for_ai("feat(unknown): something");
1367 info.run_pre_validation_checks(&scopes);
1368 assert!(
1369 !info
1370 .pre_validated_checks
1371 .iter()
1372 .any(|c| c.contains("Scope validity verified")),
1373 "should not validate unknown scope"
1374 );
1375 }
1376
1377 #[test]
1378 fn pre_validation_no_scope_message() {
1379 let scopes = vec![make_scope_def("cli", &["src/cli/**"])];
1380 let mut info = make_commit_info_for_ai("feat: no scope here");
1381 info.run_pre_validation_checks(&scopes);
1382 assert!(info.pre_validated_checks.is_empty());
1383 }
1384
1385 mod prop {
1388 use super::*;
1389 use proptest::prelude::*;
1390
1391 fn arb_conventional_type() -> impl Strategy<Value = &'static str> {
1392 prop_oneof![
1393 Just("feat"),
1394 Just("fix"),
1395 Just("docs"),
1396 Just("style"),
1397 Just("refactor"),
1398 Just("test"),
1399 Just("chore"),
1400 Just("build"),
1401 Just("ci"),
1402 Just("perf"),
1403 ]
1404 }
1405
1406 proptest! {
1407 #[test]
1408 fn valid_conventional_format_extracts_type(
1409 ctype in arb_conventional_type(),
1410 scope in "[a-z]{1,10}",
1411 desc in "[a-zA-Z ]{1,50}",
1412 ) {
1413 let message = format!("{ctype}({scope}): {desc}");
1414 let result = CommitAnalysis::extract_conventional_type(&message);
1415 prop_assert_eq!(result, Some(ctype.to_string()));
1416 }
1417
1418 #[test]
1419 fn no_colon_returns_none(s in "[^:]{0,100}") {
1420 let result = CommitAnalysis::extract_conventional_type(&s);
1421 prop_assert!(result.is_none());
1422 }
1423
1424 #[test]
1425 fn count_specificity_nonnegative(pattern in ".*") {
1426 let _ = super::count_specificity(&pattern);
1428 }
1429
1430 #[test]
1431 fn count_specificity_bounded_by_segments(
1432 segments in proptest::collection::vec("[a-z*?]{1,10}", 1..6),
1433 ) {
1434 let pattern = segments.join("/");
1435 let result = super::count_specificity(&pattern);
1436 prop_assert!(result <= segments.len());
1437 }
1438 }
1439 }
1440
1441 #[test]
1444 fn from_commit_analysis_loads_diff_content() {
1445 let dir = tempfile::tempdir().unwrap();
1446 let diff_path = dir.path().join("test.diff");
1447 std::fs::write(&diff_path, "+added line\n-removed line\n").unwrap();
1448
1449 let analysis = CommitAnalysis {
1450 detected_type: "feat".to_string(),
1451 detected_scope: "cli".to_string(),
1452 proposed_message: "feat(cli): test".to_string(),
1453 file_changes: make_file_changes(&[]),
1454 diff_summary: "file.rs | 2 +-".to_string(),
1455 diff_file: diff_path.to_string_lossy().to_string(),
1456 file_diffs: Vec::new(),
1457 };
1458
1459 let ai = CommitAnalysisForAI::from_commit_analysis(analysis.clone()).unwrap();
1460 assert_eq!(ai.diff_content, "+added line\n-removed line\n");
1461 assert_eq!(ai.base.detected_type, analysis.detected_type);
1462 assert_eq!(ai.base.diff_file, analysis.diff_file);
1463 }
1464
1465 #[test]
1466 fn from_commit_info_wraps_and_loads_diff() {
1467 let dir = tempfile::tempdir().unwrap();
1468 let diff_path = dir.path().join("test.diff");
1469 std::fs::write(&diff_path, "diff content").unwrap();
1470
1471 let info = CommitInfo {
1472 hash: "a".repeat(40),
1473 author: "Test <test@example.com>".to_string(),
1474 date: chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:00+00:00").unwrap(),
1475 original_message: "feat(cli): add flag".to_string(),
1476 in_main_branches: vec!["origin/main".to_string()],
1477 analysis: CommitAnalysis {
1478 detected_type: "feat".to_string(),
1479 detected_scope: "cli".to_string(),
1480 proposed_message: "feat(cli): add flag".to_string(),
1481 file_changes: make_file_changes(&[("M", "src/cli.rs")]),
1482 diff_summary: "cli.rs | 1 +".to_string(),
1483 diff_file: diff_path.to_string_lossy().to_string(),
1484 file_diffs: Vec::new(),
1485 },
1486 };
1487
1488 let ai = CommitInfoForAI::from_commit_info(info).unwrap();
1489 assert_eq!(ai.base.analysis.diff_content, "diff content");
1490 assert_eq!(ai.base.hash, "a".repeat(40));
1491 assert_eq!(ai.base.original_message, "feat(cli): add flag");
1492 assert!(ai.pre_validated_checks.is_empty());
1493 }
1494
1495 #[test]
1496 fn file_diffs_default_empty_on_deserialize() {
1497 let yaml = r#"
1498detected_type: feat
1499detected_scope: cli
1500proposed_message: "feat(cli): test"
1501file_changes:
1502 total_files: 0
1503 files_added: 0
1504 files_deleted: 0
1505 file_list: []
1506diff_summary: ""
1507diff_file: "/tmp/test.diff"
1508"#;
1509 let analysis: CommitAnalysis = serde_yaml::from_str(yaml).unwrap();
1510 assert!(analysis.file_diffs.is_empty());
1511 }
1512
1513 #[test]
1514 fn file_diffs_omitted_when_empty_on_serialize() {
1515 let analysis = CommitAnalysis {
1516 detected_type: "feat".to_string(),
1517 detected_scope: "cli".to_string(),
1518 proposed_message: "feat(cli): test".to_string(),
1519 file_changes: make_file_changes(&[]),
1520 diff_summary: String::new(),
1521 diff_file: String::new(),
1522 file_diffs: Vec::new(),
1523 };
1524 let yaml = serde_yaml::to_string(&analysis).unwrap();
1525 assert!(!yaml.contains("file_diffs"));
1526 }
1527
1528 #[test]
1529 fn file_diffs_included_when_populated() {
1530 let analysis = CommitAnalysis {
1531 detected_type: "feat".to_string(),
1532 detected_scope: "cli".to_string(),
1533 proposed_message: "feat(cli): test".to_string(),
1534 file_changes: make_file_changes(&[]),
1535 diff_summary: String::new(),
1536 diff_file: String::new(),
1537 file_diffs: vec![FileDiffRef {
1538 path: "src/main.rs".to_string(),
1539 diff_file: "/tmp/diffs/abc/0000.diff".to_string(),
1540 byte_len: 42,
1541 }],
1542 };
1543 let yaml = serde_yaml::to_string(&analysis).unwrap();
1544 assert!(yaml.contains("file_diffs"));
1545 assert!(yaml.contains("src/main.rs"));
1546 assert!(yaml.contains("byte_len: 42"));
1547 }
1548
1549 fn make_commit_with_file_diffs(
1553 dir: &tempfile::TempDir,
1554 files: &[(&str, &str)], ) -> CommitInfo {
1556 let file_diffs: Vec<FileDiffRef> = files
1557 .iter()
1558 .enumerate()
1559 .map(|(i, (path, content))| {
1560 let diff_path = dir.path().join(format!("{i:04}.diff"));
1561 fs::write(&diff_path, content).unwrap();
1562 FileDiffRef {
1563 path: (*path).to_string(),
1564 diff_file: diff_path.to_string_lossy().to_string(),
1565 byte_len: content.len(),
1566 }
1567 })
1568 .collect();
1569
1570 CommitInfo {
1571 hash: "abc123def456abc123def456abc123def456abc1".to_string(),
1572 author: "Test Author".to_string(),
1573 date: DateTime::parse_from_rfc3339("2025-01-01T00:00:00+00:00").unwrap(),
1574 original_message: "feat(cli): original message".to_string(),
1575 in_main_branches: vec!["main".to_string()],
1576 analysis: CommitAnalysis {
1577 detected_type: "feat".to_string(),
1578 detected_scope: "cli".to_string(),
1579 proposed_message: "feat(cli): proposed".to_string(),
1580 file_changes: make_file_changes(
1581 &files.iter().map(|(p, _)| ("M", *p)).collect::<Vec<_>>(),
1582 ),
1583 diff_summary: " src/main.rs | 10 ++++\n src/lib.rs | 5 ++\n".to_string(),
1584 diff_file: dir.path().join("full.diff").to_string_lossy().to_string(),
1585 file_diffs,
1586 },
1587 }
1588 }
1589
1590 #[test]
1591 fn from_commit_info_partial_loads_subset() -> Result<()> {
1592 let dir = tempfile::tempdir()?;
1593 let commit = make_commit_with_file_diffs(
1594 &dir,
1595 &[
1596 ("src/main.rs", "diff --git a/src/main.rs\n+main\n"),
1597 ("src/lib.rs", "diff --git a/src/lib.rs\n+lib\n"),
1598 ("src/utils.rs", "diff --git a/src/utils.rs\n+utils\n"),
1599 ],
1600 );
1601
1602 let paths = vec!["src/main.rs".to_string(), "src/utils.rs".to_string()];
1603 let partial = CommitInfoForAI::from_commit_info_partial(commit, &paths)?;
1604
1605 assert!(partial.base.analysis.diff_content.contains("+main"));
1607 assert!(partial.base.analysis.diff_content.contains("+utils"));
1608 assert!(!partial.base.analysis.diff_content.contains("+lib"));
1609
1610 let ref_paths: Vec<&str> = partial
1612 .base
1613 .analysis
1614 .base
1615 .file_diffs
1616 .iter()
1617 .map(|r| r.path.as_str())
1618 .collect();
1619 assert_eq!(ref_paths, &["src/main.rs", "src/utils.rs"]);
1620
1621 Ok(())
1622 }
1623
1624 #[test]
1625 fn from_commit_info_partial_deduplicates_paths() -> Result<()> {
1626 let dir = tempfile::tempdir()?;
1627 let commit = make_commit_with_file_diffs(
1628 &dir,
1629 &[("src/main.rs", "diff --git a/src/main.rs\n+main\n")],
1630 );
1631
1632 let paths = vec!["src/main.rs".to_string(), "src/main.rs".to_string()];
1634 let partial = CommitInfoForAI::from_commit_info_partial(commit, &paths)?;
1635
1636 assert_eq!(
1638 partial.base.analysis.diff_content.matches("+main").count(),
1639 1
1640 );
1641
1642 Ok(())
1643 }
1644
1645 #[test]
1646 fn from_commit_info_partial_preserves_metadata() -> Result<()> {
1647 let dir = tempfile::tempdir()?;
1648 let commit = make_commit_with_file_diffs(
1649 &dir,
1650 &[("src/main.rs", "diff --git a/src/main.rs\n+main\n")],
1651 );
1652
1653 let original_hash = commit.hash.clone();
1654 let original_author = commit.author.clone();
1655 let original_date = commit.date;
1656 let original_message = commit.original_message.clone();
1657 let original_summary = commit.analysis.diff_summary.clone();
1658
1659 let paths = vec!["src/main.rs".to_string()];
1660 let partial = CommitInfoForAI::from_commit_info_partial(commit, &paths)?;
1661
1662 assert_eq!(partial.base.hash, original_hash);
1663 assert_eq!(partial.base.author, original_author);
1664 assert_eq!(partial.base.date, original_date);
1665 assert_eq!(partial.base.original_message, original_message);
1666 assert_eq!(partial.base.analysis.base.diff_summary, original_summary);
1667
1668 Ok(())
1669 }
1670
1671 #[test]
1674 fn with_overrides_uses_override_content() -> Result<()> {
1675 let dir = tempfile::tempdir()?;
1676 let commit = make_commit_with_file_diffs(
1677 &dir,
1678 &[(
1679 "src/big.rs",
1680 "diff --git a/src/big.rs\n+full-file-content\n",
1681 )],
1682 );
1683
1684 let paths = vec!["src/big.rs".to_string(), "src/big.rs".to_string()];
1685 let overrides = vec![
1686 Some("diff --git a/src/big.rs\n@@ -1,3 +1,4 @@\n+hunk1\n".to_string()),
1687 Some("diff --git a/src/big.rs\n@@ -10,3 +10,4 @@\n+hunk2\n".to_string()),
1688 ];
1689 let partial =
1690 CommitInfoForAI::from_commit_info_partial_with_overrides(commit, &paths, &overrides)?;
1691
1692 assert!(partial.base.analysis.diff_content.contains("+hunk1"));
1694 assert!(partial.base.analysis.diff_content.contains("+hunk2"));
1695 assert!(
1696 !partial
1697 .base
1698 .analysis
1699 .diff_content
1700 .contains("+full-file-content"),
1701 "should not contain full file content"
1702 );
1703
1704 Ok(())
1705 }
1706
1707 #[test]
1708 fn with_overrides_mixed_override_and_disk() -> Result<()> {
1709 let dir = tempfile::tempdir()?;
1710 let commit = make_commit_with_file_diffs(
1711 &dir,
1712 &[
1713 ("src/big.rs", "diff --git a/src/big.rs\n+big-full\n"),
1714 ("src/small.rs", "diff --git a/src/small.rs\n+small-disk\n"),
1715 ],
1716 );
1717
1718 let paths = vec!["src/big.rs".to_string(), "src/small.rs".to_string()];
1719 let overrides = vec![
1720 Some("diff --git a/src/big.rs\n@@ -1,3 +1,4 @@\n+big-hunk\n".to_string()),
1721 None, ];
1723 let partial =
1724 CommitInfoForAI::from_commit_info_partial_with_overrides(commit, &paths, &overrides)?;
1725
1726 assert!(partial.base.analysis.diff_content.contains("+big-hunk"));
1728 assert!(!partial.base.analysis.diff_content.contains("+big-full"));
1729 assert!(partial.base.analysis.diff_content.contains("+small-disk"));
1731
1732 let ref_paths: Vec<&str> = partial
1734 .base
1735 .analysis
1736 .base
1737 .file_diffs
1738 .iter()
1739 .map(|r| r.path.as_str())
1740 .collect();
1741 assert!(ref_paths.contains(&"src/big.rs"));
1742 assert!(ref_paths.contains(&"src/small.rs"));
1743
1744 Ok(())
1745 }
1746
1747 #[test]
1748 fn with_overrides_deduplicates_disk_reads() -> Result<()> {
1749 let dir = tempfile::tempdir()?;
1750 let commit = make_commit_with_file_diffs(
1751 &dir,
1752 &[("src/main.rs", "diff --git a/src/main.rs\n+main\n")],
1753 );
1754
1755 let paths = vec!["src/main.rs".to_string(), "src/main.rs".to_string()];
1757 let overrides = vec![None, None];
1758 let partial =
1759 CommitInfoForAI::from_commit_info_partial_with_overrides(commit, &paths, &overrides)?;
1760
1761 assert_eq!(
1763 partial.base.analysis.diff_content.matches("+main").count(),
1764 1
1765 );
1766
1767 Ok(())
1768 }
1769
1770 #[test]
1771 fn with_overrides_preserves_metadata() -> Result<()> {
1772 let dir = tempfile::tempdir()?;
1773 let commit = make_commit_with_file_diffs(
1774 &dir,
1775 &[("src/main.rs", "diff --git a/src/main.rs\n+main\n")],
1776 );
1777
1778 let original_hash = commit.hash.clone();
1779 let original_author = commit.author.clone();
1780 let original_message = commit.original_message.clone();
1781
1782 let paths = vec!["src/main.rs".to_string()];
1783 let overrides = vec![Some("+override-content\n".to_string())];
1784 let partial =
1785 CommitInfoForAI::from_commit_info_partial_with_overrides(commit, &paths, &overrides)?;
1786
1787 assert_eq!(partial.base.hash, original_hash);
1788 assert_eq!(partial.base.author, original_author);
1789 assert_eq!(partial.base.original_message, original_message);
1790 assert!(partial.pre_validated_checks.is_empty());
1791
1792 Ok(())
1793 }
1794
1795 fn infer_type(message: &str, files: &[(&str, &str)]) -> String {
1801 CommitAnalysis::detect_commit_type_from_message(message, &make_file_changes(files))
1802 }
1803
1804 #[test]
1805 fn commit_type_existing_conventional_wins() {
1806 assert_eq!(infer_type("feat(cli): add", &[("A", "src/x.rs")]), "feat");
1807 }
1808
1809 #[test]
1810 fn commit_type_test_files() {
1811 assert_eq!(infer_type("update", &[("A", "tests/foo_test.rs")]), "test");
1812 }
1813
1814 #[test]
1815 fn commit_type_docs_files() {
1816 assert_eq!(infer_type("update", &[("M", "README.md")]), "docs");
1817 }
1818
1819 #[test]
1820 fn commit_type_config_added_is_feat() {
1821 assert_eq!(infer_type("update", &[("A", "Cargo.toml")]), "feat");
1822 }
1823
1824 #[test]
1825 fn commit_type_config_modified_is_chore() {
1826 assert_eq!(infer_type("update", &[("M", "Cargo.toml")]), "chore");
1827 }
1828
1829 #[test]
1830 fn commit_type_added_source_is_feat() {
1831 assert_eq!(infer_type("add module", &[("A", "src/lib.rs")]), "feat");
1832 }
1833
1834 #[test]
1835 fn commit_type_fix_from_message() {
1836 assert_eq!(infer_type("fix the bug", &[("M", "src/lib.rs")]), "fix");
1838 }
1839
1840 #[test]
1841 fn commit_type_refactor_when_more_deletions() {
1842 assert_eq!(
1843 infer_type("cleanup", &[("D", "src/a.rs"), ("D", "src/b.rs")]),
1844 "refactor"
1845 );
1846 }
1847
1848 #[test]
1849 fn commit_type_default_chore() {
1850 assert_eq!(infer_type("update stuff", &[("M", "src/c.rs")]), "chore");
1851 }
1852
1853 #[test]
1856 fn proposed_message_with_scope() {
1857 let fc = make_file_changes(&[("A", "src/x.rs")]);
1858 let msg = CommitAnalysis::generate_proposed_message_from("do thing", "feat", "cli", &fc);
1859 assert_eq!(msg, "feat(cli): do thing");
1860 }
1861
1862 #[test]
1863 fn proposed_message_without_scope() {
1864 let fc = make_file_changes(&[("A", "src/x.rs")]);
1865 let msg = CommitAnalysis::generate_proposed_message_from("do thing", "feat", "", &fc);
1866 assert_eq!(msg, "feat: do thing");
1867 }
1868
1869 #[test]
1870 fn proposed_message_keeps_already_conventional() {
1871 let fc = make_file_changes(&[("A", "src/x.rs")]);
1872 let msg = CommitAnalysis::generate_proposed_message_from("fix(x): y", "feat", "cli", &fc);
1873 assert_eq!(msg, "fix(x): y");
1874 }
1875
1876 #[test]
1877 fn proposed_message_generates_description_when_empty() {
1878 let fc = make_file_changes(&[("A", "src/x.rs")]);
1879 let msg = CommitAnalysis::generate_proposed_message_from("", "chore", "", &fc);
1880 assert!(msg.starts_with("chore: "), "got: {msg}");
1881 }
1882
1883 #[test]
1890 fn analyze_file_changes_covers_delta_arms() -> Result<()> {
1891 let dir = tempfile::tempdir()?;
1892 let repo = git2::Repository::init(dir.path())?;
1893 let sig = git2::Signature::now("T", "t@e.com")?;
1894
1895 for (name, content) in [("a.txt", "a"), ("b.txt", "b")] {
1897 std::fs::write(dir.path().join(name), content)?;
1898 }
1899 let mut index = repo.index()?;
1900 index.add_path(std::path::Path::new("a.txt"))?;
1901 index.add_path(std::path::Path::new("b.txt"))?;
1902 index.write()?;
1903 let tree1 = repo.find_tree(index.write_tree()?)?;
1904 let c1 = repo.commit(Some("HEAD"), &sig, &sig, "init", &tree1, &[])?;
1905
1906 std::fs::remove_file(dir.path().join("a.txt"))?;
1908 std::fs::write(dir.path().join("b.txt"), "b2")?;
1909 std::fs::write(dir.path().join("c.txt"), "c")?;
1910 let mut index = repo.index()?;
1911 index.remove_path(std::path::Path::new("a.txt"))?;
1912 index.add_path(std::path::Path::new("b.txt"))?;
1913 index.add_path(std::path::Path::new("c.txt"))?;
1914 index.write()?;
1915 let tree2 = repo.find_tree(index.write_tree()?)?;
1916 let parent = repo.find_commit(c1)?;
1917 let c2 = repo.commit(Some("HEAD"), &sig, &sig, "change", &tree2, &[&parent])?;
1918
1919 let commit2 = repo.find_commit(c2)?;
1920 let changes = CommitAnalysis::analyze_file_changes(&repo, &commit2)?;
1921 assert_eq!(changes.files_added, 1, "c.txt added");
1922 assert_eq!(changes.files_deleted, 1, "a.txt deleted");
1923 Ok(())
1924 }
1925}