rustic_git/commands/
tag.rs

1//! Git tag operations
2//!
3//! This module provides functionality for creating, listing, deleting, and managing Git tags.
4//! It supports both lightweight and annotated tags with comprehensive type safety.
5//!
6//! # Examples
7//!
8//! ```rust,no_run
9//! use rustic_git::{Repository, TagType, TagOptions};
10//!
11//! let repo = Repository::open(".")?;
12//!
13//! // List all tags
14//! let tags = repo.tags()?;
15//! for tag in tags.iter() {
16//!     println!("{} -> {}", tag.name, tag.hash.short());
17//! }
18//!
19//! // Create a lightweight tag
20//! let tag = repo.create_tag("v1.0.0", None)?;
21//!
22//! // Create an annotated tag
23//! let options = TagOptions::new()
24//!     .with_message("Release version 1.0.0".to_string())
25//!     .with_annotated();
26//! let tag = repo.create_tag_with_options("v1.0.0-rc1", None, options)?;
27//!
28//! # Ok::<(), rustic_git::GitError>(())
29//! ```
30
31use 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/// Represents a Git tag
39#[derive(Debug, Clone, PartialEq)]
40pub struct Tag {
41    /// The name of the tag
42    pub name: String,
43    /// The commit hash this tag points to
44    pub hash: Hash,
45    /// The type of tag (lightweight or annotated)
46    pub tag_type: TagType,
47    /// The tag message (only for annotated tags)
48    pub message: Option<String>,
49    /// The tagger information (only for annotated tags)
50    pub tagger: Option<Author>,
51    /// The tag creation timestamp (only for annotated tags)
52    pub timestamp: Option<DateTime<Utc>>,
53}
54
55/// Type of Git tag
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum TagType {
58    /// Lightweight tag - just a reference to a commit
59    Lightweight,
60    /// Annotated tag - full object with message, author, and date
61    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/// Author information for annotated tags
74#[derive(Debug, Clone, PartialEq)]
75pub struct Author {
76    /// Author name
77    pub name: String,
78    /// Author email
79    pub email: String,
80    /// Author timestamp
81    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/// A collection of tags with efficient iteration and filtering methods
91#[derive(Debug, Clone)]
92pub struct TagList {
93    tags: Box<[Tag]>,
94}
95
96impl TagList {
97    /// Create a new TagList from a vector of tags
98    pub fn new(mut tags: Vec<Tag>) -> Self {
99        // Sort tags by name for consistent ordering
100        tags.sort_by(|a, b| a.name.cmp(&b.name));
101        Self {
102            tags: tags.into_boxed_slice(),
103        }
104    }
105
106    /// Get an iterator over all tags
107    pub fn iter(&self) -> impl Iterator<Item = &Tag> + '_ {
108        self.tags.iter()
109    }
110
111    /// Get an iterator over lightweight tags only
112    pub fn lightweight(&self) -> impl Iterator<Item = &Tag> + '_ {
113        self.tags
114            .iter()
115            .filter(|tag| tag.tag_type == TagType::Lightweight)
116    }
117
118    /// Get an iterator over annotated tags only
119    pub fn annotated(&self) -> impl Iterator<Item = &Tag> + '_ {
120        self.tags
121            .iter()
122            .filter(|tag| tag.tag_type == TagType::Annotated)
123    }
124
125    /// Find a tag by exact name
126    pub fn find(&self, name: &str) -> Option<&Tag> {
127        self.tags.iter().find(|tag| tag.name == name)
128    }
129
130    /// Find tags whose names contain the given substring
131    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    /// Get the total number of tags
138    pub fn len(&self) -> usize {
139        self.tags.len()
140    }
141
142    /// Check if the tag list is empty
143    pub fn is_empty(&self) -> bool {
144        self.tags.is_empty()
145    }
146
147    /// Get the number of lightweight tags
148    pub fn lightweight_count(&self) -> usize {
149        self.lightweight().count()
150    }
151
152    /// Get the number of annotated tags
153    pub fn annotated_count(&self) -> usize {
154        self.annotated().count()
155    }
156
157    /// Get tags that point to a specific commit
158    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/// Options for creating tags
164#[derive(Debug, Clone, Default)]
165pub struct TagOptions {
166    /// Create an annotated tag (default: false - lightweight)
167    pub annotated: bool,
168    /// Force tag creation (overwrite existing tag)
169    pub force: bool,
170    /// Tag message (for annotated tags)
171    pub message: Option<String>,
172    /// Sign the tag with GPG (requires annotated)
173    pub sign: bool,
174}
175
176impl TagOptions {
177    /// Create new default tag options
178    pub fn new() -> Self {
179        Self::default()
180    }
181
182    /// Create an annotated tag instead of lightweight
183    pub fn with_annotated(mut self) -> Self {
184        self.annotated = true;
185        self
186    }
187
188    /// Force tag creation (overwrite existing)
189    pub fn with_force(mut self) -> Self {
190        self.force = true;
191        self
192    }
193
194    /// Set the tag message (implies annotated)
195    pub fn with_message(mut self, message: String) -> Self {
196        self.message = Some(message);
197        self.annotated = true; // Message implies annotated tag
198        self
199    }
200
201    /// Sign the tag with GPG (implies annotated)
202    pub fn with_sign(mut self) -> Self {
203        self.sign = true;
204        self.annotated = true; // Signing implies annotated tag
205        self
206    }
207}
208
209impl Repository {
210    /// List all tags in the repository
211    ///
212    /// Returns a `TagList` containing all tags sorted by name.
213    ///
214    /// # Example
215    ///
216    /// ```rust,no_run
217    /// use rustic_git::Repository;
218    ///
219    /// let repo = Repository::open(".")?;
220    /// let tags = repo.tags()?;
221    ///
222    /// println!("Found {} tags:", tags.len());
223    /// for tag in tags.iter() {
224    ///     println!("  {} ({}) -> {}", tag.name, tag.tag_type, tag.hash.short());
225    /// }
226    /// # Ok::<(), rustic_git::GitError>(())
227    /// ```
228    pub fn tags(&self) -> Result<TagList> {
229        Self::ensure_git()?;
230
231        // Get list of tag names
232        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            // Get tag information
247            let show_output = git(
248                &["show", "--format=fuller", tag_name],
249                Some(self.repo_path()),
250            )?;
251
252            // Parse tag information
253            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    /// Create a lightweight tag pointing to the current HEAD or specified commit
262    ///
263    /// # Arguments
264    ///
265    /// * `name` - The name of the tag to create
266    /// * `target` - Optional commit hash to tag (defaults to HEAD)
267    ///
268    /// # Example
269    ///
270    /// ```rust,no_run
271    /// use rustic_git::Repository;
272    ///
273    /// let repo = Repository::open(".")?;
274    ///
275    /// // Tag current HEAD
276    /// let tag = repo.create_tag("v1.0.0", None)?;
277    ///
278    /// // Tag specific commit
279    /// let commits = repo.recent_commits(1)?;
280    /// if let Some(commit) = commits.iter().next() {
281    ///     let tag = repo.create_tag("v0.9.0", Some(&commit.hash))?;
282    /// }
283    /// # Ok::<(), rustic_git::GitError>(())
284    /// ```
285    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    /// Create a tag with custom options
290    ///
291    /// # Arguments
292    ///
293    /// * `name` - The name of the tag to create
294    /// * `target` - Optional commit hash to tag (defaults to HEAD)
295    /// * `options` - Tag creation options
296    ///
297    /// # Example
298    ///
299    /// ```rust,no_run
300    /// use rustic_git::{Repository, TagOptions};
301    ///
302    /// let repo = Repository::open(".")?;
303    ///
304    /// // Create annotated tag with message
305    /// let options = TagOptions::new()
306    ///     .with_message("Release version 1.0.0".to_string());
307    /// let tag = repo.create_tag_with_options("v1.0.0", None, options)?;
308    ///
309    /// // Create and force overwrite existing tag
310    /// let options = TagOptions::new().with_force();
311    /// let tag = repo.create_tag_with_options("latest", None, options)?;
312    /// # Ok::<(), rustic_git::GitError>(())
313    /// ```
314    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        // Get the created tag information
350        let show_output = git(&["show", "--format=fuller", name], Some(self.repo_path()))?;
351        parse_tag_info(name, &show_output)
352    }
353
354    /// Delete a tag
355    ///
356    /// # Arguments
357    ///
358    /// * `name` - The name of the tag to delete
359    ///
360    /// # Example
361    ///
362    /// ```rust,no_run
363    /// use rustic_git::Repository;
364    ///
365    /// let repo = Repository::open(".")?;
366    /// repo.delete_tag("v0.1.0")?;
367    /// # Ok::<(), rustic_git::GitError>(())
368    /// ```
369    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    /// Show detailed information about a specific tag
377    ///
378    /// # Arguments
379    ///
380    /// * `name` - The name of the tag to show
381    ///
382    /// # Example
383    ///
384    /// ```rust,no_run
385    /// use rustic_git::Repository;
386    ///
387    /// let repo = Repository::open(".")?;
388    /// let tag = repo.show_tag("v1.0.0")?;
389    ///
390    /// println!("Tag: {} ({})", tag.name, tag.tag_type);
391    /// println!("Commit: {}", tag.hash.short());
392    /// if let Some(message) = &tag.message {
393    ///     println!("Message: {}", message);
394    /// }
395    /// # Ok::<(), rustic_git::GitError>(())
396    /// ```
397    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
405/// Parse tag information from git show output
406fn parse_tag_info(tag_name: &str, show_output: &str) -> Result<Tag> {
407    let lines: Vec<&str> = show_output.lines().collect();
408
409    // Determine if this is an annotated tag or lightweight tag
410    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
419/// Parse annotated tag information
420fn 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
461/// Parse lightweight tag information
462fn 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
486/// Parse author information from a git log line
487fn parse_author_line(line: &str) -> Option<Author> {
488    // Parse format: "Name <email> timestamp timezone"
489    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        // Parse timestamp (simplified - just use current time for now)
496        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        // Ensure clean state
530        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        // Configure git user for commits
537        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        // Clean up
559        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        // Verify tag exists in list
574        let tags = repo.tags().unwrap();
575        assert_eq!(tags.len(), 1);
576        assert!(tags.find("v1.0.0").is_some());
577
578        // Clean up
579        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        // Clean up
597        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        // Create a tag
606        repo.create_tag("to-delete", None).unwrap();
607
608        // Verify it exists
609        let tags = repo.tags().unwrap();
610        assert_eq!(tags.len(), 1);
611
612        // Delete it
613        repo.delete_tag("to-delete").unwrap();
614
615        // Verify it's gone
616        let tags = repo.tags().unwrap();
617        assert_eq!(tags.len(), 0);
618
619        // Clean up
620        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        // Create multiple tags
629        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        // Test filtering
641        let v1_tags: Vec<_> = tags.find_containing("v1").collect();
642        assert_eq!(v1_tags.len(), 2);
643
644        // Clean up
645        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        // Clean up
672        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        // Create initial tag
681        repo.create_tag("overwrite-test", None).unwrap();
682
683        // Try to create again without force (should fail)
684        let result = repo.create_tag("overwrite-test", None);
685        assert!(result.is_err());
686
687        // Create with force (should succeed)
688        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        // Clean up
693        fs::remove_dir_all(&test_path).unwrap();
694    }
695}