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