Skip to main content

release_kit/plan/
guidance.rs

1//! The guidance the bundle carries for one target.
2//!
3//! The interval from the recorded release to the candidate, the files
4//! inside it, the filter against the destinations the target has, and
5//! the coverage judgment. "No applicable steps" and "history unavailable" are different
6//! outcomes, and the code keeps them apart: `covered` with no step is the
7//! first, `unavailable` is the second. Partial coverage is a decision the
8//! operator takes knowingly, and unavailable guidance for a rendered file
9//! the plan writes blocks, because the operator cannot reconcile what
10//! nobody described.
11
12use 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
19/// The decision id partial coverage asks.
20pub const PARTIAL_GUIDANCE_DECISION: &str = "partial-guidance";
21
22/// Everything the selection reads.
23pub struct Inputs<'a> {
24    /// The recorded release, where a record exists.
25    pub recorded_version: Option<&'a str>,
26    /// The candidate release.
27    pub candidate_version: &'a str,
28    /// Whether the candidate bundle carries a guidance root at all.
29    pub carries_root: bool,
30    /// The release above which the bundle describes every release.
31    pub since: Option<&'a str>,
32    /// Every guidance file the bundle carries, in version order.
33    pub files: &'a [GuidanceFile],
34    /// Every destination present at the target.
35    pub present: &'a BTreeSet<String>,
36    /// Whether the operations write a rendered file.
37    pub rendered_write: bool,
38    /// The decisions the operator selected, by id.
39    pub selected: &'a BTreeMap<String, String>,
40    /// The evidence the selection cites.
41    pub evidence_refs: Vec<String>,
42}
43
44/// What the selection produces.
45pub struct Selected {
46    /// The section, for the plan.
47    pub guidance: Guidance,
48    /// The coverage precondition, where coverage is a condition at all.
49    pub precondition: Option<Precondition>,
50    /// The decision partial coverage asks, where it asks one.
51    pub decision: Option<Decision>,
52}
53
54/// Select and judge.
55#[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
129/// The coverage judged: partial asks, unavailable blocks a rendered
130/// write, and the rest is no condition at all.
131fn 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    /// Owned inputs, so a test tunes fields and selects.
211    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}