Skip to main content

nextest_runner/record/
summary.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Serializable summary types for test events.
5//!
6//! This module provides types that can be serialized to JSON for recording test runs.
7//! The types here mirror the runtime types in [`crate::reporter::events`] but are
8//! designed for serialization rather than runtime use.
9//!
10//! The `S` type parameter specifies how output is stored (see
11//! [`OutputSpec`](crate::output_spec::OutputSpec)):
12//! - [`LiveSpec`](crate::output_spec::LiveSpec): output stored in memory with
13//!   lazy string conversion.
14//! - [`RecordingSpec`](crate::output_spec::RecordingSpec): reference to a file stored
15//!   in the zip archive.
16
17#[cfg(test)]
18use crate::output_spec::ArbitraryOutputSpec;
19use crate::{
20    config::{
21        elements::{JunitFlakyFailStatus, ReportSkipPolicy},
22        scripts::ScriptId,
23    },
24    list::OwnedTestInstanceId,
25    output_spec::{LiveSpec, OutputSpec, SerializableOutputSpec},
26    reporter::{
27        TestOutputDisplay,
28        events::{
29            CancelReason, ExecuteStatus, ExecutionStatuses, RetryData, RunFinishedStats, RunStats,
30            SetupScriptExecuteStatus, StressIndex, StressProgress, TestEvent, TestEventKind,
31            TestSlotAssignment,
32        },
33    },
34    run_mode::NextestRunMode,
35    runner::StressCondition,
36};
37use chrono::{DateTime, FixedOffset};
38use nextest_metadata::MismatchReason;
39use quick_junit::ReportUuid;
40use serde::{Deserialize, Serialize};
41use std::{fmt, num::NonZero, time::Duration};
42
43// ---
44// Record options
45// ---
46
47/// Options that affect how test results are interpreted during replay.
48///
49/// These options are captured at record time and stored in the archive,
50/// allowing replay to produce the same exit code as the original run.
51#[derive(Clone, Debug, Default, Deserialize, Serialize)]
52#[serde(rename_all = "kebab-case")]
53#[non_exhaustive]
54pub struct RecordOpts {
55    /// The run mode (test or benchmark).
56    #[serde(default)]
57    pub run_mode: NextestRunMode,
58}
59
60impl RecordOpts {
61    /// Creates a new `RecordOpts` with the given settings.
62    pub fn new(run_mode: NextestRunMode) -> Self {
63        Self { run_mode }
64    }
65}
66
67// ---
68// Test event summaries
69// ---
70
71/// A serializable form of a test event.
72///
73/// The `S` parameter specifies how test outputs are stored (see
74/// [`OutputSpec`]).
75#[derive_where::derive_where(Debug, PartialEq; S::ChildOutputDesc)]
76#[derive(Deserialize, Serialize)]
77#[serde(
78    rename_all = "kebab-case",
79    bound(
80        serialize = "S: SerializableOutputSpec",
81        deserialize = "S: SerializableOutputSpec"
82    )
83)]
84#[cfg_attr(
85    test,
86    derive(test_strategy::Arbitrary),
87    arbitrary(bound(S: ArbitraryOutputSpec))
88)]
89pub struct TestEventSummary<S: OutputSpec> {
90    /// The timestamp of the event.
91    #[cfg_attr(
92        test,
93        strategy(crate::reporter::test_helpers::arb_datetime_fixed_offset())
94    )]
95    pub timestamp: DateTime<FixedOffset>,
96
97    /// The time elapsed since the start of the test run.
98    #[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
99    pub elapsed: Duration,
100
101    /// The kind of test event this is.
102    pub kind: TestEventKindSummary<S>,
103}
104
105impl TestEventSummary<LiveSpec> {
106    /// Converts a [`TestEvent`] to a serializable summary.
107    ///
108    /// Returns `None` for events that should not be recorded (informational and
109    /// interactive events like `InfoStarted`, `InputEnter`, etc.).
110    pub(crate) fn from_test_event(event: TestEvent<'_>) -> Option<Self> {
111        let kind = TestEventKindSummary::from_test_event_kind(event.kind)?;
112        Some(Self {
113            timestamp: event.timestamp,
114            elapsed: event.elapsed,
115            kind,
116        })
117    }
118}
119
120/// The kind of test event.
121///
122/// This is a combined enum that wraps either a [`CoreEventKind`] (events
123/// without output) or an [`OutputEventKind`] (events with output). The split
124/// design allows conversion between output representations to only touch the
125/// output-carrying variants.
126///
127/// The type parameter `S` specifies how test output is stored (see
128/// [`OutputSpec`]).
129#[derive_where::derive_where(Debug, PartialEq; S::ChildOutputDesc)]
130#[derive(Deserialize, Serialize)]
131#[serde(
132    tag = "type",
133    rename_all = "kebab-case",
134    bound(
135        serialize = "S: SerializableOutputSpec",
136        deserialize = "S: SerializableOutputSpec"
137    )
138)]
139#[cfg_attr(
140    test,
141    derive(test_strategy::Arbitrary),
142    arbitrary(bound(S: ArbitraryOutputSpec))
143)]
144pub enum TestEventKindSummary<S: OutputSpec> {
145    /// An event that doesn't carry output.
146    Core(CoreEventKind),
147    /// An event that carries output.
148    Output(OutputEventKind<S>),
149}
150
151/// Events that don't carry test output.
152///
153/// These events pass through unchanged during conversion between output
154/// representations (e.g., from [`LiveSpec`] to
155/// [`RecordingSpec`](crate::output_spec::RecordingSpec)).
156#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
157#[serde(tag = "kind", rename_all = "kebab-case")]
158#[cfg_attr(test, derive(test_strategy::Arbitrary))]
159pub enum CoreEventKind {
160    /// A test run started.
161    #[serde(rename_all = "kebab-case")]
162    RunStarted {
163        /// The run ID.
164        run_id: ReportUuid,
165        /// The profile name.
166        profile_name: String,
167        /// The CLI arguments.
168        cli_args: Vec<String>,
169        /// The stress condition, if any.
170        stress_condition: Option<StressConditionSummary>,
171    },
172
173    /// A stress sub-run started.
174    #[serde(rename_all = "kebab-case")]
175    StressSubRunStarted {
176        /// The stress progress.
177        progress: StressProgress,
178    },
179
180    /// A setup script started.
181    #[serde(rename_all = "kebab-case")]
182    SetupScriptStarted {
183        /// The stress index, if running a stress test.
184        stress_index: Option<StressIndexSummary>,
185        /// The index of this setup script.
186        index: usize,
187        /// The total number of setup scripts.
188        total: usize,
189        /// The script ID.
190        script_id: ScriptId,
191        /// The program being run.
192        program: String,
193        /// The arguments to the program.
194        args: Vec<String>,
195        /// Whether output capture is disabled.
196        no_capture: bool,
197    },
198
199    /// A setup script is slow.
200    #[serde(rename_all = "kebab-case")]
201    SetupScriptSlow {
202        /// The stress index, if running a stress test.
203        stress_index: Option<StressIndexSummary>,
204        /// The script ID.
205        script_id: ScriptId,
206        /// The program being run.
207        program: String,
208        /// The arguments to the program.
209        args: Vec<String>,
210        /// The time elapsed.
211        #[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
212        elapsed: Duration,
213        /// Whether the script will be terminated.
214        will_terminate: bool,
215    },
216
217    /// A test started.
218    #[serde(rename_all = "kebab-case")]
219    TestStarted {
220        /// The stress index, if running a stress test.
221        stress_index: Option<StressIndexSummary>,
222        /// The test instance.
223        test_instance: OwnedTestInstanceId,
224        /// Scheduling information (slot and group assignment).
225        slot_assignment: TestSlotAssignment,
226        /// The current run statistics.
227        current_stats: RunStats,
228        /// The number of tests currently running.
229        running: usize,
230        /// The command line used to run this test.
231        command_line: Vec<String>,
232    },
233
234    /// A test is slow.
235    #[serde(rename_all = "kebab-case")]
236    TestSlow {
237        /// The stress index, if running a stress test.
238        stress_index: Option<StressIndexSummary>,
239        /// The test instance.
240        test_instance: OwnedTestInstanceId,
241        /// Retry data.
242        retry_data: RetryData,
243        /// The time elapsed.
244        #[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
245        elapsed: Duration,
246        /// Whether the test will be terminated.
247        will_terminate: bool,
248    },
249
250    /// A test retry started.
251    #[serde(rename_all = "kebab-case")]
252    TestRetryStarted {
253        /// The stress index, if running a stress test.
254        stress_index: Option<StressIndexSummary>,
255        /// The test instance.
256        test_instance: OwnedTestInstanceId,
257        /// Scheduling information (slot and group assignment).
258        slot_assignment: TestSlotAssignment,
259        /// Retry data.
260        retry_data: RetryData,
261        /// The number of tests currently running.
262        running: usize,
263        /// The command line used to run this test.
264        command_line: Vec<String>,
265    },
266
267    /// A test was skipped.
268    #[serde(rename_all = "kebab-case")]
269    TestSkipped {
270        /// The stress index, if running a stress test.
271        stress_index: Option<StressIndexSummary>,
272        /// The test instance.
273        test_instance: OwnedTestInstanceId,
274        /// The reason the test was skipped.
275        reason: MismatchReason,
276        /// The per-test resolved policy controlling which skipped tests are
277        /// emitted in machine-readable reports such as JUnit.
278        #[serde(default)]
279        junit_report_skipped: ReportSkipPolicy,
280    },
281
282    /// A run began being cancelled.
283    #[serde(rename_all = "kebab-case")]
284    RunBeginCancel {
285        /// The number of setup scripts currently running.
286        setup_scripts_running: usize,
287        /// The number of tests currently running.
288        running: usize,
289        /// The reason for cancellation.
290        reason: CancelReason,
291    },
292
293    /// A run was paused.
294    #[serde(rename_all = "kebab-case")]
295    RunPaused {
296        /// The number of setup scripts currently running.
297        setup_scripts_running: usize,
298        /// The number of tests currently running.
299        running: usize,
300    },
301
302    /// A run was continued after being paused.
303    #[serde(rename_all = "kebab-case")]
304    RunContinued {
305        /// The number of setup scripts currently running.
306        setup_scripts_running: usize,
307        /// The number of tests currently running.
308        running: usize,
309    },
310
311    /// A stress sub-run finished.
312    #[serde(rename_all = "kebab-case")]
313    StressSubRunFinished {
314        /// The stress progress.
315        progress: StressProgress,
316        /// The time taken for this sub-run.
317        #[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
318        sub_elapsed: Duration,
319        /// The run statistics for this sub-run.
320        sub_stats: RunStats,
321    },
322
323    /// A run finished.
324    #[serde(rename_all = "kebab-case")]
325    RunFinished {
326        /// The run ID.
327        run_id: ReportUuid,
328        /// The start time.
329        #[cfg_attr(
330            test,
331            strategy(crate::reporter::test_helpers::arb_datetime_fixed_offset())
332        )]
333        start_time: DateTime<FixedOffset>,
334        /// The total elapsed time.
335        #[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
336        elapsed: Duration,
337        /// The final run statistics.
338        run_stats: RunFinishedStats,
339        /// Tests that were expected to run but were not seen during this run.
340        outstanding_not_seen: Option<TestsNotSeenSummary>,
341    },
342}
343
344/// Tests that were expected to run but were not seen during a rerun.
345#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
346#[serde(rename_all = "kebab-case")]
347#[cfg_attr(test, derive(test_strategy::Arbitrary))]
348pub struct TestsNotSeenSummary {
349    /// A sample of test instance IDs that were not seen.
350    pub not_seen: Vec<OwnedTestInstanceId>,
351    /// The total number of tests not seen.
352    pub total_not_seen: usize,
353}
354
355/// Events that carry test output.
356///
357/// These events require conversion when changing output representations
358/// (e.g., from [`LiveSpec`] to
359/// [`RecordingSpec`](crate::output_spec::RecordingSpec)).
360///
361/// The type parameter `S` specifies how test output is stored (see
362/// [`OutputSpec`]).
363#[derive_where::derive_where(Debug, PartialEq; S::ChildOutputDesc)]
364#[derive(Deserialize, Serialize)]
365#[serde(
366    tag = "kind",
367    rename_all = "kebab-case",
368    bound(
369        serialize = "S: SerializableOutputSpec",
370        deserialize = "S: SerializableOutputSpec"
371    )
372)]
373#[cfg_attr(
374    test,
375    derive(test_strategy::Arbitrary),
376    arbitrary(bound(S: ArbitraryOutputSpec))
377)]
378pub enum OutputEventKind<S: OutputSpec> {
379    /// A setup script finished.
380    #[serde(rename_all = "kebab-case")]
381    SetupScriptFinished {
382        /// The stress index, if running a stress test.
383        stress_index: Option<StressIndexSummary>,
384        /// The index of this setup script.
385        index: usize,
386        /// The total number of setup scripts.
387        total: usize,
388        /// The script ID.
389        script_id: ScriptId,
390        /// The program that was run.
391        program: String,
392        /// The arguments to the program.
393        args: Vec<String>,
394        /// Whether output capture was disabled.
395        no_capture: bool,
396        /// The execution status.
397        run_status: SetupScriptExecuteStatus<S>,
398    },
399
400    /// A test attempt failed and will be retried.
401    #[serde(rename_all = "kebab-case")]
402    TestAttemptFailedWillRetry {
403        /// The stress index, if running a stress test.
404        stress_index: Option<StressIndexSummary>,
405        /// The test instance.
406        test_instance: OwnedTestInstanceId,
407        /// The execution status.
408        run_status: ExecuteStatus<S>,
409        /// The delay before the next attempt.
410        #[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
411        delay_before_next_attempt: Duration,
412        /// How to display failure output.
413        failure_output: TestOutputDisplay,
414        /// The number of tests currently running.
415        running: usize,
416    },
417
418    /// A test finished.
419    #[serde(rename_all = "kebab-case")]
420    TestFinished {
421        /// The stress index, if running a stress test.
422        stress_index: Option<StressIndexSummary>,
423        /// The test instance.
424        test_instance: OwnedTestInstanceId,
425        /// How to display success output.
426        success_output: TestOutputDisplay,
427        /// How to display failure output.
428        failure_output: TestOutputDisplay,
429        /// Whether to store success output in JUnit.
430        junit_store_success_output: bool,
431        /// Whether to store failure output in JUnit.
432        junit_store_failure_output: bool,
433        /// How flaky-fail tests should be reported in JUnit.
434        #[serde(default)]
435        junit_flaky_fail_status: JunitFlakyFailStatus,
436        /// The execution statuses.
437        run_statuses: ExecutionStatuses<S>,
438        /// The current run statistics.
439        current_stats: RunStats,
440        /// The number of tests currently running.
441        running: usize,
442    },
443}
444
445impl TestEventKindSummary<LiveSpec> {
446    fn from_test_event_kind(kind: TestEventKind<'_>) -> Option<Self> {
447        Some(match kind {
448            TestEventKind::RunStarted {
449                run_id,
450                test_list: _,
451                profile_name,
452                cli_args,
453                stress_condition,
454            } => Self::Core(CoreEventKind::RunStarted {
455                run_id,
456                profile_name,
457                cli_args,
458                stress_condition: stress_condition.map(StressConditionSummary::from),
459            }),
460            TestEventKind::StressSubRunStarted { progress } => {
461                Self::Core(CoreEventKind::StressSubRunStarted { progress })
462            }
463            TestEventKind::SetupScriptStarted {
464                stress_index,
465                index,
466                total,
467                script_id,
468                program,
469                args,
470                no_capture,
471            } => Self::Core(CoreEventKind::SetupScriptStarted {
472                stress_index: stress_index.map(StressIndexSummary::from),
473                index,
474                total,
475                script_id,
476                program,
477                args: args.to_vec(),
478                no_capture,
479            }),
480            TestEventKind::SetupScriptSlow {
481                stress_index,
482                script_id,
483                program,
484                args,
485                elapsed,
486                will_terminate,
487            } => Self::Core(CoreEventKind::SetupScriptSlow {
488                stress_index: stress_index.map(StressIndexSummary::from),
489                script_id,
490                program,
491                args: args.to_vec(),
492                elapsed,
493                will_terminate,
494            }),
495            TestEventKind::TestStarted {
496                stress_index,
497                test_instance,
498                slot_assignment,
499                current_stats,
500                running,
501                command_line,
502            } => Self::Core(CoreEventKind::TestStarted {
503                stress_index: stress_index.map(StressIndexSummary::from),
504                test_instance: test_instance.to_owned(),
505                slot_assignment,
506                current_stats,
507                running,
508                command_line,
509            }),
510            TestEventKind::TestSlow {
511                stress_index,
512                test_instance,
513                retry_data,
514                elapsed,
515                will_terminate,
516            } => Self::Core(CoreEventKind::TestSlow {
517                stress_index: stress_index.map(StressIndexSummary::from),
518                test_instance: test_instance.to_owned(),
519                retry_data,
520                elapsed,
521                will_terminate,
522            }),
523            TestEventKind::TestRetryStarted {
524                stress_index,
525                test_instance,
526                slot_assignment,
527                retry_data,
528                running,
529                command_line,
530            } => Self::Core(CoreEventKind::TestRetryStarted {
531                stress_index: stress_index.map(StressIndexSummary::from),
532                test_instance: test_instance.to_owned(),
533                slot_assignment,
534                retry_data,
535                running,
536                command_line,
537            }),
538            TestEventKind::TestSkipped {
539                stress_index,
540                test_instance,
541                reason,
542                junit_report_skipped,
543            } => Self::Core(CoreEventKind::TestSkipped {
544                stress_index: stress_index.map(StressIndexSummary::from),
545                test_instance: test_instance.to_owned(),
546                reason,
547                junit_report_skipped,
548            }),
549            TestEventKind::RunBeginCancel {
550                setup_scripts_running,
551                current_stats,
552                running,
553            } => Self::Core(CoreEventKind::RunBeginCancel {
554                setup_scripts_running,
555                running,
556                reason: current_stats
557                    .cancel_reason
558                    .expect("RunBeginCancel event has cancel reason"),
559            }),
560            TestEventKind::RunPaused {
561                setup_scripts_running,
562                running,
563            } => Self::Core(CoreEventKind::RunPaused {
564                setup_scripts_running,
565                running,
566            }),
567            TestEventKind::RunContinued {
568                setup_scripts_running,
569                running,
570            } => Self::Core(CoreEventKind::RunContinued {
571                setup_scripts_running,
572                running,
573            }),
574            TestEventKind::StressSubRunFinished {
575                progress,
576                sub_elapsed,
577                sub_stats,
578            } => Self::Core(CoreEventKind::StressSubRunFinished {
579                progress,
580                sub_elapsed,
581                sub_stats,
582            }),
583            TestEventKind::RunFinished {
584                run_id,
585                start_time,
586                elapsed,
587                run_stats,
588                outstanding_not_seen,
589            } => Self::Core(CoreEventKind::RunFinished {
590                run_id,
591                start_time,
592                elapsed,
593                run_stats,
594                outstanding_not_seen: outstanding_not_seen.map(|t| TestsNotSeenSummary {
595                    not_seen: t.not_seen,
596                    total_not_seen: t.total_not_seen,
597                }),
598            }),
599
600            TestEventKind::SetupScriptFinished {
601                stress_index,
602                index,
603                total,
604                script_id,
605                program,
606                args,
607                junit_store_success_output: _,
608                junit_store_failure_output: _,
609                no_capture,
610                run_status,
611            } => Self::Output(OutputEventKind::SetupScriptFinished {
612                stress_index: stress_index.map(StressIndexSummary::from),
613                index,
614                total,
615                script_id,
616                program,
617                args: args.to_vec(),
618                no_capture,
619                run_status,
620            }),
621            TestEventKind::TestAttemptFailedWillRetry {
622                stress_index,
623                test_instance,
624                run_status,
625                delay_before_next_attempt,
626                failure_output,
627                running,
628            } => Self::Output(OutputEventKind::TestAttemptFailedWillRetry {
629                stress_index: stress_index.map(StressIndexSummary::from),
630                test_instance: test_instance.to_owned(),
631                run_status,
632                delay_before_next_attempt,
633                failure_output,
634                running,
635            }),
636            TestEventKind::TestFinished {
637                stress_index,
638                test_instance,
639                success_output,
640                failure_output,
641                junit_store_success_output,
642                junit_store_failure_output,
643                junit_flaky_fail_status,
644                run_statuses,
645                current_stats,
646                running,
647            } => Self::Output(OutputEventKind::TestFinished {
648                stress_index: stress_index.map(StressIndexSummary::from),
649                test_instance: test_instance.to_owned(),
650                success_output,
651                failure_output,
652                junit_store_success_output,
653                junit_store_failure_output,
654                junit_flaky_fail_status,
655                run_statuses,
656                current_stats,
657                running,
658            }),
659
660            TestEventKind::InfoStarted { .. }
661            | TestEventKind::InfoResponse { .. }
662            | TestEventKind::InfoFinished { .. }
663            | TestEventKind::InputEnter { .. }
664            | TestEventKind::RunBeginKill { .. } => return None,
665        })
666    }
667}
668
669/// Serializable version of [`StressIndex`].
670#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq)]
671#[serde(rename_all = "kebab-case")]
672#[cfg_attr(test, derive(test_strategy::Arbitrary))]
673pub struct StressIndexSummary {
674    /// The current stress index (0-indexed).
675    pub current: u32,
676    /// The total number of stress runs, if known.
677    pub total: Option<NonZero<u32>>,
678}
679
680impl From<StressIndex> for StressIndexSummary {
681    fn from(index: StressIndex) -> Self {
682        Self {
683            current: index.current,
684            total: index.total,
685        }
686    }
687}
688
689/// Serializable version of [`StressCondition`].
690#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
691#[serde(tag = "type", rename_all = "kebab-case")]
692#[cfg_attr(test, derive(test_strategy::Arbitrary))]
693pub enum StressConditionSummary {
694    /// Run for a specific count.
695    Count {
696        /// The count value, or None for infinite.
697        count: Option<u32>,
698    },
699    /// Run for a specific duration.
700    Duration {
701        /// The duration to run for.
702        #[cfg_attr(test, strategy(crate::reporter::test_helpers::arb_duration()))]
703        duration: Duration,
704    },
705}
706
707impl From<StressCondition> for StressConditionSummary {
708    fn from(condition: StressCondition) -> Self {
709        use crate::runner::StressCount;
710        match condition {
711            StressCondition::Count(count) => Self::Count {
712                count: match count {
713                    StressCount::Count { count: n } => Some(n.get()),
714                    StressCount::Infinite => None,
715                },
716            },
717            StressCondition::Duration(duration) => Self::Duration { duration },
718        }
719    }
720}
721
722/// Output kind for content-addressed file names.
723///
724/// Used to determine which dictionary to use for compression and to construct
725/// content-addressed file names.
726#[derive(Clone, Copy, Debug, PartialEq, Eq)]
727pub(crate) enum OutputKind {
728    /// Standard output.
729    Stdout,
730    /// Standard error.
731    Stderr,
732    /// Combined stdout and stderr.
733    Combined,
734}
735
736impl OutputKind {
737    /// Returns the string suffix for this output kind.
738    pub(crate) fn as_str(self) -> &'static str {
739        match self {
740            Self::Stdout => "stdout",
741            Self::Stderr => "stderr",
742            Self::Combined => "combined",
743        }
744    }
745}
746
747/// A validated output file name in the zip archive.
748///
749/// File names use content-addressed format: `{content_hash}-{stdout|stderr|combined}`
750/// where `content_hash` is a 16-digit hex XXH3 hash of the output content.
751///
752/// This enables deduplication: identical outputs produce identical file names,
753/// so stress runs with many iterations store only one copy of each unique output.
754///
755/// This type validates the format during deserialization to prevent path
756/// traversal attacks from maliciously crafted archives.
757#[derive(Clone, Debug, PartialEq, Eq)]
758pub struct OutputFileName(String);
759
760impl OutputFileName {
761    /// Creates a content-addressed file name from output bytes and kind.
762    ///
763    /// The file name is based on a hash of the content, enabling deduplication
764    /// of identical outputs across stress iterations, retries, and tests.
765    pub(crate) fn from_content(content: &[u8], kind: OutputKind) -> Self {
766        let hash = xxhash_rust::xxh3::xxh3_64(content);
767        Self(format!("{hash:016x}-{}", kind.as_str()))
768    }
769
770    /// Returns the file name as a string slice.
771    pub fn as_str(&self) -> &str {
772        &self.0
773    }
774
775    /// Validates that a string is a valid output file name.
776    ///
777    /// Content-addressed format: `{16_hex_chars}-{stdout|stderr|combined}`
778    fn validate(s: &str) -> bool {
779        if s.contains('/') || s.contains('\\') || s.contains("..") {
780            return false;
781        }
782
783        let valid_suffixes = ["-stdout", "-stderr", "-combined"];
784        for suffix in valid_suffixes {
785            if let Some(hash_part) = s.strip_suffix(suffix)
786                && hash_part.len() == 16
787                && hash_part
788                    .chars()
789                    .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
790            {
791                return true;
792            }
793        }
794
795        false
796    }
797}
798
799impl fmt::Display for OutputFileName {
800    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
801        f.write_str(&self.0)
802    }
803}
804
805impl AsRef<str> for OutputFileName {
806    fn as_ref(&self) -> &str {
807        &self.0
808    }
809}
810
811impl Serialize for OutputFileName {
812    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
813    where
814        S: serde::Serializer,
815    {
816        self.0.serialize(serializer)
817    }
818}
819
820impl<'de> Deserialize<'de> for OutputFileName {
821    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
822    where
823        D: serde::Deserializer<'de>,
824    {
825        let s = String::deserialize(deserializer)?;
826        if Self::validate(&s) {
827            Ok(Self(s))
828        } else {
829            Err(serde::de::Error::custom(format!(
830                "invalid output file name: {s}"
831            )))
832        }
833    }
834}
835
836/// Output stored as a reference to a file in the zip archive.
837#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
838#[serde(tag = "status", rename_all = "kebab-case")]
839pub enum ZipStoreOutput {
840    /// The output was empty or not captured.
841    Empty,
842
843    /// The output was stored in full.
844    #[serde(rename_all = "kebab-case")]
845    Full {
846        /// The file name in the archive.
847        file_name: OutputFileName,
848    },
849
850    /// The output was truncated to fit within size limits.
851    #[serde(rename_all = "kebab-case")]
852    Truncated {
853        /// The file name in the archive.
854        file_name: OutputFileName,
855        /// The original size in bytes before truncation.
856        original_size: u64,
857    },
858}
859
860impl ZipStoreOutput {
861    /// Returns the file name if output was stored, or `None` if empty.
862    pub fn file_name(&self) -> Option<&OutputFileName> {
863        match self {
864            ZipStoreOutput::Empty => None,
865            ZipStoreOutput::Full { file_name } | ZipStoreOutput::Truncated { file_name, .. } => {
866                Some(file_name)
867            }
868        }
869    }
870}
871
872/// A description of child process output stored in a recording.
873///
874/// This is the recording-side counterpart to [`ChildOutputDescription`]. Unlike
875/// `ChildOutputDescription`, this type does not have a `NotLoaded` variant,
876/// because recorded output is always present in the archive.
877///
878/// [`ChildOutputDescription`]: crate::reporter::events::ChildOutputDescription
879#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
880#[serde(tag = "kind", rename_all = "kebab-case")]
881#[cfg_attr(test, derive(test_strategy::Arbitrary))]
882pub enum ZipStoreOutputDescription {
883    /// The output was split into stdout and stderr.
884    Split {
885        /// Standard output, or `None` if not captured.
886        stdout: Option<ZipStoreOutput>,
887        /// Standard error, or `None` if not captured.
888        stderr: Option<ZipStoreOutput>,
889    },
890
891    /// The output was combined into a single stream.
892    Combined {
893        /// The combined output.
894        output: ZipStoreOutput,
895    },
896}
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901    use crate::output_spec::RecordingSpec;
902    use nextest_metadata::{RustBinaryId, TestCaseName};
903    use test_strategy::proptest;
904
905    #[proptest]
906    fn test_event_summary_roundtrips(value: TestEventSummary<RecordingSpec>) {
907        let json = serde_json::to_string(&value).expect("serialization succeeds");
908        let roundtrip: TestEventSummary<RecordingSpec> =
909            serde_json::from_str(&json).expect("deserialization succeeds");
910        proptest::prop_assert_eq!(value, roundtrip);
911    }
912
913    #[test]
914    fn test_skipped_report_skipped_defaults_when_absent() {
915        let event = CoreEventKind::TestSkipped {
916            stress_index: None,
917            test_instance: OwnedTestInstanceId {
918                binary_id: RustBinaryId::new("my-crate::my-bin"),
919                test_name: TestCaseName::new("tests::my_test"),
920            },
921            reason: MismatchReason::Ignored,
922            junit_report_skipped: ReportSkipPolicy::All,
923        };
924
925        let mut value = serde_json::to_value(&event).expect("serialization succeeds");
926        let removed = value
927            .as_object_mut()
928            .expect("serialized event is a JSON object")
929            .remove("junit-report-skipped");
930        assert!(
931            removed.is_some(),
932            "junit-report-skipped field is present before removal"
933        );
934
935        let deserialized: CoreEventKind =
936            serde_json::from_value(value).expect("deserialization without the field succeeds");
937        match deserialized {
938            CoreEventKind::TestSkipped {
939                junit_report_skipped,
940                ..
941            } => {
942                assert_eq!(
943                    junit_report_skipped,
944                    ReportSkipPolicy::None,
945                    "a missing junit-report-skipped field defaults to None"
946                );
947            }
948            other => panic!("expected TestSkipped, got {other:?}"),
949        }
950    }
951
952    #[test]
953    fn test_output_file_name_from_content_stdout() {
954        let content = b"hello world";
955        let file_name = OutputFileName::from_content(content, OutputKind::Stdout);
956
957        let s = file_name.as_str();
958        assert!(s.ends_with("-stdout"), "should end with -stdout: {s}");
959        assert_eq!(s.len(), 16 + 1 + 6, "should be 16 hex + hyphen + 'stdout'");
960
961        let hash_part = &s[..16];
962        assert!(
963            hash_part.chars().all(|c| c.is_ascii_hexdigit()),
964            "hash portion should be hex: {hash_part}"
965        );
966    }
967
968    #[test]
969    fn test_output_file_name_from_content_stderr() {
970        let content = b"error message";
971        let file_name = OutputFileName::from_content(content, OutputKind::Stderr);
972
973        let s = file_name.as_str();
974        assert!(s.ends_with("-stderr"), "should end with -stderr: {s}");
975        assert_eq!(s.len(), 16 + 1 + 6, "should be 16 hex + hyphen + 'stderr'");
976    }
977
978    #[test]
979    fn test_output_file_name_from_content_combined() {
980        let content = b"combined output";
981        let file_name = OutputFileName::from_content(content, OutputKind::Combined);
982
983        let s = file_name.as_str();
984        assert!(s.ends_with("-combined"), "should end with -combined: {s}");
985        assert_eq!(
986            s.len(),
987            16 + 1 + 8,
988            "should be 16 hex + hyphen + 'combined'"
989        );
990    }
991
992    #[test]
993    fn test_output_file_name_deterministic() {
994        let content = b"deterministic content";
995        let name1 = OutputFileName::from_content(content, OutputKind::Stdout);
996        let name2 = OutputFileName::from_content(content, OutputKind::Stdout);
997        assert_eq!(name1.as_str(), name2.as_str());
998    }
999
1000    #[test]
1001    fn test_output_file_name_different_content_different_hash() {
1002        let content1 = b"content one";
1003        let content2 = b"content two";
1004        let name1 = OutputFileName::from_content(content1, OutputKind::Stdout);
1005        let name2 = OutputFileName::from_content(content2, OutputKind::Stdout);
1006        assert_ne!(name1.as_str(), name2.as_str());
1007    }
1008
1009    #[test]
1010    fn test_output_file_name_same_content_different_kind() {
1011        let content = b"same content";
1012        let stdout = OutputFileName::from_content(content, OutputKind::Stdout);
1013        let stderr = OutputFileName::from_content(content, OutputKind::Stderr);
1014        assert_ne!(stdout.as_str(), stderr.as_str());
1015
1016        let stdout_hash = &stdout.as_str()[..16];
1017        let stderr_hash = &stderr.as_str()[..16];
1018        assert_eq!(stdout_hash, stderr_hash);
1019    }
1020
1021    #[test]
1022    fn test_output_file_name_empty_content() {
1023        let file_name = OutputFileName::from_content(b"", OutputKind::Stdout);
1024        let s = file_name.as_str();
1025        assert!(s.ends_with("-stdout"), "should end with -stdout: {s}");
1026        assert!(OutputFileName::validate(s), "should be valid: {s}");
1027    }
1028
1029    #[test]
1030    fn test_output_file_name_validate_valid_content_addressed() {
1031        // Valid content-addressed patterns.
1032        assert!(OutputFileName::validate("0123456789abcdef-stdout"));
1033        assert!(OutputFileName::validate("fedcba9876543210-stderr"));
1034        assert!(OutputFileName::validate("aaaaaaaaaaaaaaaa-combined"));
1035        assert!(OutputFileName::validate("0000000000000000-stdout"));
1036        assert!(OutputFileName::validate("ffffffffffffffff-stderr"));
1037    }
1038
1039    #[test]
1040    fn test_output_file_name_validate_invalid_patterns() {
1041        // Too short hash.
1042        assert!(!OutputFileName::validate("0123456789abcde-stdout"));
1043        assert!(!OutputFileName::validate("abc-stdout"));
1044
1045        // Too long hash.
1046        assert!(!OutputFileName::validate("0123456789abcdef0-stdout"));
1047
1048        // Invalid suffix.
1049        assert!(!OutputFileName::validate("0123456789abcdef-unknown"));
1050        assert!(!OutputFileName::validate("0123456789abcdef-out"));
1051        assert!(!OutputFileName::validate("0123456789abcdef"));
1052
1053        // Non-hex characters in hash.
1054        assert!(!OutputFileName::validate("0123456789abcdeg-stdout"));
1055        assert!(!OutputFileName::validate("0123456789ABCDEF-stdout")); // uppercase not allowed
1056
1057        // Path traversal attempts.
1058        assert!(!OutputFileName::validate("../0123456789abcdef-stdout"));
1059        assert!(!OutputFileName::validate("0123456789abcdef-stdout/"));
1060        assert!(!OutputFileName::validate("foo/0123456789abcdef-stdout"));
1061        assert!(!OutputFileName::validate("..\\0123456789abcdef-stdout"));
1062    }
1063
1064    #[test]
1065    fn test_output_file_name_validate_rejects_old_format() {
1066        // Old identity-based format should be rejected.
1067        assert!(!OutputFileName::validate("test-abc123-1-stdout"));
1068        assert!(!OutputFileName::validate("test-abc123-s5-1-stderr"));
1069        assert!(!OutputFileName::validate("script-def456-stdout"));
1070        assert!(!OutputFileName::validate("script-def456-s3-stderr"));
1071    }
1072
1073    #[test]
1074    fn test_output_file_name_serde_round_trip() {
1075        let content = b"test content for serde";
1076        let original = OutputFileName::from_content(content, OutputKind::Stdout);
1077
1078        let json = serde_json::to_string(&original).expect("serialization failed");
1079        let deserialized: OutputFileName =
1080            serde_json::from_str(&json).expect("deserialization failed");
1081
1082        assert_eq!(original.as_str(), deserialized.as_str());
1083    }
1084
1085    #[test]
1086    fn test_output_file_name_deserialize_invalid() {
1087        // Invalid patterns should fail deserialization.
1088        let json = r#""invalid-file-name""#;
1089        let result: Result<OutputFileName, _> = serde_json::from_str(json);
1090        assert!(
1091            result.is_err(),
1092            "should fail to deserialize invalid pattern"
1093        );
1094
1095        let json = r#""test-abc123-1-stdout""#; // Old format.
1096        let result: Result<OutputFileName, _> = serde_json::from_str(json);
1097        assert!(result.is_err(), "should reject old format");
1098    }
1099
1100    #[test]
1101    fn test_zip_store_output_file_name() {
1102        let content = b"some output";
1103        let file_name = OutputFileName::from_content(content, OutputKind::Stdout);
1104
1105        let empty = ZipStoreOutput::Empty;
1106        assert!(empty.file_name().is_none());
1107
1108        let full = ZipStoreOutput::Full {
1109            file_name: file_name.clone(),
1110        };
1111        assert_eq!(
1112            full.file_name().map(|f| f.as_str()),
1113            Some(file_name.as_str())
1114        );
1115
1116        let truncated = ZipStoreOutput::Truncated {
1117            file_name: file_name.clone(),
1118            original_size: 1000,
1119        };
1120        assert_eq!(
1121            truncated.file_name().map(|f| f.as_str()),
1122            Some(file_name.as_str())
1123        );
1124    }
1125}