use std::{fmt::Display, str::FromStr};
use bstr::ByteSlice;
use crate::{
errors::GitError,
hash::ObjectHash,
internal::object::{ObjectTrait, ObjectType, signature::Signature},
};
#[derive(Eq, Debug, Clone)]
pub struct Tag {
pub id: ObjectHash,
pub object_hash: ObjectHash,
pub object_type: ObjectType,
pub tag_name: String,
pub tagger: Signature,
pub message: String,
}
impl PartialEq for Tag {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Display for Tag {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"object {}\ntype {}\ntag {}\ntagger {}\n\n{}",
self.object_hash, self.object_type, self.tag_name, self.tagger, self.message
)
}
}
impl Tag {
pub fn new(
object_hash: ObjectHash,
object_type: ObjectType,
tag_name: String,
tagger: Signature,
message: String,
) -> Self {
let mut tag = Self {
id: ObjectHash::default(),
object_hash,
object_type,
tag_name,
tagger,
message,
};
tag.id = ObjectHash::from_type_and_data(ObjectType::Tag, &tag.to_data().unwrap());
tag
}
}
impl ObjectTrait for Tag {
fn from_bytes(row_data: &[u8], hash: ObjectHash) -> Result<Self, GitError>
where
Self: Sized,
{
let mut headers = row_data;
let mut message_start = 0;
if let Some(pos) = headers.find(b"\n\n") {
message_start = pos + 2;
headers = &headers[..pos];
}
let mut object_hash: Option<ObjectHash> = None;
let mut object_type: Option<ObjectType> = None;
let mut tag_name: Option<String> = None;
let mut tagger: Option<Signature> = None;
for line in headers.lines() {
if let Some(s) = line.strip_prefix(b"object ") {
let hash_str = s.to_str().map_err(|_| {
GitError::InvalidTagObject("Invalid UTF-8 in object hash".to_string())
})?;
object_hash = Some(ObjectHash::from_str(hash_str).map_err(|_| {
GitError::InvalidTagObject("Invalid object hash format".to_string())
})?);
} else if let Some(s) = line.strip_prefix(b"type ") {
let type_str = s.to_str().map_err(|_| {
GitError::InvalidTagObject("Invalid UTF-8 in object type".to_string())
})?;
object_type = Some(ObjectType::from_string(type_str)?);
} else if let Some(s) = line.strip_prefix(b"tag ") {
let tag_str = s.to_str().map_err(|_| {
GitError::InvalidTagObject("Invalid UTF-8 in tag name".to_string())
})?;
tag_name = Some(tag_str.to_string());
} else if line.starts_with(b"tagger ") {
tagger = Some(Signature::from_data(line.to_vec())?);
}
}
let message = if message_start > 0 {
String::from_utf8_lossy(&row_data[message_start..]).to_string()
} else {
String::new()
};
Ok(Tag {
id: hash,
object_hash: object_hash
.ok_or_else(|| GitError::InvalidTagObject("Missing object hash".to_string()))?,
object_type: object_type
.ok_or_else(|| GitError::InvalidTagObject("Missing object type".to_string()))?,
tag_name: tag_name
.ok_or_else(|| GitError::InvalidTagObject("Missing tag name".to_string()))?,
tagger: tagger
.ok_or_else(|| GitError::InvalidTagObject("Missing tagger".to_string()))?,
message,
})
}
fn get_type(&self) -> ObjectType {
ObjectType::Tag
}
fn get_size(&self) -> usize {
self.to_data().map(|data| data.len()).unwrap_or(0)
}
fn to_data(&self) -> Result<Vec<u8>, GitError> {
let mut data = Vec::new();
data.extend_from_slice(b"object ");
data.extend_from_slice(self.object_hash.to_string().as_bytes());
data.extend_from_slice(b"\n");
data.extend_from_slice(b"type ");
data.extend_from_slice(self.object_type.to_string().as_bytes());
data.extend_from_slice(b"\n");
data.extend_from_slice(b"tag ");
data.extend_from_slice(self.tag_name.as_bytes());
data.extend_from_slice(b"\n");
data.extend_from_slice(&self.tagger.to_data()?);
data.extend_from_slice(b"\n\n");
data.extend_from_slice(self.message.as_bytes());
Ok(data)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
hash::{HashKind, ObjectHash, set_hash_kind_for_test},
internal::object::signature::{Signature, SignatureType},
};
fn make_sig() -> Signature {
Signature::new(
SignatureType::Tagger,
"tagger".to_string(),
"tagger@example.com".to_string(),
)
}
fn round_trip(kind: HashKind) {
let _guard = set_hash_kind_for_test(kind);
let target = match kind {
HashKind::Sha1 => {
ObjectHash::from_str("1234567890abcdef1234567890abcdef12345678").unwrap()
}
HashKind::Sha256 => ObjectHash::from_str(
"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
)
.unwrap(),
};
let sig = make_sig();
let tag = Tag::new(
target,
ObjectType::Commit,
"v1.0.0".to_string(),
sig.clone(),
"release".to_string(),
);
let data = tag.to_data().unwrap();
let parsed = Tag::from_bytes(&data, tag.id).unwrap();
assert_eq!(parsed.id, tag.id);
assert_eq!(parsed.object_hash, target);
assert_eq!(parsed.object_type, ObjectType::Commit);
assert_eq!(parsed.tag_name, "v1.0.0");
assert_eq!(parsed.message, "release");
assert_eq!(parsed.tagger.to_string(), sig.to_string());
}
#[tokio::test]
async fn tag_round_trip() {
round_trip(HashKind::Sha1);
round_trip(HashKind::Sha256);
}
#[test]
fn tag_id_is_canonical_hash_of_to_data() {
for kind in [HashKind::Sha1, HashKind::Sha256] {
let _guard = set_hash_kind_for_test(kind);
let target = match kind {
HashKind::Sha1 => {
ObjectHash::from_str("1234567890abcdef1234567890abcdef12345678").unwrap()
}
HashKind::Sha256 => ObjectHash::from_str(
"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
)
.unwrap(),
};
let tag = Tag::new(
target,
ObjectType::Commit,
"v1.0.0".to_string(),
make_sig(),
"release".to_string(),
);
let canonical =
ObjectHash::from_type_and_data(ObjectType::Tag, &tag.to_data().unwrap());
assert_eq!(
tag.id, canonical,
"tag id must be the canonical hash of its to_data() bytes ({kind:?})"
);
}
}
#[test]
fn tag_invalid_missing_fields_errors() {
let _guard = set_hash_kind_for_test(HashKind::Sha1);
let bad = b"type commit\ntag v1.0.0\n\nno object line".to_vec();
let hash = ObjectHash::from_str("ffffffffffffffffffffffffffffffffffffffff").unwrap();
assert!(Tag::from_bytes(&bad, hash).is_err());
}
}