Skip to main content

release_tool/
tag.rs

1use anyhow::{Context, Result, bail};
2use chrono::{Datelike, NaiveDate};
3use regex::Regex;
4use std::collections::{BTreeMap, HashMap};
5use std::fmt::{Display, Formatter};
6use std::sync::LazyLock;
7
8static CALENDAR_TAG: LazyLock<Regex> = LazyLock::new(|| {
9    Regex::new(r"^(\d{4})\.(\d{2})\.(\d{2})\.([1-9]\d*)$")
10        .expect("calendar release tag regex must compile")
11});
12static CALENDAR_LIKE_TAG: LazyLock<Regex> = LazyLock::new(|| {
13    Regex::new(r"^\d+\.\d+\.\d+\.\d+$").expect("calendar-like tag regex must compile")
14});
15
16#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
17pub struct CalendarTag {
18    date: NaiveDate,
19    sequence: u32,
20}
21
22impl CalendarTag {
23    pub fn parse(value: &str) -> Result<Self> {
24        let captures = CALENDAR_TAG
25            .captures(value)
26            .with_context(|| format!("invalid calendar release tag `{value}`"))?;
27        let year = parse_part(value, &captures[1], "year")?;
28        let month = parse_part(value, &captures[2], "month")?;
29        let day = parse_part(value, &captures[3], "day")?;
30        let sequence = parse_part(value, &captures[4], "sequence")?;
31        let date = NaiveDate::from_ymd_opt(year as i32, month, day)
32            .with_context(|| format!("invalid calendar release tag `{value}`"))?;
33        Ok(Self { date, sequence })
34    }
35
36    pub fn date(self) -> NaiveDate {
37        self.date
38    }
39
40    pub fn sequence(self) -> u32 {
41        self.sequence
42    }
43}
44
45impl Display for CalendarTag {
46    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
47        write!(
48            formatter,
49            "{:04}.{:02}.{:02}.{}",
50            self.date.year(),
51            self.date.month(),
52            self.date.day(),
53            self.sequence
54        )
55    }
56}
57
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct RemoteTag {
60    pub name: CalendarTag,
61    pub object: String,
62    pub commit: String,
63    pub annotated: bool,
64}
65
66#[derive(Clone, Debug, Default, Eq, PartialEq)]
67pub struct TagCatalog {
68    tags: BTreeMap<CalendarTag, RemoteTag>,
69}
70
71impl TagCatalog {
72    pub fn parse_ls_remote(output: &str) -> Result<Self> {
73        #[derive(Default)]
74        struct Refs {
75            direct: Option<String>,
76            peeled: Option<String>,
77        }
78
79        let mut refs_by_name: HashMap<String, Refs> = HashMap::new();
80        for (index, line) in output.lines().enumerate() {
81            if line.trim().is_empty() {
82                continue;
83            }
84            let (oid, reference) = line
85                .split_once(char::is_whitespace)
86                .with_context(|| format!("invalid git ls-remote output on line {}", index + 1))?;
87            let reference = reference.trim();
88            let Some(raw_name) = reference.strip_prefix("refs/tags/") else {
89                continue;
90            };
91            let (name, peeled) = raw_name
92                .strip_suffix("^{}")
93                .map_or((raw_name, false), |name| (name, true));
94            if CALENDAR_LIKE_TAG.is_match(name) {
95                CalendarTag::parse(name)?;
96            } else if !CALENDAR_TAG.is_match(name) {
97                continue;
98            }
99            let refs = refs_by_name.entry(name.to_owned()).or_default();
100            let slot = if peeled {
101                &mut refs.peeled
102            } else {
103                &mut refs.direct
104            };
105            if slot.replace(oid.to_owned()).is_some() {
106                bail!("duplicate remote ref for calendar release tag `{name}`");
107            }
108        }
109
110        let mut tags = BTreeMap::new();
111        for (name, refs) in refs_by_name {
112            let parsed = CalendarTag::parse(&name)?;
113            let object = refs
114                .direct
115                .with_context(|| format!("release tag `{name}` is missing its direct ref"))?;
116            let annotated = refs.peeled.is_some();
117            let commit = refs.peeled.unwrap_or_else(|| object.clone());
118            tags.insert(
119                parsed,
120                RemoteTag {
121                    name: parsed,
122                    object,
123                    commit,
124                    annotated,
125                },
126            );
127        }
128        Ok(Self { tags })
129    }
130
131    pub fn latest(&self) -> Option<CalendarTag> {
132        self.tags.last_key_value().map(|(tag, _)| *tag)
133    }
134
135    pub fn next(&self, today: NaiveDate) -> Result<CalendarTag> {
136        match self.latest() {
137            None => Ok(CalendarTag {
138                date: today,
139                sequence: 1,
140            }),
141            Some(latest) if latest.date > today => bail!(
142                "latest release tag {latest} is later than requested release date {}",
143                today.format("%Y.%m.%d")
144            ),
145            Some(latest) if latest.date == today => Ok(CalendarTag {
146                date: today,
147                sequence: latest
148                    .sequence
149                    .checked_add(1)
150                    .context("calendar release sequence overflow")?,
151            }),
152            Some(_) => Ok(CalendarTag {
153                date: today,
154                sequence: 1,
155            }),
156        }
157    }
158
159    pub fn get(&self, tag: CalendarTag) -> Option<&RemoteTag> {
160        self.tags.get(&tag)
161    }
162
163    pub fn iter(&self) -> impl Iterator<Item = &RemoteTag> {
164        self.tags.values()
165    }
166
167    pub fn unique_release_for_commit(&self, commit: &str) -> Result<Option<&RemoteTag>> {
168        let matches: Vec<_> = self
169            .tags
170            .values()
171            .filter(|tag| tag.commit == commit)
172            .collect();
173        match matches.as_slice() {
174            [] => Ok(None),
175            [tag] if !tag.annotated => {
176                bail!("release tag `{}` must be annotated", tag.name)
177            }
178            [tag] => Ok(Some(*tag)),
179            tags => {
180                let names = tags
181                    .iter()
182                    .map(|tag| tag.name.to_string())
183                    .collect::<Vec<_>>()
184                    .join(", ");
185                bail!("commit {commit} has multiple calendar release tags: {names}")
186            }
187        }
188    }
189}
190
191fn parse_part(value: &str, part: &str, name: &str) -> Result<u32> {
192    part.parse()
193        .with_context(|| format!("invalid {name} in calendar release tag `{value}`"))
194}