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}