release-tool 0.3.0

Configuration-driven release lifecycle for computed-parameter repositories
Documentation
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}`"))
}