1use 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#[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
29pub const PIN_MANAGER_DECISION: &str = "pin-manager";
32
33pub struct Inputs<'a> {
35 pub declared: &'a declared::Compatibility,
37 pub engine_version: &'a str,
39 pub engine_schema: u32,
41 pub bundle_schema: u32,
43 pub candidate_version: &'a str,
45 pub recorded_version: Option<&'a str>,
47 pub tech: Option<&'a str>,
49 pub forge: Option<&'a str>,
51 pub pins: &'a BTreeMap<String, String>,
53 pub host_generator: Option<&'a str>,
55 pub writes: &'a [String],
57 pub rewrites: &'a [String],
61 pub pin_wired: bool,
63 pub unwired_managers: &'a [String],
65 pub forge_version: Option<&'a str>,
67 pub selected: &'a BTreeMap<String, String>,
69 pub evidence_refs: Vec<String>,
71}
72
73pub struct Evaluated {
75 pub facts: Compatibility,
77 pub preconditions: Vec<Precondition>,
79 pub decisions: Vec<Decision>,
81}
82
83#[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
110fn 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
140fn 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
191fn 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
245fn 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
298fn 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 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 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 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(¤t, "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}