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 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
248fn 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
301fn 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 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 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 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(¤t, "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}