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                detail,
197                ..
198            } => {
199                let attempts_note = if *attempts > 1 {
200                    format!(", {attempts} attempts")
201                } else {
202                    String::new()
203                };
204                let line = format!(
205                    "    {} {}:{} — {} ({duration_ms}ms{attempts_note})",
206                    glyph(*status),
207                    step.file,
208                    step.line,
209                    step.text
210                );
211                let line = self.redactions.apply(&line);
212                // A warning with no reason is unusable — say why. (Failures
213                // get the richer end-of-run list instead.)
214                let warn_detail = (*status == Status::Warned)
215                    .then_some(detail.as_deref())
216                    .flatten()
217                    .map(|d| self.redactions.apply(&format!("      ↳ {d}")));
218                let buffer = self.buffer_for(scenario);
219                buffer.push(line);
220                if let Some(warn_detail) = warn_detail {
221                    buffer.push(warn_detail);
222                }
223            }
224            Event::ScenarioFinished { scenario, status } => {
225                let lines = self
226                    .buffers
227                    .iter()
228                    .position(|(name, _)| name == scenario)
229                    .map(|position| self.buffers.remove(position).1)
230                    .unwrap_or_default();
231                for line in lines {
232                    let _ = writeln!(self.out, "{line}");
233                }
234                let _ = writeln!(self.out, "    {} scenario {scenario}", glyph(*status));
235            }
236            Event::RunFinished {
237                passed,
238                failed,
239                skipped,
240                cancelled,
241            } => {
242                let note = if *cancelled { " · cancelled" } else { "" };
243                let _ = writeln!(
244                    self.out,
245                    "\nsummary: {passed} passed · {failed} failed · {skipped} skipped{note}"
246                );
247                let _ = self.out.flush();
248            }
249        }
250    }
251}
252
253/// Run totals derived from the event stream — the `Summarize` leg of the
254/// decorator stack (ADR-0008): leaves (GitHub summary, `--output json`, …)
255/// consume the totals at `RunFinished` instead of re-deriving them.
256#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
257pub struct RunTotals {
258    /// Scenarios that passed.
259    pub passed: usize,
260    /// Scenarios that failed.
261    pub failed: usize,
262    /// Scenarios skipped.
263    pub skipped: usize,
264    /// Steps finished (all statuses).
265    pub steps: usize,
266    /// Total attempts across steps (retries included).
267    pub attempts: u64,
268}
269
270impl RunTotals {
271    /// Fold one event into the totals.
272    pub fn observe(&mut self, event: &Event) {
273        match event {
274            Event::StepFinished { attempts, .. } => {
275                self.steps += 1;
276                self.attempts += u64::from(*attempts);
277            }
278            Event::RunFinished {
279                passed,
280                failed,
281                skipped,
282                ..
283            } => {
284                self.passed = *passed;
285                self.failed = *failed;
286                self.skipped = *skipped;
287            }
288            _ => {}
289        }
290    }
291}
292
293/// JSONL appender: the run record is the raw event stream, in arrival order
294/// (replays and tests normalize; TESTING-STRATEGY flake rule).
295pub struct JsonlReporter<W: Write + Send> {
296    out: W,
297}
298
299impl<W: Write + Send> JsonlReporter<W> {
300    /// A JSONL reporter writing to `out`.
301    pub fn new(out: W) -> Self {
302        Self { out }
303    }
304}
305
306impl<W: Write + Send> Reporter for JsonlReporter<W> {
307    fn on_event(&mut self, event: &Event) {
308        if let Ok(json) = serde_json::to_string(event) {
309            let _ = writeln!(self.out, "{json}");
310        }
311        if matches!(event, Event::RunFinished { .. }) {
312            let _ = self.out.flush();
313        }
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    #![allow(clippy::unwrap_used)]
320
321    use super::*;
322    use crate::step::StepRef;
323
324    fn sample_events() -> Vec<Event> {
325        vec![
326            Event::RunStarted {
327                schema: 1,
328                run_id: Arc::from("run-1"),
329            },
330            Event::ScenarioStarted {
331                scenario: Arc::from("S"),
332                file: Arc::from("f.feature"),
333            },
334            Event::StepFinished {
335                scenario: Arc::from("S"),
336                engine: Arc::from("hurl"),
337                step: StepRef {
338                    file: Arc::from("f.feature"),
339                    line: 3,
340                    text: Arc::from("I log in"),
341                },
342                status: Status::Passed,
343                attempts: 2,
344                duration_ms: 12,
345                captures: vec!["token".to_owned()],
346                detail: None,
347            },
348            Event::ScenarioFinished {
349                scenario: Arc::from("S"),
350                status: Status::Passed,
351            },
352            Event::RunFinished {
353                passed: 1,
354                failed: 0,
355                skipped: 0,
356                cancelled: false,
357            },
358        ]
359    }
360
361    #[test]
362    fn console_buffers_per_scenario_and_prints_on_finish() {
363        let mut out = Vec::new();
364        {
365            let mut console = ConsoleReporter::new(&mut out, Redactions::default());
366            for event in sample_events() {
367                console.on_event(&event);
368            }
369        }
370        let text = String::from_utf8(out).unwrap();
371        assert!(text.contains("Scenario: S (f.feature)"), "{text}");
372        assert!(
373            text.contains("✓ f.feature:3 — I log in (12ms, 2 attempts)"),
374            "{text}"
375        );
376        assert!(
377            text.contains("summary: 1 passed · 0 failed · 0 skipped"),
378            "{text}"
379        );
380    }
381
382    #[test]
383    fn jsonl_is_the_event_stream() {
384        let mut out = Vec::new();
385        {
386            let mut jsonl = JsonlReporter::new(&mut out);
387            for event in sample_events() {
388                jsonl.on_event(&event);
389            }
390        }
391        let text = String::from_utf8(out).unwrap();
392        let parsed: Vec<Event> = text
393            .lines()
394            .map(|line| serde_json::from_str(line).unwrap())
395            .collect();
396        assert_eq!(parsed, sample_events());
397    }
398
399    #[test]
400    fn totals_fold_steps_and_run_counts() {
401        let mut totals = RunTotals::default();
402        for event in sample_events() {
403            totals.observe(&event);
404        }
405        assert_eq!(
406            totals,
407            RunTotals {
408                passed: 1,
409                failed: 0,
410                skipped: 0,
411                steps: 1,
412                attempts: 2,
413            }
414        );
415    }
416
417    mod properties {
418        #![allow(clippy::ignored_unit_patterns)]
419
420        use super::*;
421        use proptest::prelude::*;
422
423        proptest! {
424            /// The secret-mask invariant (ADR-0005, TESTING-STRATEGY): for any
425            /// rendered text containing a known secret value, the redacted
426            /// output never contains that value.
427            #[test]
428            fn redaction_removes_known_values(
429                secret in "[a-zA-Z0-9]{4,24}",
430                prefix in ".{0,30}",
431                suffix in ".{0,30}",
432            ) {
433                let redactions = Redactions::new([secret.clone()]);
434                let rendered = format!("{prefix}{secret}{suffix}");
435                let redacted = redactions.apply(&rendered);
436                prop_assert!(!redacted.contains(&secret));
437            }
438
439            /// The sink boundary redacts before fan-out: the JSONL run record
440            /// (and therefore every reporter) never contains a known secret,
441            /// wherever it appears in an event's string fields.
442            #[test]
443            fn sink_redacts_before_any_reporter(secret in "[a-zA-Z0-9]{6,20}") {
444                #[derive(Clone)]
445                struct Shared(Arc<Mutex<Vec<u8>>>);
446                impl Write for Shared {
447                    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
448                        if let Ok(mut out) = self.0.lock() {
449                            out.extend_from_slice(buf);
450                        }
451                        Ok(buf.len())
452                    }
453                    fn flush(&mut self) -> std::io::Result<()> {
454                        Ok(())
455                    }
456                }
457                let out = Shared(Arc::new(Mutex::new(Vec::new())));
458                let sink = sink(
459                    vec![Box::new(JsonlReporter::new(out.clone()))],
460                    Redactions::new([secret.clone()]),
461                );
462                sink.emit(&Event::ScenarioStarted {
463                    scenario: Arc::from(format!("uses {secret}")),
464                    file: Arc::from(format!("{secret}.feature")),
465                });
466                sink.emit(&Event::StepFinished {
467                    scenario: Arc::from(format!("uses {secret}")),
468                    engine: Arc::from("hurl"),
469                    step: crate::step::StepRef {
470                        file: Arc::from(format!("{secret}.feature")),
471                        line: 1,
472                        text: Arc::from(format!("token is {secret}")),
473                    },
474                    status: Status::Failed,
475                    attempts: 1,
476                    duration_ms: 1,
477                    captures: vec![format!("cap-{secret}")],
478                    detail: Some(format!("boom {secret}")),
479                });
480                sink.emit(&Event::RunFinished {
481                    passed: 0,
482                    failed: 1,
483                    skipped: 0,
484                    cancelled: false,
485                });
486                let text = String::from_utf8(out.0.lock().unwrap().clone()).unwrap();
487                prop_assert!(!text.is_empty());
488                prop_assert!(!text.contains(&secret), "{text}");
489            }
490
491            /// Console output never leaks a secret embedded in step text.
492            #[test]
493            fn console_never_prints_known_secrets(secret in "[a-zA-Z0-9]{6,20}") {
494                let mut out = Vec::new();
495                {
496                    let mut console = ConsoleReporter::new(
497                        &mut out,
498                        Redactions::new([secret.clone()]),
499                    );
500                    console.on_event(&Event::ScenarioStarted {
501                        scenario: Arc::from("S"),
502                        file: Arc::from("f"),
503                    });
504                    console.on_event(&Event::StepFinished {
505                        scenario: Arc::from("S"),
506                        engine: Arc::from("hurl"),
507                        step: StepRef {
508                            file: Arc::from("f"),
509                            line: 1,
510                            text: Arc::from(format!("token is {secret}")),
511                        },
512                        status: Status::Failed,
513                        attempts: 1,
514                        duration_ms: 1,
515                        captures: Vec::new(),
516                        detail: None,
517                    });
518                    console.on_event(&Event::ScenarioFinished {
519                        scenario: Arc::from("S"),
520                        status: Status::Failed,
521                    });
522                }
523                let text = String::from_utf8(out).unwrap();
524                prop_assert!(!text.contains(&secret), "{text}");
525            }
526        }
527    }
528}