release_kit/release_engine/
guard.rs1use super::adapter::Version;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum Proposal {
12 Nothing(String),
14 Propose(Version),
16 Refuse(String),
18}
19
20#[must_use]
23pub const fn needs_cliff(current: Version, released: bool) -> bool {
24 !current.is_candidate()
25 && (released || (current.major == 0 && current.minor == 0 && current.patch == 0))
26}
27
28#[must_use]
39pub fn propose(current: Version, released: bool, cliff: Option<&str>) -> Proposal {
40 if current.is_candidate() {
41 return Proposal::Nothing(format!(
42 "{} is a release candidate; its line's request proposes the promoted version",
43 current.tag()
44 ));
45 }
46 if !needs_cliff(current, released) {
47 return Proposal::Propose(current);
48 }
49 let Some(answer) = cliff else {
50 return Proposal::Refuse("git-cliff proposed no version".to_owned());
51 };
52 let text = answer.trim().trim_start_matches('v');
53 let Some(next) = Version::parse(text) else {
54 return Proposal::Refuse(format!(
55 "git-cliff proposed {answer:?}, which is not a final version"
56 ));
57 };
58 if next.is_candidate() {
59 return Proposal::Refuse(format!(
60 "git-cliff proposed the prerelease {}; only rk lines mint numbers a candidate",
61 next.tag()
62 ));
63 }
64 if next == current {
65 return Proposal::Nothing(format!(
66 "no release-worthy commit follows {}",
67 current.tag()
68 ));
69 }
70 Proposal::Propose(next)
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum TagAt {
76 Absent,
78 At(String),
80 Unread(String),
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum Existing {
87 None,
89 Draft,
92 Published,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum Outcome {
99 Release,
101 Resume,
103 Skip(String),
105 Refuse(String),
107}
108
109impl Outcome {
110 #[must_use]
112 pub const fn as_str(&self) -> &'static str {
113 match self {
114 Self::Release => "release",
115 Self::Resume => "resume",
116 Self::Skip(_) => "skip",
117 Self::Refuse(_) => "refuse",
118 }
119 }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum Parent {
125 Absent,
127 Carries(Option<Version>),
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct Push {
135 pub version: Version,
137 pub parent: Parent,
139 pub merges_request: bool,
142}
143
144#[must_use]
153pub fn trigger(push: Push) -> Option<String> {
154 let Parent::Carries(parent) = push.parent else {
155 return Some("this commit introduces the version file; nothing to release".to_owned());
156 };
157 if push.version == super::adapter::Version::BASELINE {
158 return Some("0.0.0 is the unreleased baseline; nothing to release".to_owned());
159 }
160 if push.version.is_candidate() {
161 if parent == Some(push.version) {
162 return Some(format!(
163 "this commit does not write the candidate {}; nothing to release",
164 push.version.tag()
165 ));
166 }
167 return None;
168 }
169 if !push.merges_request {
170 return Some(format!(
171 "this commit merges no release request of the engine; {} releases at its request's merge",
172 push.version.tag()
173 ));
174 }
175 None
176}
177
178#[must_use]
187pub fn judge(head: &str, tag_name: &str, existing: Existing, tag: &TagAt) -> Outcome {
188 let unread = |reason: &str| {
189 Outcome::Refuse(format!(
190 "the tag {tag_name} could not be read: {reason}; a tag nobody read may name another commit"
191 ))
192 };
193 match (existing, tag) {
194 (_, TagAt::Unread(reason)) => unread(reason),
195 (Existing::Published, TagAt::At(at)) if at != head => Outcome::Skip(format!(
196 "{tag_name} is already released at {at}; nothing to release"
197 )),
198 (Existing::Published, TagAt::Absent) => Outcome::Refuse(format!(
199 "{tag_name} has a published release and no tag the forge answers for"
200 )),
201 (_, TagAt::At(at)) if at != head => Outcome::Refuse(format!(
202 "{tag_name} already tags {at}, not this commit {head}, and no release of it is published"
203 )),
204 (Existing::None, TagAt::Absent) => Outcome::Release,
205 _ => Outcome::Resume,
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::{Existing, Outcome, Parent, Proposal, Push, TagAt, judge, propose, trigger};
212 use crate::release_engine::adapter::Version;
213
214 fn v(text: &str) -> Version {
215 Version::parse(text).expect("a version")
216 }
217
218 #[test]
219 fn the_proposal_follows_the_candidate_shape() {
220 assert!(matches!(
221 propose(v("1.4.3-rc.1"), false, None),
222 Proposal::Nothing(_)
223 ));
224 assert_eq!(
225 propose(v("1.4.3"), false, None),
226 Proposal::Propose(v("1.4.3"))
227 );
228 assert_eq!(
229 propose(v("0.0.0"), false, Some("v0.1.0")),
230 Proposal::Propose(v("0.1.0"))
231 );
232 assert_eq!(
233 propose(v("1.4.3"), true, Some("v1.4.4\n")),
234 Proposal::Propose(v("1.4.4"))
235 );
236 assert!(matches!(
237 propose(v("1.4.3"), true, Some("v1.4.3")),
238 Proposal::Nothing(_)
239 ));
240 for prerelease in ["v1.4.4-rc.1", "v1.4.4-alpha.1", "1.4.4rc1"] {
241 assert!(
242 matches!(
243 propose(v("1.4.3"), true, Some(prerelease)),
244 Proposal::Refuse(_)
245 ),
246 "{prerelease}"
247 );
248 }
249 }
250
251 #[test]
252 fn the_trigger_reads_the_commit_before_the_forge() {
253 let push = |version: &str, parent: Option<Option<&str>>, merges| Push {
254 version: v(version),
255 parent: parent.map_or(Parent::Absent, |parent| Parent::Carries(parent.map(v))),
256 merges_request: merges,
257 };
258 assert!(trigger(push("0.1.0", None, true)).is_some());
259 assert!(trigger(push("0.0.0", Some(Some("0.0.0")), true)).is_some());
260 assert!(trigger(push("0.1.0", Some(Some("0.0.0")), false)).is_some());
261 assert!(trigger(push("0.1.0", Some(Some("0.1.0")), true)).is_none());
262 assert!(trigger(push("1.4.3-rc.1", Some(Some("1.4.2")), false)).is_none());
263 assert!(trigger(push("1.4.3-rc.1", Some(Some("1.4.3-rc.1")), false)).is_some());
264 }
265
266 #[test]
267 fn each_release_state_gets_one_outcome() {
268 let here = "aaa";
269 let at = |sha: &str| TagAt::At(sha.to_owned());
270 assert_eq!(
271 judge(here, "v1.0.0", Existing::None, &TagAt::Absent),
272 Outcome::Release
273 );
274 assert_eq!(
275 judge(here, "v1.0.0", Existing::None, &at(here)),
276 Outcome::Resume
277 );
278 assert_eq!(
279 judge(here, "v1.0.0", Existing::Draft, &TagAt::Absent),
280 Outcome::Resume
281 );
282 assert_eq!(
283 judge(here, "v1.0.0", Existing::Published, &at(here)),
284 Outcome::Resume
285 );
286 assert!(matches!(
287 judge(here, "v1.0.0", Existing::Published, &at("bbb")),
288 Outcome::Skip(_)
289 ));
290 assert!(matches!(
291 judge(here, "v1.0.0", Existing::None, &at("bbb")),
292 Outcome::Refuse(_)
293 ));
294 assert!(matches!(
295 judge(
296 here,
297 "v1.0.0",
298 Existing::Published,
299 &TagAt::Unread("HTTP 500".into())
300 ),
301 Outcome::Refuse(_)
302 ));
303 }
304}