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