use crate::commands::log::Author;
use crate::error::{GitError, Result};
use crate::repository::Repository;
use crate::types::Hash;
use crate::utils::{git, parse_unix_timestamp};
use chrono::{DateTime, Utc};
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub struct Tag {
pub name: String,
pub hash: Hash,
pub tag_type: TagType,
pub message: Option<String>,
pub tagger: Option<Author>,
pub timestamp: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TagType {
Lightweight,
Annotated,
}
impl fmt::Display for TagType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TagType::Lightweight => write!(f, "lightweight"),
TagType::Annotated => write!(f, "annotated"),
}
}
}
#[derive(Debug, Clone)]
pub struct TagList {
tags: Box<[Tag]>,
}
impl TagList {
pub fn new(mut tags: Vec<Tag>) -> Self {
tags.sort_by(|a, b| a.name.cmp(&b.name));
Self {
tags: tags.into_boxed_slice(),
}
}
pub fn iter(&self) -> impl Iterator<Item = &Tag> + '_ {
self.tags.iter()
}
pub fn lightweight(&self) -> impl Iterator<Item = &Tag> + '_ {
self.tags
.iter()
.filter(|tag| tag.tag_type == TagType::Lightweight)
}
pub fn annotated(&self) -> impl Iterator<Item = &Tag> + '_ {
self.tags
.iter()
.filter(|tag| tag.tag_type == TagType::Annotated)
}
pub fn find(&self, name: &str) -> Option<&Tag> {
self.tags.iter().find(|tag| tag.name == name)
}
pub fn find_containing<'a>(&'a self, substring: &'a str) -> impl Iterator<Item = &'a Tag> + 'a {
self.tags
.iter()
.filter(move |tag| tag.name.contains(substring))
}
pub fn len(&self) -> usize {
self.tags.len()
}
pub fn is_empty(&self) -> bool {
self.tags.is_empty()
}
pub fn lightweight_count(&self) -> usize {
self.lightweight().count()
}
pub fn annotated_count(&self) -> usize {
self.annotated().count()
}
pub fn for_commit<'a>(&'a self, hash: &'a Hash) -> impl Iterator<Item = &'a Tag> + 'a {
self.tags.iter().filter(move |tag| &tag.hash == hash)
}
}
#[derive(Debug, Clone, Default)]
pub struct TagOptions {
pub annotated: bool,
pub force: bool,
pub message: Option<String>,
pub sign: bool,
}
impl TagOptions {
pub fn new() -> Self {
Self::default()
}
pub fn with_annotated(mut self) -> Self {
self.annotated = true;
self
}
pub fn with_force(mut self) -> Self {
self.force = true;
self
}
pub fn with_message(mut self, message: String) -> Self {
self.message = Some(message);
self.annotated = true; self
}
pub fn with_sign(mut self) -> Self {
self.sign = true;
self.annotated = true; self
}
}
impl Repository {
pub fn tags(&self) -> Result<TagList> {
Self::ensure_git()?;
let output = git(
&[
"for-each-ref",
"--format=%(refname:short)|%(objecttype)|%(objectname)|%(*objectname)|%(taggername)|%(taggeremail)|%(taggerdate:unix)|%(subject)|%(body)",
"refs/tags/",
],
Some(self.repo_path()),
)?;
if output.trim().is_empty() {
return Ok(TagList::new(vec![]));
}
let mut tags = Vec::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(tag) = parse_for_each_ref_line(line) {
tags.push(tag);
}
}
Ok(TagList::new(tags))
}
pub fn create_tag(&self, name: &str, target: Option<&Hash>) -> Result<Tag> {
self.create_tag_with_options(name, target, TagOptions::new())
}
pub fn create_tag_with_options(
&self,
name: &str,
target: Option<&Hash>,
options: TagOptions,
) -> Result<Tag> {
Self::ensure_git()?;
let mut args = vec!["tag"];
if options.annotated || options.message.is_some() {
args.push("-a");
}
if options.force {
args.push("-f");
}
if options.sign {
args.push("-s");
}
if let Some(ref message) = options.message {
args.push("-m");
args.push(message);
}
args.push(name);
if let Some(target_hash) = target {
args.push(target_hash.as_str());
}
git(&args, Some(self.repo_path()))?;
let show_output = git(&["show", "--format=fuller", name], Some(self.repo_path()))?;
parse_tag_info(name, &show_output)
}
pub fn delete_tag(&self, name: &str) -> Result<()> {
Self::ensure_git()?;
git(&["tag", "-d", name], Some(self.repo_path()))?;
Ok(())
}
pub fn show_tag(&self, name: &str) -> Result<Tag> {
Self::ensure_git()?;
let show_output = git(&["show", "--format=fuller", name], Some(self.repo_path()))?;
parse_tag_info(name, &show_output)
}
}
fn parse_for_each_ref_line(line: &str) -> Result<Tag> {
let parts: Vec<&str> = line.split('|').collect();
if parts.len() < 9 {
return Err(GitError::CommandFailed(format!(
"Invalid for-each-ref format: expected 9 parts, got {}",
parts.len()
)));
}
let name = parts[0].to_string();
let object_type = parts[1];
let object_name = parts[2];
let dereferenced_object = parts[3]; let tagger_name = parts[4];
let tagger_email = parts[5];
let tagger_date = parts[6];
let subject = parts[7];
let body = parts[8];
let (tag_type, hash) = if object_type == "tag" {
(TagType::Annotated, Hash::from(dereferenced_object))
} else {
(TagType::Lightweight, Hash::from(object_name))
};
let tagger =
if tag_type == TagType::Annotated && !tagger_name.is_empty() && !tagger_email.is_empty() {
let timestamp = parse_unix_timestamp(tagger_date).unwrap_or_else(|_| {
DateTime::from_timestamp(0, 0).unwrap()
});
Some(Author {
name: tagger_name.to_string(),
email: tagger_email.to_string(),
timestamp,
})
} else {
None
};
let message = if tag_type == TagType::Annotated && (!subject.is_empty() || !body.is_empty()) {
let full_message = if !body.is_empty() {
format!("{}\n\n{}", subject, body)
} else {
subject.to_string()
};
Some(full_message.trim().to_string())
} else {
None
};
let timestamp = if tag_type == TagType::Annotated {
tagger.as_ref().map(|t| t.timestamp)
} else {
None
};
Ok(Tag {
name,
hash,
tag_type,
message,
tagger,
timestamp,
})
}
fn parse_tag_info(tag_name: &str, show_output: &str) -> Result<Tag> {
let lines: Vec<&str> = show_output.lines().collect();
let is_annotated = show_output.contains("tag ") && show_output.contains("Tagger:");
if is_annotated {
parse_annotated_tag(tag_name, &lines)
} else {
parse_lightweight_tag(tag_name, &lines)
}
}
fn parse_annotated_tag(tag_name: &str, lines: &[&str]) -> Result<Tag> {
let mut hash = None;
let mut tagger = None;
let mut collecting_message = false;
let mut message_lines = Vec::new();
for line in lines {
if line.starts_with("commit ") {
if let Some(hash_str) = line.split_whitespace().nth(1) {
hash = Some(Hash::from(hash_str));
}
} else if let Some(stripped) = line.strip_prefix("Tagger: ") {
tagger = parse_author_line(stripped);
} else if line.trim().is_empty() && !collecting_message {
collecting_message = true;
} else if collecting_message && !line.starts_with("commit ") && !line.starts_with("Author:")
{
message_lines.push(line.trim());
}
}
let message_text = if message_lines.is_empty() {
None
} else {
Some(message_lines.join("\n").trim().to_string())
};
let timestamp = tagger.as_ref().map(|t| t.timestamp);
Ok(Tag {
name: tag_name.to_string(),
hash: hash.ok_or_else(|| {
GitError::CommandFailed("Could not parse tag commit hash".to_string())
})?,
tag_type: TagType::Annotated,
message: message_text,
tagger,
timestamp,
})
}
fn parse_lightweight_tag(tag_name: &str, lines: &[&str]) -> Result<Tag> {
let mut hash = None;
for line in lines {
if line.starts_with("commit ")
&& let Some(hash_str) = line.split_whitespace().nth(1)
{
hash = Some(Hash::from(hash_str));
break;
}
}
Ok(Tag {
name: tag_name.to_string(),
hash: hash.ok_or_else(|| {
GitError::CommandFailed("Could not parse tag commit hash".to_string())
})?,
tag_type: TagType::Lightweight,
message: None,
tagger: None,
timestamp: None,
})
}
fn parse_author_line(line: &str) -> Option<Author> {
if let Some(email_start) = line.find('<')
&& let Some(email_end) = line.find('>')
{
let name = line[..email_start].trim().to_string();
let email = line[email_start + 1..email_end].to_string();
let timestamp = Utc::now();
return Some(Author {
name,
email,
timestamp,
});
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::fs;
fn create_test_repo() -> (Repository, std::path::PathBuf) {
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let thread_id = format!("{:?}", thread::current().id());
let test_path = env::temp_dir().join(format!(
"rustic_git_tag_test_{}_{}_{}",
std::process::id(),
timestamp,
thread_id.replace("ThreadId(", "").replace(")", "")
));
if test_path.exists() {
fs::remove_dir_all(&test_path).unwrap();
}
let repo = Repository::init(&test_path, false).unwrap();
repo.config()
.set_user("Test User", "test@example.com")
.unwrap();
repo.config().set("tag.gpgsign", "false").unwrap();
(repo, test_path)
}
fn create_test_commit(repo: &Repository, test_path: &std::path::Path) {
fs::write(test_path.join("test.txt"), "test content").unwrap();
repo.add(&["test.txt"]).unwrap();
repo.commit("Test commit").unwrap();
}
#[test]
fn test_tag_list_empty_repository() {
let (repo, test_path) = create_test_repo();
let tags = repo.tags().unwrap();
assert!(tags.is_empty());
assert_eq!(tags.len(), 0);
fs::remove_dir_all(&test_path).unwrap();
}
#[test]
fn test_create_lightweight_tag() {
let (repo, test_path) = create_test_repo();
create_test_commit(&repo, &test_path);
let tag = repo.create_tag("v1.0.0", None).unwrap();
assert_eq!(tag.name, "v1.0.0");
assert_eq!(tag.tag_type, TagType::Lightweight);
assert!(tag.message.is_none());
assert!(tag.tagger.is_none());
let tags = repo.tags().unwrap();
assert_eq!(tags.len(), 1);
assert!(tags.find("v1.0.0").is_some());
fs::remove_dir_all(&test_path).unwrap();
}
#[test]
fn test_create_annotated_tag() {
let (repo, test_path) = create_test_repo();
create_test_commit(&repo, &test_path);
let options = TagOptions::new().with_message("Release version 1.0.0".to_string());
let tag = repo
.create_tag_with_options("v1.0.0", None, options)
.unwrap();
assert_eq!(tag.name, "v1.0.0");
assert_eq!(tag.tag_type, TagType::Annotated);
assert!(tag.message.is_some());
fs::remove_dir_all(&test_path).unwrap();
}
#[test]
fn test_delete_tag() {
let (repo, test_path) = create_test_repo();
create_test_commit(&repo, &test_path);
repo.create_tag("to-delete", None).unwrap();
let tags = repo.tags().unwrap();
assert_eq!(tags.len(), 1);
repo.delete_tag("to-delete").unwrap();
let tags = repo.tags().unwrap();
assert_eq!(tags.len(), 0);
fs::remove_dir_all(&test_path).unwrap();
}
#[test]
fn test_tag_list_filtering() {
let (repo, test_path) = create_test_repo();
create_test_commit(&repo, &test_path);
repo.create_tag("v1.0.0", None).unwrap();
repo.create_tag("v1.1.0", None).unwrap();
let options = TagOptions::new().with_message("Annotated".to_string());
repo.create_tag_with_options("v2.0.0", None, options)
.unwrap();
let tags = repo.tags().unwrap();
assert_eq!(tags.len(), 3);
assert_eq!(tags.lightweight_count(), 2);
assert_eq!(tags.annotated_count(), 1);
let v1_tags: Vec<_> = tags.find_containing("v1").collect();
assert_eq!(v1_tags.len(), 2);
fs::remove_dir_all(&test_path).unwrap();
}
#[test]
fn test_tag_options_builder() {
let options = TagOptions::new()
.with_annotated()
.with_force()
.with_message("Test message".to_string());
assert!(options.annotated);
assert!(options.force);
assert_eq!(options.message, Some("Test message".to_string()));
}
#[test]
fn test_show_tag() {
let (repo, test_path) = create_test_repo();
create_test_commit(&repo, &test_path);
repo.create_tag("show-test", None).unwrap();
let tag = repo.show_tag("show-test").unwrap();
assert_eq!(tag.name, "show-test");
assert_eq!(tag.tag_type, TagType::Lightweight);
fs::remove_dir_all(&test_path).unwrap();
}
#[test]
fn test_tag_force_overwrite() {
let (repo, test_path) = create_test_repo();
create_test_commit(&repo, &test_path);
repo.create_tag("overwrite-test", None).unwrap();
let result = repo.create_tag("overwrite-test", None);
assert!(result.is_err());
let options = TagOptions::new().with_force();
let result = repo.create_tag_with_options("overwrite-test", None, options);
assert!(result.is_ok());
fs::remove_dir_all(&test_path).unwrap();
}
#[test]
fn test_parse_for_each_ref_line_invalid_format() {
let invalid_line = "tag1|commit|abc123"; let result = parse_for_each_ref_line(invalid_line);
assert!(result.is_err());
if let Err(GitError::CommandFailed(msg)) = result {
assert!(msg.contains("Invalid for-each-ref format"));
assert!(msg.contains("expected 9 parts"));
assert!(msg.contains("got 3"));
} else {
panic!("Expected CommandFailed error with specific message");
}
}
#[test]
fn test_parse_for_each_ref_line_with_invalid_timestamp() {
let line_with_invalid_timestamp =
"v1.0.0|tag|abc123|def456|John Doe|john@example.com|invalid-timestamp|Subject|Body";
let result = parse_for_each_ref_line(line_with_invalid_timestamp);
assert!(result.is_ok());
let tag = result.unwrap();
assert_eq!(tag.name, "v1.0.0");
assert_eq!(tag.tag_type, TagType::Annotated);
assert!(tag.tagger.is_some());
let tagger = tag.tagger.unwrap();
assert_eq!(tagger.name, "John Doe");
assert_eq!(tagger.email, "john@example.com");
assert_eq!(tagger.timestamp.timestamp(), 0); assert_eq!(
tagger.timestamp.format("%Y-%m-%d").to_string(),
"1970-01-01"
);
}
}