use anyhow::{Context, Result, bail};
use chrono::{Datelike, NaiveDate};
use regex::Regex;
use std::collections::{BTreeMap, HashMap};
use std::fmt::{Display, Formatter};
use std::sync::LazyLock;
static CALENDAR_TAG: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(\d{4})\.(\d{2})\.(\d{2})\.([1-9]\d*)$")
.expect("calendar release tag regex must compile")
});
static CALENDAR_LIKE_TAG: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^\d+\.\d+\.\d+\.\d+$").expect("calendar-like tag regex must compile")
});
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct CalendarTag {
date: NaiveDate,
sequence: u32,
}
impl CalendarTag {
pub fn parse(value: &str) -> Result<Self> {
let captures = CALENDAR_TAG
.captures(value)
.with_context(|| format!("invalid calendar release tag `{value}`"))?;
let year = parse_part(value, &captures[1], "year")?;
let month = parse_part(value, &captures[2], "month")?;
let day = parse_part(value, &captures[3], "day")?;
let sequence = parse_part(value, &captures[4], "sequence")?;
let date = NaiveDate::from_ymd_opt(year as i32, month, day)
.with_context(|| format!("invalid calendar release tag `{value}`"))?;
Ok(Self { date, sequence })
}
pub fn date(self) -> NaiveDate {
self.date
}
pub fn sequence(self) -> u32 {
self.sequence
}
}
impl Display for CalendarTag {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"{:04}.{:02}.{:02}.{}",
self.date.year(),
self.date.month(),
self.date.day(),
self.sequence
)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RemoteTag {
pub name: CalendarTag,
pub object: String,
pub commit: String,
pub annotated: bool,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct TagCatalog {
tags: BTreeMap<CalendarTag, RemoteTag>,
}
impl TagCatalog {
pub fn parse_ls_remote(output: &str) -> Result<Self> {
#[derive(Default)]
struct Refs {
direct: Option<String>,
peeled: Option<String>,
}
let mut refs_by_name: HashMap<String, Refs> = HashMap::new();
for (index, line) in output.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let (oid, reference) = line
.split_once(char::is_whitespace)
.with_context(|| format!("invalid git ls-remote output on line {}", index + 1))?;
let reference = reference.trim();
let Some(raw_name) = reference.strip_prefix("refs/tags/") else {
continue;
};
let (name, peeled) = raw_name
.strip_suffix("^{}")
.map_or((raw_name, false), |name| (name, true));
if CALENDAR_LIKE_TAG.is_match(name) {
CalendarTag::parse(name)?;
} else if !CALENDAR_TAG.is_match(name) {
continue;
}
let refs = refs_by_name.entry(name.to_owned()).or_default();
let slot = if peeled {
&mut refs.peeled
} else {
&mut refs.direct
};
if slot.replace(oid.to_owned()).is_some() {
bail!("duplicate remote ref for calendar release tag `{name}`");
}
}
let mut tags = BTreeMap::new();
for (name, refs) in refs_by_name {
let parsed = CalendarTag::parse(&name)?;
let object = refs
.direct
.with_context(|| format!("release tag `{name}` is missing its direct ref"))?;
let annotated = refs.peeled.is_some();
let commit = refs.peeled.unwrap_or_else(|| object.clone());
tags.insert(
parsed,
RemoteTag {
name: parsed,
object,
commit,
annotated,
},
);
}
Ok(Self { tags })
}
pub fn latest(&self) -> Option<CalendarTag> {
self.tags.last_key_value().map(|(tag, _)| *tag)
}
pub fn next(&self, today: NaiveDate) -> Result<CalendarTag> {
match self.latest() {
None => Ok(CalendarTag {
date: today,
sequence: 1,
}),
Some(latest) if latest.date > today => bail!(
"latest release tag {latest} is later than requested release date {}",
today.format("%Y.%m.%d")
),
Some(latest) if latest.date == today => Ok(CalendarTag {
date: today,
sequence: latest
.sequence
.checked_add(1)
.context("calendar release sequence overflow")?,
}),
Some(_) => Ok(CalendarTag {
date: today,
sequence: 1,
}),
}
}
pub fn get(&self, tag: CalendarTag) -> Option<&RemoteTag> {
self.tags.get(&tag)
}
pub fn iter(&self) -> impl Iterator<Item = &RemoteTag> {
self.tags.values()
}
pub fn unique_release_for_commit(&self, commit: &str) -> Result<Option<&RemoteTag>> {
let matches: Vec<_> = self
.tags
.values()
.filter(|tag| tag.commit == commit)
.collect();
match matches.as_slice() {
[] => Ok(None),
[tag] if !tag.annotated => {
bail!("release tag `{}` must be annotated", tag.name)
}
[tag] => Ok(Some(*tag)),
tags => {
let names = tags
.iter()
.map(|tag| tag.name.to_string())
.collect::<Vec<_>>()
.join(", ");
bail!("commit {commit} has multiple calendar release tags: {names}")
}
}
}
}
fn parse_part(value: &str, part: &str, name: &str) -> Result<u32> {
part.parse()
.with_context(|| format!("invalid {name} in calendar release tag `{value}`"))
}