1use crate::commands::log::Author;
32use crate::error::{GitError, Result};
33use crate::repository::Repository;
34use crate::types::Hash;
35use crate::utils::{git, parse_unix_timestamp};
36use chrono::{DateTime, Utc};
37use std::fmt;
38
39#[derive(Debug, Clone, PartialEq)]
41pub struct Tag {
42 pub name: String,
44 pub hash: Hash,
46 pub tag_type: TagType,
48 pub message: Option<String>,
50 pub tagger: Option<Author>,
52 pub timestamp: Option<DateTime<Utc>>,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum TagType {
59 Lightweight,
61 Annotated,
63}
64
65impl fmt::Display for TagType {
66 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67 match self {
68 TagType::Lightweight => write!(f, "lightweight"),
69 TagType::Annotated => write!(f, "annotated"),
70 }
71 }
72}
73
74#[derive(Debug, Clone)]
76pub struct TagList {
77 tags: Box<[Tag]>,
78}
79
80impl TagList {
81 pub fn new(mut tags: Vec<Tag>) -> Self {
83 tags.sort_by(|a, b| a.name.cmp(&b.name));
85 Self {
86 tags: tags.into_boxed_slice(),
87 }
88 }
89
90 pub fn iter(&self) -> impl Iterator<Item = &Tag> + '_ {
92 self.tags.iter()
93 }
94
95 pub fn lightweight(&self) -> impl Iterator<Item = &Tag> + '_ {
97 self.tags
98 .iter()
99 .filter(|tag| tag.tag_type == TagType::Lightweight)
100 }
101
102 pub fn annotated(&self) -> impl Iterator<Item = &Tag> + '_ {
104 self.tags
105 .iter()
106 .filter(|tag| tag.tag_type == TagType::Annotated)
107 }
108
109 pub fn find(&self, name: &str) -> Option<&Tag> {
111 self.tags.iter().find(|tag| tag.name == name)
112 }
113
114 pub fn find_containing<'a>(&'a self, substring: &'a str) -> impl Iterator<Item = &'a Tag> + 'a {
116 self.tags
117 .iter()
118 .filter(move |tag| tag.name.contains(substring))
119 }
120
121 pub fn len(&self) -> usize {
123 self.tags.len()
124 }
125
126 pub fn is_empty(&self) -> bool {
128 self.tags.is_empty()
129 }
130
131 pub fn lightweight_count(&self) -> usize {
133 self.lightweight().count()
134 }
135
136 pub fn annotated_count(&self) -> usize {
138 self.annotated().count()
139 }
140
141 pub fn for_commit<'a>(&'a self, hash: &'a Hash) -> impl Iterator<Item = &'a Tag> + 'a {
143 self.tags.iter().filter(move |tag| &tag.hash == hash)
144 }
145}
146
147#[derive(Debug, Clone, Default)]
149pub struct TagOptions {
150 pub annotated: bool,
152 pub force: bool,
154 pub message: Option<String>,
156 pub sign: bool,
158}
159
160impl TagOptions {
161 pub fn new() -> Self {
163 Self::default()
164 }
165
166 pub fn with_annotated(mut self) -> Self {
168 self.annotated = true;
169 self
170 }
171
172 pub fn with_force(mut self) -> Self {
174 self.force = true;
175 self
176 }
177
178 pub fn with_message(mut self, message: String) -> Self {
180 self.message = Some(message);
181 self.annotated = true; self
183 }
184
185 pub fn with_sign(mut self) -> Self {
187 self.sign = true;
188 self.annotated = true; self
190 }
191}
192
193impl Repository {
194 pub fn tags(&self) -> Result<TagList> {
213 Self::ensure_git()?;
214
215 let output = git(
218 &[
219 "for-each-ref",
220 "--format=%(refname:short)|%(objecttype)|%(objectname)|%(*objectname)|%(taggername)|%(taggeremail)|%(taggerdate:unix)|%(subject)|%(body)",
221 "refs/tags/",
222 ],
223 Some(self.repo_path()),
224 )?;
225
226 if output.trim().is_empty() {
227 return Ok(TagList::new(vec![]));
228 }
229
230 let mut tags = Vec::new();
231
232 for line in output.lines() {
233 let line = line.trim();
234 if line.is_empty() {
235 continue;
236 }
237
238 if let Ok(tag) = parse_for_each_ref_line(line) {
240 tags.push(tag);
241 }
242 }
243
244 Ok(TagList::new(tags))
245 }
246
247 pub fn create_tag(&self, name: &str, target: Option<&Hash>) -> Result<Tag> {
272 self.create_tag_with_options(name, target, TagOptions::new())
273 }
274
275 pub fn create_tag_with_options(
301 &self,
302 name: &str,
303 target: Option<&Hash>,
304 options: TagOptions,
305 ) -> Result<Tag> {
306 Self::ensure_git()?;
307
308 let mut args = vec!["tag"];
309
310 if options.annotated || options.message.is_some() {
311 args.push("-a");
312 }
313
314 if options.force {
315 args.push("-f");
316 }
317
318 if options.sign {
319 args.push("-s");
320 }
321
322 if let Some(ref message) = options.message {
323 args.push("-m");
324 args.push(message);
325 }
326
327 args.push(name);
328
329 if let Some(target_hash) = target {
330 args.push(target_hash.as_str());
331 }
332
333 git(&args, Some(self.repo_path()))?;
334
335 let show_output = git(&["show", "--format=fuller", name], Some(self.repo_path()))?;
337 parse_tag_info(name, &show_output)
338 }
339
340 pub fn delete_tag(&self, name: &str) -> Result<()> {
356 Self::ensure_git()?;
357
358 git(&["tag", "-d", name], Some(self.repo_path()))?;
359 Ok(())
360 }
361
362 pub fn show_tag(&self, name: &str) -> Result<Tag> {
384 Self::ensure_git()?;
385
386 let show_output = git(&["show", "--format=fuller", name], Some(self.repo_path()))?;
387 parse_tag_info(name, &show_output)
388 }
389}
390
391fn parse_for_each_ref_line(line: &str) -> Result<Tag> {
394 let parts: Vec<&str> = line.split('|').collect();
395
396 if parts.len() < 9 {
397 return Err(GitError::CommandFailed(format!(
398 "Invalid for-each-ref format: expected 9 parts, got {}",
399 parts.len()
400 )));
401 }
402
403 let name = parts[0].to_string();
404 let object_type = parts[1];
405 let object_name = parts[2];
406 let dereferenced_object = parts[3]; let tagger_name = parts[4];
408 let tagger_email = parts[5];
409 let tagger_date = parts[6];
410 let subject = parts[7];
411 let body = parts[8];
412
413 let (tag_type, hash) = if object_type == "tag" {
415 (TagType::Annotated, Hash::from(dereferenced_object))
417 } else {
418 (TagType::Lightweight, Hash::from(object_name))
420 };
421
422 let tagger =
424 if tag_type == TagType::Annotated && !tagger_name.is_empty() && !tagger_email.is_empty() {
425 let timestamp = parse_unix_timestamp(tagger_date).unwrap_or_else(|_| {
428 DateTime::from_timestamp(0, 0).unwrap()
431 });
432 Some(Author {
433 name: tagger_name.to_string(),
434 email: tagger_email.to_string(),
435 timestamp,
436 })
437 } else {
438 None
439 };
440
441 let message = if tag_type == TagType::Annotated && (!subject.is_empty() || !body.is_empty()) {
443 let full_message = if !body.is_empty() {
444 format!("{}\n\n{}", subject, body)
445 } else {
446 subject.to_string()
447 };
448 Some(full_message.trim().to_string())
449 } else {
450 None
451 };
452
453 let timestamp = if tag_type == TagType::Annotated {
455 tagger.as_ref().map(|t| t.timestamp)
456 } else {
457 None
458 };
459
460 Ok(Tag {
461 name,
462 hash,
463 tag_type,
464 message,
465 tagger,
466 timestamp,
467 })
468}
469
470fn parse_tag_info(tag_name: &str, show_output: &str) -> Result<Tag> {
472 let lines: Vec<&str> = show_output.lines().collect();
473
474 let is_annotated = show_output.contains("tag ") && show_output.contains("Tagger:");
476
477 if is_annotated {
478 parse_annotated_tag(tag_name, &lines)
479 } else {
480 parse_lightweight_tag(tag_name, &lines)
481 }
482}
483
484fn parse_annotated_tag(tag_name: &str, lines: &[&str]) -> Result<Tag> {
486 let mut hash = None;
487 let mut tagger = None;
488 let mut collecting_message = false;
489 let mut message_lines = Vec::new();
490
491 for line in lines {
492 if line.starts_with("commit ") {
493 if let Some(hash_str) = line.split_whitespace().nth(1) {
494 hash = Some(Hash::from(hash_str));
495 }
496 } else if let Some(stripped) = line.strip_prefix("Tagger: ") {
497 tagger = parse_author_line(stripped);
498 } else if line.trim().is_empty() && !collecting_message {
499 collecting_message = true;
500 } else if collecting_message && !line.starts_with("commit ") && !line.starts_with("Author:")
501 {
502 message_lines.push(line.trim());
503 }
504 }
505
506 let message_text = if message_lines.is_empty() {
507 None
508 } else {
509 Some(message_lines.join("\n").trim().to_string())
510 };
511
512 let timestamp = tagger.as_ref().map(|t| t.timestamp);
513
514 Ok(Tag {
515 name: tag_name.to_string(),
516 hash: hash.ok_or_else(|| {
517 GitError::CommandFailed("Could not parse tag commit hash".to_string())
518 })?,
519 tag_type: TagType::Annotated,
520 message: message_text,
521 tagger,
522 timestamp,
523 })
524}
525
526fn parse_lightweight_tag(tag_name: &str, lines: &[&str]) -> Result<Tag> {
528 let mut hash = None;
529
530 for line in lines {
531 if line.starts_with("commit ")
532 && let Some(hash_str) = line.split_whitespace().nth(1)
533 {
534 hash = Some(Hash::from(hash_str));
535 break;
536 }
537 }
538
539 Ok(Tag {
540 name: tag_name.to_string(),
541 hash: hash.ok_or_else(|| {
542 GitError::CommandFailed("Could not parse tag commit hash".to_string())
543 })?,
544 tag_type: TagType::Lightweight,
545 message: None,
546 tagger: None,
547 timestamp: None,
548 })
549}
550
551fn parse_author_line(line: &str) -> Option<Author> {
554 if let Some(email_start) = line.find('<')
556 && let Some(email_end) = line.find('>')
557 {
558 let name = line[..email_start].trim().to_string();
559 let email = line[email_start + 1..email_end].to_string();
560
561 let timestamp = Utc::now();
565
566 return Some(Author {
567 name,
568 email,
569 timestamp,
570 });
571 }
572 None
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578 use std::env;
579 use std::fs;
580
581 fn create_test_repo() -> (Repository, std::path::PathBuf) {
582 use std::thread;
583 use std::time::{SystemTime, UNIX_EPOCH};
584
585 let timestamp = SystemTime::now()
586 .duration_since(UNIX_EPOCH)
587 .unwrap()
588 .as_nanos();
589 let thread_id = format!("{:?}", thread::current().id());
590 let test_path = env::temp_dir().join(format!(
591 "rustic_git_tag_test_{}_{}_{}",
592 std::process::id(),
593 timestamp,
594 thread_id.replace("ThreadId(", "").replace(")", "")
595 ));
596
597 if test_path.exists() {
599 fs::remove_dir_all(&test_path).unwrap();
600 }
601
602 let repo = Repository::init(&test_path, false).unwrap();
603
604 repo.config()
606 .set_user("Test User", "test@example.com")
607 .unwrap();
608
609 repo.config().set("tag.gpgsign", "false").unwrap();
611
612 (repo, test_path)
613 }
614
615 fn create_test_commit(repo: &Repository, test_path: &std::path::Path) {
616 fs::write(test_path.join("test.txt"), "test content").unwrap();
617 repo.add(&["test.txt"]).unwrap();
618 repo.commit("Test commit").unwrap();
619 }
620
621 #[test]
622 fn test_tag_list_empty_repository() {
623 let (repo, test_path) = create_test_repo();
624
625 let tags = repo.tags().unwrap();
626 assert!(tags.is_empty());
627 assert_eq!(tags.len(), 0);
628
629 fs::remove_dir_all(&test_path).unwrap();
631 }
632
633 #[test]
634 fn test_create_lightweight_tag() {
635 let (repo, test_path) = create_test_repo();
636 create_test_commit(&repo, &test_path);
637
638 let tag = repo.create_tag("v1.0.0", None).unwrap();
639 assert_eq!(tag.name, "v1.0.0");
640 assert_eq!(tag.tag_type, TagType::Lightweight);
641 assert!(tag.message.is_none());
642 assert!(tag.tagger.is_none());
643
644 let tags = repo.tags().unwrap();
646 assert_eq!(tags.len(), 1);
647 assert!(tags.find("v1.0.0").is_some());
648
649 fs::remove_dir_all(&test_path).unwrap();
651 }
652
653 #[test]
654 fn test_create_annotated_tag() {
655 let (repo, test_path) = create_test_repo();
656 create_test_commit(&repo, &test_path);
657
658 let options = TagOptions::new().with_message("Release version 1.0.0".to_string());
659 let tag = repo
660 .create_tag_with_options("v1.0.0", None, options)
661 .unwrap();
662
663 assert_eq!(tag.name, "v1.0.0");
664 assert_eq!(tag.tag_type, TagType::Annotated);
665 assert!(tag.message.is_some());
666
667 fs::remove_dir_all(&test_path).unwrap();
669 }
670
671 #[test]
672 fn test_delete_tag() {
673 let (repo, test_path) = create_test_repo();
674 create_test_commit(&repo, &test_path);
675
676 repo.create_tag("to-delete", None).unwrap();
678
679 let tags = repo.tags().unwrap();
681 assert_eq!(tags.len(), 1);
682
683 repo.delete_tag("to-delete").unwrap();
685
686 let tags = repo.tags().unwrap();
688 assert_eq!(tags.len(), 0);
689
690 fs::remove_dir_all(&test_path).unwrap();
692 }
693
694 #[test]
695 fn test_tag_list_filtering() {
696 let (repo, test_path) = create_test_repo();
697 create_test_commit(&repo, &test_path);
698
699 repo.create_tag("v1.0.0", None).unwrap();
701 repo.create_tag("v1.1.0", None).unwrap();
702 let options = TagOptions::new().with_message("Annotated".to_string());
703 repo.create_tag_with_options("v2.0.0", None, options)
704 .unwrap();
705
706 let tags = repo.tags().unwrap();
707 assert_eq!(tags.len(), 3);
708 assert_eq!(tags.lightweight_count(), 2);
709 assert_eq!(tags.annotated_count(), 1);
710
711 let v1_tags: Vec<_> = tags.find_containing("v1").collect();
713 assert_eq!(v1_tags.len(), 2);
714
715 fs::remove_dir_all(&test_path).unwrap();
717 }
718
719 #[test]
720 fn test_tag_options_builder() {
721 let options = TagOptions::new()
722 .with_annotated()
723 .with_force()
724 .with_message("Test message".to_string());
725
726 assert!(options.annotated);
727 assert!(options.force);
728 assert_eq!(options.message, Some("Test message".to_string()));
729 }
730
731 #[test]
732 fn test_show_tag() {
733 let (repo, test_path) = create_test_repo();
734 create_test_commit(&repo, &test_path);
735
736 repo.create_tag("show-test", None).unwrap();
737 let tag = repo.show_tag("show-test").unwrap();
738
739 assert_eq!(tag.name, "show-test");
740 assert_eq!(tag.tag_type, TagType::Lightweight);
741
742 fs::remove_dir_all(&test_path).unwrap();
744 }
745
746 #[test]
747 fn test_tag_force_overwrite() {
748 let (repo, test_path) = create_test_repo();
749 create_test_commit(&repo, &test_path);
750
751 repo.create_tag("overwrite-test", None).unwrap();
753
754 let result = repo.create_tag("overwrite-test", None);
756 assert!(result.is_err());
757
758 let options = TagOptions::new().with_force();
760 let result = repo.create_tag_with_options("overwrite-test", None, options);
761 assert!(result.is_ok());
762
763 fs::remove_dir_all(&test_path).unwrap();
765 }
766
767 #[test]
768 fn test_parse_for_each_ref_line_invalid_format() {
769 let invalid_line = "tag1|commit|abc123"; let result = parse_for_each_ref_line(invalid_line);
772
773 assert!(result.is_err());
774
775 if let Err(GitError::CommandFailed(msg)) = result {
776 assert!(msg.contains("Invalid for-each-ref format"));
777 assert!(msg.contains("expected 9 parts"));
778 assert!(msg.contains("got 3"));
779 } else {
780 panic!("Expected CommandFailed error with specific message");
781 }
782 }
783
784 #[test]
785 fn test_parse_for_each_ref_line_with_invalid_timestamp() {
786 let line_with_invalid_timestamp =
788 "v1.0.0|tag|abc123|def456|John Doe|john@example.com|invalid-timestamp|Subject|Body";
789 let result = parse_for_each_ref_line(line_with_invalid_timestamp);
790
791 assert!(result.is_ok());
792 let tag = result.unwrap();
793 assert_eq!(tag.name, "v1.0.0");
794 assert_eq!(tag.tag_type, TagType::Annotated);
795 assert!(tag.tagger.is_some());
796
797 let tagger = tag.tagger.unwrap();
799 assert_eq!(tagger.name, "John Doe");
800 assert_eq!(tagger.email, "john@example.com");
801
802 assert_eq!(tagger.timestamp.timestamp(), 0); assert_eq!(
805 tagger.timestamp.format("%Y-%m-%d").to_string(),
806 "1970-01-01"
807 );
808 }
809}