1use crate::detect::Forge;
8use crate::landing::manifest::{Integration, Style};
9
10use super::adapter::Version;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum Proposal {
15 Nothing(String),
17 Propose(Version),
19 Refuse(String),
21}
22
23#[must_use]
26pub const fn needs_cliff(current: Version, released: bool) -> bool {
27 !current.is_candidate()
28 && (released || (current.major == 0 && current.minor == 0 && current.patch == 0))
29}
30
31#[must_use]
42pub fn propose(current: Version, released: bool, cliff: Option<&str>) -> Proposal {
43 if current.is_candidate() {
44 return Proposal::Nothing(format!(
45 "{} is a release candidate; its line's request proposes the promoted version",
46 current.tag()
47 ));
48 }
49 if !needs_cliff(current, released) {
50 return Proposal::Propose(current);
51 }
52 let Some(answer) = cliff else {
53 return Proposal::Refuse("git-cliff proposed no version".to_owned());
54 };
55 let text = answer.trim().trim_start_matches('v');
56 let Some(next) = Version::parse(text) else {
57 return Proposal::Refuse(format!(
58 "git-cliff proposed {answer:?}, which is not a final version"
59 ));
60 };
61 if next.is_candidate() {
62 return Proposal::Refuse(format!(
63 "git-cliff proposed the prerelease {}; only rk lines mint numbers a candidate",
64 next.tag()
65 ));
66 }
67 if next == current {
68 return Proposal::Nothing(format!(
69 "no release-worthy commit follows {}",
70 current.tag()
71 ));
72 }
73 Proposal::Propose(next)
74}
75
76#[must_use]
84pub fn arms(
85 forge: Forge,
86 integration: Integration,
87 style: Option<Style>,
88 base: &str,
89 trunk: &str,
90) -> bool {
91 style == Some(Style::Trunk)
92 && base == trunk
93 && !(forge == Forge::Github && integration == Integration::Local)
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum TagAt {
99 Absent,
101 At(String),
103 Unread(String),
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum Existing {
110 None,
112 Draft,
115 Published,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum Outcome {
122 Release,
124 Resume,
126 Skip(String),
128 Refuse(String),
130}
131
132impl Outcome {
133 #[must_use]
135 pub const fn as_str(&self) -> &'static str {
136 match self {
137 Self::Release => "release",
138 Self::Resume => "resume",
139 Self::Skip(_) => "skip",
140 Self::Refuse(_) => "refuse",
141 }
142 }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum Parent {
148 Absent,
150 Carries(Option<Version>),
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub struct Push {
158 pub version: Version,
160 pub parent: Parent,
162 pub merges_request: bool,
165}
166
167#[must_use]
176pub fn trigger(push: Push) -> Option<String> {
177 let Parent::Carries(parent) = push.parent else {
178 return Some("this commit introduces the version file; nothing to release".to_owned());
179 };
180 if push.version == super::adapter::Version::BASELINE {
181 return Some("0.0.0 is the unreleased baseline; nothing to release".to_owned());
182 }
183 if push.version.is_candidate() {
184 if parent == Some(push.version) {
185 return Some(format!(
186 "this commit does not write the candidate {}; nothing to release",
187 push.version.tag()
188 ));
189 }
190 return None;
191 }
192 if !push.merges_request {
193 return Some(format!(
194 "this commit merges no release request of the engine; {} releases at its request's merge",
195 push.version.tag()
196 ));
197 }
198 None
199}
200
201#[must_use]
210pub fn judge(head: &str, tag_name: &str, existing: Existing, tag: &TagAt) -> Outcome {
211 let unread = |reason: &str| {
212 Outcome::Refuse(format!(
213 "the tag {tag_name} could not be read: {reason}; a tag nobody read may name another commit"
214 ))
215 };
216 match (existing, tag) {
217 (_, TagAt::Unread(reason)) => unread(reason),
218 (Existing::Published, TagAt::At(at)) if at != head => Outcome::Skip(format!(
219 "{tag_name} is already released at {at}; nothing to release"
220 )),
221 (Existing::Published, TagAt::Absent) => Outcome::Refuse(format!(
222 "{tag_name} has a published release and no tag the forge answers for"
223 )),
224 (_, TagAt::At(at)) if at != head => Outcome::Refuse(format!(
225 "{tag_name} already tags {at}, not this commit {head}, and no release of it is published"
226 )),
227 (Existing::None, TagAt::Absent) => Outcome::Release,
228 _ => Outcome::Resume,
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::{Existing, Outcome, Parent, Proposal, Push, TagAt, arms, judge, propose, trigger};
235 use crate::detect::Forge;
236 use crate::landing::manifest::{Integration, Style};
237 use crate::release_engine::adapter::Version;
238
239 fn v(text: &str) -> Version {
240 Version::parse(text).expect("a version")
241 }
242
243 #[test]
244 fn the_proposal_follows_the_candidate_shape() {
245 assert!(matches!(
246 propose(v("1.4.3-rc.1"), false, None),
247 Proposal::Nothing(_)
248 ));
249 assert_eq!(
250 propose(v("1.4.3"), false, None),
251 Proposal::Propose(v("1.4.3"))
252 );
253 assert_eq!(
254 propose(v("0.0.0"), false, Some("v0.1.0")),
255 Proposal::Propose(v("0.1.0"))
256 );
257 assert_eq!(
258 propose(v("1.4.3"), true, Some("v1.4.4\n")),
259 Proposal::Propose(v("1.4.4"))
260 );
261 assert!(matches!(
262 propose(v("1.4.3"), true, Some("v1.4.3")),
263 Proposal::Nothing(_)
264 ));
265 for prerelease in ["v1.4.4-rc.1", "v1.4.4-alpha.1", "1.4.4rc1"] {
266 assert!(
267 matches!(
268 propose(v("1.4.3"), true, Some(prerelease)),
269 Proposal::Refuse(_)
270 ),
271 "{prerelease}"
272 );
273 }
274 }
275
276 #[test]
277 fn only_the_trunk_style_on_the_trunk_arms() {
278 let trunk = Some(Style::Trunk);
279 assert!(arms(
280 Forge::Github,
281 Integration::Forge,
282 trunk,
283 "master",
284 "master"
285 ));
286 assert!(arms(
287 Forge::Gitlab,
288 Integration::Local,
289 trunk,
290 "master",
291 "master"
292 ));
293 assert!(!arms(
294 Forge::Github,
295 Integration::Local,
296 trunk,
297 "master",
298 "master"
299 ));
300 assert!(!arms(
301 Forge::Gitlab,
302 Integration::Forge,
303 Some(Style::Lines),
304 "master",
305 "master"
306 ));
307 assert!(!arms(
308 Forge::Gitlab,
309 Integration::Forge,
310 trunk,
311 "release/1.4",
312 "master"
313 ));
314 }
315
316 #[test]
317 fn the_trigger_reads_the_commit_before_the_forge() {
318 let push = |version: &str, parent: Option<Option<&str>>, merges| Push {
319 version: v(version),
320 parent: parent.map_or(Parent::Absent, |parent| Parent::Carries(parent.map(v))),
321 merges_request: merges,
322 };
323 assert!(trigger(push("0.1.0", None, true)).is_some());
324 assert!(trigger(push("0.0.0", Some(Some("0.0.0")), true)).is_some());
325 assert!(trigger(push("0.1.0", Some(Some("0.0.0")), false)).is_some());
326 assert!(trigger(push("0.1.0", Some(Some("0.1.0")), true)).is_none());
327 assert!(trigger(push("1.4.3-rc.1", Some(Some("1.4.2")), false)).is_none());
328 assert!(trigger(push("1.4.3-rc.1", Some(Some("1.4.3-rc.1")), false)).is_some());
329 }
330
331 #[test]
332 fn each_release_state_gets_one_outcome() {
333 let here = "aaa";
334 let at = |sha: &str| TagAt::At(sha.to_owned());
335 assert_eq!(
336 judge(here, "v1.0.0", Existing::None, &TagAt::Absent),
337 Outcome::Release
338 );
339 assert_eq!(
340 judge(here, "v1.0.0", Existing::None, &at(here)),
341 Outcome::Resume
342 );
343 assert_eq!(
344 judge(here, "v1.0.0", Existing::Draft, &TagAt::Absent),
345 Outcome::Resume
346 );
347 assert_eq!(
348 judge(here, "v1.0.0", Existing::Published, &at(here)),
349 Outcome::Resume
350 );
351 assert!(matches!(
352 judge(here, "v1.0.0", Existing::Published, &at("bbb")),
353 Outcome::Skip(_)
354 ));
355 assert!(matches!(
356 judge(here, "v1.0.0", Existing::None, &at("bbb")),
357 Outcome::Refuse(_)
358 ));
359 assert!(matches!(
360 judge(
361 here,
362 "v1.0.0",
363 Existing::Published,
364 &TagAt::Unread("HTTP 500".into())
365 ),
366 Outcome::Refuse(_)
367 ));
368 }
369}