Skip to main content

release_kit/release_engine/
guard.rs

1//! The engine's judgments, pure.
2//!
3//! What the request proposes, whether it arms, and which outcome a push
4//! gets. The actions gather the facts; these
5//! rules alone decide what they mean, so each is testable without a forge.
6
7use crate::detect::Forge;
8use crate::landing::manifest::{Integration, Style};
9
10use super::adapter::Version;
11
12/// What the request half does with the version it read.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum Proposal {
15    /// Nothing remains to release, for the reason stated.
16    Nothing(String),
17    /// Propose this version.
18    Propose(Version),
19    /// Refuse, for the reason stated.
20    Refuse(String),
21}
22
23/// Whether the proposal needs git-cliff's bumped version: the baseline,
24/// and a final version that already released.
25#[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/// The version the request proposes.
32///
33/// SATISFIES release-engine:the-request-never-numbers-a-candidate
34///
35/// A candidate proposes nothing: only `rk lines mint` writes and strips an
36/// rc number. A final version with no release proposes itself, except the
37/// `0.0.0` baseline, which never releases and takes git-cliff's proposal.
38/// A released final version takes git-cliff's bumped version, and a
39/// prerelease proposal refuses. `cliff` is git-cliff's answer, read where
40/// [`needs_cliff`] says so.
41#[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/// Whether the request for `base` arms itself.
77///
78/// SATISFIES release-engine:the-request-arms-where-the-projection-arms
79///
80/// The trunk style arms its request on the trunk, except on GitHub under
81/// local integration, where the rendered gate job merges it. The lines
82/// style and every line leave the merge to a person.
83#[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/// What a tag read answered.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum TagAt {
99    /// The forge answered that no such tag exists.
100    Absent,
101    /// The tag names this commit.
102    At(String),
103    /// The read failed, with the forge CLI's words.
104    Unread(String),
105}
106
107/// The release of the version the forge holds.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum Existing {
110    /// No release of the version.
111    None,
112    /// An unpublished draft, which creates no tag and which nothing public
113    /// reads.
114    Draft,
115    /// A published release.
116    Published,
117}
118
119/// One push's outcome.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum Outcome {
122    /// Release the version: no release of it exists yet.
123    Release,
124    /// Finish the release that exists at this commit.
125    Resume,
126    /// Release nothing, with success, for the reason stated.
127    Skip(String),
128    /// Refuse, for the reason stated, under the named recovery section.
129    Refuse(String),
130}
131
132impl Outcome {
133    /// The word the report uses.
134    #[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/// What the parent commit carries.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum Parent {
148    /// The parent holds no version file, or the commit has no parent.
149    Absent,
150    /// The parent's version file, with the version it reads as, where it
151    /// reads as one.
152    Carries(Option<Version>),
153}
154
155/// What the commit itself says before the forge is read.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub struct Push {
158    /// The version the commit carries.
159    pub version: Version,
160    /// What the parent carries.
161    pub parent: Parent,
162    /// Whether the commit merges the engine's own request, read from the
163    /// forge; it decides a final version alone.
164    pub merges_request: bool,
165}
166
167/// The skip the commit alone decides, before any release or tag read.
168///
169/// SATISFIES release-engine:a-final-version-releases-at-its-request
170///
171/// The commit that introduces the version file is the landing, the
172/// baseline never releases, a final version releases only at the commit
173/// that merges the engine's own request, and a candidate releases only at
174/// the commit that writes it, the push of `rk lines mint`.
175#[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/// The outcome once the forge answered for the release and the tag.
202///
203/// SATISFIES release-engine:a-version-releases-once
204///
205/// A published release at another commit is an un-bump and skips. A
206/// release or a tag at this commit, or an unpublished draft, resumes.
207/// No release and no tag releases. A tag at another commit with no
208/// published release, and a tag nobody could read, refuse.
209#[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}