Skip to main content

nextest_runner/record/
format.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Recording format metadata shared between recorder and reader.
5
6use super::{
7    CompletedRunStats, ComponentSizes, RecordedRunInfo, RecordedRunStatus, RecordedSizes,
8    StressCompletedRunStats,
9};
10use crate::build_scope::BuildScope;
11use camino::Utf8Path;
12use chrono::{DateTime, FixedOffset, Utc};
13use eazip::{CompressionMethod, write::FileOptions};
14use iddqd::{IdOrdItem, IdOrdMap, id_upcast};
15use nextest_metadata::{RustBinaryId, TestCaseName};
16use quick_junit::ReportUuid;
17use semver::Version;
18use serde::{Deserialize, Serialize};
19use std::{
20    collections::{BTreeMap, BTreeSet},
21    fmt,
22    num::NonZero,
23};
24
25// ---
26// Format version newtypes
27// ---
28
29/// Defines a newtype wrapper around `u32` for format versions.
30///
31/// Use `@default` variant to also derive `Default` (defaults to 0).
32macro_rules! define_format_version {
33    (
34        $(#[$attr:meta])*
35        $vis:vis struct $name:ident;
36    ) => {
37        $(#[$attr])*
38        #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
39        #[serde(transparent)]
40        $vis struct $name(u32);
41
42        impl $name {
43            #[doc = concat!("Creates a new `", stringify!($name), "`.")]
44            pub const fn new(version: u32) -> Self {
45                Self(version)
46            }
47        }
48
49        impl fmt::Display for $name {
50            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51                write!(f, "{}", self.0)
52            }
53        }
54    };
55
56    (
57        @default
58        $(#[$attr:meta])*
59        $vis:vis struct $name:ident;
60    ) => {
61        $(#[$attr])*
62        #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
63        #[serde(transparent)]
64        $vis struct $name(u32);
65
66        impl $name {
67            #[doc = concat!("Creates a new `", stringify!($name), "`.")]
68            pub const fn new(version: u32) -> Self {
69                Self(version)
70            }
71        }
72
73        impl fmt::Display for $name {
74            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75                write!(f, "{}", self.0)
76            }
77        }
78    };
79}
80
81define_format_version! {
82    /// Version of the `runs.json.zst` outer format.
83    ///
84    /// Increment this when adding new semantically important fields to `runs.json.zst`.
85    /// Readers can read newer versions (assuming append-only evolution with serde
86    /// defaults), but writers must refuse to write if the file version is higher
87    /// than this.
88    pub struct RunsJsonFormatVersion;
89}
90
91define_format_version! {
92    /// Major version of the `store.zip` archive format for breaking changes to the
93    /// archive structure.
94    pub struct StoreFormatMajorVersion;
95}
96
97define_format_version! {
98    @default
99    /// Minor version of the `store.zip` archive format for additive changes.
100    pub struct StoreFormatMinorVersion;
101}
102
103/// Combined major and minor version of the `store.zip` archive format.
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105pub struct StoreFormatVersion {
106    /// The major version (breaking changes).
107    pub major: StoreFormatMajorVersion,
108    /// The minor version (additive changes).
109    pub minor: StoreFormatMinorVersion,
110}
111
112impl StoreFormatVersion {
113    /// Creates a new `StoreFormatVersion`.
114    pub const fn new(major: StoreFormatMajorVersion, minor: StoreFormatMinorVersion) -> Self {
115        Self { major, minor }
116    }
117
118    /// Checks if an archive with version `self` can be read by a reader that
119    /// supports `supported`.
120    pub fn check_readable_by(self, supported: Self) -> Result<(), StoreVersionIncompatibility> {
121        if self.major < supported.major {
122            return Err(StoreVersionIncompatibility::RecordingTooOld {
123                recording_major: self.major,
124                supported_major: supported.major,
125                guidance: self.major.too_old_guidance(),
126            });
127        }
128        if self.major > supported.major {
129            return Err(StoreVersionIncompatibility::RecordingTooNew {
130                recording_major: self.major,
131                supported_major: supported.major,
132            });
133        }
134        if self.minor > supported.minor {
135            return Err(StoreVersionIncompatibility::MinorTooNew {
136                recording_minor: self.minor,
137                supported_minor: supported.minor,
138            });
139        }
140        Ok(())
141    }
142}
143
144impl fmt::Display for StoreFormatVersion {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        write!(f, "{}.{}", self.major, self.minor)
147    }
148}
149
150impl StoreFormatMajorVersion {
151    /// Returns the guidance to display to users when a recording at this major
152    /// version is too old for this nextest.
153    pub fn too_old_guidance(self) -> TooOldRecordingGuidance {
154        match self.0 {
155            // Store formats before 3.0 get Discard due to a vulnerability.
156            1 => TooOldRecordingGuidance::Discard {
157                last_nextest_version: "0.9.130",
158            },
159            2 => TooOldRecordingGuidance::Discard {
160                last_nextest_version: "0.9.149",
161            },
162            _ => TooOldRecordingGuidance::Unknown,
163        }
164    }
165}
166
167/// The guidance for a recording whose store format major version is older than
168/// this nextest supports.
169#[derive(Clone, Copy, Debug, PartialEq, Eq)]
170pub enum TooOldRecordingGuidance {
171    /// The recording is readable by an older nextest, and switching to the
172    /// older version is believed to be safe.
173    ///
174    /// This is currently unused.
175    UseOlderNextest {
176        /// The last version of nextest that supported this recording.
177        last_nextest_version: &'static str,
178    },
179    /// The recording should be discarded rather than read with an older nextest.
180    Discard {
181        /// The last version of nextest that supported this recording.
182        last_nextest_version: &'static str,
183    },
184    /// No guidance is known for this major version.
185    Unknown,
186}
187
188/// An incompatibility between a recording's store format version and what the
189/// reader supports.
190#[derive(Clone, Debug, PartialEq, Eq)]
191pub enum StoreVersionIncompatibility {
192    /// The recording's major version is older than the supported major version.
193    RecordingTooOld {
194        /// The major version in the recording.
195        recording_major: StoreFormatMajorVersion,
196        /// The major version this nextest supports.
197        supported_major: StoreFormatMajorVersion,
198        /// The guidance to give users about the recording.
199        guidance: TooOldRecordingGuidance,
200    },
201    /// The recording's major version is newer than the supported major version.
202    RecordingTooNew {
203        /// The major version in the recording.
204        recording_major: StoreFormatMajorVersion,
205        /// The major version this nextest supports.
206        supported_major: StoreFormatMajorVersion,
207    },
208    /// The recording's minor version is newer than the supported minor version.
209    MinorTooNew {
210        /// The minor version in the recording.
211        recording_minor: StoreFormatMinorVersion,
212        /// The maximum minor version this nextest supports.
213        supported_minor: StoreFormatMinorVersion,
214    },
215}
216
217impl fmt::Display for StoreVersionIncompatibility {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        match self {
220            Self::RecordingTooOld {
221                recording_major,
222                supported_major,
223                guidance,
224            } => {
225                write!(
226                    f,
227                    "recording has major version {recording_major}, \
228                     but this nextest requires version {supported_major}"
229                )?;
230                match guidance {
231                    TooOldRecordingGuidance::UseOlderNextest {
232                        last_nextest_version,
233                    } => write!(
234                        f,
235                        "\n(use nextest <= {last_nextest_version} to replay this recording)"
236                    ),
237                    TooOldRecordingGuidance::Discard {
238                        last_nextest_version,
239                    } => write!(
240                        f,
241                        "\n(recordings from nextest {last_nextest_version} and earlier \
242                         are no longer supported; discard this recording and record a new run)"
243                    ),
244                    TooOldRecordingGuidance::Unknown => Ok(()),
245                }
246            }
247            Self::RecordingTooNew {
248                recording_major,
249                supported_major,
250            } => {
251                write!(
252                    f,
253                    "recording has major version {recording_major}, \
254                     but this nextest only supports version {supported_major} \
255                     (upgrade nextest to replay this recording)"
256                )
257            }
258            Self::MinorTooNew {
259                recording_minor,
260                supported_minor,
261            } => {
262                write!(
263                    f,
264                    "minor version {} is newer than supported version {}",
265                    recording_minor, supported_minor
266                )
267            }
268        }
269    }
270}
271
272// ---
273// runs.json.zst format types
274// ---
275
276/// The current format version for runs.json.zst.
277///
278/// Changelog:
279///
280/// - 3: `build-scope-args` removed (the build scope now lives in each run's
281///   `store.zip`). Bumped alongside store format 3.0 so older nextest versions
282///   refuse to write to `runs.json.zst`.
283pub(super) const RUNS_JSON_FORMAT_VERSION: RunsJsonFormatVersion = RunsJsonFormatVersion::new(3);
284
285/// The current format version for recorded test runs (store.zip and run.log).
286///
287/// This combines a major version (for breaking changes) and a minor version
288/// (for additive changes). Readers check compatibility via
289/// [`StoreFormatVersion::check_readable_by`].
290///
291/// Changelog:
292///
293/// - 1.1: Addition of the `flaky_result` field to `ExecutionStatuses`.
294///
295/// - 2.0: `slot_assignment` is now mandatory in `TestStarted` and
296///   `TestRetryStarted` events.
297///
298/// - 2.1: `junit_report_skipped` field added to `TestSkipped` events.
299///
300/// - 3.0: The build scope is stored as a typed struct in
301///   `meta/build-scope.json` and `rerun-info.json`'s `build-scope`,
302///   replacing `runs.json.zst`'s `build-scope-args`. Adding a `BuildScope`
303///   field or a `BuildKind` variant requires bumping the minor version here.
304pub const STORE_FORMAT_VERSION: StoreFormatVersion = StoreFormatVersion::new(
305    StoreFormatMajorVersion::new(3),
306    StoreFormatMinorVersion::new(0),
307);
308
309/// Testing-only environment variable to force a specific store format version
310/// in the per-run metadata written to `runs.json.zst`.
311///
312/// Integration tests use this to synthesize runs that exercise version mismatch
313/// error paths. The override only affects the version reported in the metadata.
314/// The `store.zip` and `run.log.zst` payloads are still written in the real
315/// current format.
316///
317/// Format: `MAJOR.MINOR` (e.g. `9999.0`). Unset in normal use.
318pub(super) const FORCE_STORE_FORMAT_VERSION_ENV: &str = "__NEXTEST_FORCE_STORE_FORMAT_VERSION";
319
320/// Returns the store format version to record for a new run.
321pub(super) fn store_format_version_for_new_run() -> StoreFormatVersion {
322    let Some(raw) = std::env::var_os(FORCE_STORE_FORMAT_VERSION_ENV) else {
323        return STORE_FORMAT_VERSION;
324    };
325    let raw = raw.to_str().unwrap_or_else(|| {
326        panic!("{FORCE_STORE_FORMAT_VERSION_ENV} contains non-UTF-8 bytes");
327    });
328    let (major, minor) = raw.split_once('.').unwrap_or_else(|| {
329        panic!(
330            "{FORCE_STORE_FORMAT_VERSION_ENV}={raw:?} is malformed \
331             (expected MAJOR.MINOR, e.g. 9999.0)"
332        )
333    });
334    let major: u32 = major.parse().unwrap_or_else(|err| {
335        panic!("{FORCE_STORE_FORMAT_VERSION_ENV}={raw:?} has invalid major version: {err}")
336    });
337    let minor: u32 = minor.parse().unwrap_or_else(|err| {
338        panic!("{FORCE_STORE_FORMAT_VERSION_ENV}={raw:?} has invalid minor version: {err}")
339    });
340    StoreFormatVersion::new(
341        StoreFormatMajorVersion::new(major),
342        StoreFormatMinorVersion::new(minor),
343    )
344}
345
346/// Whether a runs.json.zst file can be written to.
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum RunsJsonWritePermission {
349    /// Writing is allowed.
350    Allowed,
351    /// Writing is not allowed because the file has a newer format version.
352    Denied {
353        /// The format version in the file.
354        file_version: RunsJsonFormatVersion,
355        /// The maximum version this nextest can write.
356        max_supported_version: RunsJsonFormatVersion,
357    },
358}
359
360/// The list of recorded runs (serialization format for runs.json.zst).
361#[derive(Debug, Deserialize, Serialize)]
362#[serde(rename_all = "kebab-case")]
363pub(super) struct RecordedRunList {
364    /// The format version of this file.
365    pub(super) format_version: RunsJsonFormatVersion,
366
367    /// When the store was last pruned.
368    ///
369    /// Used to implement once-daily implicit pruning. Explicit pruning via CLI
370    /// always runs regardless of this value.
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub(super) last_pruned_at: Option<DateTime<Utc>>,
373
374    /// The list of runs.
375    #[serde(default)]
376    pub(super) runs: Vec<RecordedRun>,
377}
378
379/// Data extracted from a `RecordedRunList`.
380pub(super) struct RunListData {
381    pub(super) runs: Vec<RecordedRunInfo>,
382    pub(super) last_pruned_at: Option<DateTime<Utc>>,
383}
384
385impl RecordedRunList {
386    /// Creates a new, empty run list with the current format version.
387    #[cfg(test)]
388    fn new() -> Self {
389        Self {
390            format_version: RUNS_JSON_FORMAT_VERSION,
391            last_pruned_at: None,
392            runs: Vec::new(),
393        }
394    }
395
396    /// Converts the serialization format to internal representation.
397    pub(super) fn into_data(self) -> RunListData {
398        RunListData {
399            runs: self.runs.into_iter().map(RecordedRunInfo::from).collect(),
400            last_pruned_at: self.last_pruned_at,
401        }
402    }
403
404    /// Creates a serialization format from internal representation.
405    ///
406    /// Always uses the current format version. If the file had an older version,
407    /// this effectively upgrades it when written back.
408    pub(super) fn from_data(
409        runs: &[RecordedRunInfo],
410        last_pruned_at: Option<DateTime<Utc>>,
411    ) -> Self {
412        Self {
413            format_version: RUNS_JSON_FORMAT_VERSION,
414            last_pruned_at,
415            runs: runs.iter().map(RecordedRun::from).collect(),
416        }
417    }
418
419    /// Returns whether this runs.json.zst can be written to by this nextest version.
420    ///
421    /// If the file has a newer format version than we support, writing is denied
422    /// to avoid data loss.
423    pub(super) fn write_permission(&self) -> RunsJsonWritePermission {
424        if self.format_version > RUNS_JSON_FORMAT_VERSION {
425            RunsJsonWritePermission::Denied {
426                file_version: self.format_version,
427                max_supported_version: RUNS_JSON_FORMAT_VERSION,
428            }
429        } else {
430            RunsJsonWritePermission::Allowed
431        }
432    }
433}
434
435/// Metadata about a recorded run (serialization format for runs.json.zst and portable recordings).
436#[derive(Clone, Debug, Deserialize, Serialize)]
437#[serde(rename_all = "kebab-case")]
438pub(super) struct RecordedRun {
439    /// The unique identifier for this run.
440    pub(super) run_id: ReportUuid,
441    /// The major format version of this run's store.zip and run.log.
442    ///
443    /// Runs with a different major version cannot be replayed by this nextest
444    /// version.
445    pub(super) store_format_version: StoreFormatMajorVersion,
446    /// The minor format version of this run's store.zip and run.log.
447    ///
448    /// Runs with a newer minor version (same major) cannot be replayed by this
449    /// nextest version. Older minor versions are compatible.
450    #[serde(default)]
451    pub(super) store_format_minor_version: StoreFormatMinorVersion,
452    /// The version of nextest that created this run.
453    pub(super) nextest_version: Version,
454    /// When the run started.
455    pub(super) started_at: DateTime<FixedOffset>,
456    /// When this run was last written to.
457    ///
458    /// Used for LRU eviction. Updated when the run is created, when the run
459    /// completes, and in the future when operations like `rerun` reference
460    /// this run.
461    pub(super) last_written_at: DateTime<FixedOffset>,
462    /// Duration of the run in seconds.
463    #[serde(default, skip_serializing_if = "Option::is_none")]
464    pub(super) duration_secs: Option<f64>,
465    /// The command-line arguments used to invoke nextest.
466    #[serde(default)]
467    pub(super) cli_args: Vec<String>,
468    /// Environment variables that affect nextest behavior (NEXTEST_* and CARGO_*).
469    ///
470    /// This has a default for deserializing old runs.json.zst files that don't have this field.
471    #[serde(default)]
472    pub(super) env_vars: BTreeMap<String, String>,
473    /// The parent run ID.
474    #[serde(default)]
475    pub(super) parent_run_id: Option<ReportUuid>,
476    /// Sizes broken down by component (log and store).
477    ///
478    /// This is all zeros until the run completes successfully.
479    pub(super) sizes: RecordedSizesFormat,
480    /// Status and statistics for the run.
481    pub(super) status: RecordedRunStatusFormat,
482}
483
484/// Sizes broken down by component (serialization format for runs.json.zst).
485#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
486#[serde(rename_all = "kebab-case")]
487pub(super) struct RecordedSizesFormat {
488    /// Sizes for the run log (run.log.zst).
489    pub(super) log: ComponentSizesFormat,
490    /// Sizes for the store archive (store.zip).
491    pub(super) store: ComponentSizesFormat,
492}
493
494/// Compressed and uncompressed sizes for a single component (serialization format).
495#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
496#[serde(rename_all = "kebab-case")]
497pub(super) struct ComponentSizesFormat {
498    /// Compressed size in bytes.
499    pub(super) compressed: u64,
500    /// Uncompressed size in bytes.
501    pub(super) uncompressed: u64,
502    /// Number of entries (records for log, files for store).
503    #[serde(default)]
504    pub(super) entries: u64,
505}
506
507impl From<RecordedSizes> for RecordedSizesFormat {
508    fn from(sizes: RecordedSizes) -> Self {
509        Self {
510            log: ComponentSizesFormat {
511                compressed: sizes.log.compressed,
512                uncompressed: sizes.log.uncompressed,
513                entries: sizes.log.entries,
514            },
515            store: ComponentSizesFormat {
516                compressed: sizes.store.compressed,
517                uncompressed: sizes.store.uncompressed,
518                entries: sizes.store.entries,
519            },
520        }
521    }
522}
523
524impl From<RecordedSizesFormat> for RecordedSizes {
525    fn from(sizes: RecordedSizesFormat) -> Self {
526        Self {
527            log: ComponentSizes {
528                compressed: sizes.log.compressed,
529                uncompressed: sizes.log.uncompressed,
530                entries: sizes.log.entries,
531            },
532            store: ComponentSizes {
533                compressed: sizes.store.compressed,
534                uncompressed: sizes.store.uncompressed,
535                entries: sizes.store.entries,
536            },
537        }
538    }
539}
540
541/// Status of a recorded run (serialization format).
542#[derive(Clone, Debug, Deserialize, Serialize)]
543#[serde(tag = "status", rename_all = "kebab-case")]
544pub(super) enum RecordedRunStatusFormat {
545    /// The run was interrupted before completion.
546    Incomplete,
547    /// A normal test run completed.
548    #[serde(rename_all = "kebab-case")]
549    Completed {
550        /// The number of tests that were expected to run.
551        initial_run_count: usize,
552        /// The number of tests that passed.
553        passed: usize,
554        /// The number of tests that failed.
555        failed: usize,
556        /// The exit code from the run.
557        exit_code: i32,
558    },
559    /// A normal test run was cancelled.
560    #[serde(rename_all = "kebab-case")]
561    Cancelled {
562        /// The number of tests that were expected to run.
563        initial_run_count: usize,
564        /// The number of tests that passed.
565        passed: usize,
566        /// The number of tests that failed.
567        failed: usize,
568        /// The exit code from the run.
569        exit_code: i32,
570    },
571    /// A stress test run completed.
572    #[serde(rename_all = "kebab-case")]
573    StressCompleted {
574        /// The number of stress iterations that were expected to run, if known.
575        initial_iteration_count: Option<NonZero<u32>>,
576        /// The number of stress iterations that succeeded.
577        success_count: u32,
578        /// The number of stress iterations that failed.
579        failed_count: u32,
580        /// The exit code from the run.
581        exit_code: i32,
582    },
583    /// A stress test run was cancelled.
584    #[serde(rename_all = "kebab-case")]
585    StressCancelled {
586        /// The number of stress iterations that were expected to run, if known.
587        initial_iteration_count: Option<NonZero<u32>>,
588        /// The number of stress iterations that succeeded.
589        success_count: u32,
590        /// The number of stress iterations that failed.
591        failed_count: u32,
592        /// The exit code from the run.
593        exit_code: i32,
594    },
595    /// An unknown status from a newer version of nextest.
596    ///
597    /// This variant is used for forward compatibility when reading runs.json.zst
598    /// files created by newer nextest versions that may have new status types.
599    #[serde(other)]
600    Unknown,
601}
602
603impl From<RecordedRun> for RecordedRunInfo {
604    fn from(run: RecordedRun) -> Self {
605        Self {
606            run_id: run.run_id,
607            store_format_version: StoreFormatVersion::new(
608                run.store_format_version,
609                run.store_format_minor_version,
610            ),
611            nextest_version: run.nextest_version,
612            started_at: run.started_at,
613            last_written_at: run.last_written_at,
614            duration_secs: run.duration_secs,
615            cli_args: run.cli_args,
616            env_vars: run.env_vars,
617            parent_run_id: run.parent_run_id,
618            sizes: run.sizes.into(),
619            status: run.status.into(),
620        }
621    }
622}
623
624impl From<&RecordedRunInfo> for RecordedRun {
625    fn from(run: &RecordedRunInfo) -> Self {
626        Self {
627            run_id: run.run_id,
628            store_format_version: run.store_format_version.major,
629            store_format_minor_version: run.store_format_version.minor,
630            nextest_version: run.nextest_version.clone(),
631            started_at: run.started_at,
632            last_written_at: run.last_written_at,
633            duration_secs: run.duration_secs,
634            cli_args: run.cli_args.clone(),
635            env_vars: run.env_vars.clone(),
636            parent_run_id: run.parent_run_id,
637            sizes: run.sizes.into(),
638            status: (&run.status).into(),
639        }
640    }
641}
642
643impl From<RecordedRunStatusFormat> for RecordedRunStatus {
644    fn from(status: RecordedRunStatusFormat) -> Self {
645        match status {
646            RecordedRunStatusFormat::Incomplete => Self::Incomplete,
647            RecordedRunStatusFormat::Unknown => Self::Unknown,
648            RecordedRunStatusFormat::Completed {
649                initial_run_count,
650                passed,
651                failed,
652                exit_code,
653            } => Self::Completed(CompletedRunStats {
654                initial_run_count,
655                passed,
656                failed,
657                exit_code,
658            }),
659            RecordedRunStatusFormat::Cancelled {
660                initial_run_count,
661                passed,
662                failed,
663                exit_code,
664            } => Self::Cancelled(CompletedRunStats {
665                initial_run_count,
666                passed,
667                failed,
668                exit_code,
669            }),
670            RecordedRunStatusFormat::StressCompleted {
671                initial_iteration_count,
672                success_count,
673                failed_count,
674                exit_code,
675            } => Self::StressCompleted(StressCompletedRunStats {
676                initial_iteration_count,
677                success_count,
678                failed_count,
679                exit_code,
680            }),
681            RecordedRunStatusFormat::StressCancelled {
682                initial_iteration_count,
683                success_count,
684                failed_count,
685                exit_code,
686            } => Self::StressCancelled(StressCompletedRunStats {
687                initial_iteration_count,
688                success_count,
689                failed_count,
690                exit_code,
691            }),
692        }
693    }
694}
695
696impl From<&RecordedRunStatus> for RecordedRunStatusFormat {
697    fn from(status: &RecordedRunStatus) -> Self {
698        match status {
699            RecordedRunStatus::Incomplete => Self::Incomplete,
700            RecordedRunStatus::Unknown => Self::Unknown,
701            RecordedRunStatus::Completed(stats) => Self::Completed {
702                initial_run_count: stats.initial_run_count,
703                passed: stats.passed,
704                failed: stats.failed,
705                exit_code: stats.exit_code,
706            },
707            RecordedRunStatus::Cancelled(stats) => Self::Cancelled {
708                initial_run_count: stats.initial_run_count,
709                passed: stats.passed,
710                failed: stats.failed,
711                exit_code: stats.exit_code,
712            },
713            RecordedRunStatus::StressCompleted(stats) => Self::StressCompleted {
714                initial_iteration_count: stats.initial_iteration_count,
715                success_count: stats.success_count,
716                failed_count: stats.failed_count,
717                exit_code: stats.exit_code,
718            },
719            RecordedRunStatus::StressCancelled(stats) => Self::StressCancelled {
720                initial_iteration_count: stats.initial_iteration_count,
721                success_count: stats.success_count,
722                failed_count: stats.failed_count,
723                exit_code: stats.exit_code,
724            },
725        }
726    }
727}
728
729/// The contents of `meta/build-scope.json`, and of `rerun-info.json`'s root
730/// `build-scope`.
731#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
732#[serde(rename_all = "kebab-case")]
733pub struct RecordedBuildScope {
734    /// How the run got its test binaries.
735    pub build_kind: BuildKind,
736    /// The build scope for this run or rerun root.
737    ///
738    /// Currently, if [`Self::build_kind`] is [`BuildKind::Reused`], this field
739    /// is meaningless. In the future, we'll be able to populate this field.
740    pub scope: BuildScope,
741}
742
743impl RecordedBuildScope {
744    /// Creates a new [`RecordedBuildScope`] with the given `build_kind` and
745    /// `scope`.
746    pub fn new(build_kind: BuildKind, scope: BuildScope) -> Self {
747        Self { build_kind, scope }
748    }
749}
750
751/// The mechanism through which a run's test binaries were obtained.
752///
753/// This is currently stored for informational purposes only, and is
754/// not used.
755#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
756#[serde(rename_all = "kebab-case")]
757pub enum BuildKind {
758    /// Nextest used Cargo to build the binaries, with `cargo test --no-run`, or
759    /// `cargo bench --no-run` for benchmarks.
760    Cargo,
761    /// The binaries came from `--archive-file` or `--binaries-metadata`.
762    Reused,
763}
764
765// ---
766// Rerun types
767// ---
768
769/// Rerun-specific metadata stored in `meta/rerun-info.json`.
770///
771/// This is only present for reruns (runs with a parent run).
772#[derive(Clone, Debug, Deserialize, Serialize)]
773#[serde(rename_all = "kebab-case")]
774pub struct RerunInfo {
775    /// The immediate parent run ID.
776    pub parent_run_id: ReportUuid,
777
778    /// Root information from the original run.
779    pub root_info: RerunRootInfo,
780
781    /// The set of outstanding and passing test cases.
782    pub test_suites: IdOrdMap<RerunTestSuiteInfo>,
783}
784
785/// For a rerun, information obtained from the root of the rerun chain.
786#[derive(Clone, Debug, Deserialize, Serialize)]
787#[serde(rename_all = "kebab-case")]
788pub struct RerunRootInfo {
789    /// The run ID.
790    pub run_id: ReportUuid,
791
792    /// The build scope of the rerun chain's root run.
793    pub build_scope: RecordedBuildScope,
794}
795
796impl RerunRootInfo {
797    /// Creates a new `RerunRootInfo` for a root of a rerun chain.
798    ///
799    /// `build_scope` is the rerun chain root's recorded build scope, read from
800    /// its `meta/build-scope.json`.
801    pub fn new(run_id: ReportUuid, build_scope: RecordedBuildScope) -> Self {
802        Self {
803            run_id,
804            build_scope,
805        }
806    }
807}
808
809/// A test suite's outstanding and passing test cases.
810#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
811pub struct RerunTestSuiteInfo {
812    /// The binary ID.
813    pub binary_id: RustBinaryId,
814
815    /// The set of passing test cases.
816    pub passing: BTreeSet<TestCaseName>,
817
818    /// The set of outstanding test cases.
819    pub outstanding: BTreeSet<TestCaseName>,
820}
821
822impl RerunTestSuiteInfo {
823    pub(super) fn new(binary_id: RustBinaryId) -> Self {
824        Self {
825            binary_id,
826            passing: BTreeSet::new(),
827            outstanding: BTreeSet::new(),
828        }
829    }
830}
831
832impl IdOrdItem for RerunTestSuiteInfo {
833    type Key<'a> = &'a RustBinaryId;
834    fn key(&self) -> Self::Key<'_> {
835        &self.binary_id
836    }
837    id_upcast!();
838}
839
840// ---
841// Recording format types
842// ---
843
844/// File name for the store archive.
845pub static STORE_ZIP_FILE_NAME: &str = "store.zip";
846
847/// File name for the run log.
848pub static RUN_LOG_FILE_NAME: &str = "run.log.zst";
849
850/// Returns true if the path has a `.zip` extension (case-insensitive).
851pub fn has_zip_extension(path: &Utf8Path) -> bool {
852    path.extension()
853        .is_some_and(|ext| ext.eq_ignore_ascii_case("zip"))
854}
855
856// Paths within the zip archive.
857/// Path to cargo metadata within the store archive.
858pub static CARGO_METADATA_JSON_PATH: &str = "meta/cargo-metadata.json";
859/// Path to the test list within the store archive.
860pub static TEST_LIST_JSON_PATH: &str = "meta/test-list.json";
861/// Path to record options within the store archive.
862pub static RECORD_OPTS_JSON_PATH: &str = "meta/record-opts.json";
863/// Path to the run's build scope.
864///
865/// Kept out of `record-opts.json` so that an unreadable scope only blocks
866/// reruns, not replays.
867pub static BUILD_SCOPE_JSON_PATH: &str = "meta/build-scope.json";
868/// Path to rerun info within the store archive (only present for reruns).
869pub static RERUN_INFO_JSON_PATH: &str = "meta/rerun-info.json";
870/// Path to the stdout dictionary within the store archive.
871pub static STDOUT_DICT_PATH: &str = "meta/stdout.dict";
872/// Path to the stderr dictionary within the store archive.
873pub static STDERR_DICT_PATH: &str = "meta/stderr.dict";
874
875// ---
876// Portable recording format types
877// ---
878
879define_format_version! {
880    /// Major version of the portable recording format for breaking changes.
881    pub struct PortableRecordingFormatMajorVersion;
882}
883
884define_format_version! {
885    @default
886    /// Minor version of the portable recording format for additive changes.
887    pub struct PortableRecordingFormatMinorVersion;
888}
889
890/// Combined major and minor version of the portable recording format.
891#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
892pub struct PortableRecordingFormatVersion {
893    /// The major version (breaking changes).
894    pub major: PortableRecordingFormatMajorVersion,
895    /// The minor version (additive changes).
896    pub minor: PortableRecordingFormatMinorVersion,
897}
898
899impl PortableRecordingFormatVersion {
900    /// Creates a new `PortableRecordingFormatVersion`.
901    pub const fn new(
902        major: PortableRecordingFormatMajorVersion,
903        minor: PortableRecordingFormatMinorVersion,
904    ) -> Self {
905        Self { major, minor }
906    }
907
908    /// Checks if an archive with version `self` can be read by a reader that
909    /// supports `supported`.
910    pub fn check_readable_by(
911        self,
912        supported: Self,
913    ) -> Result<(), PortableRecordingVersionIncompatibility> {
914        if self.major != supported.major {
915            return Err(PortableRecordingVersionIncompatibility::MajorMismatch {
916                recording_major: self.major,
917                supported_major: supported.major,
918            });
919        }
920        if self.minor > supported.minor {
921            return Err(PortableRecordingVersionIncompatibility::MinorTooNew {
922                recording_minor: self.minor,
923                supported_minor: supported.minor,
924            });
925        }
926        Ok(())
927    }
928}
929
930impl fmt::Display for PortableRecordingFormatVersion {
931    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
932        write!(f, "{}.{}", self.major, self.minor)
933    }
934}
935
936/// An incompatibility between an archive's portable format version and what the
937/// reader supports.
938#[derive(Clone, Copy, Debug, PartialEq, Eq)]
939pub enum PortableRecordingVersionIncompatibility {
940    /// The archive's major version differs from the supported major version.
941    MajorMismatch {
942        /// The major version in the archive.
943        recording_major: PortableRecordingFormatMajorVersion,
944        /// The major version this nextest supports.
945        supported_major: PortableRecordingFormatMajorVersion,
946    },
947    /// The archive's minor version is newer than the supported minor version.
948    MinorTooNew {
949        /// The minor version in the archive.
950        recording_minor: PortableRecordingFormatMinorVersion,
951        /// The maximum minor version this nextest supports.
952        supported_minor: PortableRecordingFormatMinorVersion,
953    },
954}
955
956impl fmt::Display for PortableRecordingVersionIncompatibility {
957    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
958        match self {
959            Self::MajorMismatch {
960                recording_major,
961                supported_major,
962            } => {
963                write!(
964                    f,
965                    "major version {} differs from supported version {}",
966                    recording_major, supported_major
967                )
968            }
969            Self::MinorTooNew {
970                recording_minor,
971                supported_minor,
972            } => {
973                write!(
974                    f,
975                    "minor version {} is newer than supported version {}",
976                    recording_minor, supported_minor
977                )
978            }
979        }
980    }
981}
982
983/// The current format version for portable recordings.
984///
985/// Changelog:
986///
987/// - 1.1: the manifest's run metadata no longer has `build-scope-args`
988///   (the build scope is in the inner store's `meta/build-scope.json`).
989pub const PORTABLE_RECORDING_FORMAT_VERSION: PortableRecordingFormatVersion =
990    PortableRecordingFormatVersion::new(
991        PortableRecordingFormatMajorVersion::new(1),
992        PortableRecordingFormatMinorVersion::new(1),
993    );
994
995/// File name for the manifest within a portable recording.
996pub static PORTABLE_MANIFEST_FILE_NAME: &str = "manifest.json";
997
998/// The manifest for a portable recording.
999///
1000/// A portable recording packages a single recorded run into a self-contained
1001/// zip file for sharing and import.
1002#[derive(Debug, Deserialize, Serialize)]
1003#[serde(rename_all = "kebab-case")]
1004pub(crate) struct PortableManifest {
1005    /// The format version of this portable recording.
1006    pub(crate) format_version: PortableRecordingFormatVersion,
1007    /// The run metadata.
1008    pub(super) run: RecordedRun,
1009}
1010
1011impl PortableManifest {
1012    /// Creates a new manifest for the given run.
1013    pub(crate) fn new(run: &RecordedRunInfo) -> Self {
1014        Self {
1015            format_version: PORTABLE_RECORDING_FORMAT_VERSION,
1016            run: RecordedRun::from(run),
1017        }
1018    }
1019
1020    /// Returns the run info extracted from this manifest.
1021    pub(crate) fn run_info(&self) -> RecordedRunInfo {
1022        RecordedRunInfo::from(self.run.clone())
1023    }
1024
1025    /// Returns the store format version from the run metadata.
1026    pub(crate) fn store_format_version(&self) -> StoreFormatVersion {
1027        StoreFormatVersion::new(
1028            self.run.store_format_version,
1029            self.run.store_format_minor_version,
1030        )
1031    }
1032}
1033
1034/// Which dictionary to use for compressing/decompressing a file.
1035#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1036pub enum OutputDict {
1037    /// Use the stdout dictionary (for stdout and combined output).
1038    Stdout,
1039    /// Use the stderr dictionary.
1040    Stderr,
1041    /// Use standard zstd compression (for metadata files).
1042    None,
1043}
1044
1045impl OutputDict {
1046    /// Determines which dictionary to use based on the file path.
1047    ///
1048    /// Output files in `out/` use dictionaries based on their suffix:
1049    /// - `-stdout` and `-combined` use the stdout dictionary.
1050    /// - `-stderr` uses the stderr dictionary.
1051    ///
1052    /// All other files (metadata in `meta/`) use standard zstd.
1053    pub fn for_path(path: &Utf8Path) -> Self {
1054        let mut iter = path.iter();
1055        let Some(first_component) = iter.next() else {
1056            return Self::None;
1057        };
1058        // Output files are always in the out/ directory.
1059        if first_component != "out" {
1060            return Self::None;
1061        }
1062
1063        Self::for_output_file_name(iter.as_path().as_str())
1064    }
1065
1066    /// Determines which dictionary to use based on the output file name.
1067    ///
1068    /// The file name should be the basename without the `out/` prefix,
1069    /// e.g., `test-abc123-1-stdout`.
1070    pub fn for_output_file_name(file_name: &str) -> Self {
1071        if file_name.ends_with("-stdout") || file_name.ends_with("-combined") {
1072            Self::Stdout
1073        } else if file_name.ends_with("-stderr") {
1074            Self::Stderr
1075        } else {
1076            // Unknown output type, use standard compression.
1077            Self::None
1078        }
1079    }
1080
1081    /// Returns the dictionary bytes for this output type (for writing new archives).
1082    ///
1083    /// Returns `None` for `OutputDict::None`.
1084    pub fn dict_bytes(self) -> Option<&'static [u8]> {
1085        match self {
1086            Self::Stdout => Some(super::dicts::STDOUT),
1087            Self::Stderr => Some(super::dicts::STDERR),
1088            Self::None => None,
1089        }
1090    }
1091}
1092
1093// ---
1094// Zip file options helpers
1095// ---
1096
1097/// Returns file options for storing pre-compressed data (no additional
1098/// compression).
1099pub(super) fn stored_file_options() -> FileOptions {
1100    let mut options = FileOptions::default();
1101    options.compression_method = CompressionMethod::STORE;
1102    options
1103}
1104
1105/// Returns file options for zstd-compressed data.
1106pub(super) fn zstd_file_options() -> FileOptions {
1107    let mut options = FileOptions::default();
1108    options.compression_method = CompressionMethod::ZSTD;
1109    options.level = Some(3);
1110    options
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115    use super::*;
1116    use crate::build_scope::{
1117        FeatureSelection, NamedTargets, PackageSelection, PackageSpec, TargetSelection,
1118    };
1119
1120    #[test]
1121    fn test_output_dict_for_path() {
1122        // Metadata files should not use dictionaries.
1123        assert_eq!(
1124            OutputDict::for_path("meta/cargo-metadata.json".as_ref()),
1125            OutputDict::None
1126        );
1127        assert_eq!(
1128            OutputDict::for_path("meta/test-list.json".as_ref()),
1129            OutputDict::None
1130        );
1131
1132        // Content-addressed output files should use appropriate dictionaries.
1133        assert_eq!(
1134            OutputDict::for_path("out/0123456789abcdef-stdout".as_ref()),
1135            OutputDict::Stdout
1136        );
1137        assert_eq!(
1138            OutputDict::for_path("out/0123456789abcdef-stderr".as_ref()),
1139            OutputDict::Stderr
1140        );
1141        assert_eq!(
1142            OutputDict::for_path("out/0123456789abcdef-combined".as_ref()),
1143            OutputDict::Stdout
1144        );
1145    }
1146
1147    #[test]
1148    fn test_output_dict_for_output_file_name() {
1149        // Content-addressed file names.
1150        assert_eq!(
1151            OutputDict::for_output_file_name("0123456789abcdef-stdout"),
1152            OutputDict::Stdout
1153        );
1154        assert_eq!(
1155            OutputDict::for_output_file_name("0123456789abcdef-stderr"),
1156            OutputDict::Stderr
1157        );
1158        assert_eq!(
1159            OutputDict::for_output_file_name("0123456789abcdef-combined"),
1160            OutputDict::Stdout
1161        );
1162        assert_eq!(
1163            OutputDict::for_output_file_name("0123456789abcdef-unknown"),
1164            OutputDict::None
1165        );
1166    }
1167
1168    #[test]
1169    fn test_dict_bytes() {
1170        assert!(OutputDict::Stdout.dict_bytes().is_some());
1171        assert!(OutputDict::Stderr.dict_bytes().is_some());
1172        assert!(OutputDict::None.dict_bytes().is_none());
1173    }
1174
1175    #[test]
1176    fn test_runs_json_missing_version() {
1177        // runs.json.zst without format-version should fail to deserialize.
1178        let json = r#"{"runs": []}"#;
1179        let result: Result<RecordedRunList, _> = serde_json::from_str(json);
1180        assert!(result.is_err(), "expected error for missing format-version");
1181    }
1182
1183    #[test]
1184    fn test_runs_json_current_version() {
1185        // runs.json.zst with current version should deserialize and allow writes.
1186        let json = format!(
1187            r#"{{"format-version": {}, "runs": []}}"#,
1188            RUNS_JSON_FORMAT_VERSION
1189        );
1190        let list: RecordedRunList = serde_json::from_str(&json).expect("should deserialize");
1191        assert_eq!(list.write_permission(), RunsJsonWritePermission::Allowed);
1192    }
1193
1194    #[test]
1195    fn test_runs_json_older_version() {
1196        // runs.json.zst with older version (if any existed) should allow writes.
1197        // Since we only have version 1, test version 0 if we supported it.
1198        // For now, this test just ensures version 1 allows writes.
1199        let json = r#"{"format-version": 1, "runs": []}"#;
1200        let list: RecordedRunList = serde_json::from_str(json).expect("should deserialize");
1201        assert_eq!(list.write_permission(), RunsJsonWritePermission::Allowed);
1202    }
1203
1204    #[test]
1205    fn test_runs_json_newer_version() {
1206        // runs.json.zst with newer version should deserialize but deny writes.
1207        let json = r#"{"format-version": 99, "runs": []}"#;
1208        let list: RecordedRunList = serde_json::from_str(json).expect("should deserialize");
1209        assert_eq!(
1210            list.write_permission(),
1211            RunsJsonWritePermission::Denied {
1212                file_version: RunsJsonFormatVersion::new(99),
1213                max_supported_version: RUNS_JSON_FORMAT_VERSION,
1214            }
1215        );
1216    }
1217
1218    #[test]
1219    fn test_runs_json_serialization_includes_version() {
1220        // Serialized runs.json.zst should always include format-version.
1221        let list = RecordedRunList::from_data(&[], None);
1222        let json = serde_json::to_string(&list).expect("should serialize");
1223        assert!(
1224            json.contains("format-version"),
1225            "serialized runs.json.zst should include format-version"
1226        );
1227
1228        // Verify it's the current version.
1229        let parsed: serde_json::Value = serde_json::from_str(&json).expect("should parse");
1230        let version: RunsJsonFormatVersion =
1231            serde_json::from_value(parsed["format-version"].clone()).expect("valid version");
1232        assert_eq!(
1233            version, RUNS_JSON_FORMAT_VERSION,
1234            "format-version should be current version"
1235        );
1236    }
1237
1238    #[test]
1239    fn test_runs_json_new() {
1240        // RecordedRunList::new() should create with current version.
1241        let list = RecordedRunList::new();
1242        assert_eq!(list.format_version, RUNS_JSON_FORMAT_VERSION);
1243        assert!(list.runs.is_empty());
1244        assert_eq!(list.write_permission(), RunsJsonWritePermission::Allowed);
1245    }
1246
1247    // --- RecordedRun serialization snapshot tests ---
1248
1249    fn make_test_build_scope() -> BuildScope {
1250        BuildScope::new(
1251            PackageSelection {
1252                specs: Vec::new(),
1253                workspace: true,
1254                exclude: vec![PackageSpec::new("nextest-*").expect("spec is valid")],
1255            },
1256            TargetSelection {
1257                lib: false,
1258                bins: NamedTargets::empty(),
1259                examples: NamedTargets::empty(),
1260                tests: NamedTargets {
1261                    names: vec!["integration".to_owned()],
1262                    all: false,
1263                },
1264                benches: NamedTargets::empty(),
1265                all_targets: false,
1266            },
1267            FeatureSelection {
1268                features: vec!["feat-a".to_owned()],
1269                all_features: false,
1270                no_default_features: false,
1271            },
1272        )
1273    }
1274
1275    fn make_test_run(status: RecordedRunStatusFormat) -> RecordedRun {
1276        RecordedRun {
1277            run_id: ReportUuid::from_u128(0x550e8400_e29b_41d4_a716_446655440000),
1278            store_format_version: STORE_FORMAT_VERSION.major,
1279            store_format_minor_version: STORE_FORMAT_VERSION.minor,
1280            nextest_version: Version::new(0, 9, 111),
1281            started_at: DateTime::parse_from_rfc3339("2024-12-19T14:22:33-08:00")
1282                .expect("valid timestamp"),
1283            last_written_at: DateTime::parse_from_rfc3339("2024-12-19T22:22:33Z")
1284                .expect("valid timestamp"),
1285            duration_secs: Some(12.345),
1286            cli_args: vec![
1287                "cargo".to_owned(),
1288                "nextest".to_owned(),
1289                "run".to_owned(),
1290                "--workspace".to_owned(),
1291            ],
1292            env_vars: BTreeMap::from([
1293                ("CARGO_TERM_COLOR".to_owned(), "always".to_owned()),
1294                ("NEXTEST_PROFILE".to_owned(), "ci".to_owned()),
1295            ]),
1296            parent_run_id: Some(ReportUuid::from_u128(
1297                0x550e7400_e29b_41d4_a716_446655440000,
1298            )),
1299            sizes: RecordedSizesFormat {
1300                log: ComponentSizesFormat {
1301                    compressed: 2345,
1302                    uncompressed: 5678,
1303                    entries: 42,
1304                },
1305                store: ComponentSizesFormat {
1306                    compressed: 10000,
1307                    uncompressed: 40000,
1308                    entries: 15,
1309                },
1310            },
1311            status,
1312        }
1313    }
1314
1315    #[test]
1316    fn test_recorded_run_serialize_incomplete() {
1317        let run = make_test_run(RecordedRunStatusFormat::Incomplete);
1318        let json = serde_json::to_string_pretty(&run).expect("serialization should succeed");
1319        insta::assert_snapshot!(json);
1320    }
1321
1322    #[test]
1323    fn test_recorded_run_serialize_completed() {
1324        let run = make_test_run(RecordedRunStatusFormat::Completed {
1325            initial_run_count: 100,
1326            passed: 95,
1327            failed: 5,
1328            exit_code: 0,
1329        });
1330        let json = serde_json::to_string_pretty(&run).expect("serialization should succeed");
1331        insta::assert_snapshot!(json);
1332    }
1333
1334    #[test]
1335    fn test_recorded_run_serialize_cancelled() {
1336        let run = make_test_run(RecordedRunStatusFormat::Cancelled {
1337            initial_run_count: 100,
1338            passed: 45,
1339            failed: 5,
1340            exit_code: 100,
1341        });
1342        let json = serde_json::to_string_pretty(&run).expect("serialization should succeed");
1343        insta::assert_snapshot!(json);
1344    }
1345
1346    #[test]
1347    fn test_recorded_run_serialize_stress_completed() {
1348        let run = make_test_run(RecordedRunStatusFormat::StressCompleted {
1349            initial_iteration_count: NonZero::new(100),
1350            success_count: 98,
1351            failed_count: 2,
1352            exit_code: 0,
1353        });
1354        let json = serde_json::to_string_pretty(&run).expect("serialization should succeed");
1355        insta::assert_snapshot!(json);
1356    }
1357
1358    #[test]
1359    fn test_recorded_run_serialize_stress_cancelled() {
1360        let run = make_test_run(RecordedRunStatusFormat::StressCancelled {
1361            initial_iteration_count: NonZero::new(100),
1362            success_count: 45,
1363            failed_count: 5,
1364            exit_code: 100,
1365        });
1366        let json = serde_json::to_string_pretty(&run).expect("serialization should succeed");
1367        insta::assert_snapshot!(json);
1368    }
1369
1370    #[test]
1371    fn test_recorded_run_deserialize_unknown_status() {
1372        // Simulate a run from a future nextest version with an unknown status.
1373        // The store-format-version is set to 999 to indicate a future version.
1374        let json = r#"{
1375            "run-id": "550e8400-e29b-41d4-a716-446655440000",
1376            "store-format-version": 999,
1377            "nextest-version": "0.9.999",
1378            "started-at": "2024-12-19T14:22:33-08:00",
1379            "last-written-at": "2024-12-19T22:22:33Z",
1380            "cli-args": ["cargo", "nextest", "run"],
1381            "env-vars": {},
1382            "sizes": {
1383                "log": { "compressed": 2345, "uncompressed": 5678 },
1384                "store": { "compressed": 10000, "uncompressed": 40000 }
1385            },
1386            "status": {
1387                "status": "super-new-status",
1388                "some-future-field": 42
1389            }
1390        }"#;
1391        let run: RecordedRun = serde_json::from_str(json).expect("should deserialize");
1392        assert!(
1393            matches!(run.status, RecordedRunStatusFormat::Unknown),
1394            "unknown status should deserialize to Unknown variant"
1395        );
1396
1397        // Verify domain conversion preserves Unknown.
1398        let info: RecordedRunInfo = run.into();
1399        assert!(
1400            matches!(info.status, RecordedRunStatus::Unknown),
1401            "Unknown format should convert to Unknown domain type"
1402        );
1403    }
1404
1405    #[test]
1406    fn test_recorded_run_roundtrip() {
1407        let original = make_test_run(RecordedRunStatusFormat::Completed {
1408            initial_run_count: 100,
1409            passed: 95,
1410            failed: 5,
1411            exit_code: 0,
1412        });
1413        let json = serde_json::to_string(&original).expect("serialization should succeed");
1414        let roundtripped: RecordedRun =
1415            serde_json::from_str(&json).expect("deserialization should succeed");
1416
1417        assert_eq!(roundtripped.run_id, original.run_id);
1418        assert_eq!(roundtripped.nextest_version, original.nextest_version);
1419        assert_eq!(roundtripped.started_at, original.started_at);
1420        assert_eq!(roundtripped.sizes, original.sizes);
1421
1422        // Verify status fields via domain conversion.
1423        let info: RecordedRunInfo = roundtripped.into();
1424        match info.status {
1425            RecordedRunStatus::Completed(stats) => {
1426                assert_eq!(stats.initial_run_count, 100);
1427                assert_eq!(stats.passed, 95);
1428                assert_eq!(stats.failed, 5);
1429            }
1430            _ => panic!("expected Completed variant"),
1431        }
1432    }
1433
1434    // --- Store format version tests ---
1435
1436    /// Helper to create a StoreFormatVersion.
1437    fn version(major: u32, minor: u32) -> StoreFormatVersion {
1438        StoreFormatVersion::new(
1439            StoreFormatMajorVersion::new(major),
1440            StoreFormatMinorVersion::new(minor),
1441        )
1442    }
1443
1444    #[test]
1445    fn test_store_version_compatibility() {
1446        assert!(
1447            version(1, 0).check_readable_by(version(1, 0)).is_ok(),
1448            "same version should be compatible"
1449        );
1450
1451        assert!(
1452            version(1, 0).check_readable_by(version(1, 2)).is_ok(),
1453            "older minor version should be compatible"
1454        );
1455
1456        let error = version(1, 3).check_readable_by(version(1, 2)).unwrap_err();
1457        assert_eq!(
1458            error,
1459            StoreVersionIncompatibility::MinorTooNew {
1460                recording_minor: StoreFormatMinorVersion::new(3),
1461                supported_minor: StoreFormatMinorVersion::new(2),
1462            },
1463            "newer minor version should be incompatible"
1464        );
1465        insta::assert_snapshot!(error.to_string(), @"minor version 3 is newer than supported version 2");
1466
1467        // Archive newer than supported → RecordingTooNew.
1468        let error = version(2, 0).check_readable_by(version(1, 5)).unwrap_err();
1469        assert_eq!(
1470            error,
1471            StoreVersionIncompatibility::RecordingTooNew {
1472                recording_major: StoreFormatMajorVersion::new(2),
1473                supported_major: StoreFormatMajorVersion::new(1),
1474            },
1475        );
1476        insta::assert_snapshot!(
1477            error.to_string(),
1478            @"recording has major version 2, but this nextest only supports version 1 (upgrade nextest to replay this recording)"
1479        );
1480
1481        // Archive older than supported → ArchiveTooOld (with known version).
1482        let error = version(1, 0).check_readable_by(version(2, 0)).unwrap_err();
1483        assert_eq!(
1484            error,
1485            StoreVersionIncompatibility::RecordingTooOld {
1486                recording_major: StoreFormatMajorVersion::new(1),
1487                supported_major: StoreFormatMajorVersion::new(2),
1488                guidance: TooOldRecordingGuidance::Discard {
1489                    last_nextest_version: "0.9.130",
1490                },
1491            },
1492        );
1493        insta::assert_snapshot!(
1494            error.to_string(),
1495            @"
1496        recording has major version 1, but this nextest requires version 2
1497        (recordings from nextest 0.9.130 and earlier are no longer supported; discard this recording and record a new run)
1498        "
1499        );
1500
1501        // Archive older than supported → ArchiveTooOld (unknown version).
1502        let error = version(3, 0).check_readable_by(version(5, 0)).unwrap_err();
1503        assert_eq!(
1504            error,
1505            StoreVersionIncompatibility::RecordingTooOld {
1506                recording_major: StoreFormatMajorVersion::new(3),
1507                supported_major: StoreFormatMajorVersion::new(5),
1508                guidance: TooOldRecordingGuidance::Unknown,
1509            },
1510        );
1511        insta::assert_snapshot!(
1512            error.to_string(),
1513            @"recording has major version 3, but this nextest requires version 5"
1514        );
1515
1516        // No major version maps to UseOlderNextest yet, so construct it
1517        // directly for now.
1518        let error = StoreVersionIncompatibility::RecordingTooOld {
1519            recording_major: StoreFormatMajorVersion::new(3),
1520            supported_major: StoreFormatMajorVersion::new(4),
1521            guidance: TooOldRecordingGuidance::UseOlderNextest {
1522                last_nextest_version: "0.9.200",
1523            },
1524        };
1525        insta::assert_snapshot!(error.to_string(), @"
1526        recording has major version 3, but this nextest requires version 4
1527        (use nextest <= 0.9.200 to replay this recording)
1528        ");
1529
1530        insta::assert_snapshot!(version(1, 2).to_string(), @"1.2");
1531    }
1532
1533    // Every major older than the current one must have guidance.
1534    #[test]
1535    fn test_too_old_guidance_known_for_all_older_majors() {
1536        for major in 1..STORE_FORMAT_VERSION.major.0 {
1537            let major = StoreFormatMajorVersion::new(major);
1538            match major.too_old_guidance() {
1539                TooOldRecordingGuidance::UseOlderNextest { .. }
1540                | TooOldRecordingGuidance::Discard { .. } => {}
1541                TooOldRecordingGuidance::Unknown => {
1542                    panic!(
1543                        "store format major version {major} has no too_old_guidance entry \
1544                         (add one when bumping STORE_FORMAT_VERSION's major version)"
1545                    )
1546                }
1547            }
1548        }
1549    }
1550
1551    #[test]
1552    fn test_runs_json_with_store_format_2_entry() {
1553        // A store format 2.x entry has `build-scope-args`, which this nextest
1554        // ignores. We must still be able to load the entry.
1555        let json = r#"{
1556            "format-version": 2,
1557            "runs": [{
1558                "run-id": "550e8400-e29b-41d4-a716-446655440000",
1559                "store-format-version": 2,
1560                "store-format-minor-version": 1,
1561                "nextest-version": "0.9.149",
1562                "started-at": "2024-12-19T14:22:33-08:00",
1563                "last-written-at": "2024-12-19T22:22:33Z",
1564                "cli-args": ["cargo", "nextest", "run", "-p", "foo", "--features", "a b"],
1565                "build-scope-args": ["--package=foo", "--features=a b"],
1566                "env-vars": {},
1567                "sizes": {
1568                    "log": { "compressed": 0, "uncompressed": 0 },
1569                    "store": { "compressed": 0, "uncompressed": 0 }
1570                },
1571                "status": { "status": "incomplete" }
1572            }]
1573        }"#;
1574        let list: RecordedRunList = serde_json::from_str(json).expect("2.x entry deserializes");
1575        let data = list.into_data();
1576        assert_eq!(data.runs.len(), 1);
1577        assert_eq!(data.runs[0].store_format_version, version(2, 1));
1578
1579        // When the recorded run list is written out, the `build-scope-args`
1580        // field is dropped. (This is by design.)
1581        let rewritten = RecordedRunList::from_data(&data.runs, data.last_pruned_at);
1582        let rewritten = serde_json::to_value(&rewritten).expect("rewritten run list serializes");
1583        let rewritten_run = &rewritten["runs"][0];
1584        assert_eq!(rewritten_run.get("build-scope-args"), None);
1585    }
1586
1587    #[test]
1588    fn test_rerun_root_info_serialize() {
1589        let root_info = RerunRootInfo::new(
1590            ReportUuid::from_u128(0x550e8400_e29b_41d4_a716_446655440000),
1591            RecordedBuildScope::new(BuildKind::Cargo, make_test_build_scope()),
1592        );
1593        let json = serde_json::to_string_pretty(&root_info).expect("serialization should succeed");
1594        insta::assert_snapshot!(json);
1595
1596        let roundtripped: RerunRootInfo =
1597            serde_json::from_str(&json).expect("deserialization should succeed");
1598        assert_eq!(roundtripped.run_id, root_info.run_id);
1599        assert_eq!(roundtripped.build_scope, root_info.build_scope);
1600    }
1601
1602    #[test]
1603    fn test_rerun_root_info_deserialize_errors() {
1604        let cases = [
1605            // Store format 2.x entries have `build-scope-args` only.
1606            (
1607                "store_format_2",
1608                r#"{"run-id": "550e8400-e29b-41d4-a716-446655440000", "build-scope-args": ["--workspace"]}"#,
1609            ),
1610            // Missing `build-kind` and `scope` fields.
1611            (
1612                "missing_build_kind",
1613                r#"{"run-id": "550e8400-e29b-41d4-a716-446655440000", "build-scope": {"scope": {}}}"#,
1614            ),
1615            // Missing `scope` field.
1616            (
1617                "missing_scope",
1618                r#"{"run-id": "550e8400-e29b-41d4-a716-446655440000", "build-scope": {"build-kind": "cargo"}}"#,
1619            ),
1620            // Unknown `build-kind`.
1621            (
1622                "unknown_build_kind",
1623                r#"{"run-id": "550e8400-e29b-41d4-a716-446655440000", "build-scope": {"build-kind": "bazel", "scope": {}}}"#,
1624            ),
1625        ];
1626        let errors: Vec<_> = cases
1627            .iter()
1628            .map(|(label, json)| {
1629                let error = serde_json::from_str::<RerunRootInfo>(json)
1630                    .expect_err("rerun root info should fail to parse");
1631                format!("{label}: {error}")
1632            })
1633            .collect();
1634        insta::assert_snapshot!(errors.join("\n"), @"
1635        store_format_2: missing field `build-scope` at line 1 column 87
1636        missing_build_kind: missing field `build-kind` at line 1 column 79
1637        missing_scope: missing field `scope` at line 1 column 89
1638        unknown_build_kind: unknown variant `bazel`, expected `cargo` or `reused` at line 1 column 88
1639        ");
1640    }
1641
1642    #[test]
1643    fn test_recorded_run_deserialize_without_minor_version() {
1644        // Old archives without store-format-minor-version should default to 0.
1645        let json = r#"{
1646            "run-id": "550e8400-e29b-41d4-a716-446655440000",
1647            "store-format-version": 1,
1648            "nextest-version": "0.9.111",
1649            "started-at": "2024-12-19T14:22:33-08:00",
1650            "last-written-at": "2024-12-19T22:22:33Z",
1651            "cli-args": [],
1652            "env-vars": {},
1653            "sizes": {
1654                "log": { "compressed": 0, "uncompressed": 0 },
1655                "store": { "compressed": 0, "uncompressed": 0 }
1656            },
1657            "status": { "status": "incomplete" }
1658        }"#;
1659        let run: RecordedRun = serde_json::from_str(json).expect("should deserialize");
1660        assert_eq!(run.store_format_version, StoreFormatMajorVersion::new(1));
1661        assert_eq!(
1662            run.store_format_minor_version,
1663            StoreFormatMinorVersion::new(0)
1664        );
1665
1666        // Domain conversion should produce a StoreFormatVersion with minor 0.
1667        let info: RecordedRunInfo = run.into();
1668        assert_eq!(info.store_format_version, version(1, 0));
1669    }
1670
1671    #[test]
1672    fn test_recorded_run_serialize_includes_minor_version() {
1673        // New archives should include store-format-minor-version in serialization.
1674        let run = make_test_run(RecordedRunStatusFormat::Incomplete);
1675        let json = serde_json::to_string_pretty(&run).expect("serialization should succeed");
1676        assert!(
1677            json.contains("store-format-minor-version"),
1678            "serialized run should include store-format-minor-version"
1679        );
1680    }
1681
1682    // --- Portable archive format version tests ---
1683
1684    /// Helper to create a PortableRecordingFormatVersion.
1685    fn portable_version(major: u32, minor: u32) -> PortableRecordingFormatVersion {
1686        PortableRecordingFormatVersion::new(
1687            PortableRecordingFormatMajorVersion::new(major),
1688            PortableRecordingFormatMinorVersion::new(minor),
1689        )
1690    }
1691
1692    #[test]
1693    fn test_portable_version_compatibility() {
1694        assert!(
1695            portable_version(1, 0)
1696                .check_readable_by(portable_version(1, 0))
1697                .is_ok(),
1698            "same version should be compatible"
1699        );
1700
1701        assert!(
1702            portable_version(1, 0)
1703                .check_readable_by(portable_version(1, 2))
1704                .is_ok(),
1705            "older minor version should be compatible"
1706        );
1707
1708        let error = portable_version(1, 3)
1709            .check_readable_by(portable_version(1, 2))
1710            .unwrap_err();
1711        assert_eq!(
1712            error,
1713            PortableRecordingVersionIncompatibility::MinorTooNew {
1714                recording_minor: PortableRecordingFormatMinorVersion::new(3),
1715                supported_minor: PortableRecordingFormatMinorVersion::new(2),
1716            },
1717            "newer minor version should be incompatible"
1718        );
1719        insta::assert_snapshot!(error.to_string(), @"minor version 3 is newer than supported version 2");
1720
1721        let error = portable_version(2, 0)
1722            .check_readable_by(portable_version(1, 5))
1723            .unwrap_err();
1724        assert_eq!(
1725            error,
1726            PortableRecordingVersionIncompatibility::MajorMismatch {
1727                recording_major: PortableRecordingFormatMajorVersion::new(2),
1728                supported_major: PortableRecordingFormatMajorVersion::new(1),
1729            },
1730            "different major version should be incompatible"
1731        );
1732        insta::assert_snapshot!(error.to_string(), @"major version 2 differs from supported version 1");
1733
1734        insta::assert_snapshot!(portable_version(1, 2).to_string(), @"1.2");
1735    }
1736
1737    #[test]
1738    fn test_portable_version_serialization() {
1739        // Test that PortableRecordingFormatVersion serializes to {major: ..., minor: ...}.
1740        let version = portable_version(1, 0);
1741        let json = serde_json::to_string(&version).expect("serialization should succeed");
1742        insta::assert_snapshot!(json, @r#"{"major":1,"minor":0}"#);
1743
1744        // Test roundtrip.
1745        let roundtripped: PortableRecordingFormatVersion =
1746            serde_json::from_str(&json).expect("deserialization should succeed");
1747        assert_eq!(roundtripped, version);
1748    }
1749
1750    #[test]
1751    fn test_portable_manifest_format_version() {
1752        // Verify the current PORTABLE_RECORDING_FORMAT_VERSION constant.
1753        assert_eq!(
1754            PORTABLE_RECORDING_FORMAT_VERSION,
1755            portable_version(1, 1),
1756            "current portable recording format version should be 1.1"
1757        );
1758    }
1759}