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