1use crate::error::{GitError, Result};
32use crate::repository::Repository;
33use crate::types::Hash;
34use crate::utils::git;
35use chrono::{DateTime, Utc};
36use std::fmt;
37
38#[derive(Debug, Clone, PartialEq)]
40pub struct Tag {
41 pub name: String,
43 pub hash: Hash,
45 pub tag_type: TagType,
47 pub message: Option<String>,
49 pub tagger: Option<Author>,
51 pub timestamp: Option<DateTime<Utc>>,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum TagType {
58 Lightweight,
60 Annotated,
62}
63
64impl fmt::Display for TagType {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66 match self {
67 TagType::Lightweight => write!(f, "lightweight"),
68 TagType::Annotated => write!(f, "annotated"),
69 }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq)]
75pub struct Author {
76 pub name: String,
78 pub email: String,
80 pub timestamp: DateTime<Utc>,
82}
83
84impl fmt::Display for Author {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 write!(f, "{} <{}>", self.name, self.email)
87 }
88}
89
90#[derive(Debug, Clone)]
92pub struct TagList {
93 tags: Box<[Tag]>,
94}
95
96impl TagList {
97 pub fn new(mut tags: Vec<Tag>) -> Self {
99 tags.sort_by(|a, b| a.name.cmp(&b.name));
101 Self {
102 tags: tags.into_boxed_slice(),
103 }
104 }
105
106 pub fn iter(&self) -> impl Iterator<Item = &Tag> + '_ {
108 self.tags.iter()
109 }
110
111 pub fn lightweight(&self) -> impl Iterator<Item = &Tag> + '_ {
113 self.tags
114 .iter()
115 .filter(|tag| tag.tag_type == TagType::Lightweight)
116 }
117
118 pub fn annotated(&self) -> impl Iterator<Item = &Tag> + '_ {
120 self.tags
121 .iter()
122 .filter(|tag| tag.tag_type == TagType::Annotated)
123 }
124
125 pub fn find(&self, name: &str) -> Option<&Tag> {
127 self.tags.iter().find(|tag| tag.name == name)
128 }
129
130 pub fn find_containing<'a>(&'a self, substring: &'a str) -> impl Iterator<Item = &'a Tag> + 'a {
132 self.tags
133 .iter()
134 .filter(move |tag| tag.name.contains(substring))
135 }
136
137 pub fn len(&self) -> usize {
139 self.tags.len()
140 }
141
142 pub fn is_empty(&self) -> bool {
144 self.tags.is_empty()
145 }
146
147 pub fn lightweight_count(&self) -> usize {
149 self.lightweight().count()
150 }
151
152 pub fn annotated_count(&self) -> usize {
154 self.annotated().count()
155 }
156
157 pub fn for_commit<'a>(&'a self, hash: &'a Hash) -> impl Iterator<Item = &'a Tag> + 'a {
159 self.tags.iter().filter(move |tag| &tag.hash == hash)
160 }
161}
162
163#[derive(Debug, Clone, Default)]
165pub struct TagOptions {
166 pub annotated: bool,
168 pub force: bool,
170 pub message: Option<String>,
172 pub sign: bool,
174}
175
176impl TagOptions {
177 pub fn new() -> Self {
179 Self::default()
180 }
181
182 pub fn with_annotated(mut self) -> Self {
184 self.annotated = true;
185 self
186 }
187
188 pub fn with_force(mut self) -> Self {
190 self.force = true;
191 self
192 }
193
194 pub fn with_message(mut self, message: String) -> Self {
196 self.message = Some(message);
197 self.annotated = true; self
199 }
200
201 pub fn with_sign(mut self) -> Self {
203 self.sign = true;
204 self.annotated = true; self
206 }
207}
208
209impl Repository {
210 pub fn tags(&self) -> Result<TagList> {
229 Self::ensure_git()?;
230
231 let output = git(&["tag", "-l"], Some(self.repo_path()))?;
233
234 if output.trim().is_empty() {
235 return Ok(TagList::new(vec![]));
236 }
237
238 let mut tags = Vec::new();
239
240 for tag_name in output.lines() {
241 let tag_name = tag_name.trim();
242 if tag_name.is_empty() {
243 continue;
244 }
245
246 let show_output = git(
248 &["show", "--format=fuller", tag_name],
249 Some(self.repo_path()),
250 )?;
251
252 if let Ok(tag) = parse_tag_info(tag_name, &show_output) {
254 tags.push(tag);
255 }
256 }
257
258 Ok(TagList::new(tags))
259 }
260
261 pub fn create_tag(&self, name: &str, target: Option<&Hash>) -> Result<Tag> {
286 self.create_tag_with_options(name, target, TagOptions::new())
287 }
288
289 pub fn create_tag_with_options(
315 &self,
316 name: &str,
317 target: Option<&Hash>,
318 options: TagOptions,
319 ) -> Result<Tag> {
320 Self::ensure_git()?;
321
322 let mut args = vec!["tag"];
323
324 if options.annotated || options.message.is_some() {
325 args.push("-a");
326 }
327
328 if options.force {
329 args.push("-f");
330 }
331
332 if options.sign {
333 args.push("-s");
334 }
335
336 if let Some(ref message) = options.message {
337 args.push("-m");
338 args.push(message);
339 }
340
341 args.push(name);
342
343 if let Some(target_hash) = target {
344 args.push(target_hash.as_str());
345 }
346
347 git(&args, Some(self.repo_path()))?;
348
349 let show_output = git(&["show", "--format=fuller", name], Some(self.repo_path()))?;
351 parse_tag_info(name, &show_output)
352 }
353
354 pub fn delete_tag(&self, name: &str) -> Result<()> {
370 Self::ensure_git()?;
371
372 git(&["tag", "-d", name], Some(self.repo_path()))?;
373 Ok(())
374 }
375
376 pub fn show_tag(&self, name: &str) -> Result<Tag> {
398 Self::ensure_git()?;
399
400 let show_output = git(&["show", "--format=fuller", name], Some(self.repo_path()))?;
401 parse_tag_info(name, &show_output)
402 }
403}
404
405fn parse_tag_info(tag_name: &str, show_output: &str) -> Result<Tag> {
407 let lines: Vec<&str> = show_output.lines().collect();
408
409 let is_annotated = show_output.contains("tag ") && show_output.contains("Tagger:");
411
412 if is_annotated {
413 parse_annotated_tag(tag_name, &lines)
414 } else {
415 parse_lightweight_tag(tag_name, &lines)
416 }
417}
418
419fn parse_annotated_tag(tag_name: &str, lines: &[&str]) -> Result<Tag> {
421 let mut hash = None;
422 let mut tagger = None;
423 let mut collecting_message = false;
424 let mut message_lines = Vec::new();
425
426 for line in lines {
427 if line.starts_with("commit ") {
428 if let Some(hash_str) = line.split_whitespace().nth(1) {
429 hash = Some(Hash::from(hash_str));
430 }
431 } else if let Some(stripped) = line.strip_prefix("Tagger: ") {
432 tagger = parse_author_line(stripped);
433 } else if line.trim().is_empty() && !collecting_message {
434 collecting_message = true;
435 } else if collecting_message && !line.starts_with("commit ") && !line.starts_with("Author:")
436 {
437 message_lines.push(line.trim());
438 }
439 }
440
441 let message_text = if message_lines.is_empty() {
442 None
443 } else {
444 Some(message_lines.join("\n").trim().to_string())
445 };
446
447 let timestamp = tagger.as_ref().map(|t| t.timestamp);
448
449 Ok(Tag {
450 name: tag_name.to_string(),
451 hash: hash.ok_or_else(|| {
452 GitError::CommandFailed("Could not parse tag commit hash".to_string())
453 })?,
454 tag_type: TagType::Annotated,
455 message: message_text,
456 tagger,
457 timestamp,
458 })
459}
460
461fn parse_lightweight_tag(tag_name: &str, lines: &[&str]) -> Result<Tag> {
463 let mut hash = None;
464
465 for line in lines {
466 if line.starts_with("commit ")
467 && let Some(hash_str) = line.split_whitespace().nth(1)
468 {
469 hash = Some(Hash::from(hash_str));
470 break;
471 }
472 }
473
474 Ok(Tag {
475 name: tag_name.to_string(),
476 hash: hash.ok_or_else(|| {
477 GitError::CommandFailed("Could not parse tag commit hash".to_string())
478 })?,
479 tag_type: TagType::Lightweight,
480 message: None,
481 tagger: None,
482 timestamp: None,
483 })
484}
485
486fn parse_author_line(line: &str) -> Option<Author> {
488 if let Some(email_start) = line.find('<')
490 && let Some(email_end) = line.find('>')
491 {
492 let name = line[..email_start].trim().to_string();
493 let email = line[email_start + 1..email_end].to_string();
494
495 let timestamp = Utc::now();
497
498 return Some(Author {
499 name,
500 email,
501 timestamp,
502 });
503 }
504 None
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use std::env;
511 use std::fs;
512
513 fn create_test_repo() -> (Repository, std::path::PathBuf) {
514 use std::thread;
515 use std::time::{SystemTime, UNIX_EPOCH};
516
517 let timestamp = SystemTime::now()
518 .duration_since(UNIX_EPOCH)
519 .unwrap()
520 .as_nanos();
521 let thread_id = format!("{:?}", thread::current().id());
522 let test_path = env::temp_dir().join(format!(
523 "rustic_git_tag_test_{}_{}_{}",
524 std::process::id(),
525 timestamp,
526 thread_id.replace("ThreadId(", "").replace(")", "")
527 ));
528
529 if test_path.exists() {
531 fs::remove_dir_all(&test_path).unwrap();
532 }
533
534 let repo = Repository::init(&test_path, false).unwrap();
535
536 repo.config()
538 .set_user("Test User", "test@example.com")
539 .unwrap();
540
541 (repo, test_path)
542 }
543
544 fn create_test_commit(repo: &Repository, test_path: &std::path::Path) {
545 fs::write(test_path.join("test.txt"), "test content").unwrap();
546 repo.add(&["test.txt"]).unwrap();
547 repo.commit("Test commit").unwrap();
548 }
549
550 #[test]
551 fn test_tag_list_empty_repository() {
552 let (repo, test_path) = create_test_repo();
553
554 let tags = repo.tags().unwrap();
555 assert!(tags.is_empty());
556 assert_eq!(tags.len(), 0);
557
558 fs::remove_dir_all(&test_path).unwrap();
560 }
561
562 #[test]
563 fn test_create_lightweight_tag() {
564 let (repo, test_path) = create_test_repo();
565 create_test_commit(&repo, &test_path);
566
567 let tag = repo.create_tag("v1.0.0", None).unwrap();
568 assert_eq!(tag.name, "v1.0.0");
569 assert_eq!(tag.tag_type, TagType::Lightweight);
570 assert!(tag.message.is_none());
571 assert!(tag.tagger.is_none());
572
573 let tags = repo.tags().unwrap();
575 assert_eq!(tags.len(), 1);
576 assert!(tags.find("v1.0.0").is_some());
577
578 fs::remove_dir_all(&test_path).unwrap();
580 }
581
582 #[test]
583 fn test_create_annotated_tag() {
584 let (repo, test_path) = create_test_repo();
585 create_test_commit(&repo, &test_path);
586
587 let options = TagOptions::new().with_message("Release version 1.0.0".to_string());
588 let tag = repo
589 .create_tag_with_options("v1.0.0", None, options)
590 .unwrap();
591
592 assert_eq!(tag.name, "v1.0.0");
593 assert_eq!(tag.tag_type, TagType::Annotated);
594 assert!(tag.message.is_some());
595
596 fs::remove_dir_all(&test_path).unwrap();
598 }
599
600 #[test]
601 fn test_delete_tag() {
602 let (repo, test_path) = create_test_repo();
603 create_test_commit(&repo, &test_path);
604
605 repo.create_tag("to-delete", None).unwrap();
607
608 let tags = repo.tags().unwrap();
610 assert_eq!(tags.len(), 1);
611
612 repo.delete_tag("to-delete").unwrap();
614
615 let tags = repo.tags().unwrap();
617 assert_eq!(tags.len(), 0);
618
619 fs::remove_dir_all(&test_path).unwrap();
621 }
622
623 #[test]
624 fn test_tag_list_filtering() {
625 let (repo, test_path) = create_test_repo();
626 create_test_commit(&repo, &test_path);
627
628 repo.create_tag("v1.0.0", None).unwrap();
630 repo.create_tag("v1.1.0", None).unwrap();
631 let options = TagOptions::new().with_message("Annotated".to_string());
632 repo.create_tag_with_options("v2.0.0", None, options)
633 .unwrap();
634
635 let tags = repo.tags().unwrap();
636 assert_eq!(tags.len(), 3);
637 assert_eq!(tags.lightweight_count(), 2);
638 assert_eq!(tags.annotated_count(), 1);
639
640 let v1_tags: Vec<_> = tags.find_containing("v1").collect();
642 assert_eq!(v1_tags.len(), 2);
643
644 fs::remove_dir_all(&test_path).unwrap();
646 }
647
648 #[test]
649 fn test_tag_options_builder() {
650 let options = TagOptions::new()
651 .with_annotated()
652 .with_force()
653 .with_message("Test message".to_string());
654
655 assert!(options.annotated);
656 assert!(options.force);
657 assert_eq!(options.message, Some("Test message".to_string()));
658 }
659
660 #[test]
661 fn test_show_tag() {
662 let (repo, test_path) = create_test_repo();
663 create_test_commit(&repo, &test_path);
664
665 repo.create_tag("show-test", None).unwrap();
666 let tag = repo.show_tag("show-test").unwrap();
667
668 assert_eq!(tag.name, "show-test");
669 assert_eq!(tag.tag_type, TagType::Lightweight);
670
671 fs::remove_dir_all(&test_path).unwrap();
673 }
674
675 #[test]
676 fn test_tag_force_overwrite() {
677 let (repo, test_path) = create_test_repo();
678 create_test_commit(&repo, &test_path);
679
680 repo.create_tag("overwrite-test", None).unwrap();
682
683 let result = repo.create_tag("overwrite-test", None);
685 assert!(result.is_err());
686
687 let options = TagOptions::new().with_force();
689 let result = repo.create_tag_with_options("overwrite-test", None, options);
690 assert!(result.is_ok());
691
692 fs::remove_dir_all(&test_path).unwrap();
694 }
695}