Skip to main content

majordomus_cli/release/
landing.rs

1//! The version a landing allocates (ADR 0134): what the integration executor raises the
2//! trunk's version to when it lands a pull request, decided from three things and nothing
3//! else — the version the trunk declares, how far the pull request moves the public contract
4//! (ADR 0051's measurement), and the repository's typed `release:` policy.
5//!
6//! ```text
7//! trunk version ─┐
8//! observed impact ─ compat::Policy::required_of ─ required ─┐
9//! policy floor ── (an exemption lowers only this) ── floor ─┴─ max ─ selected ─ next
10//! ```
11//!
12//! The decision is a pure function, so the executor, `majordomus release next`, the
13//! Cockpit and a test all reach the same answer from the same inputs. Nothing here reads a
14//! branch's own version: the number is always the trunk's raised, which is what makes a
15//! retried or repeated landing allocate once and two pull requests unable to hold the same
16//! number.
17//!
18//! ```
19//! use majordomus_cli::release::compat::Impact;
20//! use majordomus_cli::release::landing::{decide, Allocation, ReleasePolicy};
21//! use majordomus_cli::release::version::Version;
22//!
23//! let policy = ReleasePolicy { allocation: Allocation::Landing, floor: Impact::Minor, exempt: vec![] };
24//! // a fix behind the boundary still advances a minor under a minor floor
25//! let d = decide(Version::parse("0.20.0").unwrap(), Impact::Patch, &policy, &["src/a.rs".into()]);
26//! assert_eq!(d.next_version, "0.21.0");
27//! assert_eq!(d.selected, Impact::Minor);
28//! ```
29
30use schemars::JsonSchema;
31use serde::{Deserialize, Serialize};
32
33use crate::discovery::glob::Glob;
34use crate::release::compat::{Impact, Mode, Policy as CompatPolicy};
35use crate::release::version::Version;
36
37/// The schema a [`LandingDecision`] carries, so a reader knows which rules reached it.
38pub const DECISION_SCHEMA: &str = "majordomus/landing-decision/v1";
39
40/// Whether the integration executor allocates the version when it lands a pull request.
41///
42/// ```
43/// use majordomus_cli::release::landing::Allocation;
44/// // a policy that says nothing changes nothing: a pull request raises its own version
45/// assert_eq!(Allocation::default(), Allocation::Off);
46/// let a: Allocation = serde_json::from_str("\"landing\"").unwrap();
47/// assert_eq!(a.as_str(), "landing");
48/// ```
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
50#[serde(rename_all = "snake_case")]
51#[schemars(rename = "ReleaseAllocation")]
52pub enum Allocation {
53    /// The version is raised by whoever changes the contract, as before ADR 0134.
54    #[default]
55    Off,
56    /// The integration executor raises it at landing; a pull request does not.
57    Landing,
58}
59
60impl Allocation {
61    /// The word the policy file and every surface use.
62    ///
63    /// ```
64    /// use majordomus_cli::release::landing::Allocation;
65    /// assert_eq!(Allocation::Off.as_str(), "off");
66    /// ```
67    pub fn as_str(self) -> &'static str {
68        match self {
69            Allocation::Off => "off",
70            Allocation::Landing => "landing",
71        }
72    }
73}
74
75/// One exemption class of `release.exempt`: a pull request whose every changed path one
76/// class covers is not held to the floor. The compatibility requirement still applies.
77///
78/// ```
79/// use majordomus_cli::release::landing::ReleaseExemption;
80/// let e = ReleaseExemption {
81///     id: "plan-records".into(),
82///     description: "plan records".into(),
83///     paths: vec![".ai/repo/project/**".into()],
84/// };
85/// assert!(e.covers(".ai/repo/project/issues/I1.yaml"));
86/// assert!(!e.covers("apps/majordomus-cli/src/lib.rs"));
87/// ```
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
89#[serde(deny_unknown_fields)]
90pub struct ReleaseExemption {
91    /// The class, as a decision names it.
92    pub id: String,
93    /// What the class is, in a sentence a reviewer reads.
94    pub description: String,
95    /// Repository-relative globs, in the `:(glob)` subset `discovery::glob` reads.
96    pub paths: Vec<String>,
97}
98
99impl ReleaseExemption {
100    /// Whether this class covers one changed path.
101    ///
102    /// ```
103    /// use majordomus_cli::release::landing::ReleaseExemption;
104    /// let e = ReleaseExemption { id: "x".into(), description: "x".into(), paths: vec!["docs/*.md".into()] };
105    /// assert!(e.covers("docs/A.md") && !e.covers("docs/sub/A.md"));
106    /// ```
107    pub fn covers(&self, path: &str) -> bool {
108        self.paths.iter().any(|p| Glob::new(p).matches(path))
109    }
110}
111
112/// The `release:` block of `.ai/repo/policy.yaml` (ADR 0134).
113///
114/// ```
115/// use majordomus_cli::release::compat::Impact;
116/// use majordomus_cli::release::landing::{Allocation, ReleasePolicy};
117/// // absent is off with no floor: a repository whose policy predates the block is unchanged
118/// let p = ReleasePolicy::default();
119/// assert_eq!((p.allocation, p.floor), (Allocation::Off, Impact::None));
120/// let p: ReleasePolicy = serde_json::from_str(r#"{"allocation":"landing","floor":"minor"}"#).unwrap();
121/// assert_eq!((p.allocation, p.floor), (Allocation::Landing, Impact::Minor));
122/// ```
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
124#[serde(deny_unknown_fields)]
125pub struct ReleasePolicy {
126    /// `release.allocation:` — `off` or `landing`.
127    #[serde(default)]
128    pub allocation: Allocation,
129    /// `release.floor:` — the least an eligible integration advances: `none`, `patch`,
130    /// `minor` or `major`.
131    #[serde(default = "no_floor")]
132    pub floor: Impact,
133    /// `release.exempt:` — the classes a pull request may fall in instead of the floor.
134    #[serde(default)]
135    pub exempt: Vec<ReleaseExemption>,
136}
137
138fn no_floor() -> Impact {
139    Impact::None
140}
141
142impl Default for ReleasePolicy {
143    /// Off, with no floor and no exemption: what a policy that says nothing means.
144    fn default() -> Self {
145        ReleasePolicy {
146            allocation: Allocation::Off,
147            floor: no_floor(),
148            exempt: Vec::new(),
149        }
150    }
151}
152
153/// The exemption a decision applied, by class.
154///
155/// ```
156/// use majordomus_cli::release::landing::AppliedExemption;
157/// let e = AppliedExemption { id: "plan-records".into(), reason: "plan records".into() };
158/// assert_eq!(e.id, "plan-records");
159/// ```
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
161pub struct AppliedExemption {
162    /// The class's id.
163    pub id: String,
164    /// The class's description, which is the reason the floor did not apply.
165    pub reason: String,
166}
167
168/// What a landing allocates, and why, step by step.
169///
170/// ```
171/// use majordomus_cli::release::compat::Impact;
172/// use majordomus_cli::release::landing::{decide, LandingDecision, ReleasePolicy, DECISION_SCHEMA};
173/// use majordomus_cli::release::version::Version;
174/// let d: LandingDecision = decide(Version::parse("1.2.3").unwrap(), Impact::None, &ReleasePolicy::default(), &[]);
175/// assert_eq!(d.schema, DECISION_SCHEMA);
176/// assert_eq!(d.next_version, "1.2.3", "nothing moved and nothing is owed");
177/// ```
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
179pub struct LandingDecision {
180    /// [`DECISION_SCHEMA`].
181    pub schema: String,
182    /// What the trunk declares: the number the next one is raised from.
183    pub trunk_version: String,
184    /// How far the change moves the public contract, measured (ADR 0051).
185    pub observed: Impact,
186    /// What that movement requires under the version's mode.
187    pub required_by_compatibility: Impact,
188    /// The policy's floor, or `none` when an exemption applied.
189    pub floor: Impact,
190    /// The exemption that lowered the floor, if one did.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub exemption: Option<AppliedExemption>,
193    /// The larger of the requirement and the floor.
194    pub selected: Impact,
195    /// The trunk's version raised by `selected`.
196    pub next_version: String,
197    /// Whether the policy allocates at landing; the decision is computed either way.
198    pub allocation: Allocation,
199    /// The compatibility mode the trunk's version puts in force.
200    pub mode: Mode,
201    /// One sentence per step, in the order the decision was made.
202    pub explanation: Vec<String>,
203}
204
205/// The exemption class covering every changed path, when one does. An empty change is
206/// covered by nothing: a landing that changed nothing is not an exempt landing, it is no
207/// landing.
208fn exemption_for<'a>(
209    policy: &'a ReleasePolicy,
210    changed: &[String],
211) -> Option<&'a ReleaseExemption> {
212    if changed.is_empty() {
213        return None;
214    }
215    policy
216        .exempt
217        .iter()
218        .find(|class| changed.iter().all(|path| class.covers(path)))
219}
220
221/// Where a release's record lives: `<RECORDS>v<version>.yaml`.
222const RECORDS: &str = ".ai/repo/releases/";
223
224/// The record of the release `trunk` declares, when the change adds it. A release is tagged
225/// at the commit that declares its version and its record follows in a pull request of its
226/// own, which may carry more than the record class covers (the v0.20.0 record also changed
227/// `.gitattributes` and `docs/INSTALL.md`): held to the floor, it would be raised past the
228/// tag it records. What makes it the record is the file it adds, not the paths around it;
229/// a change that edits a record already written is no record pull request, and owes the
230/// floor like any other.
231fn records_release_of(trunk: Version, added: &[String]) -> Option<String> {
232    let path = format!("{RECORDS}v{trunk}.yaml");
233    added.contains(&path).then_some(path)
234}
235
236/// The policy floor as the version's mode reads it: below 1.0.0 the contract is held by
237/// minors (ADR 0051), so a `major` floor there asks what a major asks of compatibility, a
238/// minor. Every other floor is taken as written.
239fn floor_in(mode: Mode, floor: Impact) -> Impact {
240    match (mode, floor) {
241        (Mode::Pre1_0Strict, Impact::Major) => Impact::Minor,
242        (_, floor) => floor,
243    }
244}
245
246/// Decide what a landing allocates (ADR 0134 §2-4).
247///
248/// `selected = max(required_by_compatibility, floor)`, where an exemption covering every
249/// changed path sets the floor to `none` and never touches the requirement.
250///
251/// ```
252/// use majordomus_cli::release::compat::Impact;
253/// use majordomus_cli::release::landing::{decide, Allocation, ReleaseExemption, ReleasePolicy};
254/// use majordomus_cli::release::version::Version;
255///
256/// let policy = ReleasePolicy {
257///     allocation: Allocation::Landing,
258///     floor: Impact::Minor,
259///     exempt: vec![ReleaseExemption {
260///         id: "plan-records".into(),
261///         description: "plan records change no product behaviour".into(),
262///         paths: vec![".ai/repo/project/**".into()],
263///     }],
264/// };
265/// let trunk = Version::parse("0.20.0").unwrap();
266/// // a plan record alone is exempt from the floor, and so allocates nothing
267/// let d = decide(trunk, Impact::None, &policy, &[".ai/repo/project/issues/I9.yaml".into()]);
268/// assert_eq!(d.next_version, "0.20.0");
269/// assert_eq!(d.exemption.unwrap().id, "plan-records");
270/// // a breaking change below 1.0.0 costs a minor, which the floor already asks
271/// let d = decide(trunk, Impact::Major, &policy, &["src/a.rs".into()]);
272/// assert_eq!(d.next_version, "0.21.0");
273/// ```
274pub fn decide(
275    trunk: Version,
276    observed: Impact,
277    policy: &ReleasePolicy,
278    changed: &[String],
279) -> LandingDecision {
280    decide_adding(trunk, observed, policy, changed, &[])
281}
282
283/// [`decide`] for a change that also says which of its `changed` paths it `added`: the
284/// record of the trunk's release lands at that release's number only when the change adds
285/// it. [`decide`] knows of no added path, so it never grants that.
286///
287/// ```
288/// use majordomus_cli::release::compat::Impact;
289/// use majordomus_cli::release::landing::{decide_adding, Allocation, ReleasePolicy};
290/// use majordomus_cli::release::version::Version;
291///
292/// let policy = ReleasePolicy {
293///     allocation: Allocation::Landing,
294///     floor: Impact::Minor,
295///     exempt: vec![],
296/// };
297/// let trunk = Version::parse("0.20.0").unwrap();
298/// let changed = [
299///     ".ai/repo/releases/v0.20.0.yaml".to_string(),
300///     "docs/INSTALL.md".to_string(),
301/// ];
302/// // the record pull request adds the record, and lands at the release's number
303/// let d = decide_adding(trunk, Impact::None, &policy, &changed, &changed[..1]);
304/// assert_eq!(d.next_version, "0.20.0");
305/// // a change that edits the record already written owes the floor
306/// let d = decide_adding(trunk, Impact::None, &policy, &changed, &[]);
307/// assert_eq!(d.next_version, "0.21.0");
308/// ```
309pub fn decide_adding(
310    trunk: Version,
311    observed: Impact,
312    policy: &ReleasePolicy,
313    changed: &[String],
314    added: &[String],
315) -> LandingDecision {
316    let compat = CompatPolicy::for_version(trunk);
317    let required = compat.required_of(observed);
318    let exemption = exemption_for(policy, changed);
319    let record = records_release_of(trunk, added);
320    // a floor speaks the version's mode as compatibility does: below 1.0.0 nothing costs a
321    // major, so a `major` floor asks a minor there; a change of nothing is no landing; and
322    // the record of the release the trunk already declares lands at that release's number,
323    // so no floor applies to either
324    let floor = if exemption.is_some() || changed.is_empty() || record.is_some() {
325        Impact::None
326    } else {
327        floor_in(compat.mode, policy.floor)
328    };
329    let selected = required.max(floor);
330    let next = trunk.raised_to(selected);
331
332    let mut explanation = vec![
333        format!("the trunk declares {trunk}"),
334        format!(
335            "the change moves the public contract by {}: {}",
336            observed.as_str(),
337            compat.statement()
338        ),
339        format!("compatibility therefore requires {}", required.as_str()),
340    ];
341    match exemption {
342        None if record.is_some() => explanation.push(format!(
343            "the change writes {}, the record of the release the trunk declares, which lands at that release's number, so no policy floor applies",
344            record.unwrap_or_default()
345        )),
346        None if changed.is_empty() => explanation.push(
347            "the change touches no path, which is no landing, so no policy floor applies"
348                .to_string(),
349        ),
350        Some(class) => explanation.push(format!(
351            "every changed path is in the exemption class {} ({}), so the policy floor of {} does not apply; the compatibility requirement still does",
352            class.id,
353            class.description,
354            policy.floor.as_str()
355        )),
356        None if floor != policy.floor => explanation.push(format!(
357            "the policy floor is {}, which the version's mode reads as {}",
358            policy.floor.as_str(),
359            floor.as_str()
360        )),
361        None => explanation.push(format!("the policy floor is {}", policy.floor.as_str())),
362    }
363    explanation.push(format!(
364        "the larger of the two is {}, so the next version is {next}",
365        selected.as_str()
366    ));
367    if policy.allocation == Allocation::Off {
368        explanation.push(
369            "the policy does not allocate at landing (release.allocation: off): this is what a landing would raise to, and a pull request still raises its own version"
370                .to_string(),
371        );
372    }
373
374    LandingDecision {
375        schema: DECISION_SCHEMA.to_string(),
376        trunk_version: trunk.to_string(),
377        observed,
378        required_by_compatibility: required,
379        floor,
380        exemption: exemption.map(|class| AppliedExemption {
381            id: class.id.clone(),
382            reason: class.description.clone(),
383        }),
384        selected,
385        next_version: next.to_string(),
386        allocation: policy.allocation,
387        mode: compat.mode,
388        explanation,
389    }
390}
391
392/// Why a landing decision could not be made: an input that could not be read. Never a
393/// decision of its own — an unreadable trunk or surface is refused, not guessed.
394///
395/// ```
396/// use majordomus_cli::release::landing::Unreadable;
397/// let e = Unreadable("no ref nonesuch".into());
398/// assert_eq!(e.to_string(), "no ref nonesuch");
399/// ```
400#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct Unreadable(pub String);
402
403impl std::fmt::Display for Unreadable {
404    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405        f.write_str(&self.0)
406    }
407}
408
409impl std::error::Error for Unreadable {}
410
411/// What a landing of `head` onto `base` allocates in the repository at `root`: the trunk's
412/// version is the manifest at `base`; the movement is the committed registry where `head`
413/// forked from `base` against `head`'s, and the changed paths are git's over the same span.
414/// Measuring from the fork point is what makes the movement the pull request's own: a head
415/// that is behind `base` lacks what `base` added since, and read against `base` itself that
416/// absence would count as a removal it never made. Everything is read from git, so the
417/// answer is the same in any checkout and does not depend on which executable runs.
418///
419/// ```
420/// use majordomus_cli::release::landing::{next_for, ReleasePolicy};
421/// // a directory that is no repository has no trunk to raise from
422/// let err = next_for(std::path::Path::new("/"), "origin/master", "HEAD", &ReleasePolicy::default());
423/// assert!(err.is_err());
424/// ```
425pub fn next_for(
426    root: &std::path::Path,
427    base: &str,
428    head: &str,
429    policy: &ReleasePolicy,
430) -> Result<LandingDecision, Unreadable> {
431    use crate::release::surface::Surface;
432    let git = |args: &[&str]| -> Result<String, Unreadable> {
433        let out = std::process::Command::new("git")
434            .current_dir(root)
435            .args(args)
436            .output()
437            .map_err(|e| Unreadable(format!("git could not run: {e}")))?;
438        if !out.status.success() {
439            return Err(Unreadable(format!(
440                "git {} failed: {}",
441                args.join(" "),
442                String::from_utf8_lossy(&out.stderr).trim()
443            )));
444        }
445        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
446    };
447    let manifest = git(&[
448        "show",
449        &format!("{base}:{}", crate::release::version::MANIFEST),
450    ])?;
451    let trunk = crate::release::version::declared_in(&manifest)
452        .and_then(|v| Version::parse(&v))
453        .ok_or_else(|| {
454            Unreadable(format!(
455                "{base}:{} declares no version of three numbers",
456                crate::release::version::MANIFEST
457            ))
458        })?;
459    let fork = git(&["merge-base", base, head])?.trim().to_string();
460    let before = Surface::read_ref(root, &fork).map_err(|e| Unreadable(e.to_string()))?;
461    let after = Surface::read_ref(root, head).map_err(|e| Unreadable(e.to_string()))?;
462    let observed = crate::release::compat::diff(&before, &after)
463        .iter()
464        .map(|c| c.impact)
465        .max()
466        .unwrap_or(Impact::None);
467    let names = |text: String| -> Vec<String> {
468        text.lines()
469            .filter(|l| !l.is_empty())
470            .map(str::to_string)
471            .collect()
472    };
473    git(&["diff", "--name-only", "--no-renames", &fork, head]).and_then(|changed| {
474        git(&[
475            "diff",
476            "--name-only",
477            "--no-renames",
478            "--diff-filter=A",
479            &fork,
480            head,
481        ])
482        .map(|added| decide_adding(trunk, observed, policy, &names(changed), &names(added)))
483    })
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    /// The decision read from git at two refs, in this crate's own repository: its head
491    /// landed onto itself moves nothing, the trunk's version is the one committed at that
492    /// head, and every step is said. A ref git cannot resolve is refused by name. HEAD on
493    /// both sides holds in a shallow clone, where origin/master may not exist.
494    #[test]
495    fn a_head_landed_onto_itself_is_read_from_git_and_moves_nothing() {
496        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
497        let d =
498            next_for(&root, "HEAD", "HEAD", &ReleasePolicy::default()).expect("readable at HEAD");
499        assert_eq!(d.observed, Impact::None);
500        let committed = std::process::Command::new("git")
501            .current_dir(&root)
502            .args([
503                "show",
504                &format!("HEAD:{}", crate::release::version::MANIFEST),
505            ])
506            .output()
507            .expect("git");
508        let committed =
509            crate::release::version::declared_in(&String::from_utf8_lossy(&committed.stdout));
510        assert_eq!(Some(d.trunk_version.clone()), committed);
511        assert!(!d.explanation.is_empty());
512        assert_eq!(
513            (Allocation::Off.as_str(), Allocation::Landing.as_str()),
514            ("off", "landing")
515        );
516        let refused = next_for(
517            &root,
518            "no-such-ref-of-ours",
519            "HEAD",
520            &ReleasePolicy::default(),
521        )
522        .expect_err("an unknown base is refused");
523        assert!(refused.0.contains("no-such-ref-of-ours"), "{}", refused.0);
524    }
525
526    /// Every input that cannot be read refuses the decision by name: git that cannot start
527    /// in the directory, a head git cannot find, and a trunk whose manifest declares no
528    /// version of three numbers.
529    #[test]
530    fn an_unreadable_input_refuses_the_decision_by_name() {
531        let p = ReleasePolicy::default();
532        let refused = next_for(std::path::Path::new("/no\0where"), "HEAD", "HEAD", &p)
533            .expect_err("no git there");
534        assert!(refused.0.contains("git could not run"), "{}", refused.0);
535
536        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
537        let refused = next_for(&root, "HEAD", "no-such-head-of-ours", &p).expect_err("unknown");
538        assert!(refused.0.contains("merge-base"), "{}", refused.0);
539
540        let dir = tempfile::tempdir().unwrap();
541        let git = |args: &[&str]| {
542            let out = std::process::Command::new("git")
543                .arg("-C")
544                .arg(dir.path())
545                .args(args)
546                .env("GIT_AUTHOR_NAME", "t")
547                .env("GIT_AUTHOR_EMAIL", "t@example.com")
548                .env("GIT_COMMITTER_NAME", "t")
549                .env("GIT_COMMITTER_EMAIL", "t@example.com")
550                .output()
551                .expect("git");
552            assert!(out.status.success(), "git {args:?}: {out:?}");
553        };
554        git(&["init", "-q"]);
555        let manifest = dir.path().join(crate::release::version::MANIFEST);
556        std::fs::create_dir_all(manifest.parent().unwrap()).unwrap();
557        std::fs::write(&manifest, "[package]\nname = \"x\"\nversion = \"one\"\n").unwrap();
558        git(&["add", "-A"]);
559        git(&["commit", "-qm", "base"]);
560        let refused = next_for(dir.path(), "HEAD", "HEAD", &p).expect_err("no version");
561        assert!(refused.0.contains("declares no version"), "{}", refused.0);
562    }
563
564    fn v(text: &str) -> Version {
565        Version::parse(text).expect("a version")
566    }
567
568    fn landing(floor: Impact) -> ReleasePolicy {
569        ReleasePolicy {
570            allocation: Allocation::Landing,
571            floor,
572            exempt: vec![
573                ReleaseExemption {
574                    id: "plan-records".into(),
575                    description: "plan records change no product behaviour".into(),
576                    paths: vec![".ai/repo/project/**".into(), ".ai/repo/sessions/**".into()],
577                },
578                ReleaseExemption {
579                    id: "release-record".into(),
580                    description: "the record a release writes of itself".into(),
581                    paths: vec![
582                        ".ai/repo/releases/**".into(),
583                        "site/static/releases/**".into(),
584                    ],
585                },
586            ],
587        }
588    }
589
590    fn src() -> Vec<String> {
591        vec!["apps/majordomus-cli/src/lib.rs".into()]
592    }
593
594    #[test]
595    fn the_selected_increment_is_the_larger_of_compatibility_and_the_floor() {
596        let p = landing(Impact::Minor);
597        for (observed, trunk, next) in [
598            (Impact::None, "0.20.0", "0.21.0"),
599            (Impact::Patch, "0.20.0", "0.21.0"),
600            (Impact::Minor, "0.20.0", "0.21.0"),
601            (Impact::Major, "0.20.0", "0.21.0"),
602            (Impact::Patch, "1.4.2", "1.5.0"),
603            (Impact::Major, "1.4.2", "2.0.0"),
604        ] {
605            let d = decide(v(trunk), observed, &p, &src());
606            assert_eq!(
607                d.next_version, next,
608                "{observed:?} on {trunk}: {:#?}",
609                d.explanation
610            );
611        }
612    }
613
614    #[test]
615    fn below_one_a_breaking_change_costs_a_minor_and_from_one_a_major() {
616        let p = landing(Impact::None);
617        assert_eq!(
618            decide(v("0.9.0"), Impact::Major, &p, &src()).selected,
619            Impact::Minor
620        );
621        assert_eq!(
622            decide(v("0.9.0"), Impact::Major, &p, &src()).mode,
623            Mode::Pre1_0Strict
624        );
625        assert_eq!(
626            decide(v("1.0.0"), Impact::Major, &p, &src()).selected,
627            Impact::Major
628        );
629        assert_eq!(
630            decide(v("1.0.0"), Impact::Major, &p, &src()).mode,
631            Mode::Semver
632        );
633    }
634
635    #[test]
636    fn with_no_floor_a_change_behind_the_boundary_advances_nothing() {
637        let d = decide(v("0.20.0"), Impact::Patch, &landing(Impact::None), &src());
638        assert_eq!(
639            (d.selected, d.next_version.as_str()),
640            (Impact::None, "0.20.0")
641        );
642    }
643
644    #[test]
645    fn an_exemption_lowers_the_floor_and_never_the_compatibility_requirement() {
646        let p = landing(Impact::Minor);
647        let plan = vec![".ai/repo/project/issues/I9.yaml".to_string()];
648        let d = decide(v("1.0.0"), Impact::None, &p, &plan);
649        assert_eq!(
650            d.exemption.as_ref().map(|e| e.id.as_str()),
651            Some("plan-records")
652        );
653        assert_eq!((d.floor, d.next_version.as_str()), (Impact::None, "1.0.0"));
654        // the same paths, had they moved the contract, still owe what the movement owes
655        let d = decide(v("1.0.0"), Impact::Major, &p, &plan);
656        assert_eq!(
657            (d.selected, d.next_version.as_str()),
658            (Impact::Major, "2.0.0")
659        );
660    }
661
662    #[test]
663    fn a_change_one_class_covers_only_in_part_is_not_exempt() {
664        let p = landing(Impact::Minor);
665        let mixed = vec![
666            ".ai/repo/project/issues/I9.yaml".to_string(),
667            "apps/majordomus-cli/src/lib.rs".to_string(),
668        ];
669        let d = decide(v("0.20.0"), Impact::None, &p, &mixed);
670        assert!(d.exemption.is_none());
671        assert_eq!(d.next_version, "0.21.0");
672        // nor is a change two classes cover between them: one class must cover it all
673        let split = vec![
674            ".ai/repo/project/issues/I9.yaml".to_string(),
675            ".ai/repo/releases/v0.20.0.yaml".to_string(),
676        ];
677        assert!(decide(v("0.20.0"), Impact::None, &p, &split)
678            .exemption
679            .is_none());
680    }
681
682    #[test]
683    fn the_same_inputs_give_the_same_decision_and_explanation() {
684        let p = landing(Impact::Minor);
685        let a = decide(v("0.20.0"), Impact::Minor, &p, &src());
686        let b = decide(v("0.20.0"), Impact::Minor, &p, &src());
687        assert_eq!(a, b);
688        assert!(a.explanation[0].contains("0.20.0"));
689        assert!(a
690            .explanation
691            .iter()
692            .any(|line| line.contains("floor is minor")));
693    }
694
695    #[test]
696    fn a_policy_that_does_not_allocate_says_so_and_still_decides() {
697        let p = ReleasePolicy {
698            allocation: Allocation::Off,
699            ..landing(Impact::Minor)
700        };
701        let d = decide(v("0.20.0"), Impact::Minor, &p, &src());
702        assert_eq!(d.next_version, "0.21.0");
703        assert!(d
704            .explanation
705            .last()
706            .unwrap()
707            .contains("release.allocation: off"));
708    }
709
710    #[test]
711    fn the_default_policy_changes_nothing() {
712        let d = decide(
713            v("0.20.0"),
714            Impact::Patch,
715            &ReleasePolicy::default(),
716            &src(),
717        );
718        assert_eq!(
719            (d.allocation, d.floor, d.next_version.as_str()),
720            (Allocation::Off, Impact::None, "0.20.0")
721        );
722    }
723
724    #[test]
725    fn the_policy_block_reads_from_the_shape_the_policy_file_has() {
726        let p: ReleasePolicy = serde_json::from_value(serde_json::json!({
727            "allocation": "landing",
728            "floor": "minor",
729            "exempt": [{"id": "plan-records", "description": "plans", "paths": [".ai/repo/project/**"]}],
730        }))
731        .expect("the block");
732        assert_eq!(
733            p,
734            ReleasePolicy {
735                exempt: p.exempt.clone(),
736                ..landing(Impact::Minor)
737            }
738        );
739        assert!(
740            serde_json::from_value::<ReleasePolicy>(serde_json::json!({"skip": true})).is_err(),
741            "there is no switch outside the declared keys"
742        );
743    }
744
745    #[test]
746    fn a_change_of_nothing_is_no_exempt_landing_and_owes_no_floor() {
747        let d = decide(v("0.20.0"), Impact::None, &landing(Impact::Minor), &[]);
748        assert!(d.exemption.is_none());
749        assert_eq!((d.floor, d.selected), (Impact::None, Impact::None));
750        assert_eq!(d.next_version, "0.20.0");
751        assert!(
752            d.explanation.iter().any(|l| l.contains("no landing")),
753            "{:?}",
754            d.explanation
755        );
756    }
757
758    #[test]
759    fn a_major_floor_below_one_asks_a_minor_and_from_one_a_major() {
760        let changed = ["apps/majordomus-cli/src/x.rs".to_string()];
761        let below = decide(v("0.20.0"), Impact::None, &landing(Impact::Major), &changed);
762        assert_eq!(
763            (below.floor, below.next_version.as_str()),
764            (Impact::Minor, "0.21.0")
765        );
766        assert!(
767            below
768                .explanation
769                .iter()
770                .any(|l| l.contains("reads as minor")),
771            "{:?}",
772            below.explanation
773        );
774        let above = decide(v("1.4.0"), Impact::None, &landing(Impact::Major), &changed);
775        assert_eq!(
776            (above.floor, above.next_version.as_str()),
777            (Impact::Major, "2.0.0")
778        );
779    }
780
781    #[test]
782    fn the_record_of_the_trunks_release_lands_at_its_number() {
783        let changed = [
784            ".ai/repo/releases/v0.20.0.yaml".to_string(),
785            "docs/INSTALL.md".to_string(),
786            ".gitattributes".to_string(),
787        ];
788        let added = &changed[..1];
789        let d = decide_adding(
790            v("0.20.0"),
791            Impact::None,
792            &landing(Impact::Minor),
793            &changed,
794            added,
795        );
796        assert_eq!((d.floor, d.next_version.as_str()), (Impact::None, "0.20.0"));
797        assert!(
798            d.explanation
799                .iter()
800                .any(|l| l.contains("record of the release")),
801            "{:?}",
802            d.explanation
803        );
804        // a change that edits the record already written is no record pull request
805        let d = decide_adding(
806            v("0.20.0"),
807            Impact::None,
808            &landing(Impact::Minor),
809            &changed,
810            &[],
811        );
812        assert_eq!(d.next_version, "0.21.0");
813        // nor is a change that adds the record of another release
814        let other = [
815            ".ai/repo/releases/v0.19.0.yaml".to_string(),
816            "docs/INSTALL.md".to_string(),
817        ];
818        let d = decide_adding(
819            v("0.20.0"),
820            Impact::None,
821            &landing(Impact::Minor),
822            &other,
823            &other,
824        );
825        assert_eq!(d.next_version, "0.21.0");
826    }
827}