1use std::io::Write;
13use std::sync::{Arc, Mutex};
14
15use crate::event::{Event, EventSink};
16use crate::step::Status;
17
18#[derive(Debug, Clone, Default)]
20pub struct Redactions(Vec<String>);
21
22impl Redactions {
23 pub fn new(values: impl IntoIterator<Item = String>) -> Self {
25 Self(values.into_iter().filter(|v| !v.is_empty()).collect())
26 }
27
28 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 pub fn is_empty(&self) -> bool {
39 self.0.is_empty()
40 }
41
42 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
109pub trait Reporter: Send {
111 fn on_event(&mut self, event: &Event);
113}
114
115pub 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
137pub 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 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 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 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
257pub struct RunTotals {
258 pub passed: usize,
260 pub failed: usize,
262 pub skipped: usize,
264 pub steps: usize,
266 pub attempts: u64,
268}
269
270impl RunTotals {
271 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
293pub struct JsonlReporter<W: Write + Send> {
296 out: W,
297}
298
299impl<W: Write + Send> JsonlReporter<W> {
300 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 #[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 #[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 #[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}