Skip to main content

proef_core/
report.rs

1//! Reporters (ADR-0008): composable consumers of the event spine.
2//!
3//! The JSONL run record **is** the appended event stream — no second record
4//! format. The console reporter buffers per scenario (a natural `Normalize`:
5//! parallel scenarios never interleave their lines) and prints a BDD tree with
6//! attempts and engine-measured timings on completion.
7//!
8//! Secret values never enter events by construction (capture *names* only,
9//! engine-redacted details); [`Redactions`] is the defense-in-depth applied to
10//! every rendered string, property-tested in this module.
11
12use std::io::Write;
13use std::sync::{Arc, Mutex};
14
15use crate::event::{Event, EventSink};
16use crate::step::Status;
17
18/// Known secret values, replaced by `***` in every rendered string.
19#[derive(Debug, Clone, Default)]
20pub struct Redactions(Vec<String>);
21
22impl Redactions {
23    /// Redact these values (empty values are ignored — nothing to leak).
24    pub fn new(values: impl IntoIterator<Item = String>) -> Self {
25        Self(values.into_iter().filter(|v| !v.is_empty()).collect())
26    }
27
28    /// `text` with every known secret value replaced.
29    pub fn apply(&self, text: &str) -> String {
30        let mut out = text.to_owned();
31        for value in &self.0 {
32            out = out.replace(value, "***");
33        }
34        out
35    }
36
37    /// No values to redact.
38    pub fn is_empty(&self) -> bool {
39        self.0.is_empty()
40    }
41
42    /// The event with every string field redacted. The match is exhaustive on
43    /// purpose: adding an event variant forces a redaction decision here, so
44    /// the invariant (ADR-0005: secrets reach **no** sink) cannot silently
45    /// erode as the schema grows.
46    pub fn apply_event(&self, event: &Event) -> Event {
47        let s = |text: &Arc<str>| -> Arc<str> { Arc::from(self.apply(text)) };
48        match event {
49            Event::RunStarted { schema, run_id } => Event::RunStarted {
50                schema: *schema,
51                run_id: s(run_id),
52            },
53            Event::ScenarioStarted { scenario, file } => Event::ScenarioStarted {
54                scenario: s(scenario),
55                file: s(file),
56            },
57            Event::BatchStarted {
58                scenario,
59                engine,
60                steps,
61            } => Event::BatchStarted {
62                scenario: s(scenario),
63                engine: s(engine),
64                steps: *steps,
65            },
66            Event::EntryRunning {
67                scenario,
68                engine,
69                entry,
70                retry,
71            } => Event::EntryRunning {
72                scenario: s(scenario),
73                engine: s(engine),
74                entry: *entry,
75                retry: *retry,
76            },
77            Event::StepFinished {
78                scenario,
79                engine,
80                step,
81                status,
82                attempts,
83                duration_ms,
84                captures,
85                detail,
86            } => Event::StepFinished {
87                scenario: s(scenario),
88                engine: s(engine),
89                step: crate::step::StepRef {
90                    file: s(&step.file),
91                    line: step.line,
92                    text: s(&step.text),
93                },
94                status: *status,
95                attempts: *attempts,
96                duration_ms: *duration_ms,
97                captures: captures.iter().map(|name| self.apply(name)).collect(),
98                detail: detail.as_deref().map(|text| self.apply(text)),
99            },
100            Event::ScenarioFinished { scenario, status } => Event::ScenarioFinished {
101                scenario: s(scenario),
102                status: *status,
103            },
104            Event::RunFinished { .. } => event.clone(),
105        }
106    }
107}
108
109/// One reporter in the stack.
110pub trait Reporter: Send {
111    /// Consume one event.
112    fn on_event(&mut self, event: &Event);
113}
114
115/// Fan a reporter stack out as an [`EventSink`] (thread-safe: scenario threads
116/// share the sink). Redaction happens **here**, once, before fan-out — every
117/// reporter (console, JSONL record, future sinks) sees only redacted events,
118/// so the invariant does not depend on each leaf remembering to redact.
119pub fn sink(reporters: Vec<Box<dyn Reporter>>, redactions: Redactions) -> EventSink {
120    let stack = Arc::new(Mutex::new(reporters));
121    EventSink::new(move |event| {
122        if let Ok(mut stack) = stack.lock() {
123            if redactions.is_empty() {
124                for reporter in stack.iter_mut() {
125                    reporter.on_event(event);
126                }
127            } else {
128                let redacted = redactions.apply_event(event);
129                for reporter in stack.iter_mut() {
130                    reporter.on_event(&redacted);
131                }
132            }
133        }
134    })
135}
136
137/// Console BDD tree, buffered per scenario.
138pub struct ConsoleReporter<W: Write + Send> {
139    out: W,
140    redactions: Redactions,
141    buffers: Vec<(Arc<str>, Vec<String>)>,
142}
143
144impl<W: Write + Send> ConsoleReporter<W> {
145    /// A console reporter writing to `out`.
146    pub fn new(out: W, redactions: Redactions) -> Self {
147        Self {
148            out,
149            redactions,
150            buffers: Vec::new(),
151        }
152    }
153
154    fn buffer_for(&mut self, scenario: &Arc<str>) -> &mut Vec<String> {
155        if let Some(position) = self.buffers.iter().position(|(name, _)| name == scenario) {
156            &mut self.buffers[position].1
157        } else {
158            self.buffers.push((Arc::clone(scenario), Vec::new()));
159            &mut self
160                .buffers
161                .last_mut()
162                .unwrap_or_else(|| unreachable!("buffer just pushed"))
163                .1
164        }
165    }
166}
167
168fn glyph(status: Status) -> &'static str {
169    match status {
170        Status::Passed => "✓",
171        Status::Failed => "✗",
172        Status::Skipped => "∅",
173        Status::Warned => "⚠",
174    }
175}
176
177impl<W: Write + Send> Reporter for ConsoleReporter<W> {
178    fn on_event(&mut self, event: &Event) {
179        match event {
180            Event::RunStarted { run_id, .. } => {
181                let _ = writeln!(self.out, "proef run {run_id}");
182            }
183            Event::ScenarioStarted { scenario, file } => {
184                let header = format!("\n  Scenario: {scenario} ({file})");
185                self.buffer_for(scenario).push(header);
186            }
187            // Buffered console renders on completion; the live progress
188            // signal is for streaming consumers (JSONL record, future TTY).
189            Event::BatchStarted { .. } | Event::EntryRunning { .. } => {}
190            Event::StepFinished {
191                scenario,
192                step,
193                status,
194                attempts,
195                duration_ms,
196                ..
197            } => {
198                let attempts_note = if *attempts > 1 {
199                    format!(", {attempts} attempts")
200                } else {
201                    String::new()
202                };
203                let line = format!(
204                    "    {} {}:{} — {} ({duration_ms}ms{attempts_note})",
205                    glyph(*status),
206                    step.file,
207                    step.line,
208                    step.text
209                );
210                let line = self.redactions.apply(&line);
211                self.buffer_for(scenario).push(line);
212            }
213            Event::ScenarioFinished { scenario, status } => {
214                let lines = self
215                    .buffers
216                    .iter()
217                    .position(|(name, _)| name == scenario)
218                    .map(|position| self.buffers.remove(position).1)
219                    .unwrap_or_default();
220                for line in lines {
221                    let _ = writeln!(self.out, "{line}");
222                }
223                let _ = writeln!(self.out, "    {} scenario {scenario}", glyph(*status));
224            }
225            Event::RunFinished {
226                passed,
227                failed,
228                skipped,
229                cancelled,
230            } => {
231                let note = if *cancelled { " · cancelled" } else { "" };
232                let _ = writeln!(
233                    self.out,
234                    "\nsummary: {passed} passed · {failed} failed · {skipped} skipped{note}"
235                );
236                let _ = self.out.flush();
237            }
238        }
239    }
240}
241
242/// Run totals derived from the event stream — the `Summarize` leg of the
243/// decorator stack (ADR-0008): leaves (GitHub summary, `--output json`, …)
244/// consume the totals at `RunFinished` instead of re-deriving them.
245#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
246pub struct RunTotals {
247    /// Scenarios that passed.
248    pub passed: usize,
249    /// Scenarios that failed.
250    pub failed: usize,
251    /// Scenarios skipped.
252    pub skipped: usize,
253    /// Steps finished (all statuses).
254    pub steps: usize,
255    /// Total attempts across steps (retries included).
256    pub attempts: u64,
257}
258
259impl RunTotals {
260    /// Fold one event into the totals.
261    pub fn observe(&mut self, event: &Event) {
262        match event {
263            Event::StepFinished { attempts, .. } => {
264                self.steps += 1;
265                self.attempts += u64::from(*attempts);
266            }
267            Event::RunFinished {
268                passed,
269                failed,
270                skipped,
271                ..
272            } => {
273                self.passed = *passed;
274                self.failed = *failed;
275                self.skipped = *skipped;
276            }
277            _ => {}
278        }
279    }
280}
281
282/// JSONL appender: the run record is the raw event stream, in arrival order
283/// (replays and tests normalize; TESTING-STRATEGY flake rule).
284pub struct JsonlReporter<W: Write + Send> {
285    out: W,
286}
287
288impl<W: Write + Send> JsonlReporter<W> {
289    /// A JSONL reporter writing to `out`.
290    pub fn new(out: W) -> Self {
291        Self { out }
292    }
293}
294
295impl<W: Write + Send> Reporter for JsonlReporter<W> {
296    fn on_event(&mut self, event: &Event) {
297        if let Ok(json) = serde_json::to_string(event) {
298            let _ = writeln!(self.out, "{json}");
299        }
300        if matches!(event, Event::RunFinished { .. }) {
301            let _ = self.out.flush();
302        }
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    #![allow(clippy::unwrap_used)]
309
310    use super::*;
311    use crate::step::StepRef;
312
313    fn sample_events() -> Vec<Event> {
314        vec![
315            Event::RunStarted {
316                schema: 1,
317                run_id: Arc::from("run-1"),
318            },
319            Event::ScenarioStarted {
320                scenario: Arc::from("S"),
321                file: Arc::from("f.feature"),
322            },
323            Event::StepFinished {
324                scenario: Arc::from("S"),
325                engine: Arc::from("hurl"),
326                step: StepRef {
327                    file: Arc::from("f.feature"),
328                    line: 3,
329                    text: Arc::from("I log in"),
330                },
331                status: Status::Passed,
332                attempts: 2,
333                duration_ms: 12,
334                captures: vec!["token".to_owned()],
335                detail: None,
336            },
337            Event::ScenarioFinished {
338                scenario: Arc::from("S"),
339                status: Status::Passed,
340            },
341            Event::RunFinished {
342                passed: 1,
343                failed: 0,
344                skipped: 0,
345                cancelled: false,
346            },
347        ]
348    }
349
350    #[test]
351    fn console_buffers_per_scenario_and_prints_on_finish() {
352        let mut out = Vec::new();
353        {
354            let mut console = ConsoleReporter::new(&mut out, Redactions::default());
355            for event in sample_events() {
356                console.on_event(&event);
357            }
358        }
359        let text = String::from_utf8(out).unwrap();
360        assert!(text.contains("Scenario: S (f.feature)"), "{text}");
361        assert!(
362            text.contains("✓ f.feature:3 — I log in (12ms, 2 attempts)"),
363            "{text}"
364        );
365        assert!(
366            text.contains("summary: 1 passed · 0 failed · 0 skipped"),
367            "{text}"
368        );
369    }
370
371    #[test]
372    fn jsonl_is_the_event_stream() {
373        let mut out = Vec::new();
374        {
375            let mut jsonl = JsonlReporter::new(&mut out);
376            for event in sample_events() {
377                jsonl.on_event(&event);
378            }
379        }
380        let text = String::from_utf8(out).unwrap();
381        let parsed: Vec<Event> = text
382            .lines()
383            .map(|line| serde_json::from_str(line).unwrap())
384            .collect();
385        assert_eq!(parsed, sample_events());
386    }
387
388    #[test]
389    fn totals_fold_steps_and_run_counts() {
390        let mut totals = RunTotals::default();
391        for event in sample_events() {
392            totals.observe(&event);
393        }
394        assert_eq!(
395            totals,
396            RunTotals {
397                passed: 1,
398                failed: 0,
399                skipped: 0,
400                steps: 1,
401                attempts: 2,
402            }
403        );
404    }
405
406    mod properties {
407        #![allow(clippy::ignored_unit_patterns)]
408
409        use super::*;
410        use proptest::prelude::*;
411
412        proptest! {
413            /// The secret-mask invariant (ADR-0005, TESTING-STRATEGY): for any
414            /// rendered text containing a known secret value, the redacted
415            /// output never contains that value.
416            #[test]
417            fn redaction_removes_known_values(
418                secret in "[a-zA-Z0-9]{4,24}",
419                prefix in ".{0,30}",
420                suffix in ".{0,30}",
421            ) {
422                let redactions = Redactions::new([secret.clone()]);
423                let rendered = format!("{prefix}{secret}{suffix}");
424                let redacted = redactions.apply(&rendered);
425                prop_assert!(!redacted.contains(&secret));
426            }
427
428            /// The sink boundary redacts before fan-out: the JSONL run record
429            /// (and therefore every reporter) never contains a known secret,
430            /// wherever it appears in an event's string fields.
431            #[test]
432            fn sink_redacts_before_any_reporter(secret in "[a-zA-Z0-9]{6,20}") {
433                #[derive(Clone)]
434                struct Shared(Arc<Mutex<Vec<u8>>>);
435                impl Write for Shared {
436                    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
437                        if let Ok(mut out) = self.0.lock() {
438                            out.extend_from_slice(buf);
439                        }
440                        Ok(buf.len())
441                    }
442                    fn flush(&mut self) -> std::io::Result<()> {
443                        Ok(())
444                    }
445                }
446                let out = Shared(Arc::new(Mutex::new(Vec::new())));
447                let sink = sink(
448                    vec![Box::new(JsonlReporter::new(out.clone()))],
449                    Redactions::new([secret.clone()]),
450                );
451                sink.emit(&Event::ScenarioStarted {
452                    scenario: Arc::from(format!("uses {secret}")),
453                    file: Arc::from(format!("{secret}.feature")),
454                });
455                sink.emit(&Event::StepFinished {
456                    scenario: Arc::from(format!("uses {secret}")),
457                    engine: Arc::from("hurl"),
458                    step: crate::step::StepRef {
459                        file: Arc::from(format!("{secret}.feature")),
460                        line: 1,
461                        text: Arc::from(format!("token is {secret}")),
462                    },
463                    status: Status::Failed,
464                    attempts: 1,
465                    duration_ms: 1,
466                    captures: vec![format!("cap-{secret}")],
467                    detail: Some(format!("boom {secret}")),
468                });
469                sink.emit(&Event::RunFinished {
470                    passed: 0,
471                    failed: 1,
472                    skipped: 0,
473                    cancelled: false,
474                });
475                let text = String::from_utf8(out.0.lock().unwrap().clone()).unwrap();
476                prop_assert!(!text.is_empty());
477                prop_assert!(!text.contains(&secret), "{text}");
478            }
479
480            /// Console output never leaks a secret embedded in step text.
481            #[test]
482            fn console_never_prints_known_secrets(secret in "[a-zA-Z0-9]{6,20}") {
483                let mut out = Vec::new();
484                {
485                    let mut console = ConsoleReporter::new(
486                        &mut out,
487                        Redactions::new([secret.clone()]),
488                    );
489                    console.on_event(&Event::ScenarioStarted {
490                        scenario: Arc::from("S"),
491                        file: Arc::from("f"),
492                    });
493                    console.on_event(&Event::StepFinished {
494                        scenario: Arc::from("S"),
495                        engine: Arc::from("hurl"),
496                        step: StepRef {
497                            file: Arc::from("f"),
498                            line: 1,
499                            text: Arc::from(format!("token is {secret}")),
500                        },
501                        status: Status::Failed,
502                        attempts: 1,
503                        duration_ms: 1,
504                        captures: Vec::new(),
505                        detail: None,
506                    });
507                    console.on_event(&Event::ScenarioFinished {
508                        scenario: Arc::from("S"),
509                        status: Status::Failed,
510                    });
511                }
512                let text = String::from_utf8(out).unwrap();
513                prop_assert!(!text.contains(&secret), "{text}");
514            }
515        }
516    }
517}