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