Skip to main content

proef_core/
event.rs

1//! The serde event spine (ADR-0008).
2//!
3//! One versioned, serde-able [`Event`] enum is the single source of truth for live
4//! progress *and* persistence: the JSONL run record **is** the appended event stream —
5//! there is no second record format. Changes are **additive-only**; the stream head
6//! ([`Event::RunStarted`]) declares [`EVENT_SCHEMA_VERSION`].
7//!
8//! Secret values never enter events — captures are reported by *name* only
9//! (redaction invariant, ADR-0005).
10
11use std::fmt;
12use std::sync::Arc;
13
14use serde::{Deserialize, Serialize};
15
16use crate::step::{Status, StepRef};
17
18/// Version of the event schema, declared once per stream in [`Event::RunStarted`].
19pub const EVENT_SCHEMA_VERSION: u32 = 1;
20
21/// One event in a run's stream. Serialized as JSONL, tagged by `event`.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23#[serde(tag = "event", rename_all = "snake_case")]
24pub enum Event {
25    /// The run began. Head of every stream; declares the schema version.
26    RunStarted {
27        /// Event schema version ([`EVENT_SCHEMA_VERSION`]).
28        schema: u32,
29        /// Injected run identifier (uuid-v7-derived; core never generates it).
30        run_id: Arc<str>,
31    },
32    /// A scenario began executing.
33    ScenarioStarted {
34        /// Scenario name as authored.
35        scenario: Arc<str>,
36        /// Feature file the scenario comes from.
37        file: Arc<str>,
38    },
39    /// A batch of contiguous same-engine steps was dispatched.
40    BatchStarted {
41        /// Scenario name as authored.
42        scenario: Arc<str>,
43        /// Engine executing the batch.
44        engine: Arc<str>,
45        /// Number of steps in the batch.
46        steps: usize,
47    },
48    /// An artifact entry began an execution attempt — the engine's live
49    /// progress signal (ADR-0001's `EventListener`, surfaced on the spine).
50    /// Additive schema variant: absent from pre-existing streams.
51    EntryRunning {
52        /// Scenario name as authored.
53        scenario: Arc<str>,
54        /// Engine executing the entry.
55        engine: Arc<str>,
56        /// 0-based entry ordinal within the scenario's artifact.
57        entry: usize,
58        /// Retry number of this attempt (`0` = first attempt).
59        retry: u32,
60    },
61    /// A step finished (in success or failure).
62    StepFinished {
63        /// Scenario name as authored.
64        scenario: Arc<str>,
65        /// Engine that executed the step.
66        engine: Arc<str>,
67        /// Anchor to the authored feature line.
68        step: StepRef,
69        /// Outcome status.
70        status: Status,
71        /// Number of attempts made.
72        attempts: u32,
73        /// Wall-clock duration in milliseconds.
74        duration_ms: u64,
75        /// Names (never values) of captures produced by this step.
76        captures: Vec<String>,
77        /// Failure detail, when the step failed (additive schema field —
78        /// absent on passing steps, so pre-existing streams are unchanged).
79        #[serde(default, skip_serializing_if = "Option::is_none")]
80        detail: Option<String>,
81    },
82    /// A scenario finished.
83    ScenarioFinished {
84        /// Scenario name as authored.
85        scenario: Arc<str>,
86        /// Aggregate scenario status.
87        status: Status,
88    },
89    /// The run finished. Tail of every stream.
90    RunFinished {
91        /// Scenarios that passed.
92        passed: usize,
93        /// Scenarios that failed.
94        failed: usize,
95        /// Scenarios that were skipped.
96        skipped: usize,
97        /// The run was cancelled before completing (additive schema field —
98        /// absent means `false`, and `false` is not serialized, so pre-existing
99        /// streams and snapshots are unchanged).
100        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
101        cancelled: bool,
102    },
103}
104
105/// Fan-out point for [`Event`]s — reporters subscribe here (borrowed
106/// events). Cheap to clone; threads share the same sink.
107#[derive(Clone)]
108pub struct EventSink(Arc<dyn Fn(&Event) + Send + Sync>);
109
110impl EventSink {
111    /// A sink invoking `f` for every emitted event.
112    pub fn new(f: impl Fn(&Event) + Send + Sync + 'static) -> Self {
113        Self(Arc::new(f))
114    }
115
116    /// A sink that discards every event (tests, `--dry-run`).
117    pub fn null() -> Self {
118        Self(Arc::new(|_| {}))
119    }
120
121    /// Emit one event to all consumers.
122    pub fn emit(&self, event: &Event) {
123        (self.0)(event);
124    }
125}
126
127impl fmt::Debug for EventSink {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        f.write_str("EventSink(..)")
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    /// The wire shape is a compatibility surface (ADR-0008): pin the JSON of the
138    /// stream head exactly. Additive changes only.
139    #[test]
140    fn run_started_wire_shape_is_stable() {
141        let event = Event::RunStarted {
142            schema: EVENT_SCHEMA_VERSION,
143            run_id: Arc::from("run-0001"),
144        };
145        let json = serde_json::to_string(&event).unwrap_or_default();
146        assert_eq!(
147            json,
148            r#"{"event":"run_started","schema":1,"run_id":"run-0001"}"#
149        );
150    }
151
152    #[test]
153    fn events_round_trip_through_jsonl() {
154        let event = Event::StepFinished {
155            scenario: Arc::from("Search finds a client"),
156            engine: Arc::from("http"),
157            step: StepRef {
158                file: Arc::from("tests/features/501_search.feature"),
159                line: 12,
160                text: Arc::from("the admin searches for \"Jansen\""),
161            },
162            status: Status::Passed,
163            attempts: 1,
164            duration_ms: 42,
165            captures: vec!["clientId".to_owned()],
166            detail: None,
167        };
168        let json = serde_json::to_string(&event).unwrap_or_default();
169        let back: Event = serde_json::from_str(&json).unwrap_or(Event::RunFinished {
170            passed: 0,
171            failed: 0,
172            skipped: 0,
173            cancelled: false,
174        });
175        assert_eq!(back, event);
176    }
177
178    #[test]
179    fn sink_fans_out_borrowed_events() {
180        use std::sync::atomic::{AtomicUsize, Ordering};
181        let seen = Arc::new(AtomicUsize::new(0));
182        let counter = Arc::clone(&seen);
183        let sink = EventSink::new(move |_| {
184            counter.fetch_add(1, Ordering::SeqCst);
185        });
186        let clone = sink.clone();
187        clone.emit(&Event::RunFinished {
188            passed: 1,
189            failed: 0,
190            skipped: 0,
191            cancelled: false,
192        });
193        sink.emit(&Event::RunFinished {
194            passed: 1,
195            failed: 0,
196            skipped: 0,
197            cancelled: false,
198        });
199        assert_eq!(seen.load(Ordering::SeqCst), 2);
200    }
201}