use serde::de::{Error as DeError, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
use std::num::ParseIntError;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitInfo {
pub tag: Option<String>,
pub commits_past_tag: Option<usize>,
pub commit_hash_short: String,
pub dirty: bool,
}
impl GitInfo {
pub fn new<T: Into<String>>(
tag: Option<T>,
commits_past_tag: Option<usize>,
commit_hash_short: T,
dirty: bool,
) -> Self {
GitInfo {
tag: tag.map(Into::into),
commits_past_tag,
commit_hash_short: commit_hash_short.into(),
dirty,
}
}
}
impl fmt::Display for GitInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let (Some(tag), Some(count)) = (&self.tag, &self.commits_past_tag) {
if self.dirty {
write!(f, "{}-{}-{}-dirty", tag, count, self.commit_hash_short)
} else {
write!(f, "{}-{}-{}", tag, count, self.commit_hash_short)
}
} else {
if self.dirty {
write!(f, "{}-dirty", self.commit_hash_short)
} else {
write!(f, "{}", self.commit_hash_short)
}
}
}
}
impl FromStr for GitInfo {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.trim();
if s.is_empty() {
return Err("Empty input string".into());
}
let (base, dirty) = if let Some(stripped) = s.strip_suffix("-dirty") {
if stripped.is_empty() {
return Err("Empty GitInfo before `-dirty`".into());
}
(stripped, true)
} else {
(s, false)
};
if let Some(idx1) = base.rfind('-') {
let after_idx1 = &base[idx1 + 1..];
if after_idx1.is_empty() {
return Err("Empty hash after last '-'".into());
}
if let Some(idx2) = base[..idx1].rfind('-') {
let tag_part = base[..idx2].trim();
let commits_part = base[idx2 + 1..idx1].trim();
let hash_part = after_idx1.trim();
if tag_part.is_empty() {
return Err("Tag is empty before commits count".into());
}
if commits_part.is_empty() {
return Err("Commits-past-tag portion is empty".into());
}
let commits_num: usize = commits_part.parse().map_err(|e: ParseIntError| {
format!("Invalid commits-past-tag `{}`: {}", commits_part, e)
})?;
if !hash_part.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!("Invalid commit-hash `{}`", hash_part));
}
return Ok(GitInfo {
tag: Some(tag_part.to_string()),
commits_past_tag: Some(commits_num),
commit_hash_short: hash_part.to_string(),
dirty,
});
}
}
let hash_candidate = base;
if !hash_candidate.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!("Invalid commit-hash `{}`", hash_candidate));
}
Ok(GitInfo {
tag: None,
commits_past_tag: None,
commit_hash_short: hash_candidate.to_string(),
dirty,
})
}
}
impl Serialize for GitInfo {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let as_str = self.to_string();
serializer.serialize_str(&as_str)
}
}
struct GitInfoVisitor;
impl<'de> Visitor<'de> for GitInfoVisitor {
type Value = GitInfo;
fn expecting(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
write!(
fmt,
"a string of the form \"tag-<count>-<hash>[-dirty]\" or just \"<hash>[-dirty]\""
)
}
fn visit_str<E>(self, v: &str) -> Result<GitInfo, E>
where
E: DeError,
{
GitInfo::from_str(v).map_err(DeError::custom)
}
fn visit_string<E>(self, v: String) -> Result<GitInfo, E>
where
E: DeError,
{
GitInfo::from_str(&v).map_err(DeError::custom)
}
}
impl<'de> Deserialize<'de> for GitInfo {
fn deserialize<D>(deserializer: D) -> Result<GitInfo, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_string(GitInfoVisitor)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json;
#[test]
fn gitinfo_display_with_tag_clean() {
let gi = GitInfo::new(Some("v0.2.0"), Some(95), "5a85959", false);
assert_eq!(gi.to_string(), "v0.2.0-95-5a85959");
let gi2 = GitInfo::new(Some("release-1.2.3"), Some(4), "abcd123", false);
assert_eq!(gi2.to_string(), "release-1.2.3-4-abcd123");
}
#[test]
fn gitinfo_display_with_tag_dirty() {
let gi = GitInfo::new(Some("v0.2.0"), Some(95), "5a85959", true);
assert_eq!(gi.to_string(), "v0.2.0-95-5a85959-dirty");
let gi2 = GitInfo::new(Some("release-1.2.3"), Some(4), "abcd123", true);
assert_eq!(gi2.to_string(), "release-1.2.3-4-abcd123-dirty");
}
#[test]
fn gitinfo_display_without_tag_clean() {
let gi = GitInfo::new(None::<&str>, None, "deadbeef", false);
assert_eq!(gi.to_string(), "deadbeef");
}
#[test]
fn gitinfo_display_without_tag_dirty() {
let gi = GitInfo::new(None::<&str>, None, "deadbeef", true);
assert_eq!(gi.to_string(), "deadbeef-dirty");
}
#[test]
fn gitinfo_from_str_with_tag_clean() {
let s = "v0.2.0-95-5a85959";
let gi: GitInfo = s.parse().expect("Parsing failed");
assert_eq!(
gi,
GitInfo {
tag: Some("v0.2.0".into()),
commits_past_tag: Some(95),
commit_hash_short: "5a85959".into(),
dirty: false,
}
);
let s2 = "release-1.2.3-4-abcd123";
let gi2: GitInfo = s2.parse().expect("Parsing failed");
assert_eq!(
gi2,
GitInfo {
tag: Some("release-1.2.3".into()),
commits_past_tag: Some(4),
commit_hash_short: "abcd123".into(),
dirty: false,
}
);
}
#[test]
fn gitinfo_from_str_with_tag_dirty() {
let s = "v0.2.0-95-5a85959-dirty";
let gi: GitInfo = s.parse().expect("Parsing failed");
assert_eq!(
gi,
GitInfo {
tag: Some("v0.2.0".into()),
commits_past_tag: Some(95),
commit_hash_short: "5a85959".into(),
dirty: true,
}
);
let s2 = "release-1.2.3-4-abcd123-dirty";
let gi2: GitInfo = s2.parse().expect("Parsing failed");
assert_eq!(
gi2,
GitInfo {
tag: Some("release-1.2.3".into()),
commits_past_tag: Some(4),
commit_hash_short: "abcd123".into(),
dirty: true,
}
);
}
#[test]
fn gitinfo_from_str_without_tag_clean() {
let s = "abcdef";
let gi: GitInfo = s.parse().expect("Parsing failed");
assert_eq!(
gi,
GitInfo {
tag: None,
commits_past_tag: None,
commit_hash_short: "abcdef".into(),
dirty: false,
}
);
}
#[test]
fn gitinfo_from_str_without_tag_dirty() {
let s = "abcdef-dirty";
let gi: GitInfo = s.parse().expect("Parsing failed");
assert_eq!(
gi,
GitInfo {
tag: None,
commits_past_tag: None,
commit_hash_short: "abcdef".into(),
dirty: true,
}
);
}
#[test]
fn gitinfo_from_str_invalid() {
let err = GitInfo::from_str("v1.0.0-5-").unwrap_err();
assert!(err.contains("Empty hash"), "Unexpected error: {}", err);
let err2 = GitInfo::from_str("v1.0.0-xx-abcdef").unwrap_err();
assert!(
err2.contains("Invalid commits-past-tag"),
"Unexpected error: {}",
err2
);
let err3 = GitInfo::from_str("v1.0.0-5-ghijk").unwrap_err();
assert!(
err3.contains("Invalid commit-hash"),
"Unexpected error: {}",
err3
);
let err4 = GitInfo::from_str("").unwrap_err();
assert!(err4.contains("Empty input string"));
let err5 = GitInfo::from_str("-dirty").unwrap_err();
assert!(err5.contains("Empty GitInfo before `-dirty`"));
}
#[test]
fn gitinfo_serialize_deserialize() {
let gi = GitInfo::new(Some("v0.2.0"), Some(95), "5a85959", false);
let json = serde_json::to_string(&gi).expect("Serialization failed");
assert_eq!(json, r#""v0.2.0-95-5a85959""#);
let parsed: GitInfo = serde_json::from_str(&json).expect("Deserialization failed");
assert_eq!(parsed, gi);
let gi2 = GitInfo::new(Some("v0.2.0"), Some(95), "5a85959", true);
let json2 = serde_json::to_string(&gi2).expect("Serialization failed");
assert_eq!(json2, r#""v0.2.0-95-5a85959-dirty""#);
let parsed2: GitInfo = serde_json::from_str(&json2).expect("Deserialization failed");
assert_eq!(parsed2, gi2);
let gi3 = GitInfo::new(None::<&str>, None, "deadbeef", false);
let json3 = serde_json::to_string(&gi3).expect("Serialization failed");
assert_eq!(json3, r#""deadbeef""#);
let parsed3: GitInfo = serde_json::from_str(&json3).expect("Deserialization failed");
assert_eq!(parsed3, gi3);
let gi4 = GitInfo::new(None::<&str>, None, "deadbeef", true);
let json4 = serde_json::to_string(&gi4).expect("Serialization failed");
assert_eq!(json4, r#""deadbeef-dirty""#);
let parsed4: GitInfo = serde_json::from_str(&json4).expect("Deserialization failed");
assert_eq!(parsed4, gi4);
}
#[test]
fn gitinfo_serde_error() {
let bad = r#""v1.0.0-5-""#; let res: Result<GitInfo, _> = serde_json::from_str(bad);
assert!(res.is_err());
let bad_dirty = r#""-dirty""#; let res2: Result<GitInfo, _> = serde_json::from_str(bad_dirty);
assert!(res2.is_err());
}
}