1use std::path::{Path, PathBuf};
6use std::process::Command;
7use std::sync::Arc;
8use std::sync::atomic::{AtomicUsize, Ordering};
9
10use rayon::prelude::*;
11
12#[derive(Debug)]
14pub struct RepoError(pub String);
15
16impl std::fmt::Display for RepoError {
17 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18 write!(f, "{}", self.0)
19 }
20}
21
22impl std::error::Error for RepoError {}
23
24impl From<std::io::Error> for RepoError {
25 fn from(e: std::io::Error) -> Self {
26 Self(format!("IO error: {e}"))
27 }
28}
29
30#[must_use]
34pub fn repos_dir() -> Option<PathBuf> {
35 std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".enya").join("repos"))
36}
37
38#[must_use]
50pub fn repo_name_from_url(url: &str) -> String {
51 let url = url.strip_suffix(".git").unwrap_or(url);
53
54 url.rsplit('/')
56 .next()
57 .or_else(|| url.rsplit(':').next())
58 .unwrap_or("repo")
59 .to_string()
60}
61
62pub fn clone_repo(url: &str) -> Result<PathBuf, RepoError> {
71 let Some(base_dir) = repos_dir() else {
72 return Err(RepoError("Could not determine home directory".to_string()));
73 };
74
75 std::fs::create_dir_all(&base_dir)?;
77
78 let repo_name = repo_name_from_url(url);
79 let repo_path = base_dir.join(&repo_name);
80
81 if repo_path.exists() {
83 unshallow_if_needed(&repo_path)?;
84 return Ok(repo_path);
85 }
86
87 let output = Command::new("git")
89 .args(["clone", url])
90 .arg(&repo_path)
91 .output()
92 .map_err(|e| RepoError(format!("Failed to run git clone: {e}")))?;
93
94 if !output.status.success() {
95 let stderr = String::from_utf8_lossy(&output.stderr);
96 return Err(RepoError(format!("git clone failed: {stderr}")));
97 }
98
99 Ok(repo_path)
100}
101
102pub fn fetch_updates(repo_path: &Path) -> Result<bool, RepoError> {
110 let head_before = get_head_commit(repo_path)?;
112
113 let output = Command::new("git")
115 .args(["fetch", "origin"])
116 .current_dir(repo_path)
117 .output()
118 .map_err(|e| RepoError(format!("Failed to run git fetch: {e}")))?;
119
120 if !output.status.success() {
121 let stderr = String::from_utf8_lossy(&output.stderr);
122 return Err(RepoError(format!("git fetch failed: {stderr}")));
123 }
124
125 let output = Command::new("git")
127 .args(["pull", "--ff-only"])
128 .current_dir(repo_path)
129 .output()
130 .map_err(|e| RepoError(format!("Failed to run git pull: {e}")))?;
131
132 if !output.status.success() {
133 let stderr = String::from_utf8_lossy(&output.stderr);
134 return Err(RepoError(format!("git pull failed: {stderr}")));
135 }
136
137 let head_after = get_head_commit(repo_path)?;
139 Ok(head_before != head_after)
140}
141
142fn unshallow_if_needed(repo_path: &Path) -> Result<(), RepoError> {
147 let output = Command::new("git")
149 .args(["rev-parse", "--is-shallow-repository"])
150 .current_dir(repo_path)
151 .output()
152 .map_err(|e| RepoError(format!("Failed to check if shallow: {e}")))?;
153
154 let is_shallow = String::from_utf8_lossy(&output.stdout)
155 .trim()
156 .eq_ignore_ascii_case("true");
157
158 if !is_shallow {
159 return Ok(());
160 }
161
162 log::info!(
163 "Converting shallow clone to full history: {}",
164 repo_path.display()
165 );
166
167 let output = Command::new("git")
169 .args(["fetch", "--unshallow"])
170 .current_dir(repo_path)
171 .output()
172 .map_err(|e| RepoError(format!("Failed to unshallow repository: {e}")))?;
173
174 if !output.status.success() {
175 let stderr = String::from_utf8_lossy(&output.stderr);
176 return Err(RepoError(format!("git fetch --unshallow failed: {stderr}")));
177 }
178
179 log::info!("Successfully unshallowed repository");
180 Ok(())
181}
182
183pub fn get_head_commit(repo_path: &Path) -> Result<String, RepoError> {
189 let output = Command::new("git")
190 .args(["rev-parse", "HEAD"])
191 .current_dir(repo_path)
192 .output()
193 .map_err(|e| RepoError(format!("Failed to run git rev-parse: {e}")))?;
194
195 if !output.status.success() {
196 let stderr = String::from_utf8_lossy(&output.stderr);
197 return Err(RepoError(format!("git rev-parse failed: {stderr}")));
198 }
199
200 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
201}
202
203pub fn get_head_commit_message(repo_path: &Path) -> Result<String, RepoError> {
209 let output = Command::new("git")
210 .args(["log", "-1", "--format=%s"])
211 .current_dir(repo_path)
212 .output()
213 .map_err(|e| RepoError(format!("Failed to run git log: {e}")))?;
214
215 if !output.status.success() {
216 let stderr = String::from_utf8_lossy(&output.stderr);
217 return Err(RepoError(format!("git log failed: {stderr}")));
218 }
219
220 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, Default)]
225pub struct CommitInfo {
226 pub hash: String,
228 pub timestamp: i64,
230 pub message: String,
232 pub files_changed: Vec<String>,
234 pub diff: String,
236 pub semantics: DiffSemantics,
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Default)]
242pub struct DiffSemantics {
243 pub functions_added: Vec<String>,
245 pub functions_removed: Vec<String>,
247 pub functions_modified: Vec<String>,
249 pub metrics_added: Vec<String>,
251 pub metrics_removed: Vec<String>,
253 pub imports_added: Vec<String>,
255 pub imports_removed: Vec<String>,
257}
258
259pub fn fetch_commit_history(
268 repo_path: &Path,
269 start_secs: i64,
270 end_secs: i64,
271) -> Result<Vec<CommitInfo>, RepoError> {
272 let output = Command::new("git")
273 .args([
274 "log",
275 &format!("--after=@{start_secs}"),
276 &format!("--before=@{end_secs}"),
277 "--format=%H|%ct|%s",
278 ])
279 .current_dir(repo_path)
280 .output()
281 .map_err(|e| RepoError(format!("Failed to run git log: {e}")))?;
282
283 if !output.status.success() {
284 let stderr = String::from_utf8_lossy(&output.stderr);
285 return Err(RepoError(format!("git log failed: {stderr}")));
286 }
287
288 let stdout = String::from_utf8_lossy(&output.stdout);
289 parse_git_log_output(&stdout)
290}
291
292pub fn count_commits(repo_path: &Path) -> Result<usize, RepoError> {
300 let output = Command::new("git")
301 .args(["rev-list", "--count", "HEAD"])
302 .current_dir(repo_path)
303 .output()
304 .map_err(|e| RepoError(format!("Failed to run git rev-list --count: {e}")))?;
305
306 if !output.status.success() {
307 let stderr = String::from_utf8_lossy(&output.stderr);
308 return Err(RepoError(format!("git rev-list --count failed: {stderr}")));
309 }
310
311 let count_str = String::from_utf8_lossy(&output.stdout);
312 count_str
313 .trim()
314 .parse::<usize>()
315 .map_err(|e| RepoError(format!("Failed to parse commit count: {e}")))
316}
317
318pub fn fetch_all_commits(repo_path: &Path) -> Result<Vec<CommitInfo>, RepoError> {
328 let output = Command::new("git")
331 .args(["log", "--format=%H|%ct|%s", "--name-only"])
332 .current_dir(repo_path)
333 .output()
334 .map_err(|e| RepoError(format!("Failed to run git log: {e}")))?;
335
336 if !output.status.success() {
337 let stderr = String::from_utf8_lossy(&output.stderr);
338 return Err(RepoError(format!("git log failed: {stderr}")));
339 }
340
341 let stdout = String::from_utf8_lossy(&output.stdout);
342 Ok(parse_git_log_with_files(&stdout))
343}
344
345pub fn fetch_recent_commits(repo_path: &Path, limit: usize) -> Result<Vec<CommitInfo>, RepoError> {
355 let output = Command::new("git")
358 .args([
359 "log",
360 &format!("-{limit}"),
361 "--format=%H|%ct|%s",
362 "--name-only",
363 ])
364 .current_dir(repo_path)
365 .output()
366 .map_err(|e| RepoError(format!("Failed to run git log: {e}")))?;
367
368 if !output.status.success() {
369 let stderr = String::from_utf8_lossy(&output.stderr);
370 return Err(RepoError(format!("git log failed: {stderr}")));
371 }
372
373 let stdout = String::from_utf8_lossy(&output.stdout);
374 Ok(parse_git_log_with_files(&stdout))
375}
376
377const MAX_DIFF_SIZE: usize = 64 * 1024;
380
381pub fn fetch_commit_diff(repo_path: &Path, commit_hash: &str) -> Result<String, RepoError> {
390 let output = Command::new("git")
391 .args([
392 "show",
393 commit_hash,
394 "--format=", "--unified=3", "-p", ])
398 .current_dir(repo_path)
399 .output()
400 .map_err(|e| RepoError(format!("Failed to run git show: {e}")))?;
401
402 if !output.status.success() {
403 let stderr = String::from_utf8_lossy(&output.stderr);
404 return Err(RepoError(format!("git show failed: {stderr}")));
405 }
406
407 let diff = String::from_utf8_lossy(&output.stdout);
408
409 if diff.len() > MAX_DIFF_SIZE {
411 Ok(format!(
412 "{}\n\n[... diff truncated, {} bytes total ...]",
413 &diff[..MAX_DIFF_SIZE],
414 diff.len()
415 ))
416 } else {
417 Ok(diff.into_owned())
418 }
419}
420
421pub fn fetch_recent_commits_with_diffs(
430 repo_path: &Path,
431 limit: usize,
432) -> Result<Vec<CommitInfo>, RepoError> {
433 let mut commits = fetch_recent_commits(repo_path, limit)?;
435
436 for commit in &mut commits {
438 match fetch_commit_diff(repo_path, &commit.hash) {
439 Ok(diff) => {
440 commit.semantics = crate::diff::extract_semantics(&diff);
442 commit.diff = diff;
443 }
444 Err(e) => {
445 log::warn!("Failed to fetch diff for {}: {e}", &commit.hash[..8]);
446 }
448 }
449 }
450
451 Ok(commits)
452}
453
454pub type ProgressCallback = Box<dyn Fn(usize, usize, Option<&str>) + Send + Sync>;
461
462const COMMIT_DELIMITER: &str = "\n__ENYA_COMMIT_BOUNDARY__\n";
464
465pub fn fetch_all_commits_with_diffs_batch(
491 repo_path: &Path,
492 since_commit: Option<&str>,
493 progress: Option<&ProgressCallback>,
494) -> Result<Vec<CommitInfo>, RepoError> {
495 let format_arg = format!("{}%H|%ct|%s", COMMIT_DELIMITER.trim_start());
498
499 let mut args = vec![
500 "log".to_string(),
501 format!("--format={format_arg}"),
502 "-p".to_string(), "--unified=3".to_string(), "--no-merges".to_string(), "--first-parent".to_string(), ];
507 if let Some(since) = since_commit {
511 args.push(format!("{since}..HEAD"));
512 }
513
514 log::info!(
515 "Fetching commits with diffs in batch mode from: {}{}",
516 repo_path.display(),
517 since_commit.map_or(String::new(), |s| format!(
518 " (since {})",
519 &s[..7.min(s.len())]
520 ))
521 );
522
523 let output = Command::new("git")
524 .args(&args)
525 .current_dir(repo_path)
526 .output()
527 .map_err(|e| RepoError(format!("Failed to run git log -p: {e}")))?;
528
529 if !output.status.success() {
530 let stderr = String::from_utf8_lossy(&output.stderr);
531 return Err(RepoError(format!("git log -p failed: {stderr}")));
532 }
533
534 let stdout = String::from_utf8_lossy(&output.stdout);
535
536 let mut commits = parse_batch_log_output(&stdout);
538 let total = commits.len();
539
540 if total == 0 {
541 log::info!("No commits to process");
542 return Ok(commits);
543 }
544
545 log::info!("Parsing diffs and extracting semantics for {total} commits");
546
547 let processed = Arc::new(AtomicUsize::new(0));
549
550 commits.par_iter_mut().for_each(|commit| {
551 commit.semantics = crate::diff::extract_semantics(&commit.diff);
553
554 if commit.diff.len() > MAX_DIFF_SIZE {
556 let truncated_diff = format!(
557 "{}\n\n[... diff truncated, {} bytes total ...]",
558 &commit.diff[..MAX_DIFF_SIZE],
559 commit.diff.len()
560 );
561 commit.diff = truncated_diff;
562 }
563
564 let count = processed.fetch_add(1, Ordering::Relaxed) + 1;
566
567 if let Some(ref callback) = progress {
569 if count % 50 == 0 || count == total {
570 let short_hash = &commit.hash[..7.min(commit.hash.len())];
571 let first_line = commit.message.lines().next().unwrap_or("");
572 let truncated = if first_line.chars().count() > 35 {
574 let boundary = first_line
575 .char_indices()
576 .nth(32)
577 .map_or(first_line.len(), |(i, _)| i);
578 format!("{}...", &first_line[..boundary])
579 } else {
580 first_line.to_string()
581 };
582 let item_desc = format!("{short_hash} {truncated}");
583 callback(count, total, Some(&item_desc));
584 }
585 }
586 });
587
588 log::info!(
589 "Completed batch diff fetching for {} commits",
590 commits.len()
591 );
592
593 Ok(commits)
594}
595
596fn parse_batch_log_output(output: &str) -> Vec<CommitInfo> {
601 let mut commits = Vec::new();
602
603 for section in output.split(COMMIT_DELIMITER.trim()) {
605 let section = section.trim();
606 if section.is_empty() {
607 continue;
608 }
609
610 let mut lines = section.lines();
612 let Some(header) = lines.next() else {
613 continue;
614 };
615
616 let mut parts = header.splitn(3, '|');
618 let Some(hash) = parts.next() else { continue };
619 let Some(timestamp_str) = parts.next() else {
620 continue;
621 };
622 let message = parts.next().unwrap_or("");
623
624 let hash = hash.trim();
626 if hash.len() < 40 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
627 continue;
628 }
629
630 let timestamp = timestamp_str.trim().parse::<i64>().unwrap_or(0);
631
632 let diff: String = lines.collect::<Vec<_>>().join("\n");
634
635 let files_changed = extract_files_from_diff(&diff);
637
638 commits.push(CommitInfo {
639 hash: hash.to_string(),
640 timestamp,
641 message: message.to_string(),
642 files_changed,
643 diff,
644 semantics: DiffSemantics::default(),
645 });
646 }
647
648 commits
649}
650
651fn extract_files_from_diff(diff: &str) -> Vec<String> {
653 let mut files = Vec::new();
654
655 for line in diff.lines() {
656 if let Some(rest) = line.strip_prefix("diff --git a/") {
658 if let Some(space_idx) = rest.find(" b/") {
660 let file_path = &rest[..space_idx];
661 if !files.contains(&file_path.to_string()) {
662 files.push(file_path.to_string());
663 }
664 }
665 }
666 }
667
668 files
669}
670
671#[deprecated(
693 since = "0.1.0",
694 note = "Use fetch_all_commits_with_diffs_batch for better performance"
695)]
696pub fn fetch_all_commits_with_diffs_parallel(
697 repo_path: &Path,
698 progress: Option<&ProgressCallback>,
699) -> Result<Vec<CommitInfo>, RepoError> {
700 let mut commits = fetch_all_commits(repo_path)?;
702
703 if commits.is_empty() {
704 return Ok(commits);
705 }
706
707 let total = commits.len();
708 log::info!(
709 "Fetching diffs for {} commits in parallel from: {}",
710 total,
711 repo_path.display()
712 );
713
714 let processed = Arc::new(AtomicUsize::new(0));
716
717 let repo_path = repo_path.to_path_buf();
719 commits.par_iter_mut().for_each(|commit| {
720 match fetch_commit_diff(&repo_path, &commit.hash) {
722 Ok(diff) => {
723 commit.semantics = crate::diff::extract_semantics(&diff);
724 commit.diff = diff;
725 }
726 Err(e) => {
727 log::warn!("Failed to fetch diff for {}: {e}", &commit.hash[..8]);
728 }
729 }
730
731 let count = processed.fetch_add(1, Ordering::Relaxed) + 1;
733
734 if let Some(ref callback) = progress {
736 if count % 50 == 0 || count == total {
737 let short_hash = &commit.hash[..7.min(commit.hash.len())];
738 let first_line = commit.message.lines().next().unwrap_or("");
739 let truncated = if first_line.chars().count() > 35 {
741 let boundary = first_line
742 .char_indices()
743 .nth(32)
744 .map_or(first_line.len(), |(i, _)| i);
745 format!("{}...", &first_line[..boundary])
746 } else {
747 first_line.to_string()
748 };
749 let item_desc = format!("{short_hash} {truncated}");
750 callback(count, total, Some(&item_desc));
751 }
752 }
753 });
754
755 log::info!(
756 "Completed parallel diff fetching for {} commits",
757 commits.len()
758 );
759
760 Ok(commits)
761}
762
763fn parse_git_log_output(output: &str) -> Result<Vec<CommitInfo>, RepoError> {
765 let mut commits = Vec::new();
766
767 for line in output.lines() {
768 let line = line.trim();
769 if line.is_empty() {
770 continue;
771 }
772
773 let mut parts = line.splitn(3, '|');
775
776 let hash = parts
777 .next()
778 .ok_or_else(|| RepoError("Missing hash in git log output".to_string()))?
779 .to_string();
780
781 let timestamp_str = parts
782 .next()
783 .ok_or_else(|| RepoError("Missing timestamp in git log output".to_string()))?;
784
785 let timestamp = timestamp_str
786 .parse::<i64>()
787 .map_err(|e| RepoError(format!("Invalid timestamp '{timestamp_str}': {e}")))?;
788
789 let message = parts.next().unwrap_or("").to_string();
790
791 commits.push(CommitInfo {
792 hash,
793 timestamp,
794 message,
795 ..Default::default()
796 });
797 }
798
799 Ok(commits)
800}
801
802fn parse_git_log_with_files(output: &str) -> Vec<CommitInfo> {
807 let mut commits = Vec::new();
808 let mut current_commit: Option<CommitInfo> = None;
809
810 for line in output.lines() {
811 let line = line.trim();
812
813 if line.contains('|') && line.len() >= 40 {
815 let mut parts = line.splitn(3, '|');
817
818 let hash = parts.next().unwrap_or("");
819 let timestamp_str = parts.next().unwrap_or("");
820 let message = parts.next().unwrap_or("");
821
822 if hash.len() >= 40 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
824 if let Some(commit) = current_commit.take() {
826 commits.push(commit);
827 }
828
829 let timestamp = timestamp_str.parse::<i64>().unwrap_or(0);
830
831 current_commit = Some(CommitInfo {
832 hash: hash.to_string(),
833 timestamp,
834 message: message.to_string(),
835 ..Default::default()
836 });
837 continue;
838 }
839 }
840
841 if line.is_empty() {
843 continue;
844 }
845
846 if let Some(ref mut commit) = current_commit {
848 commit.files_changed.push(line.to_string());
849 }
850 }
851
852 if let Some(commit) = current_commit {
854 commits.push(commit);
855 }
856
857 commits
858}
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863
864 #[test]
865 fn test_repo_name_from_url() {
866 assert_eq!(
867 repo_name_from_url("https://github.com/org/repo.git"),
868 "repo"
869 );
870 assert_eq!(repo_name_from_url("https://github.com/org/repo"), "repo");
871 assert_eq!(
872 repo_name_from_url("git@github.com:org/my-repo.git"),
873 "my-repo"
874 );
875 assert_eq!(
876 repo_name_from_url("https://gitlab.com/group/subgroup/project.git"),
877 "project"
878 );
879 }
880
881 #[test]
882 fn test_parse_git_log_output_single_commit() {
883 let output = "abc123def456|1700000000|Initial commit\n";
884 let commits = parse_git_log_output(output).expect("should parse");
885 assert_eq!(commits.len(), 1);
886 assert_eq!(commits[0].hash, "abc123def456");
887 assert_eq!(commits[0].timestamp, 1_700_000_000);
888 assert_eq!(commits[0].message, "Initial commit");
889 }
890
891 #[test]
892 fn test_parse_git_log_output_multiple_commits() {
893 let output = "\
894abc123|1700000000|First commit
895def456|1700001000|Second commit
896ghi789|1700002000|Third commit
897";
898 let commits = parse_git_log_output(output).expect("should parse");
899 assert_eq!(commits.len(), 3);
900 assert_eq!(commits[0].hash, "abc123");
901 assert_eq!(commits[1].hash, "def456");
902 assert_eq!(commits[2].hash, "ghi789");
903 }
904
905 #[test]
906 fn test_parse_git_log_output_message_with_pipes() {
907 let output = "abc123|1700000000|Fix bug | add feature | cleanup\n";
908 let commits = parse_git_log_output(output).expect("should parse");
909 assert_eq!(commits.len(), 1);
910 assert_eq!(commits[0].message, "Fix bug | add feature | cleanup");
911 }
912
913 #[test]
914 fn test_parse_git_log_output_empty() {
915 let output = "";
916 let commits = parse_git_log_output(output).expect("should parse");
917 assert!(commits.is_empty());
918 }
919
920 #[test]
921 fn test_parse_git_log_output_whitespace_only() {
922 let output = " \n \n ";
923 let commits = parse_git_log_output(output).expect("should parse");
924 assert!(commits.is_empty());
925 }
926
927 #[test]
928 fn test_parse_git_log_output_empty_message() {
929 let output = "abc123|1700000000|\n";
930 let commits = parse_git_log_output(output).expect("should parse");
931 assert_eq!(commits.len(), 1);
932 assert_eq!(commits[0].message, "");
933 }
934
935 #[test]
936 fn test_commit_info_equality() {
937 let c1 = CommitInfo {
938 hash: "abc".to_string(),
939 timestamp: 1000,
940 message: "test".to_string(),
941 files_changed: vec!["file.rs".to_string()],
942 ..Default::default()
943 };
944 let c2 = CommitInfo {
945 hash: "abc".to_string(),
946 timestamp: 1000,
947 message: "test".to_string(),
948 files_changed: vec!["file.rs".to_string()],
949 ..Default::default()
950 };
951 assert_eq!(c1, c2);
952 }
953
954 #[test]
955 fn test_commit_info_clone() {
956 let c1 = CommitInfo {
957 hash: "abc".to_string(),
958 timestamp: 1000,
959 message: "test".to_string(),
960 files_changed: vec!["file.rs".to_string()],
961 ..Default::default()
962 };
963 let c2 = c1.clone();
964 assert_eq!(c1, c2);
965 }
966
967 #[test]
968 fn test_parse_git_log_with_files() {
969 let output = "\
971abc123def456789012345678901234567890abcd|1700000000|Add executor
972
973src/executor.rs
974src/lib.rs
975
976def456789012345678901234567890abcdef12ab|1700001000|Fix bug
977
978src/main.rs
979";
980 let commits = parse_git_log_with_files(output);
981 assert_eq!(commits.len(), 2);
982 assert_eq!(commits[0].hash, "abc123def456789012345678901234567890abcd");
983 assert_eq!(commits[0].message, "Add executor");
984 assert_eq!(
985 commits[0].files_changed,
986 vec!["src/executor.rs", "src/lib.rs"]
987 );
988 assert_eq!(commits[1].hash, "def456789012345678901234567890abcdef12ab");
989 assert_eq!(commits[1].message, "Fix bug");
990 assert_eq!(commits[1].files_changed, vec!["src/main.rs"]);
991 }
992
993 #[test]
994 fn test_parse_git_log_with_files_empty() {
995 let output = "";
996 let commits = parse_git_log_with_files(output);
997 assert!(commits.is_empty());
998 }
999
1000 #[test]
1001 fn test_parse_git_log_with_files_no_files() {
1002 let output = "abc123def456789012345678901234567890abcd|1700000000|Empty commit\n";
1004 let commits = parse_git_log_with_files(output);
1005 assert_eq!(commits.len(), 1);
1006 assert!(commits[0].files_changed.is_empty());
1007 }
1008
1009 #[test]
1010 fn test_parse_batch_log_output_with_diff_content() {
1011 let output = "__ENYA_COMMIT_BOUNDARY__
1015abc123def456789012345678901234567890abcd|1700000000|Add new feature
1016
1017diff --git a/src/main.rs b/src/main.rs
1018index 1234567..abcdefg 100644
1019--- a/src/main.rs
1020+++ b/src/main.rs
1021@@ -1,3 +1,5 @@
1022 fn main() {
1023+ println!(\"Hello, world!\");
1024+ do_something();
1025 }
1026diff --git a/src/lib.rs b/src/lib.rs
1027index 2345678..bcdefgh 100644
1028--- a/src/lib.rs
1029+++ b/src/lib.rs
1030@@ -1 +1,3 @@
1031+pub fn do_something() {
1032+}
1033";
1034 let commits = parse_batch_log_output(output);
1035
1036 assert_eq!(commits.len(), 1);
1037 let commit = &commits[0];
1038
1039 assert_eq!(commit.hash, "abc123def456789012345678901234567890abcd");
1041 assert_eq!(commit.timestamp, 1_700_000_000);
1042 assert_eq!(commit.message, "Add new feature");
1043
1044 assert!(
1048 commit.diff.contains(r#"println!("Hello, world!");"#),
1049 "Diff should contain actual code changes, not just file names. Got: {}",
1050 &commit.diff[..200.min(commit.diff.len())]
1051 );
1052 assert!(
1053 commit.diff.contains("pub fn do_something()"),
1054 "Diff should contain function definition"
1055 );
1056 assert!(
1057 commit.diff.contains("@@ -1,3 +1,5 @@"),
1058 "Diff should contain hunk headers"
1059 );
1060
1061 assert_eq!(commit.files_changed.len(), 2);
1063 assert!(commit.files_changed.contains(&"src/main.rs".to_string()));
1064 assert!(commit.files_changed.contains(&"src/lib.rs".to_string()));
1065 }
1066
1067 #[test]
1068 fn test_parse_batch_log_output_multiple_commits() {
1069 let output = "__ENYA_COMMIT_BOUNDARY__
1070abc123def456789012345678901234567890abcd|1700000000|First commit
1071
1072diff --git a/file1.rs b/file1.rs
1073--- a/file1.rs
1074+++ b/file1.rs
1075@@ -1 +1,2 @@
1076+// added line
1077__ENYA_COMMIT_BOUNDARY__
1078def456789012345678901234567890abcdef1234|1700001000|Second commit
1079
1080diff --git a/file2.rs b/file2.rs
1081--- a/file2.rs
1082+++ b/file2.rs
1083@@ -1 +1,2 @@
1084+// another line
1085";
1086 let commits = parse_batch_log_output(output);
1087
1088 assert_eq!(commits.len(), 2);
1089 assert_eq!(commits[0].hash, "abc123def456789012345678901234567890abcd");
1090 assert_eq!(commits[0].message, "First commit");
1091 assert!(commits[0].diff.contains("// added line"));
1092
1093 assert_eq!(commits[1].hash, "def456789012345678901234567890abcdef1234");
1094 assert_eq!(commits[1].message, "Second commit");
1095 assert!(commits[1].diff.contains("// another line"));
1096 }
1097
1098 #[test]
1099 fn test_parse_batch_log_output_empty() {
1100 let commits = parse_batch_log_output("");
1101 assert!(commits.is_empty());
1102 }
1103
1104 #[test]
1105 fn test_extract_files_from_diff() {
1106 let diff = "diff --git a/src/main.rs b/src/main.rs
1107index 1234567..abcdefg 100644
1108--- a/src/main.rs
1109+++ b/src/main.rs
1110@@ -1,3 +1,5 @@
1111 fn main() {}
1112diff --git a/src/lib.rs b/src/lib.rs
1113--- a/src/lib.rs
1114+++ b/src/lib.rs
1115@@ -1 +1,2 @@
1116+pub fn foo() {}
1117";
1118 let files = extract_files_from_diff(diff);
1119 assert_eq!(files.len(), 2);
1120 assert_eq!(files[0], "src/main.rs");
1121 assert_eq!(files[1], "src/lib.rs");
1122 }
1123
1124 #[test]
1125 fn test_extract_files_from_diff_no_duplicates() {
1126 let diff = "diff --git a/src/main.rs b/src/main.rs
1128--- a/src/main.rs
1129+++ b/src/main.rs
1130@@ -1,3 +1,5 @@
1131 fn main() {}
1132";
1133 let files = extract_files_from_diff(diff);
1134 assert_eq!(files.len(), 1);
1135 assert_eq!(files[0], "src/main.rs");
1136 }
1137}