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