use std::collections::{BTreeMap, BTreeSet};
use crate::release::declared::{GuidanceFile, version_below};
use super::readiness::{Evaluation, Precondition, Requirement};
use super::{Choice, Coverage, Decision, Guidance, GuidanceStep, Interval};
pub const PARTIAL_GUIDANCE_DECISION: &str = "partial-guidance";
pub struct Inputs<'a> {
pub recorded_version: Option<&'a str>,
pub candidate_version: &'a str,
pub carries_root: bool,
pub since: Option<&'a str>,
pub files: &'a [GuidanceFile],
pub present: &'a BTreeSet<String>,
pub rendered_write: bool,
pub selected: &'a BTreeMap<String, String>,
pub evidence_refs: Vec<String>,
}
pub struct Selected {
pub guidance: Guidance,
pub precondition: Option<Precondition>,
pub decision: Option<Decision>,
}
#[must_use]
pub fn select(inputs: &Inputs<'_>) -> Selected {
let refs = inputs.evidence_refs.clone();
let Some(recorded) = inputs.recorded_version else {
return Selected {
guidance: Guidance {
coverage: Coverage::NotNeeded,
interval: None,
steps: Vec::new(),
excluded: 0,
evidence_refs: refs,
},
precondition: None,
decision: None,
};
};
let interval = Interval {
from: recorded.to_owned(),
to: inputs.candidate_version.to_owned(),
};
let in_interval = |version: &str| {
version_below(recorded, version) && !version_below(inputs.candidate_version, version)
};
let mut steps = Vec::new();
let mut excluded = 0;
for file in inputs
.files
.iter()
.filter(|file| in_interval(&file.version))
{
if file
.destinations
.iter()
.any(|destination| inputs.present.contains(destination))
{
steps.push(GuidanceStep {
version: file.version.clone(),
title: file.title.clone(),
destinations: file.destinations.clone(),
action: file.action.clone(),
body: file.body.clone(),
});
} else {
excluded += 1;
}
}
let empty_interval = !version_below(recorded, inputs.candidate_version);
let coverage = if empty_interval {
Coverage::Covered
} else if !inputs.carries_root {
Coverage::Unavailable
} else {
match inputs.since {
Some(since) if version_below(recorded, since) => Coverage::Partial {
since: since.to_owned(),
},
Some(_) => Coverage::Covered,
None => Coverage::Unavailable,
}
};
let (precondition, decision) = judge(&coverage, recorded, inputs, &refs);
Selected {
guidance: Guidance {
coverage,
interval: Some(interval),
steps,
excluded,
evidence_refs: refs,
},
precondition,
decision,
}
}
fn judge(
coverage: &Coverage,
recorded: &str,
inputs: &Inputs<'_>,
refs: &[String],
) -> (Option<Precondition>, Option<Decision>) {
match coverage {
Coverage::NotNeeded | Coverage::Covered => (None, None),
Coverage::Partial { since } => {
let answered = inputs.selected.get(PARTIAL_GUIDANCE_DECISION).cloned();
let decision = Decision {
id: PARTIAL_GUIDANCE_DECISION.into(),
question: format!(
"the bundle describes every release above {since}, and the record is at {recorded}; proceed with the releases between them undescribed?"
),
choices: vec![Choice {
answer: "accept".into(),
consequence: "the plan carries the steps the bundle does have, and the operator reads the changelog for the rest".into(),
}],
selected: answered.clone(),
};
let precondition = Precondition {
id: "guidance-covered".into(),
requirement: Requirement::DecisionRequired,
evaluation: if answered.as_deref() == Some("accept") {
Evaluation::Satisfied
} else {
Evaluation::NotObserved {
reason: format!(
"guidance is partial: the releases from {recorded} up to {since} are not described"
),
}
},
decision: Some(PARTIAL_GUIDANCE_DECISION.into()),
evidence_refs: refs.to_vec(),
};
(Some(precondition), Some(decision))
}
Coverage::Unavailable => (
Some(Precondition {
id: "guidance-covered".into(),
requirement: if inputs.rendered_write {
Requirement::Required
} else {
Requirement::Advisory
},
evaluation: Evaluation::Unsatisfied {
reason: format!(
"the bundle for release-kit {} carries no guidance, so no release between {recorded} and it is described",
inputs.candidate_version
),
},
decision: None,
evidence_refs: refs.to_vec(),
}),
None,
),
}
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, BTreeSet};
use super::{Inputs, Selected, select};
use crate::plan::Coverage;
use crate::plan::readiness::{self, Evaluation, Readiness, Requirement};
use crate::release::declared::GuidanceFile;
fn file(version: &str, destinations: &[&str]) -> GuidanceFile {
GuidanceFile {
version: version.to_owned(),
title: format!("release-kit {version}"),
destinations: destinations.iter().map(|d| (*d).to_owned()).collect(),
action: "operator-step".into(),
body: "## What to do\n\nEdit the line.".into(),
}
}
struct Fixture {
recorded_version: Option<&'static str>,
candidate_version: &'static str,
carries_root: bool,
since: Option<&'static str>,
files: Vec<GuidanceFile>,
present: BTreeSet<String>,
rendered_write: bool,
selected: BTreeMap<String, String>,
}
impl Fixture {
fn new(files: Vec<GuidanceFile>) -> Self {
Self {
recorded_version: Some("0.3.18"),
candidate_version: "0.3.21",
carries_root: true,
since: Some("0.3.18"),
files,
present: [".envrc", "SECURITY.md"]
.into_iter()
.map(str::to_owned)
.collect(),
rendered_write: false,
selected: BTreeMap::new(),
}
}
fn select(&self) -> Selected {
select(&Inputs {
recorded_version: self.recorded_version,
candidate_version: self.candidate_version,
carries_root: self.carries_root,
since: self.since,
files: &self.files,
present: &self.present,
rendered_write: self.rendered_write,
selected: &self.selected,
evidence_refs: vec!["candidate-bundle".into()],
})
}
}
#[test]
fn a_release_with_no_steps_reports_no_applicable_steps() {
let mut fixture = Fixture::new(Vec::new());
let covered = fixture.select();
assert_eq!(covered.guidance.coverage, Coverage::Covered);
assert!(covered.guidance.steps.is_empty());
assert!(
covered.precondition.is_none(),
"no applicable steps is not a condition"
);
fixture.carries_root = false;
let unavailable = fixture.select();
assert_eq!(unavailable.guidance.coverage, Coverage::Unavailable);
assert!(
unavailable.precondition.is_some(),
"unavailable is a condition"
);
fixture.carries_root = true;
fixture.since = None;
assert_eq!(fixture.select().guidance.coverage, Coverage::Unavailable);
fixture.since = Some("0.3.18");
fixture.recorded_version = None;
let fresh = fixture.select();
assert_eq!(fresh.guidance.coverage, Coverage::NotNeeded);
assert!(fresh.guidance.interval.is_none());
let mut current = Fixture::new(vec![file("0.3.19", &[".envrc"])]);
current.recorded_version = Some("0.3.21");
let current = current.select();
assert_eq!(current.guidance.coverage, Coverage::Covered);
assert!(
current.guidance.steps.is_empty(),
"nothing above the record"
);
}
#[test]
fn guidance_is_filtered_against_the_targets_destinations_with_a_count() {
let fixture = Fixture::new(vec![
file("0.3.19", &[".envrc"]),
file("0.3.20", &["nix/package.nix"]),
file("0.3.21", &["SECURITY.md", "nix/package.nix"]),
file("0.3.22", &[".envrc"]),
file("0.3.18", &[".envrc"]),
]);
let selected = fixture.select();
let versions: Vec<&str> = selected
.guidance
.steps
.iter()
.map(|s| s.version.as_str())
.collect();
assert_eq!(
versions,
["0.3.19", "0.3.21"],
"in the interval, and concerning a present destination"
);
assert_eq!(
selected.guidance.excluded, 1,
"the nix-only step on a target without nix"
);
let interval = selected
.guidance
.interval
.expect("a record bounds the interval");
assert_eq!(
(interval.from.as_str(), interval.to.as_str()),
("0.3.18", "0.3.21")
);
assert_eq!(
selected.guidance.steps[1].destinations,
["SECURITY.md", "nix/package.nix"]
);
}
#[test]
fn partial_guidance_is_a_decision_and_a_selected_decision_resolves_it() {
let mut fixture = Fixture::new(vec![file("0.3.19", &[".envrc"])]);
fixture.recorded_version = Some("0.3.10");
let partial = fixture.select();
assert_eq!(
partial.guidance.coverage,
Coverage::Partial {
since: "0.3.18".into()
}
);
assert_eq!(
partial.guidance.steps.len(),
1,
"the steps it does have still ride"
);
let p = partial
.precondition
.as_ref()
.expect("partial is a condition");
assert_eq!(p.requirement, Requirement::DecisionRequired);
assert_eq!(p.decision.as_deref(), Some("partial-guidance"));
assert!(matches!(p.evaluation, Evaluation::NotObserved { .. }));
assert_eq!(
readiness::derive(std::slice::from_ref(p)),
Readiness::NeedsDecision
);
assert_eq!(
partial.decision.as_ref().map(|d| d.id.as_str()),
Some("partial-guidance")
);
fixture.selected = BTreeMap::from([("partial-guidance".to_owned(), "accept".to_owned())]);
let accepted = fixture.select();
let p = accepted.precondition.as_ref().expect("still a condition");
assert!(p.evaluation.holds());
assert_eq!(readiness::derive(std::slice::from_ref(p)), Readiness::Ready);
assert_eq!(
accepted
.decision
.as_ref()
.and_then(|d| d.selected.as_deref()),
Some("accept")
);
}
#[test]
fn unavailable_guidance_for_a_planned_rendered_file_blocks() {
let mut fixture = Fixture::new(Vec::new());
fixture.carries_root = false;
let idle = fixture.select();
let p = idle.precondition.as_ref().expect("a condition");
assert_eq!(p.requirement, Requirement::Advisory);
assert_eq!(readiness::derive(std::slice::from_ref(p)), Readiness::Ready);
fixture.rendered_write = true;
let writing = fixture.select();
let p = writing.precondition.as_ref().expect("a condition");
assert_eq!(p.requirement, Requirement::Required);
assert!(matches!(p.evaluation, Evaluation::Unsatisfied { .. }));
assert_eq!(
readiness::derive(std::slice::from_ref(p)),
Readiness::Blocked
);
assert!(writing.decision.is_none(), "unavailable is not a decision");
}
}