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/// Normalize decorator (ADR-0008, §12): repairs parallel-scenario interleaving
243/// by buffering each scenario's events and replaying them contiguously
244/// (`ScenarioStarted` … `ScenarioFinished`) in completion order. Run-level
245/// events pass straight through.
246pub struct NormalizeReporter {
247    inner: Box<dyn Reporter>,
248    buffers: Vec<(Arc<str>, Vec<Event>)>,
249}
250
251impl NormalizeReporter {
252    /// Wrap `inner` with interleaving repair.
253    pub fn new(inner: Box<dyn Reporter>) -> Self {
254        Self {
255            inner,
256            buffers: Vec::new(),
257        }
258    }
259
260    fn buffer(&mut self, scenario: &Arc<str>, event: Event) {
261        if let Some((_, events)) = self.buffers.iter_mut().find(|(name, _)| name == scenario) {
262            events.push(event);
263        } else {
264            self.buffers.push((Arc::clone(scenario), vec![event]));
265        }
266    }
267}
268
269impl Reporter for NormalizeReporter {
270    fn on_event(&mut self, event: &Event) {
271        match event {
272            Event::RunStarted { .. } | Event::RunFinished { .. } => {
273                self.inner.on_event(event);
274            }
275            Event::ScenarioStarted { scenario, .. }
276            | Event::BatchStarted { scenario, .. }
277            | Event::EntryRunning { scenario, .. }
278            | Event::StepFinished { scenario, .. } => {
279                let scenario = Arc::clone(scenario);
280                self.buffer(&scenario, event.clone());
281            }
282            Event::ScenarioFinished { scenario, .. } => {
283                let buffered = self
284                    .buffers
285                    .iter()
286                    .position(|(name, _)| name == scenario)
287                    .map(|position| self.buffers.remove(position).1)
288                    .unwrap_or_default();
289                for buffered_event in &buffered {
290                    self.inner.on_event(buffered_event);
291                }
292                self.inner.on_event(event);
293            }
294        }
295    }
296}
297
298/// Run totals derived from the event stream — the `Summarize` leg of the
299/// decorator stack (ADR-0008): leaves (GitHub summary, `--output json`, …)
300/// consume the totals at `RunFinished` instead of re-deriving them.
301#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
302pub struct RunTotals {
303    /// Scenarios that passed.
304    pub passed: usize,
305    /// Scenarios that failed.
306    pub failed: usize,
307    /// Scenarios skipped.
308    pub skipped: usize,
309    /// Steps finished (all statuses).
310    pub steps: usize,
311    /// Total attempts across steps (retries included).
312    pub attempts: u64,
313}
314
315impl RunTotals {
316    /// Fold one event into the totals.
317    pub fn observe(&mut self, event: &Event) {
318        match event {
319            Event::StepFinished { attempts, .. } => {
320                self.steps += 1;
321                self.attempts += u64::from(*attempts);
322            }
323            Event::RunFinished {
324                passed,
325                failed,
326                skipped,
327                ..
328            } => {
329                self.passed = *passed;
330                self.failed = *failed;
331                self.skipped = *skipped;
332            }
333            _ => {}
334        }
335    }
336}
337
338/// JSONL appender: the run record is the raw event stream, in arrival order
339/// (replays and tests normalize; TESTING-STRATEGY flake rule).
340pub struct JsonlReporter<W: Write + Send> {
341    out: W,
342}
343
344impl<W: Write + Send> JsonlReporter<W> {
345    /// A JSONL reporter writing to `out`.
346    pub fn new(out: W) -> Self {
347        Self { out }
348    }
349}
350
351impl<W: Write + Send> Reporter for JsonlReporter<W> {
352    fn on_event(&mut self, event: &Event) {
353        if let Ok(json) = serde_json::to_string(event) {
354            let _ = writeln!(self.out, "{json}");
355        }
356        if matches!(event, Event::RunFinished { .. }) {
357            let _ = self.out.flush();
358        }
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    #![allow(clippy::unwrap_used)]
365
366    use super::*;
367    use crate::step::StepRef;
368
369    fn sample_events() -> Vec<Event> {
370        vec![
371            Event::RunStarted {
372                schema: 1,
373                run_id: Arc::from("run-1"),
374            },
375            Event::ScenarioStarted {
376                scenario: Arc::from("S"),
377                file: Arc::from("f.feature"),
378            },
379            Event::StepFinished {
380                scenario: Arc::from("S"),
381                engine: Arc::from("hurl"),
382                step: StepRef {
383                    file: Arc::from("f.feature"),
384                    line: 3,
385                    text: Arc::from("I log in"),
386                },
387                status: Status::Passed,
388                attempts: 2,
389                duration_ms: 12,
390                captures: vec!["token".to_owned()],
391                detail: None,
392            },
393            Event::ScenarioFinished {
394                scenario: Arc::from("S"),
395                status: Status::Passed,
396            },
397            Event::RunFinished {
398                passed: 1,
399                failed: 0,
400                skipped: 0,
401                cancelled: false,
402            },
403        ]
404    }
405
406    #[test]
407    fn console_buffers_per_scenario_and_prints_on_finish() {
408        let mut out = Vec::new();
409        {
410            let mut console = ConsoleReporter::new(&mut out, Redactions::default());
411            for event in sample_events() {
412                console.on_event(&event);
413            }
414        }
415        let text = String::from_utf8(out).unwrap();
416        assert!(text.contains("Scenario: S (f.feature)"), "{text}");
417        assert!(
418            text.contains("✓ f.feature:3 — I log in (12ms, 2 attempts)"),
419            "{text}"
420        );
421        assert!(
422            text.contains("summary: 1 passed · 0 failed · 0 skipped"),
423            "{text}"
424        );
425    }
426
427    #[test]
428    fn jsonl_is_the_event_stream() {
429        let mut out = Vec::new();
430        {
431            let mut jsonl = JsonlReporter::new(&mut out);
432            for event in sample_events() {
433                jsonl.on_event(&event);
434            }
435        }
436        let text = String::from_utf8(out).unwrap();
437        let parsed: Vec<Event> = text
438            .lines()
439            .map(|line| serde_json::from_str(line).unwrap())
440            .collect();
441        assert_eq!(parsed, sample_events());
442    }
443
444    #[test]
445    fn normalize_repairs_interleaving_in_completion_order() {
446        struct Capture(std::sync::Arc<Mutex<Vec<Event>>>);
447        impl Reporter for Capture {
448            fn on_event(&mut self, event: &Event) {
449                if let Ok(mut events) = self.0.lock() {
450                    events.push(event.clone());
451                }
452            }
453        }
454        let captured = std::sync::Arc::new(Mutex::new(Vec::new()));
455        let mut normalize =
456            NormalizeReporter::new(Box::new(Capture(std::sync::Arc::clone(&captured))));
457
458        let started = |name: &str| Event::ScenarioStarted {
459            scenario: Arc::from(name),
460            file: Arc::from("f"),
461        };
462        let finished = |name: &str| Event::ScenarioFinished {
463            scenario: Arc::from(name),
464            status: Status::Passed,
465        };
466        // a and b interleave; b completes first.
467        normalize.on_event(&started("a"));
468        normalize.on_event(&started("b"));
469        normalize.on_event(&finished("b"));
470        normalize.on_event(&finished("a"));
471
472        let events = captured.lock().unwrap().clone();
473        let names: Vec<String> = events
474            .iter()
475            .map(|e| match e {
476                Event::ScenarioStarted { scenario, .. } => format!("start-{scenario}"),
477                Event::ScenarioFinished { scenario, .. } => format!("finish-{scenario}"),
478                _ => "other".to_owned(),
479            })
480            .collect();
481        assert_eq!(names, ["start-b", "finish-b", "start-a", "finish-a"]);
482    }
483
484    #[test]
485    fn totals_fold_steps_and_run_counts() {
486        let mut totals = RunTotals::default();
487        for event in sample_events() {
488            totals.observe(&event);
489        }
490        assert_eq!(
491            totals,
492            RunTotals {
493                passed: 1,
494                failed: 0,
495                skipped: 0,
496                steps: 1,
497                attempts: 2,
498            }
499        );
500    }
501
502    mod properties {
503        #![allow(clippy::ignored_unit_patterns)]
504
505        use super::*;
506        use proptest::prelude::*;
507
508        proptest! {
509            /// The secret-mask invariant (ADR-0005, TESTING-STRATEGY): for any
510            /// rendered text containing a known secret value, the redacted
511            /// output never contains that value.
512            #[test]
513            fn redaction_removes_known_values(
514                secret in "[a-zA-Z0-9]{4,24}",
515                prefix in ".{0,30}",
516                suffix in ".{0,30}",
517            ) {
518                let redactions = Redactions::new([secret.clone()]);
519                let rendered = format!("{prefix}{secret}{suffix}");
520                let redacted = redactions.apply(&rendered);
521                prop_assert!(!redacted.contains(&secret));
522            }
523
524            /// The sink boundary redacts before fan-out: the JSONL run record
525            /// (and therefore every reporter) never contains a known secret,
526            /// wherever it appears in an event's string fields.
527            #[test]
528            fn sink_redacts_before_any_reporter(secret in "[a-zA-Z0-9]{6,20}") {
529                #[derive(Clone)]
530                struct Shared(Arc<Mutex<Vec<u8>>>);
531                impl Write for Shared {
532                    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
533                        if let Ok(mut out) = self.0.lock() {
534                            out.extend_from_slice(buf);
535                        }
536                        Ok(buf.len())
537                    }
538                    fn flush(&mut self) -> std::io::Result<()> {
539                        Ok(())
540                    }
541                }
542                let out = Shared(Arc::new(Mutex::new(Vec::new())));
543                let sink = sink(
544                    vec![Box::new(JsonlReporter::new(out.clone()))],
545                    Redactions::new([secret.clone()]),
546                );
547                sink.emit(&Event::ScenarioStarted {
548                    scenario: Arc::from(format!("uses {secret}")),
549                    file: Arc::from(format!("{secret}.feature")),
550                });
551                sink.emit(&Event::StepFinished {
552                    scenario: Arc::from(format!("uses {secret}")),
553                    engine: Arc::from("hurl"),
554                    step: crate::step::StepRef {
555                        file: Arc::from(format!("{secret}.feature")),
556                        line: 1,
557                        text: Arc::from(format!("token is {secret}")),
558                    },
559                    status: Status::Failed,
560                    attempts: 1,
561                    duration_ms: 1,
562                    captures: vec![format!("cap-{secret}")],
563                    detail: Some(format!("boom {secret}")),
564                });
565                sink.emit(&Event::RunFinished {
566                    passed: 0,
567                    failed: 1,
568                    skipped: 0,
569                    cancelled: false,
570                });
571                let text = String::from_utf8(out.0.lock().unwrap().clone()).unwrap();
572                prop_assert!(!text.is_empty());
573                prop_assert!(!text.contains(&secret), "{text}");
574            }
575
576            /// Console output never leaks a secret embedded in step text.
577            #[test]
578            fn console_never_prints_known_secrets(secret in "[a-zA-Z0-9]{6,20}") {
579                let mut out = Vec::new();
580                {
581                    let mut console = ConsoleReporter::new(
582                        &mut out,
583                        Redactions::new([secret.clone()]),
584                    );
585                    console.on_event(&Event::ScenarioStarted {
586                        scenario: Arc::from("S"),
587                        file: Arc::from("f"),
588                    });
589                    console.on_event(&Event::StepFinished {
590                        scenario: Arc::from("S"),
591                        engine: Arc::from("hurl"),
592                        step: StepRef {
593                            file: Arc::from("f"),
594                            line: 1,
595                            text: Arc::from(format!("token is {secret}")),
596                        },
597                        status: Status::Failed,
598                        attempts: 1,
599                        duration_ms: 1,
600                        captures: Vec::new(),
601                        detail: None,
602                    });
603                    console.on_event(&Event::ScenarioFinished {
604                        scenario: Arc::from("S"),
605                        status: Status::Failed,
606                    });
607                }
608                let text = String::from_utf8(out).unwrap();
609                prop_assert!(!text.contains(&secret), "{text}");
610            }
611        }
612    }
613}