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        evaluation: if answered.is_some() {
232            Evaluation::Satisfied
233        } else {
234            Evaluation::NotObserved {
235                reason: format!(
236                    "{managers} could pin rk and the operator has not said whether it does"
237                ),
238            }
239        },
240        decision: Some(PIN_MANAGER_DECISION.into()),
241        evidence_refs: refs.to_vec(),
242    });
243}
244
245/// The forge axis: the landed pipeline rests on a forge floor, and the
246/// floor is a fact the plan carries rather than a discovery at setup.
247fn forge_axis(
248    inputs: &Inputs<'_>,
249    refs: &[String],
250    preconditions: &mut Vec<Precondition>,
251) -> Option<ForgeFloor> {
252    let forge_floor = inputs.forge.and_then(|forge| {
253        let minimum = inputs.declared.forge.get(forge)?.minimum.clone()?;
254        Some(ForgeFloor {
255            forge: forge.to_owned(),
256            minimum,
257            observed: inputs.forge_version.map(str::to_owned),
258        })
259    });
260    if let Some(floor) = &forge_floor {
261        let pipeline_planned = inputs
262            .writes
263            .iter()
264            .any(|path| path == ".gitlab-ci.yml" || path.starts_with(".gitlab/ci/"));
265        let below = floor
266            .observed
267            .as_deref()
268            .is_some_and(|observed| version_below(observed, &floor.minimum));
269        preconditions.push(Precondition {
270            id: "forge-meets-floor".into(),
271            requirement: if below && pipeline_planned {
272                Requirement::Required
273            } else {
274                Requirement::Advisory
275            },
276            evaluation: match &floor.observed {
277                None => Evaluation::NotObserved {
278                    reason: format!(
279                        "the {} version was not read; --observe forge reads it, and rk setup refuses an instance below {}",
280                        floor.forge, floor.minimum
281                    ),
282                },
283                Some(observed) if below => Evaluation::Unsatisfied {
284                    reason: format!(
285                        "this {} instance reports {observed}, and the landed files need {} or newer",
286                        floor.forge, floor.minimum
287                    ),
288                },
289                Some(_) => Evaluation::Satisfied,
290            },
291            decision: None,
292            evidence_refs: refs.to_vec(),
293        });
294    }
295    forge_floor
296}
297
298/// The intermediate axis: a release the landing must pass through,
299/// between the record and the candidate.
300fn intermediate_axis(
301    inputs: &Inputs<'_>,
302    refs: &[String],
303    preconditions: &mut Vec<Precondition>,
304) -> Vec<IntermediateFact> {
305    let intermediate: Vec<IntermediateFact> = inputs
306        .recorded_version
307        .map(|recorded| {
308            inputs
309                .declared
310                .intermediate
311                .iter()
312                .filter(|step| {
313                    version_below(recorded, &step.version)
314                        && version_below(&step.version, inputs.candidate_version)
315                })
316                .map(|step| IntermediateFact {
317                    version: step.version.clone(),
318                    reason: step.reason.clone(),
319                })
320                .collect()
321        })
322        .unwrap_or_default();
323    for step in &intermediate {
324        preconditions.push(Precondition {
325            id: format!("intermediate-release:{}", step.version),
326            requirement: Requirement::Required,
327            evaluation: Evaluation::Unsatisfied {
328                reason: format!(
329                    "the landing must pass through release-kit {} first: {}; plan with --to {}",
330                    step.version, step.reason, step.version
331                ),
332            },
333            decision: None,
334            evidence_refs: refs.to_vec(),
335        });
336    }
337    intermediate
338}
339
340#[cfg(test)]
341mod tests {
342    use std::collections::BTreeMap;
343
344    use super::{Evaluated, Inputs, evaluate};
345    use crate::plan::readiness::{self, Evaluation, Precondition, Readiness, Requirement};
346    use crate::release::declared::{Compatibility, parse_compatibility};
347
348    /// Owned inputs, so a test tunes fields and evaluates.
349    struct Fixture {
350        declared: Compatibility,
351        engine_version: &'static str,
352        candidate_version: &'static str,
353        recorded_version: Option<&'static str>,
354        tech: Option<&'static str>,
355        forge: Option<&'static str>,
356        pins: BTreeMap<String, String>,
357        host_generator: Option<&'static str>,
358        writes: Vec<String>,
359        rewrites: Vec<String>,
360        pin_wired: bool,
361        unwired_managers: Vec<String>,
362        forge_version: Option<&'static str>,
363        selected: BTreeMap<String, String>,
364    }
365
366    impl Fixture {
367        fn new(declared: Compatibility) -> Self {
368            Self {
369                declared,
370                engine_version: "0.3.18",
371                candidate_version: "0.3.21",
372                recorded_version: Some("0.3.18"),
373                tech: Some("rust"),
374                forge: Some("gitlab"),
375                pins: BTreeMap::from([("cargo-dist".to_owned(), "0.32.0".to_owned())]),
376                host_generator: None,
377                writes: Vec::new(),
378                rewrites: Vec::new(),
379                pin_wired: false,
380                unwired_managers: Vec::new(),
381                forge_version: None,
382                selected: BTreeMap::new(),
383            }
384        }
385
386        fn evaluate(&self) -> Evaluated {
387            evaluate(&Inputs {
388                declared: &self.declared,
389                engine_version: self.engine_version,
390                engine_schema: 1,
391                bundle_schema: 1,
392                candidate_version: self.candidate_version,
393                recorded_version: self.recorded_version,
394                tech: self.tech,
395                forge: self.forge,
396                pins: &self.pins,
397                host_generator: self.host_generator,
398                writes: &self.writes,
399                rewrites: &self.rewrites,
400                pin_wired: self.pin_wired,
401                unwired_managers: &self.unwired_managers,
402                forge_version: self.forge_version,
403                selected: &self.selected,
404                evidence_refs: vec!["candidate-bundle".into()],
405            })
406        }
407    }
408
409    fn precondition<'a>(evaluated: &'a Evaluated, id: &str) -> &'a Precondition {
410        evaluated
411            .preconditions
412            .iter()
413            .find(|p| p.id == id)
414            .unwrap_or_else(|| panic!("precondition {id} exists"))
415    }
416
417    #[test]
418    fn a_bundle_without_compatibility_requires_only_its_schema() {
419        let mut fixture = Fixture::new(Compatibility::default());
420        fixture.forge = Some("github");
421        let evaluated = fixture.evaluate();
422        let ids: Vec<&str> = evaluated
423            .preconditions
424            .iter()
425            .map(|p| p.id.as_str())
426            .collect();
427        assert_eq!(
428            ids,
429            ["generator-at-pin"],
430            "the schema and the binding's generator are the only axes an empty declaration leaves"
431        );
432        assert_eq!(
433            precondition(&evaluated, "generator-at-pin").requirement,
434            Requirement::Advisory
435        );
436        assert!(evaluated.facts.engine_minimum.is_none());
437        assert!(evaluated.facts.forge_floor.is_none());
438        assert!(evaluated.facts.intermediate.is_empty());
439        assert!(evaluated.decisions.is_empty());
440        assert_eq!(
441            readiness::derive(&evaluated.preconditions),
442            Readiness::Ready
443        );
444    }
445
446    #[test]
447    fn an_engine_below_requirement_is_blocked_naming_the_engine() {
448        let declared = parse_compatibility("[engine]\nminimum = \"0.4.0\"\n").expect("parses");
449        let mut fixture = Fixture::new(declared);
450        let evaluated = fixture.evaluate();
451        let p = precondition(&evaluated, "engine-meets-minimum");
452        assert_eq!(p.requirement, Requirement::Required);
453        let Evaluation::Unsatisfied { reason } = &p.evaluation else {
454            panic!(
455                "an engine below the floor is unsatisfied: {:?}",
456                p.evaluation
457            );
458        };
459        assert!(reason.contains("install release-kit 0.4.0"), "{reason}");
460        assert_eq!(
461            readiness::derive(&evaluated.preconditions),
462            Readiness::Blocked
463        );
464        fixture.engine_version = "0.4.0";
465        let ok = fixture.evaluate();
466        assert!(precondition(&ok, "engine-meets-minimum").evaluation.holds());
467    }
468
469    #[test]
470    fn a_missing_generator_blocks_only_when_a_generated_artifact_is_planned() {
471        let mut fixture = Fixture::new(Compatibility::default());
472        let idle = fixture.evaluate();
473        let p = precondition(&idle, "generator-at-pin");
474        assert_eq!(p.requirement, Requirement::Advisory);
475        assert!(matches!(p.evaluation, Evaluation::NotObserved { .. }));
476        assert_eq!(readiness::derive(&idle.preconditions), Readiness::Ready);
477        // A first landing writes the artifact and generates nothing yet.
478        fixture.writes = vec!["dist-workspace.toml".to_owned()];
479        let first = fixture.evaluate();
480        assert_eq!(
481            precondition(&first, "generator-at-pin").requirement,
482            Requirement::Advisory
483        );
484        assert_eq!(readiness::derive(&first.preconditions), Readiness::Ready);
485        // A rewrite of an artifact the target already holds regenerates.
486        fixture.rewrites = vec!["dist-workspace.toml".to_owned()];
487        let planned = fixture.evaluate();
488        let p = precondition(&planned, "generator-at-pin");
489        assert_eq!(p.requirement, Requirement::Required);
490        assert_eq!(
491            readiness::derive(&planned.preconditions),
492            Readiness::Blocked
493        );
494        fixture.host_generator = Some("0.31.0");
495        let old = fixture.evaluate();
496        let Evaluation::Unsatisfied { reason } = &precondition(&old, "generator-at-pin").evaluation
497        else {
498            panic!("a generator below its pin is unsatisfied");
499        };
500        assert!(reason.contains("install cargo-dist 0.32.0"), "{reason}");
501        fixture.host_generator = Some("0.32.0");
502        let current = fixture.evaluate();
503        assert!(
504            precondition(&current, "generator-at-pin")
505                .evaluation
506                .holds()
507        );
508        assert_eq!(
509            current
510                .facts
511                .generator
512                .as_ref()
513                .map(|g| g.artifact.as_str()),
514            Some("dist-workspace.toml")
515        );
516    }
517
518    #[test]
519    fn an_unwired_manager_is_a_decision() {
520        let mut fixture = Fixture::new(Compatibility::default());
521        fixture.unwired_managers = vec!["mise".to_owned()];
522        let asked = fixture.evaluate();
523        let p = precondition(&asked, "pin-manager-answered");
524        assert_eq!(p.requirement, Requirement::DecisionRequired);
525        assert_eq!(p.decision.as_deref(), Some("pin-manager"));
526        assert_eq!(
527            readiness::derive(&asked.preconditions),
528            Readiness::NeedsDecision
529        );
530        assert_eq!(asked.decisions[0].id, "pin-manager");
531        fixture.selected = BTreeMap::from([("pin-manager".to_owned(), "host".to_owned())]);
532        let answered = fixture.evaluate();
533        assert!(
534            precondition(&answered, "pin-manager-answered")
535                .evaluation
536                .holds()
537        );
538        assert_eq!(readiness::derive(&answered.preconditions), Readiness::Ready);
539        fixture.selected.clear();
540        fixture.pin_wired = true;
541        assert!(
542            fixture.evaluate().decisions.is_empty(),
543            "a wired pin asks nothing"
544        );
545        fixture.pin_wired = false;
546        fixture.recorded_version = Some("0.3.21");
547        assert!(
548            fixture.evaluate().decisions.is_empty(),
549            "no move wanted, no question"
550        );
551        fixture.recorded_version = None;
552        assert!(
553            fixture.evaluate().decisions.is_empty(),
554            "a first landing wires later"
555        );
556    }
557
558    #[test]
559    fn a_forge_below_floor_blocks_only_when_a_forge_fact_affects_an_operation() {
560        let declared = parse_compatibility("[forge.gitlab]\nminimum = \"18.2\"\n").expect("parses");
561        let mut fixture = Fixture::new(declared);
562        let unread = fixture.evaluate();
563        let p = precondition(&unread, "forge-meets-floor");
564        assert_eq!(p.requirement, Requirement::Advisory);
565        assert!(matches!(p.evaluation, Evaluation::NotObserved { .. }));
566        fixture.forge_version = Some("18.1.0");
567        let below_idle = fixture.evaluate();
568        let p = precondition(&below_idle, "forge-meets-floor");
569        assert_eq!(p.requirement, Requirement::Advisory);
570        assert!(matches!(p.evaluation, Evaluation::Unsatisfied { .. }));
571        assert_eq!(
572            readiness::derive(&below_idle.preconditions),
573            Readiness::Ready
574        );
575        fixture.writes = vec![".gitlab-ci.yml".to_owned()];
576        let below_planned = fixture.evaluate();
577        assert_eq!(
578            precondition(&below_planned, "forge-meets-floor").requirement,
579            Requirement::Required
580        );
581        assert_eq!(
582            readiness::derive(&below_planned.preconditions),
583            Readiness::Blocked
584        );
585        fixture.forge_version = Some("18.2.0-ee");
586        let ok = fixture.evaluate();
587        assert!(precondition(&ok, "forge-meets-floor").evaluation.holds());
588        fixture.forge = Some("github");
589        assert!(
590            fixture.evaluate().facts.forge_floor.is_none(),
591            "no floor is declared for github"
592        );
593    }
594
595    #[test]
596    fn a_skipped_intermediate_version_is_blocked_naming_it() {
597        let declared = parse_compatibility(
598            "[[intermediate]]\nversion = \"0.3.20\"\nreason = \"the record changed shape\"\n[[intermediate]]\nversion = \"0.1.0\"\nreason = \"already behind the record\"\n",
599        )
600        .expect("parses");
601        let mut fixture = Fixture::new(declared);
602        let skipped = fixture.evaluate();
603        let p = precondition(&skipped, "intermediate-release:0.3.20");
604        assert_eq!(p.requirement, Requirement::Required);
605        let Evaluation::Unsatisfied { reason } = &p.evaluation else {
606            panic!("a skipped release is unsatisfied");
607        };
608        assert!(
609            reason.contains("0.3.20") && reason.contains("--to 0.3.20"),
610            "{reason}"
611        );
612        assert_eq!(
613            skipped.facts.intermediate.len(),
614            1,
615            "a release behind the record is not in the way"
616        );
617        assert_eq!(
618            readiness::derive(&skipped.preconditions),
619            Readiness::Blocked
620        );
621        fixture.recorded_version = Some("0.3.20");
622        assert!(fixture.evaluate().facts.intermediate.is_empty());
623        fixture.recorded_version = None;
624        assert!(
625            fixture.evaluate().facts.intermediate.is_empty(),
626            "no record, nothing to pass through"
627        );
628    }
629}