Skip to main content

release_kit/plan/
compatibility.rs

1//! The compatibility axes beyond the payload schema, each evaluated into
2//! a precondition with a requirement.
3//!
4//! The seam settles engine-against-payload through `payload_schema`. Four
5//! axes remain, and each one has stranded someone: the engine floor a
6//! bundle names where the schema is too coarse, the generator a committed
7//! artifact needs at its pin, the manager a pin move would go through,
8//! and the forge floor the landed files rest on, plus the intermediate
9//! release a landing must pass through. The facts land in the plan's
10//! `release.compatibility` section; the policy lands in the preconditions.
11
12use std::collections::BTreeMap;
13
14use crate::release::declared::{self, version_below};
15
16use super::readiness::{Evaluation, Precondition, Requirement};
17use super::{Choice, Compatibility, Decision, ForgeFloor, GeneratorFact, IntermediateFact};
18
19/// The generator a technology's committed artifact needs, as
20/// `versions.toml` names it, and the artifact it regenerates.
21#[must_use]
22pub fn generator_for(tech: &str) -> Option<(&'static str, &'static str)> {
23    match tech {
24        "rust" => Some(("cargo-dist", "dist-workspace.toml")),
25        _ => None,
26    }
27}
28
29/// The decision id for a pin move through a manager the target does not
30/// wire.
31pub const PIN_MANAGER_DECISION: &str = "pin-manager";
32
33/// Everything the evaluation reads.
34pub struct Inputs<'a> {
35    /// What the candidate bundle declares.
36    pub declared: &'a declared::Compatibility,
37    /// The engine computing the plan.
38    pub engine_version: &'a str,
39    /// The engine's protocol version.
40    pub engine_schema: u32,
41    /// The candidate's protocol version.
42    pub bundle_schema: u32,
43    /// The candidate release.
44    pub candidate_version: &'a str,
45    /// The recorded release, where a record exists.
46    pub recorded_version: Option<&'a str>,
47    /// The resolved technology, where one resolved.
48    pub tech: Option<&'a str>,
49    /// The resolved forge, where one resolved.
50    pub forge: Option<&'a str>,
51    /// The candidate's pins, by tool name.
52    pub pins: &'a BTreeMap<String, String>,
53    /// The generator's version on the host, where one was read.
54    pub host_generator: Option<&'a str>,
55    /// Every path the operations write.
56    pub writes: &'a [String],
57    /// The paths the operations rewrite: a file the target already holds.
58    /// A first write generates nothing yet, so only a rewrite of the
59    /// generator's artifact needs the generator on the host.
60    pub rewrites: &'a [String],
61    /// Whether a manager file names release-kit.
62    pub pin_wired: bool,
63    /// The managers whose file is present and names no release-kit.
64    pub unwired_managers: &'a [String],
65    /// The forge's version, where the forge was asked.
66    pub forge_version: Option<&'a str>,
67    /// The decisions the operator selected, by id.
68    pub selected: &'a BTreeMap<String, String>,
69    /// The evidence the axes cite.
70    pub evidence_refs: Vec<String>,
71}
72
73/// What the evaluation produces.
74pub struct Evaluated {
75    /// The facts, for the plan's release section.
76    pub facts: Compatibility,
77    /// One precondition per axis that applies.
78    pub preconditions: Vec<Precondition>,
79    /// The decision the manager axis asks, where it asks one.
80    pub decisions: Vec<Decision>,
81}
82
83/// Evaluate every axis.
84#[must_use]
85pub fn evaluate(inputs: &Inputs<'_>) -> Evaluated {
86    let mut preconditions = Vec::new();
87    let mut decisions = Vec::new();
88    let refs = inputs.evidence_refs.clone();
89    let engine_minimum = engine_axis(inputs, &refs, &mut preconditions);
90    let generator = generator_axis(inputs, &refs, &mut preconditions);
91    manager_axis(inputs, &refs, &mut preconditions, &mut decisions);
92    let forge_floor = forge_axis(inputs, &refs, &mut preconditions);
93    let intermediate = intermediate_axis(inputs, &refs, &mut preconditions);
94    Evaluated {
95        facts: Compatibility {
96            engine_schema: inputs.engine_schema,
97            bundle_schema: inputs.bundle_schema,
98            readable: inputs.bundle_schema <= inputs.engine_schema,
99            engine_minimum,
100            generator,
101            forge_floor,
102            intermediate,
103            evidence_refs: refs,
104        },
105        preconditions,
106        decisions,
107    }
108}
109
110/// The engine axis: the bundle names an engine floor the schema alone
111/// does not express.
112fn engine_axis(
113    inputs: &Inputs<'_>,
114    refs: &[String],
115    preconditions: &mut Vec<Precondition>,
116) -> Option<String> {
117    let engine_minimum = inputs.declared.engine.minimum.clone();
118    if let Some(minimum) = &engine_minimum {
119        let below = version_below(inputs.engine_version, minimum);
120        preconditions.push(Precondition {
121            id: "engine-meets-minimum".into(),
122            requirement: Requirement::Required,
123            evaluation: if below {
124                Evaluation::Unsatisfied {
125                    reason: format!(
126                        "the bundle for release-kit {} needs engine {minimum} or newer, and this engine is {}; install release-kit {minimum} or newer",
127                        inputs.candidate_version, inputs.engine_version
128                    ),
129                }
130            } else {
131                Evaluation::Satisfied
132            },
133            decision: None,
134            evidence_refs: refs.to_vec(),
135        });
136    }
137    engine_minimum
138}
139
140/// The generator axis: a committed artifact that predates the landed
141/// configuration needs the binding's generator at its pin.
142fn generator_axis(
143    inputs: &Inputs<'_>,
144    refs: &[String],
145    preconditions: &mut Vec<Precondition>,
146) -> Option<GeneratorFact> {
147    let generator = inputs
148        .tech
149        .and_then(generator_for)
150        .and_then(|(name, artifact)| {
151            let pin = inputs.pins.get(name)?;
152            Some(GeneratorFact {
153                name: name.to_owned(),
154                pin: pin.clone(),
155                artifact: artifact.to_owned(),
156                host: inputs.host_generator.map(str::to_owned),
157            })
158        });
159    if let Some(fact) = &generator {
160        let planned = inputs.rewrites.iter().any(|path| path == &fact.artifact);
161        let evaluation = match &fact.host {
162            None => Evaluation::NotObserved {
163                reason: format!(
164                    "{} is not on PATH; the landed {} is regenerated with {} {}",
165                    fact.name, fact.artifact, fact.name, fact.pin
166                ),
167            },
168            Some(host) if version_below(host, &fact.pin) => Evaluation::Unsatisfied {
169                reason: format!(
170                    "{} {host} is on PATH and the landed {} needs {}; install {} {}",
171                    fact.name, fact.artifact, fact.pin, fact.name, fact.pin
172                ),
173            },
174            Some(_) => Evaluation::Satisfied,
175        };
176        preconditions.push(Precondition {
177            id: "generator-at-pin".into(),
178            requirement: if planned {
179                Requirement::Required
180            } else {
181                Requirement::Advisory
182            },
183            evaluation,
184            decision: None,
185            evidence_refs: refs.to_vec(),
186        });
187    }
188    generator
189}
190
191/// The manager axis: a pin move through a manager the target does not
192/// wire is not an operation, it is a decision. It is asked where a move
193/// is wanted, which is a recorded target behind the candidate.
194fn manager_axis(
195    inputs: &Inputs<'_>,
196    refs: &[String],
197    preconditions: &mut Vec<Precondition>,
198    decisions: &mut Vec<Decision>,
199) {
200    let move_wanted = inputs
201        .recorded_version
202        .is_some_and(|recorded| recorded != inputs.candidate_version);
203    if !move_wanted || inputs.pin_wired || inputs.unwired_managers.is_empty() {
204        return;
205    }
206    let answered = inputs.selected.get(PIN_MANAGER_DECISION).cloned();
207    let managers = inputs.unwired_managers.join(", ");
208    decisions.push(Decision {
209        id: PIN_MANAGER_DECISION.into(),
210        question: format!(
211            "{managers} is present and names no release-kit; does the rk pin move through it?"
212        ),
213        choices: vec![
214            Choice {
215                answer: "wire".into(),
216                consequence: format!(
217                    "run rk self-depend add --manager <{}> --apply first, then plan again so the move is an update-pin operation",
218                    inputs.unwired_managers.join("|")
219                ),
220            },
221            Choice {
222                answer: "host".into(),
223                consequence: "rk stays a host install here, nothing pins it, and the plan carries no pin move".into(),
224            },
225        ],
226        selected: answered.clone(),
227    });
228    preconditions.push(Precondition {
229        id: "pin-manager-answered".into(),
230        requirement: Requirement::DecisionRequired,
231        // Only an answer the decision declares satisfies it. An answer
232        // outside the closed set is refused where the operator typed it,
233        // and a stored plan carrying one is judged unanswered here.
234        evaluation: if super::decision_answered(PIN_MANAGER_DECISION, answered.as_deref()) {
235            Evaluation::Satisfied
236        } else {
237            Evaluation::NotObserved {
238                reason: format!(
239                    "{managers} could pin rk and the operator has not said whether it does"
240                ),
241            }
242        },
243        decision: Some(PIN_MANAGER_DECISION.into()),
244        evidence_refs: refs.to_vec(),
245    });
246}
247
248/// The forge axis: the landed pipeline rests on a forge floor, and the
249/// floor is a fact the plan carries rather than a discovery at setup.
250fn forge_axis(
251    inputs: &Inputs<'_>,
252    refs: &[String],
253    preconditions: &mut Vec<Precondition>,
254) -> Option<ForgeFloor> {
255    let forge_floor = inputs.forge.and_then(|forge| {
256        let minimum = inputs.declared.forge.get(forge)?.minimum.clone()?;
257        Some(ForgeFloor {
258            forge: forge.to_owned(),
259            minimum,
260            observed: inputs.forge_version.map(str::to_owned),
261        })
262    });
263    if let Some(floor) = &forge_floor {
264        let pipeline_planned = inputs
265            .writes
266            .iter()
267            .any(|path| path == ".gitlab-ci.yml" || path.starts_with(".gitlab/ci/"));
268        let below = floor
269            .observed
270            .as_deref()
271            .is_some_and(|observed| version_below(observed, &floor.minimum));
272        preconditions.push(Precondition {
273            id: "forge-meets-floor".into(),
274            requirement: if below && pipeline_planned {
275                Requirement::Required
276            } else {
277                Requirement::Advisory
278            },
279            evaluation: match &floor.observed {
280                None => Evaluation::NotObserved {
281                    reason: format!(
282                        "the {} version was not read; --observe forge reads it, and rk setup refuses an instance below {}",
283                        floor.forge, floor.minimum
284                    ),
285                },
286                Some(observed) if below => Evaluation::Unsatisfied {
287                    reason: format!(
288                        "this {} instance reports {observed}, and the landed files need {} or newer",
289                        floor.forge, floor.minimum
290                    ),
291                },
292                Some(_) => Evaluation::Satisfied,
293            },
294            decision: None,
295            evidence_refs: refs.to_vec(),
296        });
297    }
298    forge_floor
299}
300
301/// The intermediate axis: a release the landing must pass through,
302/// between the record and the candidate.
303fn intermediate_axis(
304    inputs: &Inputs<'_>,
305    refs: &[String],
306    preconditions: &mut Vec<Precondition>,
307) -> Vec<IntermediateFact> {
308    let intermediate: Vec<IntermediateFact> = inputs
309        .recorded_version
310        .map(|recorded| {
311            inputs
312                .declared
313                .intermediate
314                .iter()
315                .filter(|step| {
316                    version_below(recorded, &step.version)
317                        && version_below(&step.version, inputs.candidate_version)
318                })
319                .map(|step| IntermediateFact {
320                    version: step.version.clone(),
321                    reason: step.reason.clone(),
322                })
323                .collect()
324        })
325        .unwrap_or_default();
326    for step in &intermediate {
327        preconditions.push(Precondition {
328            id: format!("intermediate-release:{}", step.version),
329            requirement: Requirement::Required,
330            evaluation: Evaluation::Unsatisfied {
331                reason: format!(
332                    "the landing must pass through release-kit {} first: {}; plan with --to {}",
333                    step.version, step.reason, step.version
334                ),
335            },
336            decision: None,
337            evidence_refs: refs.to_vec(),
338        });
339    }
340    intermediate
341}
342
343#[cfg(test)]
344mod tests {
345    use std::collections::BTreeMap;
346
347    use super::{Evaluated, Inputs, evaluate};
348    use crate::plan::readiness::{self, Evaluation, Precondition, Readiness, Requirement};
349    use crate::release::declared::{Compatibility, parse_compatibility};
350
351    /// Owned inputs, so a test tunes fields and evaluates.
352    struct Fixture {
353        declared: Compatibility,
354        engine_version: &'static str,
355        candidate_version: &'static str,
356        recorded_version: Option<&'static str>,
357        tech: Option<&'static str>,
358        forge: Option<&'static str>,
359        pins: BTreeMap<String, String>,
360        host_generator: Option<&'static str>,
361        writes: Vec<String>,
362        rewrites: Vec<String>,
363        pin_wired: bool,
364        unwired_managers: Vec<String>,
365        forge_version: Option<&'static str>,
366        selected: BTreeMap<String, String>,
367    }
368
369    impl Fixture {
370        fn new(declared: Compatibility) -> Self {
371            Self {
372                declared,
373                engine_version: "0.3.18",
374                candidate_version: "0.3.21",
375                recorded_version: Some("0.3.18"),
376                tech: Some("rust"),
377                forge: Some("gitlab"),
378                pins: BTreeMap::from([("cargo-dist".to_owned(), "0.32.0".to_owned())]),
379                host_generator: None,
380                writes: Vec::new(),
381                rewrites: Vec::new(),
382                pin_wired: false,
383                unwired_managers: Vec::new(),
384                forge_version: None,
385                selected: BTreeMap::new(),
386            }
387        }
388
389        fn evaluate(&self) -> Evaluated {
390            evaluate(&Inputs {
391                declared: &self.declared,
392                engine_version: self.engine_version,
393                engine_schema: 1,
394                bundle_schema: 1,
395                candidate_version: self.candidate_version,
396                recorded_version: self.recorded_version,
397                tech: self.tech,
398                forge: self.forge,
399                pins: &self.pins,
400                host_generator: self.host_generator,
401                writes: &self.writes,
402                rewrites: &self.rewrites,
403                pin_wired: self.pin_wired,
404                unwired_managers: &self.unwired_managers,
405                forge_version: self.forge_version,
406                selected: &self.selected,
407                evidence_refs: vec!["candidate-bundle".into()],
408            })
409        }
410    }
411
412    fn precondition<'a>(evaluated: &'a Evaluated, id: &str) -> &'a Precondition {
413        evaluated
414            .preconditions
415            .iter()
416            .find(|p| p.id == id)
417            .unwrap_or_else(|| panic!("precondition {id} exists"))
418    }
419
420    #[test]
421    fn a_bundle_without_compatibility_requires_only_its_schema() {
422        let mut fixture = Fixture::new(Compatibility::default());
423        fixture.forge = Some("github");
424        let evaluated = fixture.evaluate();
425        let ids: Vec<&str> = evaluated
426            .preconditions
427            .iter()
428            .map(|p| p.id.as_str())
429            .collect();
430        assert_eq!(
431            ids,
432            ["generator-at-pin"],
433            "the schema and the binding's generator are the only axes an empty declaration leaves"
434        );
435        assert_eq!(
436            precondition(&evaluated, "generator-at-pin").requirement,
437            Requirement::Advisory
438        );
439        assert!(evaluated.facts.engine_minimum.is_none());
440        assert!(evaluated.facts.forge_floor.is_none());
441        assert!(evaluated.facts.intermediate.is_empty());
442        assert!(evaluated.decisions.is_empty());
443        assert_eq!(
444            readiness::derive(&evaluated.preconditions),
445            Readiness::Ready
446        );
447    }
448
449    #[test]
450    fn an_engine_below_requirement_is_blocked_naming_the_engine() {
451        let declared = parse_compatibility("[engine]\nminimum = \"0.4.0\"\n").expect("parses");
452        let mut fixture = Fixture::new(declared);
453        let evaluated = fixture.evaluate();
454        let p = precondition(&evaluated, "engine-meets-minimum");
455        assert_eq!(p.requirement, Requirement::Required);
456        let Evaluation::Unsatisfied { reason } = &p.evaluation else {
457            panic!(
458                "an engine below the floor is unsatisfied: {:?}",
459                p.evaluation
460            );
461        };
462        assert!(reason.contains("install release-kit 0.4.0"), "{reason}");
463        assert_eq!(
464            readiness::derive(&evaluated.preconditions),
465            Readiness::Blocked
466        );
467        fixture.engine_version = "0.4.0";
468        let ok = fixture.evaluate();
469        assert!(precondition(&ok, "engine-meets-minimum").evaluation.holds());
470    }
471
472    #[test]
473    fn a_missing_generator_blocks_only_when_a_generated_artifact_is_planned() {
474        let mut fixture = Fixture::new(Compatibility::default());
475        let idle = fixture.evaluate();
476        let p = precondition(&idle, "generator-at-pin");
477        assert_eq!(p.requirement, Requirement::Advisory);
478        assert!(matches!(p.evaluation, Evaluation::NotObserved { .. }));
479        assert_eq!(readiness::derive(&idle.preconditions), Readiness::Ready);
480        // A first landing writes the artifact and generates nothing yet.
481        fixture.writes = vec!["dist-workspace.toml".to_owned()];
482        let first = fixture.evaluate();
483        assert_eq!(
484            precondition(&first, "generator-at-pin").requirement,
485            Requirement::Advisory
486        );
487        assert_eq!(readiness::derive(&first.preconditions), Readiness::Ready);
488        // A rewrite of an artifact the target already holds regenerates.
489        fixture.rewrites = vec!["dist-workspace.toml".to_owned()];
490        let planned = fixture.evaluate();
491        let p = precondition(&planned, "generator-at-pin");
492        assert_eq!(p.requirement, Requirement::Required);
493        assert_eq!(
494            readiness::derive(&planned.preconditions),
495            Readiness::Blocked
496        );
497        fixture.host_generator = Some("0.31.0");
498        let old = fixture.evaluate();
499        let Evaluation::Unsatisfied { reason } = &precondition(&old, "generator-at-pin").evaluation
500        else {
501            panic!("a generator below its pin is unsatisfied");
502        };
503        assert!(reason.contains("install cargo-dist 0.32.0"), "{reason}");
504        fixture.host_generator = Some("0.32.0");
505        let current = fixture.evaluate();
506        assert!(
507            precondition(&current, "generator-at-pin")
508                .evaluation
509                .holds()
510        );
511        assert_eq!(
512            current
513                .facts
514                .generator
515                .as_ref()
516                .map(|g| g.artifact.as_str()),
517            Some("dist-workspace.toml")
518        );
519    }
520
521    #[test]
522    fn an_unwired_manager_is_a_decision() {
523        let mut fixture = Fixture::new(Compatibility::default());
524        fixture.unwired_managers = vec!["mise".to_owned()];
525        let asked = fixture.evaluate();
526        let p = precondition(&asked, "pin-manager-answered");
527        assert_eq!(p.requirement, Requirement::DecisionRequired);
528        assert_eq!(p.decision.as_deref(), Some("pin-manager"));
529        assert_eq!(
530            readiness::derive(&asked.preconditions),
531            Readiness::NeedsDecision
532        );
533        assert_eq!(asked.decisions[0].id, "pin-manager");
534        fixture.selected = BTreeMap::from([("pin-manager".to_owned(), "host".to_owned())]);
535        let answered = fixture.evaluate();
536        assert!(
537            precondition(&answered, "pin-manager-answered")
538                .evaluation
539                .holds()
540        );
541        assert_eq!(readiness::derive(&answered.preconditions), Readiness::Ready);
542        fixture.selected.clear();
543        fixture.pin_wired = true;
544        assert!(
545            fixture.evaluate().decisions.is_empty(),
546            "a wired pin asks nothing"
547        );
548        fixture.pin_wired = false;
549        fixture.recorded_version = Some("0.3.21");
550        assert!(
551            fixture.evaluate().decisions.is_empty(),
552            "no move wanted, no question"
553        );
554        fixture.recorded_version = None;
555        assert!(
556            fixture.evaluate().decisions.is_empty(),
557            "a first landing wires later"
558        );
559    }
560
561    #[test]
562    fn a_forge_below_floor_blocks_only_when_a_forge_fact_affects_an_operation() {
563        let declared = parse_compatibility("[forge.gitlab]\nminimum = \"18.2\"\n").expect("parses");
564        let mut fixture = Fixture::new(declared);
565        let unread = fixture.evaluate();
566        let p = precondition(&unread, "forge-meets-floor");
567        assert_eq!(p.requirement, Requirement::Advisory);
568        assert!(matches!(p.evaluation, Evaluation::NotObserved { .. }));
569        fixture.forge_version = Some("18.1.0");
570        let below_idle = fixture.evaluate();
571        let p = precondition(&below_idle, "forge-meets-floor");
572        assert_eq!(p.requirement, Requirement::Advisory);
573        assert!(matches!(p.evaluation, Evaluation::Unsatisfied { .. }));
574        assert_eq!(
575            readiness::derive(&below_idle.preconditions),
576            Readiness::Ready
577        );
578        fixture.writes = vec![".gitlab-ci.yml".to_owned()];
579        let below_planned = fixture.evaluate();
580        assert_eq!(
581            precondition(&below_planned, "forge-meets-floor").requirement,
582            Requirement::Required
583        );
584        assert_eq!(
585            readiness::derive(&below_planned.preconditions),
586            Readiness::Blocked
587        );
588        fixture.forge_version = Some("18.2.0-ee");
589        let ok = fixture.evaluate();
590        assert!(precondition(&ok, "forge-meets-floor").evaluation.holds());
591        fixture.forge = Some("github");
592        assert!(
593            fixture.evaluate().facts.forge_floor.is_none(),
594            "no floor is declared for github"
595        );
596    }
597
598    #[test]
599    fn a_skipped_intermediate_version_is_blocked_naming_it() {
600        let declared = parse_compatibility(
601            "[[intermediate]]\nversion = \"0.3.20\"\nreason = \"the record changed shape\"\n[[intermediate]]\nversion = \"0.1.0\"\nreason = \"already behind the record\"\n",
602        )
603        .expect("parses");
604        let mut fixture = Fixture::new(declared);
605        let skipped = fixture.evaluate();
606        let p = precondition(&skipped, "intermediate-release:0.3.20");
607        assert_eq!(p.requirement, Requirement::Required);
608        let Evaluation::Unsatisfied { reason } = &p.evaluation else {
609            panic!("a skipped release is unsatisfied");
610        };
611        assert!(
612            reason.contains("0.3.20") && reason.contains("--to 0.3.20"),
613            "{reason}"
614        );
615        assert_eq!(
616            skipped.facts.intermediate.len(),
617            1,
618            "a release behind the record is not in the way"
619        );
620        assert_eq!(
621            readiness::derive(&skipped.preconditions),
622            Readiness::Blocked
623        );
624        fixture.recorded_version = Some("0.3.20");
625        assert!(fixture.evaluate().facts.intermediate.is_empty());
626        fixture.recorded_version = None;
627        assert!(
628            fixture.evaluate().facts.intermediate.is_empty(),
629            "no record, nothing to pass through"
630        );
631    }
632}