Skip to main content

release_kit/release_engine/
guard.rs

1//! The engine's judgments, pure.
2//!
3//! What the request proposes, and which outcome a push gets. The actions
4//! gather the facts; these rules alone decide what they mean, so each is
5//! testable without a forge.
6
7use super::adapter::Version;
8
9/// What the request half does with the version it read.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum Proposal {
12    /// Nothing remains to release, for the reason stated.
13    Nothing(String),
14    /// Propose this version.
15    Propose(Version),
16    /// Refuse, for the reason stated.
17    Refuse(String),
18}
19
20/// Whether the proposal needs git-cliff's bumped version: the baseline,
21/// and a final version that already released.
22#[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/// The version the request proposes.
29///
30/// SATISFIES release-engine:the-request-never-numbers-a-candidate
31///
32/// A candidate proposes nothing: only `rk lines mint` writes and strips an
33/// rc number. A final version with no release proposes itself, except the
34/// `0.0.0` baseline, which never releases and takes git-cliff's proposal.
35/// A released final version takes git-cliff's bumped version, and a
36/// prerelease proposal refuses. `cliff` is git-cliff's answer, read where
37/// [`needs_cliff`] says so.
38#[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/// What a tag read answered.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum TagAt {
76    /// The forge answered that no such tag exists.
77    Absent,
78    /// The tag names this commit.
79    At(String),
80    /// The read failed, with the forge CLI's words.
81    Unread(String),
82}
83
84/// The release of the version the forge holds.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum Existing {
87    /// No release of the version.
88    None,
89    /// An unpublished draft, which creates no tag and which nothing public
90    /// reads.
91    Draft,
92    /// A published release.
93    Published,
94}
95
96/// One push's outcome.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum Outcome {
99    /// Release the version: no release of it exists yet.
100    Release,
101    /// Finish the release that exists at this commit.
102    Resume,
103    /// Release nothing, with success, for the reason stated.
104    Skip(String),
105    /// Refuse, for the reason stated, under the named recovery section.
106    Refuse(String),
107}
108
109impl Outcome {
110    /// The word the report uses.
111    #[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/// What the parent commit carries.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum Parent {
125    /// The parent holds no version file, or the commit has no parent.
126    Absent,
127    /// The parent's version file, with the version it reads as, where it
128    /// reads as one.
129    Carries(Option<Version>),
130}
131
132/// What the commit itself says before the forge is read.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct Push {
135    /// The version the commit carries.
136    pub version: Version,
137    /// What the parent carries.
138    pub parent: Parent,
139    /// Whether the commit merges the engine's own request, read from the
140    /// forge; it decides a final version alone.
141    pub merges_request: bool,
142}
143
144/// The skip the commit alone decides, before any release or tag read.
145///
146/// SATISFIES release-engine:a-final-version-releases-at-its-request
147///
148/// The commit that introduces the version file is the landing, the
149/// baseline never releases, a final version releases only at the commit
150/// that merges the engine's own request, and a candidate releases only at
151/// the commit that writes it, the push of `rk lines mint`.
152#[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/// The outcome once the forge answered for the release and the tag.
179///
180/// SATISFIES release-engine:a-version-releases-once
181///
182/// A published release at another commit is an un-bump and skips. A
183/// release or a tag at this commit, or an unpublished draft, resumes.
184/// No release and no tag releases. A tag at another commit with no
185/// published release, and a tag nobody could read, refuse.
186#[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}