1use crate::types::Hash;
2use crate::utils::git;
3use crate::{Repository, Result};
4use chrono::{DateTime, Utc};
5use std::fmt;
6use std::path::PathBuf;
7
8const GIT_LOG_FORMAT: &str = "--pretty=format:%H|%an|%ae|%at|%cn|%ce|%ct|%P|%s|%b";
11
12const DATE_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Author {
17 pub name: String,
18 pub email: String,
19 pub timestamp: DateTime<Utc>,
20}
21
22impl fmt::Display for Author {
23 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24 write!(f, "{} <{}>", self.name, self.email)
25 }
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct CommitMessage {
30 pub subject: String,
31 pub body: Option<String>,
32}
33
34impl CommitMessage {
35 pub fn new(subject: String, body: Option<String>) -> Self {
36 Self { subject, body }
37 }
38
39 pub fn full(&self) -> String {
41 match &self.body {
42 Some(body) => format!("{}\n\n{}", self.subject, body),
43 None => self.subject.clone(),
44 }
45 }
46
47 pub fn is_empty(&self) -> bool {
49 self.subject.is_empty()
50 }
51}
52
53impl fmt::Display for CommitMessage {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 write!(f, "{}", self.full())
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct Commit {
61 pub hash: Hash,
62 pub author: Author,
63 pub committer: Author,
64 pub message: CommitMessage,
65 pub timestamp: DateTime<Utc>,
66 pub parents: Box<[Hash]>,
67}
68
69impl Commit {
70 pub fn is_merge(&self) -> bool {
72 self.parents.len() > 1
73 }
74
75 pub fn is_root(&self) -> bool {
77 self.parents.is_empty()
78 }
79
80 pub fn main_parent(&self) -> Option<&Hash> {
82 self.parents.first()
83 }
84
85 pub fn is_authored_by(&self, author: &str) -> bool {
87 self.author.name.contains(author) || self.author.email.contains(author)
88 }
89
90 pub fn message_contains(&self, text: &str) -> bool {
92 self.message
93 .subject
94 .to_lowercase()
95 .contains(&text.to_lowercase())
96 || self
97 .message
98 .body
99 .as_ref()
100 .is_some_and(|body| body.to_lowercase().contains(&text.to_lowercase()))
101 }
102}
103
104impl fmt::Display for Commit {
105 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106 write!(
107 f,
108 "{} {} by {} at {}",
109 self.hash.short(),
110 self.message.subject,
111 self.author.name,
112 self.timestamp.format("%Y-%m-%d %H:%M:%S UTC")
113 )
114 }
115}
116
117#[derive(Debug, Clone, PartialEq)]
118pub struct CommitLog {
119 commits: Box<[Commit]>,
120}
121
122impl CommitLog {
123 pub fn new(commits: Vec<Commit>) -> Self {
125 Self {
126 commits: commits.into_boxed_slice(),
127 }
128 }
129
130 pub fn all(&self) -> &[Commit] {
132 &self.commits
133 }
134
135 pub fn iter(&self) -> impl Iterator<Item = &Commit> {
137 self.commits.iter()
138 }
139
140 pub fn by_author(&self, author: &str) -> impl Iterator<Item = &Commit> {
142 self.commits
143 .iter()
144 .filter(move |c| c.is_authored_by(author))
145 }
146
147 pub fn since(&self, date: DateTime<Utc>) -> impl Iterator<Item = &Commit> {
149 self.commits.iter().filter(move |c| c.timestamp >= date)
150 }
151
152 pub fn until(&self, date: DateTime<Utc>) -> impl Iterator<Item = &Commit> {
154 self.commits.iter().filter(move |c| c.timestamp <= date)
155 }
156
157 pub fn with_message_containing(&self, text: &str) -> impl Iterator<Item = &Commit> {
159 let text = text.to_lowercase();
160 self.commits
161 .iter()
162 .filter(move |c| c.message_contains(&text))
163 }
164
165 pub fn merges_only(&self) -> impl Iterator<Item = &Commit> {
167 self.commits.iter().filter(|c| c.is_merge())
168 }
169
170 pub fn no_merges(&self) -> impl Iterator<Item = &Commit> {
172 self.commits.iter().filter(|c| !c.is_merge())
173 }
174
175 pub fn find_by_hash(&self, hash: &Hash) -> Option<&Commit> {
177 self.commits.iter().find(|c| &c.hash == hash)
178 }
179
180 pub fn find_by_short_hash(&self, short: &str) -> Option<&Commit> {
182 self.commits.iter().find(|c| c.hash.short() == short)
183 }
184
185 pub fn is_empty(&self) -> bool {
187 self.commits.is_empty()
188 }
189
190 pub fn len(&self) -> usize {
192 self.commits.len()
193 }
194
195 pub fn first(&self) -> Option<&Commit> {
197 self.commits.first()
198 }
199
200 pub fn last(&self) -> Option<&Commit> {
202 self.commits.last()
203 }
204}
205
206impl fmt::Display for CommitLog {
207 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208 for commit in &self.commits {
209 writeln!(f, "{}", commit)?;
210 }
211 Ok(())
212 }
213}
214
215#[derive(Debug, Clone, Default)]
216pub struct LogOptions {
217 pub max_count: Option<usize>,
218 pub since: Option<DateTime<Utc>>,
219 pub until: Option<DateTime<Utc>>,
220 pub author: Option<String>,
221 pub committer: Option<String>,
222 pub grep: Option<String>,
223 pub paths: Vec<PathBuf>,
224 pub follow_renames: bool,
225 pub merges_only: bool,
226 pub no_merges: bool,
227}
228
229impl LogOptions {
230 pub fn new() -> Self {
231 Self::default()
232 }
233
234 pub fn max_count(mut self, count: usize) -> Self {
236 self.max_count = Some(count);
237 self
238 }
239
240 pub fn since(mut self, date: DateTime<Utc>) -> Self {
242 self.since = Some(date);
243 self
244 }
245
246 pub fn until(mut self, date: DateTime<Utc>) -> Self {
248 self.until = Some(date);
249 self
250 }
251
252 pub fn author(mut self, author: String) -> Self {
254 self.author = Some(author);
255 self
256 }
257
258 pub fn committer(mut self, committer: String) -> Self {
260 self.committer = Some(committer);
261 self
262 }
263
264 pub fn grep(mut self, pattern: String) -> Self {
266 self.grep = Some(pattern);
267 self
268 }
269
270 pub fn paths(mut self, paths: Vec<PathBuf>) -> Self {
272 self.paths = paths;
273 self
274 }
275
276 pub fn follow_renames(mut self, follow: bool) -> Self {
278 self.follow_renames = follow;
279 self
280 }
281
282 pub fn merges_only(mut self, only: bool) -> Self {
284 self.merges_only = only;
285 self
286 }
287
288 pub fn no_merges(mut self, exclude: bool) -> Self {
290 self.no_merges = exclude;
291 self
292 }
293}
294
295#[derive(Debug, Clone, PartialEq, Eq)]
296pub struct CommitDetails {
297 pub commit: Commit,
298 pub files_changed: Vec<PathBuf>,
299 pub insertions: usize,
300 pub deletions: usize,
301}
302
303impl CommitDetails {
304 pub fn total_changes(&self) -> usize {
306 self.insertions + self.deletions
307 }
308
309 pub fn has_changes(&self) -> bool {
311 !self.files_changed.is_empty()
312 }
313}
314
315impl fmt::Display for CommitDetails {
316 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
317 writeln!(f, "{}", self.commit)?;
318 writeln!(f, "Files changed: {}", self.files_changed.len())?;
319 writeln!(f, "Insertions: +{}", self.insertions)?;
320 writeln!(f, "Deletions: -{}", self.deletions)?;
321
322 if !self.files_changed.is_empty() {
323 writeln!(f, "\nFiles:")?;
324 for file in &self.files_changed {
325 writeln!(f, " {}", file.display())?;
326 }
327 }
328
329 Ok(())
330 }
331}
332
333fn parse_log_output(output: &str) -> Result<Vec<Commit>> {
335 let mut commits = Vec::new();
336
337 for line in output.lines() {
338 let line = line.trim();
339 if line.is_empty() {
340 continue;
341 }
342
343 let parts: Vec<&str> = line.splitn(10, '|').collect();
345 if parts.len() < 9 {
346 continue; }
348
349 let hash = Hash::from(parts[0].to_string());
350 let author_name = parts[1].to_string();
351 let author_email = parts[2].to_string();
352 let author_timestamp = parse_timestamp(parts[3])?;
353 let committer_name = parts[4].to_string();
354 let committer_email = parts[5].to_string();
355 let committer_timestamp = parse_timestamp(parts[6])?;
356 let parent_hashes = parse_parent_hashes(parts[7]);
357 let subject = parts[8].to_string();
358 let body = if parts.len() > 9 && !parts[9].is_empty() {
359 Some(parts[9].to_string())
360 } else {
361 None
362 };
363
364 let author = Author {
365 name: author_name,
366 email: author_email,
367 timestamp: author_timestamp,
368 };
369
370 let committer = Author {
371 name: committer_name,
372 email: committer_email,
373 timestamp: committer_timestamp,
374 };
375
376 let message = CommitMessage::new(subject, body);
377
378 let commit = Commit {
379 hash,
380 author,
381 committer,
382 message,
383 timestamp: author_timestamp, parents: parent_hashes,
385 };
386
387 commits.push(commit);
388 }
389
390 Ok(commits)
391}
392
393fn parse_timestamp(timestamp_str: &str) -> Result<DateTime<Utc>> {
395 let timestamp: i64 = timestamp_str.parse().map_err(|_| {
396 crate::error::GitError::CommandFailed(format!("Invalid timestamp: {}", timestamp_str))
397 })?;
398
399 DateTime::from_timestamp(timestamp, 0).ok_or_else(|| {
400 crate::error::GitError::CommandFailed(format!("Invalid timestamp value: {}", timestamp))
401 })
402}
403
404fn parse_parent_hashes(parents_str: &str) -> Box<[Hash]> {
406 if parents_str.is_empty() {
407 return Box::new([]);
408 }
409
410 parents_str
411 .split_whitespace()
412 .map(|hash| Hash::from(hash.to_string()))
413 .collect::<Vec<_>>()
414 .into_boxed_slice()
415}
416
417impl Repository {
418 pub fn log(&self) -> Result<CommitLog> {
420 self.log_with_options(&LogOptions::new().max_count(100))
421 }
422
423 pub fn recent_commits(&self, count: usize) -> Result<CommitLog> {
425 self.log_with_options(&LogOptions::new().max_count(count))
426 }
427
428 pub fn log_with_options(&self, options: &LogOptions) -> Result<CommitLog> {
430 Self::ensure_git()?;
431
432 let mut args_vec: Vec<String> = vec![
434 "log".to_string(),
435 GIT_LOG_FORMAT.to_string(),
436 "--no-show-signature".to_string(),
437 ];
438
439 if let Some(count) = options.max_count {
441 args_vec.push("-n".to_string());
442 args_vec.push(count.to_string());
443 }
444
445 if let Some(since) = &options.since {
446 args_vec.push(format!("--since={}", since.format(DATE_FORMAT)));
447 }
448
449 if let Some(until) = &options.until {
450 args_vec.push(format!("--until={}", until.format(DATE_FORMAT)));
451 }
452
453 if let Some(author) = &options.author {
454 args_vec.push(format!("--author={}", author));
455 }
456
457 if let Some(committer) = &options.committer {
458 args_vec.push(format!("--committer={}", committer));
459 }
460
461 if let Some(grep) = &options.grep {
462 args_vec.push(format!("--grep={}", grep));
463 }
464
465 if options.follow_renames {
467 args_vec.push("--follow".to_string());
468 }
469
470 if options.merges_only {
471 args_vec.push("--merges".to_string());
472 }
473
474 if options.no_merges {
475 args_vec.push("--no-merges".to_string());
476 }
477
478 if !options.paths.is_empty() {
480 args_vec.push("--".to_string());
481 for path in &options.paths {
482 args_vec.push(path.to_string_lossy().to_string());
483 }
484 }
485
486 let all_args: Vec<&str> = args_vec.iter().map(|s| s.as_str()).collect();
488
489 let stdout = git(&all_args, Some(self.repo_path()))?;
490 let commits = parse_log_output(&stdout)?;
491 Ok(CommitLog::new(commits))
492 }
493
494 pub fn log_range(&self, from: &Hash, to: &Hash) -> Result<CommitLog> {
496 Self::ensure_git()?;
497
498 let range = format!("{}..{}", from.as_str(), to.as_str());
499 let args = vec!["log", GIT_LOG_FORMAT, "--no-show-signature", &range];
500
501 let stdout = git(&args, Some(self.repo_path()))?;
502 let commits = parse_log_output(&stdout)?;
503 Ok(CommitLog::new(commits))
504 }
505
506 pub fn log_for_paths(&self, paths: &[impl AsRef<std::path::Path>]) -> Result<CommitLog> {
508 let path_bufs: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
509 let options = LogOptions::new().paths(path_bufs);
510 self.log_with_options(&options)
511 }
512
513 pub fn show_commit(&self, hash: &Hash) -> Result<CommitDetails> {
515 Self::ensure_git()?;
516
517 let commit_args = vec![
519 "log",
520 GIT_LOG_FORMAT,
521 "--no-show-signature",
522 "-n",
523 "1",
524 hash.as_str(),
525 ];
526
527 let commit_output = git(&commit_args, Some(self.repo_path()))?;
528 let mut commits = parse_log_output(&commit_output)?;
529
530 if commits.is_empty() {
531 return Err(crate::error::GitError::CommandFailed(format!(
532 "Commit not found: {}",
533 hash
534 )));
535 }
536
537 let commit = commits.remove(0);
538
539 let stats_args = vec!["show", "--stat", "--format=", hash.as_str()];
541
542 let stats_output = git(&stats_args, Some(self.repo_path()))?;
543 let (files_changed, insertions, deletions) = parse_diff_stats(&stats_output);
544
545 Ok(CommitDetails {
546 commit,
547 files_changed,
548 insertions,
549 deletions,
550 })
551 }
552}
553
554fn parse_diff_stats(output: &str) -> (Vec<PathBuf>, usize, usize) {
556 let mut files_changed = Vec::new();
557 let mut total_insertions = 0;
558 let mut total_deletions = 0;
559
560 for line in output.lines() {
561 let line = line.trim();
562 if line.is_empty() {
563 continue;
564 }
565
566 if let Some(pipe_pos) = line.find(" | ") {
568 let filename = line[..pipe_pos].trim();
569 files_changed.push(PathBuf::from(filename));
570
571 let stats_part = &line[pipe_pos + 3..];
573 if let Some(space_pos) = stats_part.find(' ')
574 && let Ok(changes) = stats_part[..space_pos].parse::<usize>()
575 {
576 let symbols = &stats_part[space_pos + 1..];
577 let plus_count = symbols.chars().filter(|&c| c == '+').count();
578 let minus_count = symbols.chars().filter(|&c| c == '-').count();
579
580 let total_symbols = plus_count + minus_count;
582 if total_symbols > 0 {
583 let insertions = (changes * plus_count) / total_symbols;
584 let deletions = changes - insertions;
585 total_insertions += insertions;
586 total_deletions += deletions;
587 }
588 }
589 }
590 else if line.contains("files changed") || line.contains("file changed") {
592 if let Some(insertions_pos) = line.find(" insertions(+)")
593 && let Some(start) = line[..insertions_pos].rfind(' ')
594 && let Ok(insertions) = line[start + 1..insertions_pos].parse::<usize>()
595 {
596 total_insertions = insertions;
597 }
598 if let Some(deletions_pos) = line.find(" deletions(-)")
599 && let Some(start) = line[..deletions_pos].rfind(' ')
600 && let Ok(deletions) = line[start + 1..deletions_pos].parse::<usize>()
601 {
602 total_deletions = deletions;
603 }
604 }
605 }
606
607 (files_changed, total_insertions, total_deletions)
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613 use std::fs;
614 use std::path::Path;
615
616 #[test]
617 fn test_author_display() {
618 let author = Author {
619 name: "John Doe".to_string(),
620 email: "john@example.com".to_string(),
621 timestamp: DateTime::from_timestamp(1640995200, 0).unwrap(),
622 };
623 assert_eq!(format!("{}", author), "John Doe <john@example.com>");
624 }
625
626 #[test]
627 fn test_commit_message_creation() {
628 let msg = CommitMessage::new("Initial commit".to_string(), None);
629 assert_eq!(msg.subject, "Initial commit");
630 assert!(msg.body.is_none());
631 assert_eq!(msg.full(), "Initial commit");
632
633 let msg_with_body = CommitMessage::new(
634 "Add feature".to_string(),
635 Some("This adds a new feature\nwith multiple lines".to_string()),
636 );
637 assert_eq!(
638 msg_with_body.full(),
639 "Add feature\n\nThis adds a new feature\nwith multiple lines"
640 );
641 }
642
643 #[test]
644 fn test_commit_is_merge() {
645 let commit = Commit {
646 hash: Hash::from("abc123".to_string()),
647 author: Author {
648 name: "Test".to_string(),
649 email: "test@example.com".to_string(),
650 timestamp: DateTime::from_timestamp(1640995200, 0).unwrap(),
651 },
652 committer: Author {
653 name: "Test".to_string(),
654 email: "test@example.com".to_string(),
655 timestamp: DateTime::from_timestamp(1640995200, 0).unwrap(),
656 },
657 message: CommitMessage::new("Test commit".to_string(), None),
658 timestamp: DateTime::from_timestamp(1640995200, 0).unwrap(),
659 parents: vec![
660 Hash::from("parent1".to_string()),
661 Hash::from("parent2".to_string()),
662 ]
663 .into_boxed_slice(),
664 };
665
666 assert!(commit.is_merge());
667 assert!(!commit.is_root());
668 }
669
670 #[test]
671 fn test_commit_log_filtering() {
672 let commits = vec![
673 create_test_commit(
674 "abc123",
675 "John Doe",
676 "john@example.com",
677 "Fix bug",
678 1640995200,
679 ),
680 create_test_commit(
681 "def456",
682 "Jane Smith",
683 "jane@example.com",
684 "Add feature",
685 1640995300,
686 ),
687 create_test_commit(
688 "ghi789",
689 "John Doe",
690 "john@example.com",
691 "Update docs",
692 1640995400,
693 ),
694 ];
695
696 let log = CommitLog::new(commits);
697
698 let john_commits: Vec<_> = log.by_author("John Doe").collect();
700 assert_eq!(john_commits.len(), 2);
701
702 let fix_commits: Vec<_> = log.with_message_containing("fix").collect();
704 assert_eq!(fix_commits.len(), 1);
705 assert_eq!(fix_commits[0].message.subject, "Fix bug");
706 }
707
708 #[test]
709 fn test_parse_timestamp() {
710 let timestamp = parse_timestamp("1640995200").unwrap();
711 assert_eq!(timestamp.timestamp(), 1640995200);
712 }
713
714 #[test]
715 fn test_parse_parent_hashes() {
716 let parents = parse_parent_hashes("abc123 def456 ghi789");
717 assert_eq!(parents.len(), 3);
718 assert_eq!(parents[0].as_str(), "abc123");
719 assert_eq!(parents[1].as_str(), "def456");
720 assert_eq!(parents[2].as_str(), "ghi789");
721
722 let no_parents = parse_parent_hashes("");
723 assert_eq!(no_parents.len(), 0);
724 }
725
726 #[test]
727 fn test_log_options_builder() {
728 let options = LogOptions::new()
729 .max_count(50)
730 .author("john@example.com".to_string())
731 .follow_renames(true);
732
733 assert_eq!(options.max_count, Some(50));
734 assert_eq!(options.author, Some("john@example.com".to_string()));
735 assert!(options.follow_renames);
736 }
737
738 #[test]
739 fn test_parse_diff_stats() {
740 let output = "src/main.rs | 15 +++++++++------\nREADME.md | 3 +++\n 2 files changed, 18 insertions(+), 6 deletions(-)";
741 let (files, insertions, deletions) = parse_diff_stats(output);
742
743 assert_eq!(files.len(), 2);
744 assert_eq!(files[0], PathBuf::from("src/main.rs"));
745 assert_eq!(files[1], PathBuf::from("README.md"));
746 assert_eq!(insertions, 18);
747 assert_eq!(deletions, 6);
748 }
749
750 #[test]
751 fn test_commit_details_display() {
752 let commit = create_test_commit(
753 "abc123",
754 "John Doe",
755 "john@example.com",
756 "Test commit",
757 1640995200,
758 );
759 let details = CommitDetails {
760 commit,
761 files_changed: vec![PathBuf::from("src/main.rs"), PathBuf::from("README.md")],
762 insertions: 15,
763 deletions: 8,
764 };
765
766 assert_eq!(details.total_changes(), 23);
767 assert!(details.has_changes());
768
769 let display_output = format!("{}", details);
770 assert!(display_output.contains("Files changed: 2"));
771 assert!(display_output.contains("Insertions: +15"));
772 assert!(display_output.contains("Deletions: -8"));
773 }
774
775 fn create_test_commit(
777 hash: &str,
778 author_name: &str,
779 author_email: &str,
780 subject: &str,
781 timestamp: i64,
782 ) -> Commit {
783 Commit {
784 hash: Hash::from(hash.to_string()),
785 author: Author {
786 name: author_name.to_string(),
787 email: author_email.to_string(),
788 timestamp: DateTime::from_timestamp(timestamp, 0).unwrap(),
789 },
790 committer: Author {
791 name: author_name.to_string(),
792 email: author_email.to_string(),
793 timestamp: DateTime::from_timestamp(timestamp, 0).unwrap(),
794 },
795 message: CommitMessage::new(subject.to_string(), None),
796 timestamp: DateTime::from_timestamp(timestamp, 0).unwrap(),
797 parents: Box::new([]),
798 }
799 }
800
801 #[test]
802 fn test_repository_log() {
803 let test_path = "/tmp/test_log_repo";
804
805 if Path::new(test_path).exists() {
807 fs::remove_dir_all(test_path).unwrap();
808 }
809
810 let repo = Repository::init(test_path, false).unwrap();
812
813 repo.config()
815 .set_user("Test User", "test@example.com")
816 .unwrap();
817
818 std::fs::write(format!("{}/test1.txt", test_path), "content1").unwrap();
820 repo.add(&["test1.txt"]).unwrap();
821 let _hash1 = repo.commit("First commit").unwrap();
822
823 std::fs::write(format!("{}/test2.txt", test_path), "content2").unwrap();
825 repo.add(&["test2.txt"]).unwrap();
826 let _hash2 = repo.commit("Second commit").unwrap();
827
828 let log = repo.log().unwrap();
830 assert_eq!(log.len(), 2);
831
832 let recent = repo.recent_commits(1).unwrap();
833 assert_eq!(recent.len(), 1);
834 assert_eq!(recent.first().unwrap().message.subject, "Second commit");
835
836 fs::remove_dir_all(test_path).unwrap();
838 }
839}