Skip to main content

pixelactions_core/
report.rs

1//! Run reports — what happened, in a shape a machine can read.
2//!
3//! A report is written whether the run succeeded or not: the failure
4//! case is the one worth reading. Every step records the point it acted
5//! on *after conversion*, so a wrong click is diagnosable from the
6//! artifact rather than by rerunning with a camera pointed at the screen.
7
8use serde::{Deserialize, Serialize};
9
10use crate::convert::ResolvedPoint;
11
12/// What became of one step.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum StepOutcome {
16    /// Executed, and verification confirmed it.
17    Verified,
18    /// Executed; verification was not requested. Reported distinctly from
19    /// `Verified` on purpose — "nothing errored" is not "it worked".
20    Executed,
21    /// Not executed: an earlier step failed.
22    Skipped,
23    /// Executed but verification failed, or the step itself failed.
24    Failed,
25    /// Not executed, on purpose: a guard said no. The kill switch, or a
26    /// point that wandered outside its own marked region. Reported apart
27    /// from `Failed` because "it did not work" and "I declined to try"
28    /// call for different responses — one may be worth retrying, the
29    /// other never is.
30    Refused,
31}
32
33impl StepOutcome {
34    /// The wire name, for printing and for the line protocol. Kept beside
35    /// the serde attribute so the two can't drift apart — a client that
36    /// matches on the JSON sees exactly what a human reading the terminal
37    /// sees.
38    pub fn name(self) -> &'static str {
39        match self {
40            Self::Verified => "verified",
41            Self::Executed => "executed",
42            Self::Skipped => "skipped",
43            Self::Failed => "failed",
44            Self::Refused => "refused",
45        }
46    }
47}
48
49/// One step's record.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct StepReport {
52    pub index: usize,
53    pub summary: String,
54    pub outcome: StepOutcome,
55    pub points: Vec<ResolvedPoint>,
56    /// Present when the outcome is `Failed` — what went wrong, in words.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub detail: Option<String>,
59    pub elapsed_ms: u64,
60}
61
62/// The whole run.
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64pub struct RunReport {
65    pub schema: u32,
66    pub session: String,
67    /// False for a plan-only run, so a consumer can never mistake a
68    /// resolved plan for a performed one.
69    pub executed: bool,
70    pub steps: Vec<StepReport>,
71}
72
73impl RunReport {
74    pub const SCHEMA: u32 = 1;
75
76    /// The exit code this run earns: 3 when a guard refused, 1 when a
77    /// step failed, 0 otherwise.
78    ///
79    /// A run stops at its first non-success, so at most one of these is
80    /// ever present — the ordering states the precedence rather than
81    /// resolving a real conflict.
82    pub fn exit_code(&self) -> i32 {
83        if self.steps.iter().any(|s| s.outcome == StepOutcome::Refused) {
84            return 3;
85        }
86        if self.steps.iter().any(|s| s.outcome == StepOutcome::Failed) {
87            return 1;
88        }
89        0
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    fn step(index: usize, outcome: StepOutcome) -> StepReport {
98        StepReport {
99            index,
100            summary: format!("step {index}"),
101            outcome,
102            points: Vec::new(),
103            detail: None,
104            elapsed_ms: 1,
105        }
106    }
107
108    fn report(steps: Vec<StepReport>) -> RunReport {
109        RunReport {
110            schema: RunReport::SCHEMA,
111            session: "s".into(),
112            executed: true,
113            steps,
114        }
115    }
116
117    #[test]
118    fn a_clean_run_exits_zero() {
119        let run = report(vec![
120            step(0, StepOutcome::Verified),
121            step(1, StepOutcome::Executed),
122        ]);
123        assert_eq!(run.exit_code(), 0);
124    }
125
126    #[test]
127    fn any_failure_exits_one() {
128        let run = report(vec![
129            step(0, StepOutcome::Verified),
130            step(1, StepOutcome::Failed),
131        ]);
132        assert_eq!(run.exit_code(), 1);
133    }
134
135    #[test]
136    fn skipped_steps_do_not_themselves_fail_the_run() {
137        // Skipped means "an earlier step failed" — that earlier failure is
138        // what sets the code, so this stays 0 when nothing actually failed.
139        let run = report(vec![
140            step(0, StepOutcome::Executed),
141            step(1, StepOutcome::Skipped),
142        ]);
143        assert_eq!(run.exit_code(), 0);
144    }
145
146    #[test]
147    fn executed_and_verified_are_distinct_in_the_wire_format() {
148        let executed = serde_json::to_string(&StepOutcome::Executed).expect("serialize");
149        let verified = serde_json::to_string(&StepOutcome::Verified).expect("serialize");
150        assert_eq!(executed, "\"executed\"");
151        assert_eq!(verified, "\"verified\"");
152    }
153
154    #[test]
155    fn a_refusal_exits_three_not_one() {
156        // "I declined to act" is operationally different from "it did not
157        // work" — a CI job and an agent both need to tell them apart.
158        let run = report(vec![step(0, StepOutcome::Refused)]);
159        assert_eq!(run.exit_code(), 3);
160    }
161
162    #[test]
163    fn the_printed_name_is_the_wire_name() {
164        for outcome in [
165            StepOutcome::Verified,
166            StepOutcome::Executed,
167            StepOutcome::Skipped,
168            StepOutcome::Failed,
169            StepOutcome::Refused,
170        ] {
171            let json = serde_json::to_string(&outcome).expect("serialize");
172            assert_eq!(json, format!("\"{}\"", outcome.name()));
173        }
174    }
175
176    #[test]
177    fn a_report_round_trips() {
178        let run = report(vec![step(0, StepOutcome::Verified)]);
179        let text = serde_json::to_string(&run).expect("serialize");
180        let back: RunReport = serde_json::from_str(&text).expect("deserialize");
181        assert_eq!(run, back);
182    }
183}