Skip to main content

release_kit/setup/
report.rs

1//! One complete setup observation, typed.
2//!
3//! A check walks every step once and classifies it twice: where the step
4//! stands at this target, which the target configuration answers, and
5//! what the forge answered, where the step applies. The two stay separate
6//! types, and a command failure is neither: it is an error the walk
7//! returns. The human report, the event stream, the check's verdict, and
8//! the committed setup proof all read this one classification, so none of
9//! them can disagree about what a run found.
10//!
11//! SATISFIES setup-proof:one-report-owns-every-classification
12
13use super::context::Ctx;
14use super::observe::StepState;
15use super::steps::StepSpec;
16
17/// Where one step stands at this target, before anything runs.
18///
19/// Applicability is the profile's answer and carries no operator reason;
20/// an exclusion is the operator's own statement about a step that does
21/// apply. An exclusion declared for a step that does not apply is
22/// redundant: it is reported as such and turns the step into no work.
23///
24/// SATISFIES forge-setup:applicability-follows-the-target-configuration
25#[derive(Debug, Clone)]
26pub enum Stance {
27    /// The step applies and the run acts on it.
28    Applies,
29    /// The target configuration does not select it, with the value that
30    /// decided so.
31    NotApplicable(String),
32    /// The target declared it does not run it, with its stated reason.
33    Excluded(String),
34    /// The target excluded a step that does not apply here.
35    Redundant {
36        /// The reason the target stated.
37        reason: String,
38        /// Why the step does not apply either way.
39        inapplicable: String,
40    },
41}
42
43impl Stance {
44    /// The word a report and an event use.
45    #[must_use]
46    pub const fn word(&self) -> &'static str {
47        match self {
48            Self::Applies => "applicable",
49            Self::NotApplicable(_) => "not-applicable",
50            Self::Excluded(_) => "excluded",
51            Self::Redundant { .. } => "redundant",
52        }
53    }
54
55    /// Whether the run acts on the step.
56    #[must_use]
57    pub const fn acts(&self) -> bool {
58        matches!(self, Self::Applies)
59    }
60
61    /// The reason alone, as an event and a check line carry it.
62    #[must_use]
63    pub fn detail(&self) -> String {
64        match self {
65            Self::Applies => String::new(),
66            Self::NotApplicable(reason) | Self::Excluded(reason) => reason.clone(),
67            Self::Redundant {
68                reason,
69                inapplicable,
70            } => format!("{inapplicable}; the stated reason was {reason}"),
71        }
72    }
73
74    /// The same, framed by what decided it, as a preview and an apply
75    /// name it.
76    #[must_use]
77    pub fn framed(&self) -> String {
78        match self {
79            Self::Applies => String::new(),
80            Self::NotApplicable(reason) => format!("not applicable: {reason}"),
81            Self::Excluded(reason) => {
82                format!("excluded by {}: {reason}", crate::config::CONFIG_PATH)
83            }
84            Self::Redundant { .. } => format!(
85                "{} excludes a step that does not apply here: {}",
86                crate::config::CONFIG_PATH,
87                self.detail()
88            ),
89        }
90    }
91}
92
93/// Where `step` stands at this target.
94#[must_use]
95pub fn stance(ctx: &Ctx, step: &StepSpec) -> Stance {
96    let inapplicable = (step.applies)(ctx);
97    match (ctx.excluded(step.name).map(str::to_owned), inapplicable) {
98        (Some(reason), Some(inapplicable)) => Stance::Redundant {
99            reason,
100            inapplicable,
101        },
102        (Some(reason), None) => Stance::Excluded(reason),
103        (None, Some(reason)) => Stance::NotApplicable(reason),
104        (None, None) => Stance::Applies,
105    }
106}
107
108/// What the forge answered for a step that applies, without the words a
109/// person reads: the classification alone.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum Observed {
112    /// The desired state holds, with the weaker guarantee named where the
113    /// forge enforces less than the step's proof claims.
114    Satisfied {
115        /// The stable limitation text, where one exists.
116        limitation: Option<String>,
117    },
118    /// An optional step's condition does not hold: nothing is wrong and
119    /// nothing is proven.
120    Skipped,
121    /// The desired state does not hold.
122    Unsatisfied,
123    /// The observation could not decide.
124    Unknown,
125}
126
127impl Observed {
128    /// The classification of one observer answer.
129    #[must_use]
130    pub fn of(state: &StepState) -> Self {
131        match state {
132            StepState::Satisfied { limitation, .. } => Self::Satisfied {
133                limitation: limitation.clone(),
134            },
135            StepState::Inapplicable { .. } => Self::Skipped,
136            StepState::Unsatisfied { .. } => Self::Unsatisfied,
137            StepState::Unknown { .. } => Self::Unknown,
138        }
139    }
140
141    /// The word a human report line opens with.
142    #[must_use]
143    pub const fn label(&self) -> &'static str {
144        match self {
145            Self::Satisfied { .. } => "ok",
146            Self::Skipped => "skipped",
147            Self::Unsatisfied => "unsatisfied",
148            Self::Unknown => "unknown",
149        }
150    }
151
152    /// The word an event and a machine record carry.
153    #[must_use]
154    pub const fn wire(&self) -> &'static str {
155        match self {
156            Self::Satisfied {
157                limitation: Some(_),
158            } => "satisfied-with-limitation",
159            Self::Satisfied { limitation: None } => "satisfied",
160            Self::Skipped => "skipped",
161            Self::Unsatisfied => "unsatisfied",
162            Self::Unknown => "unknown",
163        }
164    }
165}
166
167/// One step's row in a complete observation.
168#[derive(Debug, Clone)]
169pub struct Row {
170    /// The step, from the step table.
171    pub name: &'static str,
172    /// Where the step stands at this target.
173    pub stance: Stance,
174    /// What the forge answered, where the step applies; `None` where the
175    /// run did not act on it, so no observation is ever fabricated.
176    pub observed: Option<Observed>,
177    /// What the observer found, one line, for the report a person reads
178    /// now. It is never recorded: it can carry a forge's own words.
179    pub detail: String,
180}
181
182impl Row {
183    /// A row the run did not act on.
184    #[must_use]
185    pub const fn stated(step: &StepSpec, stance: Stance) -> Self {
186        Self {
187            name: step.name,
188            stance,
189            observed: None,
190            detail: String::new(),
191        }
192    }
193
194    /// A row the run observed.
195    #[must_use]
196    pub fn observed(step: &StepSpec, stance: Stance, state: &StepState) -> Self {
197        Self {
198            name: step.name,
199            stance,
200            observed: Some(Observed::of(state)),
201            detail: state.detail().to_owned(),
202        }
203    }
204
205    /// The human report line.
206    #[must_use]
207    pub fn line(&self) -> String {
208        let Some(observed) = &self.observed else {
209            return format!(
210                "{} {} — {}",
211                self.stance.word(),
212                self.name,
213                self.stance.detail()
214            );
215        };
216        let mut line = format!("{} {} — {}", observed.label(), self.name, self.detail);
217        if let Observed::Satisfied {
218            limitation: Some(limit),
219        } = observed
220        {
221            use std::fmt::Write as _;
222            let _ = write!(line, " (limitation: {limit})");
223        }
224        line
225    }
226
227    /// The status and the detail an event carries.
228    #[must_use]
229    pub fn event_fields(&self) -> (String, String) {
230        match &self.observed {
231            None => (self.stance.word().to_owned(), self.stance.detail()),
232            Some(Observed::Satisfied { .. }) => ("satisfied".to_owned(), self.detail.clone()),
233            Some(observed) => (observed.wire().to_owned(), self.detail.clone()),
234        }
235    }
236}
237
238/// Every step's row, in step-table order.
239#[derive(Debug, Clone, Default)]
240pub struct Report {
241    /// One row per step.
242    pub rows: Vec<Row>,
243}
244
245impl Report {
246    fn count(&self, wanted: &Observed) -> usize {
247        self.rows
248            .iter()
249            .filter(|row| row.observed.as_ref() == Some(wanted))
250            .count()
251    }
252
253    /// The applicable steps whose desired state does not hold.
254    #[must_use]
255    pub fn unsatisfied(&self) -> usize {
256        self.count(&Observed::Unsatisfied)
257    }
258
259    /// The applicable steps the observation could not decide.
260    #[must_use]
261    pub fn unknown(&self) -> usize {
262        self.count(&Observed::Unknown)
263    }
264
265    /// The steps the run acted on.
266    #[must_use]
267    pub fn judged(&self) -> usize {
268        self.rows.iter().filter(|row| row.stance.acts()).count()
269    }
270
271    /// Whether this observation may become a setup proof: every
272    /// applicable step holds or was skipped by its own condition, and
273    /// nothing is unknown.
274    ///
275    /// SATISFIES setup-proof:a-checkpoint-records-only-a-complete-observation
276    #[must_use]
277    pub fn checkpointable(&self) -> bool {
278        self.unsatisfied() == 0 && self.unknown() == 0
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::setup::steps::STEPS;
286
287    fn row(observed: Option<Observed>) -> Row {
288        let stance = if observed.is_some() {
289            Stance::Applies
290        } else {
291            Stance::Excluded("the target runs it elsewhere".into())
292        };
293        Row {
294            name: STEPS[0].name,
295            stance,
296            observed,
297            detail: String::new(),
298        }
299    }
300
301    /// Only a complete observation may become a proof: one unknown or one
302    /// unsatisfied row refuses it, and every other state is accepted.
303    #[test]
304    fn only_a_complete_observation_is_checkpointable() {
305        let accepted = Report {
306            rows: vec![
307                row(Some(Observed::Satisfied { limitation: None })),
308                row(Some(Observed::Satisfied {
309                    limitation: Some("weaker".into()),
310                })),
311                row(Some(Observed::Skipped)),
312                row(None),
313            ],
314        };
315        assert!(accepted.checkpointable());
316        for refused in [Observed::Unknown, Observed::Unsatisfied] {
317            let mut report = accepted.clone();
318            report.rows.push(row(Some(refused.clone())));
319            assert!(!report.checkpointable(), "{refused:?} was accepted");
320        }
321    }
322
323    /// Every observer answer keeps its distinction through the
324    /// classification.
325    #[test]
326    fn an_observation_keeps_its_distinctions() {
327        let limited = StepState::Satisfied {
328            detail: "found".into(),
329            limitation: Some("weaker".into()),
330        };
331        assert_eq!(Observed::of(&limited).wire(), "satisfied-with-limitation");
332        let plain = StepState::Satisfied {
333            detail: "found".into(),
334            limitation: None,
335        };
336        assert_eq!(Observed::of(&plain).wire(), "satisfied");
337        let skipped = StepState::Inapplicable {
338            detail: "no line".into(),
339        };
340        assert_eq!(Observed::of(&skipped).wire(), "skipped");
341        let unknown = StepState::Unknown {
342            detail: "unreadable".into(),
343        };
344        assert_eq!(Observed::of(&unknown).wire(), "unknown");
345        let not = StepState::Unsatisfied {
346            detail: "absent".into(),
347        };
348        assert_eq!(Observed::of(&not).wire(), "unsatisfied");
349    }
350}