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