1use std::collections::{BTreeMap, BTreeSet};
13
14use crate::release::declared::{GuidanceFile, version_below};
15
16use super::readiness::{Evaluation, Precondition, Requirement};
17use super::{Choice, Coverage, Decision, Guidance, GuidanceStep, Interval};
18
19pub const PARTIAL_GUIDANCE_DECISION: &str = "partial-guidance";
21
22pub struct Inputs<'a> {
24 pub recorded_version: Option<&'a str>,
26 pub candidate_version: &'a str,
28 pub carries_root: bool,
30 pub since: Option<&'a str>,
32 pub files: &'a [GuidanceFile],
34 pub present: &'a BTreeSet<String>,
36 pub rendered_write: bool,
38 pub selected: &'a BTreeMap<String, String>,
40 pub evidence_refs: Vec<String>,
42}
43
44pub struct Selected {
46 pub guidance: Guidance,
48 pub precondition: Option<Precondition>,
50 pub decision: Option<Decision>,
52}
53
54#[must_use]
56pub fn select(inputs: &Inputs<'_>) -> Selected {
57 let refs = inputs.evidence_refs.clone();
58 let Some(recorded) = inputs.recorded_version else {
59 return Selected {
60 guidance: Guidance {
61 coverage: Coverage::NotNeeded,
62 interval: None,
63 steps: Vec::new(),
64 excluded: 0,
65 evidence_refs: refs,
66 },
67 precondition: None,
68 decision: None,
69 };
70 };
71 let interval = Interval {
72 from: recorded.to_owned(),
73 to: inputs.candidate_version.to_owned(),
74 };
75 let in_interval = |version: &str| {
76 version_below(recorded, version) && !version_below(inputs.candidate_version, version)
77 };
78 let mut steps = Vec::new();
79 let mut excluded = 0;
80 for file in inputs
81 .files
82 .iter()
83 .filter(|file| in_interval(&file.version))
84 {
85 if file
86 .destinations
87 .iter()
88 .any(|destination| inputs.present.contains(destination))
89 {
90 steps.push(GuidanceStep {
91 version: file.version.clone(),
92 title: file.title.clone(),
93 destinations: file.destinations.clone(),
94 action: file.action.clone(),
95 body: file.body.clone(),
96 });
97 } else {
98 excluded += 1;
99 }
100 }
101 let empty_interval = !version_below(recorded, inputs.candidate_version);
102 let coverage = if empty_interval {
103 Coverage::Covered
104 } else if !inputs.carries_root {
105 Coverage::Unavailable
106 } else {
107 match inputs.since {
108 Some(since) if version_below(recorded, since) => Coverage::Partial {
109 since: since.to_owned(),
110 },
111 Some(_) => Coverage::Covered,
112 None => Coverage::Unavailable,
113 }
114 };
115 let (precondition, decision) = judge(&coverage, recorded, inputs, &refs);
116 Selected {
117 guidance: Guidance {
118 coverage,
119 interval: Some(interval),
120 steps,
121 excluded,
122 evidence_refs: refs,
123 },
124 precondition,
125 decision,
126 }
127}
128
129fn judge(
132 coverage: &Coverage,
133 recorded: &str,
134 inputs: &Inputs<'_>,
135 refs: &[String],
136) -> (Option<Precondition>, Option<Decision>) {
137 match coverage {
138 Coverage::NotNeeded | Coverage::Covered => (None, None),
139 Coverage::Partial { since } => {
140 let answered = inputs.selected.get(PARTIAL_GUIDANCE_DECISION).cloned();
141 let decision = Decision {
142 id: PARTIAL_GUIDANCE_DECISION.into(),
143 question: format!(
144 "the bundle describes every release above {since}, and the record is at {recorded}; proceed with the releases between them undescribed?"
145 ),
146 choices: vec![Choice {
147 answer: "accept".into(),
148 consequence: "the plan carries the steps the bundle does have, and the operator reads the changelog for the rest".into(),
149 }],
150 selected: answered.clone(),
151 };
152 let precondition = Precondition {
153 id: "guidance-covered".into(),
154 requirement: Requirement::DecisionRequired,
155 evaluation: if answered.as_deref() == Some("accept") {
156 Evaluation::Satisfied
157 } else {
158 Evaluation::NotObserved {
159 reason: format!(
160 "guidance is partial: the releases from {recorded} up to {since} are not described"
161 ),
162 }
163 },
164 decision: Some(PARTIAL_GUIDANCE_DECISION.into()),
165 evidence_refs: refs.to_vec(),
166 };
167 (Some(precondition), Some(decision))
168 }
169 Coverage::Unavailable => (
170 Some(Precondition {
171 id: "guidance-covered".into(),
172 requirement: if inputs.rendered_write {
173 Requirement::Required
174 } else {
175 Requirement::Advisory
176 },
177 evaluation: Evaluation::Unsatisfied {
178 reason: format!(
179 "the bundle for release-kit {} carries no guidance, so no release between {recorded} and it is described",
180 inputs.candidate_version
181 ),
182 },
183 decision: None,
184 evidence_refs: refs.to_vec(),
185 }),
186 None,
187 ),
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use std::collections::{BTreeMap, BTreeSet};
194
195 use super::{Inputs, Selected, select};
196 use crate::plan::Coverage;
197 use crate::plan::readiness::{self, Evaluation, Readiness, Requirement};
198 use crate::release::declared::GuidanceFile;
199
200 fn file(version: &str, destinations: &[&str]) -> GuidanceFile {
201 GuidanceFile {
202 version: version.to_owned(),
203 title: format!("release-kit {version}"),
204 destinations: destinations.iter().map(|d| (*d).to_owned()).collect(),
205 action: "operator-step".into(),
206 body: "## What to do\n\nEdit the line.".into(),
207 }
208 }
209
210 struct Fixture {
212 recorded_version: Option<&'static str>,
213 candidate_version: &'static str,
214 carries_root: bool,
215 since: Option<&'static str>,
216 files: Vec<GuidanceFile>,
217 present: BTreeSet<String>,
218 rendered_write: bool,
219 selected: BTreeMap<String, String>,
220 }
221
222 impl Fixture {
223 fn new(files: Vec<GuidanceFile>) -> Self {
224 Self {
225 recorded_version: Some("0.3.18"),
226 candidate_version: "0.3.21",
227 carries_root: true,
228 since: Some("0.3.18"),
229 files,
230 present: [".envrc", "SECURITY.md"]
231 .into_iter()
232 .map(str::to_owned)
233 .collect(),
234 rendered_write: false,
235 selected: BTreeMap::new(),
236 }
237 }
238
239 fn select(&self) -> Selected {
240 select(&Inputs {
241 recorded_version: self.recorded_version,
242 candidate_version: self.candidate_version,
243 carries_root: self.carries_root,
244 since: self.since,
245 files: &self.files,
246 present: &self.present,
247 rendered_write: self.rendered_write,
248 selected: &self.selected,
249 evidence_refs: vec!["candidate-bundle".into()],
250 })
251 }
252 }
253
254 #[test]
255 fn a_release_with_no_steps_reports_no_applicable_steps() {
256 let mut fixture = Fixture::new(Vec::new());
257 let covered = fixture.select();
258 assert_eq!(covered.guidance.coverage, Coverage::Covered);
259 assert!(covered.guidance.steps.is_empty());
260 assert!(
261 covered.precondition.is_none(),
262 "no applicable steps is not a condition"
263 );
264 fixture.carries_root = false;
265 let unavailable = fixture.select();
266 assert_eq!(unavailable.guidance.coverage, Coverage::Unavailable);
267 assert!(
268 unavailable.precondition.is_some(),
269 "unavailable is a condition"
270 );
271 fixture.carries_root = true;
272 fixture.since = None;
273 assert_eq!(fixture.select().guidance.coverage, Coverage::Unavailable);
274 fixture.since = Some("0.3.18");
275 fixture.recorded_version = None;
276 let fresh = fixture.select();
277 assert_eq!(fresh.guidance.coverage, Coverage::NotNeeded);
278 assert!(fresh.guidance.interval.is_none());
279 let mut current = Fixture::new(vec![file("0.3.19", &[".envrc"])]);
280 current.recorded_version = Some("0.3.21");
281 let current = current.select();
282 assert_eq!(current.guidance.coverage, Coverage::Covered);
283 assert!(
284 current.guidance.steps.is_empty(),
285 "nothing above the record"
286 );
287 }
288
289 #[test]
290 fn guidance_is_filtered_against_the_targets_destinations_with_a_count() {
291 let fixture = Fixture::new(vec![
292 file("0.3.19", &[".envrc"]),
293 file("0.3.20", &["nix/package.nix"]),
294 file("0.3.21", &["SECURITY.md", "nix/package.nix"]),
295 file("0.3.22", &[".envrc"]),
296 file("0.3.18", &[".envrc"]),
297 ]);
298 let selected = fixture.select();
299 let versions: Vec<&str> = selected
300 .guidance
301 .steps
302 .iter()
303 .map(|s| s.version.as_str())
304 .collect();
305 assert_eq!(
306 versions,
307 ["0.3.19", "0.3.21"],
308 "in the interval, and concerning a present destination"
309 );
310 assert_eq!(
311 selected.guidance.excluded, 1,
312 "the nix-only step on a target without nix"
313 );
314 let interval = selected
315 .guidance
316 .interval
317 .expect("a record bounds the interval");
318 assert_eq!(
319 (interval.from.as_str(), interval.to.as_str()),
320 ("0.3.18", "0.3.21")
321 );
322 assert_eq!(
323 selected.guidance.steps[1].destinations,
324 ["SECURITY.md", "nix/package.nix"]
325 );
326 }
327
328 #[test]
329 fn partial_guidance_is_a_decision_and_a_selected_decision_resolves_it() {
330 let mut fixture = Fixture::new(vec![file("0.3.19", &[".envrc"])]);
331 fixture.recorded_version = Some("0.3.10");
332 let partial = fixture.select();
333 assert_eq!(
334 partial.guidance.coverage,
335 Coverage::Partial {
336 since: "0.3.18".into()
337 }
338 );
339 assert_eq!(
340 partial.guidance.steps.len(),
341 1,
342 "the steps it does have still ride"
343 );
344 let p = partial
345 .precondition
346 .as_ref()
347 .expect("partial is a condition");
348 assert_eq!(p.requirement, Requirement::DecisionRequired);
349 assert_eq!(p.decision.as_deref(), Some("partial-guidance"));
350 assert!(matches!(p.evaluation, Evaluation::NotObserved { .. }));
351 assert_eq!(
352 readiness::derive(std::slice::from_ref(p)),
353 Readiness::NeedsDecision
354 );
355 assert_eq!(
356 partial.decision.as_ref().map(|d| d.id.as_str()),
357 Some("partial-guidance")
358 );
359 fixture.selected = BTreeMap::from([("partial-guidance".to_owned(), "accept".to_owned())]);
360 let accepted = fixture.select();
361 let p = accepted.precondition.as_ref().expect("still a condition");
362 assert!(p.evaluation.holds());
363 assert_eq!(readiness::derive(std::slice::from_ref(p)), Readiness::Ready);
364 assert_eq!(
365 accepted
366 .decision
367 .as_ref()
368 .and_then(|d| d.selected.as_deref()),
369 Some("accept")
370 );
371 }
372
373 #[test]
374 fn unavailable_guidance_for_a_planned_rendered_file_blocks() {
375 let mut fixture = Fixture::new(Vec::new());
376 fixture.carries_root = false;
377 let idle = fixture.select();
378 let p = idle.precondition.as_ref().expect("a condition");
379 assert_eq!(p.requirement, Requirement::Advisory);
380 assert_eq!(readiness::derive(std::slice::from_ref(p)), Readiness::Ready);
381 fixture.rendered_write = true;
382 let writing = fixture.select();
383 let p = writing.precondition.as_ref().expect("a condition");
384 assert_eq!(p.requirement, Requirement::Required);
385 assert!(matches!(p.evaluation, Evaluation::Unsatisfied { .. }));
386 assert_eq!(
387 readiness::derive(std::slice::from_ref(p)),
388 Readiness::Blocked
389 );
390 assert!(writing.decision.is_none(), "unavailable is not a decision");
391 }
392}