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