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