nextest_runner/record/
format.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Archive 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    num::NonZero,
20};
21
22// ---
23// runs.json.zst format types
24// ---
25
26/// The current format version for runs.json.zst.
27///
28/// Increment this when adding new semantically important fields. Readers can
29/// read newer versions (assuming append-only evolution with serde defaults),
30/// but writers must refuse to write if the file version is higher than this.
31pub(super) const RUNS_JSON_FORMAT_VERSION: u32 = 1;
32
33/// Whether a runs.json.zst file can be written to.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum RunsJsonWritePermission {
36    /// Writing is allowed.
37    Allowed,
38    /// Writing is not allowed because the file has a newer format version.
39    Denied {
40        /// The format version in the file.
41        file_version: u32,
42        /// The maximum version this nextest can write.
43        max_supported_version: u32,
44    },
45}
46
47/// The list of recorded runs (serialization format for runs.json.zst).
48#[derive(Debug, Deserialize, Serialize)]
49#[serde(rename_all = "kebab-case")]
50pub(super) struct RecordedRunList {
51    /// The format version of this file.
52    pub(super) format_version: u32,
53
54    /// When the store was last pruned.
55    ///
56    /// Used to implement once-daily implicit pruning. Explicit pruning via CLI
57    /// always runs regardless of this value.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub(super) last_pruned_at: Option<DateTime<Utc>>,
60
61    /// The list of runs.
62    #[serde(default)]
63    pub(super) runs: Vec<RecordedRun>,
64}
65
66/// Data extracted from a `RecordedRunList`.
67pub(super) struct RunListData {
68    pub(super) runs: Vec<RecordedRunInfo>,
69    pub(super) last_pruned_at: Option<DateTime<Utc>>,
70}
71
72impl RecordedRunList {
73    /// Creates a new, empty run list with the current format version.
74    #[cfg(test)]
75    fn new() -> Self {
76        Self {
77            format_version: RUNS_JSON_FORMAT_VERSION,
78            last_pruned_at: None,
79            runs: Vec::new(),
80        }
81    }
82
83    /// Converts the serialization format to internal representation.
84    pub(super) fn into_data(self) -> RunListData {
85        RunListData {
86            runs: self.runs.into_iter().map(RecordedRunInfo::from).collect(),
87            last_pruned_at: self.last_pruned_at,
88        }
89    }
90
91    /// Creates a serialization format from internal representation.
92    ///
93    /// Always uses the current format version. If the file had an older version,
94    /// this effectively upgrades it when written back.
95    pub(super) fn from_data(
96        runs: &[RecordedRunInfo],
97        last_pruned_at: Option<DateTime<Utc>>,
98    ) -> Self {
99        Self {
100            format_version: RUNS_JSON_FORMAT_VERSION,
101            last_pruned_at,
102            runs: runs.iter().map(RecordedRun::from).collect(),
103        }
104    }
105
106    /// Returns whether this runs.json.zst can be written to by this nextest version.
107    ///
108    /// If the file has a newer format version than we support, writing is denied
109    /// to avoid data loss.
110    pub(super) fn write_permission(&self) -> RunsJsonWritePermission {
111        if self.format_version > RUNS_JSON_FORMAT_VERSION {
112            RunsJsonWritePermission::Denied {
113                file_version: self.format_version,
114                max_supported_version: RUNS_JSON_FORMAT_VERSION,
115            }
116        } else {
117            RunsJsonWritePermission::Allowed
118        }
119    }
120}
121
122/// Metadata about a recorded run (serialization format for runs.json.zst).
123#[derive(Clone, Debug, Deserialize, Serialize)]
124#[serde(rename_all = "kebab-case")]
125pub(super) struct RecordedRun {
126    /// The unique identifier for this run.
127    pub(super) run_id: ReportUuid,
128    /// The format version of this run's store.zip and run.log.
129    ///
130    /// Runs with a store format version different from `RECORD_FORMAT_VERSION`
131    /// cannot be replayed by this nextest version.
132    pub(super) store_format_version: u32,
133    /// The version of nextest that created this run.
134    pub(super) nextest_version: Version,
135    /// When the run started.
136    pub(super) started_at: DateTime<FixedOffset>,
137    /// When this run was last written to.
138    ///
139    /// Used for LRU eviction. Updated when the run is created, when the run
140    /// completes, and in the future when operations like `rerun` reference
141    /// this run.
142    pub(super) last_written_at: DateTime<FixedOffset>,
143    /// Duration of the run in seconds.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub(super) duration_secs: Option<f64>,
146    /// The command-line arguments used to invoke nextest.
147    #[serde(default)]
148    pub(super) cli_args: Vec<String>,
149    /// Build scope arguments (package and target selection).
150    ///
151    /// These determine which packages and targets are built. In a rerun chain,
152    /// these are inherited from the original run unless explicitly overridden.
153    #[serde(default)]
154    pub(super) build_scope_args: Vec<String>,
155    /// Environment variables that affect nextest behavior (NEXTEST_* and CARGO_*).
156    ///
157    /// This has a default for deserializing old runs.json.zst files that don't have this field.
158    #[serde(default)]
159    pub(super) env_vars: BTreeMap<String, String>,
160    /// The parent run ID.
161    #[serde(default)]
162    pub(super) parent_run_id: Option<ReportUuid>,
163    /// Sizes broken down by component (log and store).
164    ///
165    /// This is all zeros until the run completes successfully.
166    pub(super) sizes: RecordedSizesFormat,
167    /// Status and statistics for the run.
168    pub(super) status: RecordedRunStatusFormat,
169}
170
171/// Sizes broken down by component (serialization format for runs.json.zst).
172#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
173#[serde(rename_all = "kebab-case")]
174pub(super) struct RecordedSizesFormat {
175    /// Sizes for the run log (run.log.zst).
176    pub(super) log: ComponentSizesFormat,
177    /// Sizes for the store archive (store.zip).
178    pub(super) store: ComponentSizesFormat,
179}
180
181/// Compressed and uncompressed sizes for a single component (serialization format).
182#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
183#[serde(rename_all = "kebab-case")]
184pub(super) struct ComponentSizesFormat {
185    /// Compressed size in bytes.
186    pub(super) compressed: u64,
187    /// Uncompressed size in bytes.
188    pub(super) uncompressed: u64,
189    /// Number of entries (records for log, files for store).
190    #[serde(default)]
191    pub(super) entries: u64,
192}
193
194impl From<RecordedSizes> for RecordedSizesFormat {
195    fn from(sizes: RecordedSizes) -> Self {
196        Self {
197            log: ComponentSizesFormat {
198                compressed: sizes.log.compressed,
199                uncompressed: sizes.log.uncompressed,
200                entries: sizes.log.entries,
201            },
202            store: ComponentSizesFormat {
203                compressed: sizes.store.compressed,
204                uncompressed: sizes.store.uncompressed,
205                entries: sizes.store.entries,
206            },
207        }
208    }
209}
210
211impl From<RecordedSizesFormat> for RecordedSizes {
212    fn from(sizes: RecordedSizesFormat) -> Self {
213        Self {
214            log: ComponentSizes {
215                compressed: sizes.log.compressed,
216                uncompressed: sizes.log.uncompressed,
217                entries: sizes.log.entries,
218            },
219            store: ComponentSizes {
220                compressed: sizes.store.compressed,
221                uncompressed: sizes.store.uncompressed,
222                entries: sizes.store.entries,
223            },
224        }
225    }
226}
227
228/// Status of a recorded run (serialization format).
229#[derive(Clone, Debug, Deserialize, Serialize)]
230#[serde(tag = "status", rename_all = "kebab-case")]
231pub(super) enum RecordedRunStatusFormat {
232    /// The run was interrupted before completion.
233    Incomplete,
234    /// A normal test run completed.
235    #[serde(rename_all = "kebab-case")]
236    Completed {
237        /// The number of tests that were expected to run.
238        initial_run_count: usize,
239        /// The number of tests that passed.
240        passed: usize,
241        /// The number of tests that failed.
242        failed: usize,
243        /// The exit code from the run.
244        exit_code: i32,
245    },
246    /// A normal test run was cancelled.
247    #[serde(rename_all = "kebab-case")]
248    Cancelled {
249        /// The number of tests that were expected to run.
250        initial_run_count: usize,
251        /// The number of tests that passed.
252        passed: usize,
253        /// The number of tests that failed.
254        failed: usize,
255        /// The exit code from the run.
256        exit_code: i32,
257    },
258    /// A stress test run completed.
259    #[serde(rename_all = "kebab-case")]
260    StressCompleted {
261        /// The number of stress iterations that were expected to run, if known.
262        initial_iteration_count: Option<NonZero<u32>>,
263        /// The number of stress iterations that succeeded.
264        success_count: u32,
265        /// The number of stress iterations that failed.
266        failed_count: u32,
267        /// The exit code from the run.
268        exit_code: i32,
269    },
270    /// A stress test run was cancelled.
271    #[serde(rename_all = "kebab-case")]
272    StressCancelled {
273        /// The number of stress iterations that were expected to run, if known.
274        initial_iteration_count: Option<NonZero<u32>>,
275        /// The number of stress iterations that succeeded.
276        success_count: u32,
277        /// The number of stress iterations that failed.
278        failed_count: u32,
279        /// The exit code from the run.
280        exit_code: i32,
281    },
282    /// An unknown status from a newer version of nextest.
283    ///
284    /// This variant is used for forward compatibility when reading runs.json.zst
285    /// files created by newer nextest versions that may have new status types.
286    #[serde(other)]
287    Unknown,
288}
289
290impl From<RecordedRun> for RecordedRunInfo {
291    fn from(run: RecordedRun) -> Self {
292        Self {
293            run_id: run.run_id,
294            store_format_version: run.store_format_version,
295            nextest_version: run.nextest_version,
296            started_at: run.started_at,
297            last_written_at: run.last_written_at,
298            duration_secs: run.duration_secs,
299            cli_args: run.cli_args,
300            build_scope_args: run.build_scope_args,
301            env_vars: run.env_vars,
302            parent_run_id: run.parent_run_id,
303            sizes: run.sizes.into(),
304            status: run.status.into(),
305        }
306    }
307}
308
309impl From<&RecordedRunInfo> for RecordedRun {
310    fn from(run: &RecordedRunInfo) -> Self {
311        Self {
312            run_id: run.run_id,
313            store_format_version: run.store_format_version,
314            nextest_version: run.nextest_version.clone(),
315            started_at: run.started_at,
316            last_written_at: run.last_written_at,
317            duration_secs: run.duration_secs,
318            cli_args: run.cli_args.clone(),
319            build_scope_args: run.build_scope_args.clone(),
320            env_vars: run.env_vars.clone(),
321            parent_run_id: run.parent_run_id,
322            sizes: run.sizes.into(),
323            status: (&run.status).into(),
324        }
325    }
326}
327
328impl From<RecordedRunStatusFormat> for RecordedRunStatus {
329    fn from(status: RecordedRunStatusFormat) -> Self {
330        match status {
331            RecordedRunStatusFormat::Incomplete => Self::Incomplete,
332            RecordedRunStatusFormat::Unknown => Self::Unknown,
333            RecordedRunStatusFormat::Completed {
334                initial_run_count,
335                passed,
336                failed,
337                exit_code,
338            } => Self::Completed(CompletedRunStats {
339                initial_run_count,
340                passed,
341                failed,
342                exit_code,
343            }),
344            RecordedRunStatusFormat::Cancelled {
345                initial_run_count,
346                passed,
347                failed,
348                exit_code,
349            } => Self::Cancelled(CompletedRunStats {
350                initial_run_count,
351                passed,
352                failed,
353                exit_code,
354            }),
355            RecordedRunStatusFormat::StressCompleted {
356                initial_iteration_count,
357                success_count,
358                failed_count,
359                exit_code,
360            } => Self::StressCompleted(StressCompletedRunStats {
361                initial_iteration_count,
362                success_count,
363                failed_count,
364                exit_code,
365            }),
366            RecordedRunStatusFormat::StressCancelled {
367                initial_iteration_count,
368                success_count,
369                failed_count,
370                exit_code,
371            } => Self::StressCancelled(StressCompletedRunStats {
372                initial_iteration_count,
373                success_count,
374                failed_count,
375                exit_code,
376            }),
377        }
378    }
379}
380
381impl From<&RecordedRunStatus> for RecordedRunStatusFormat {
382    fn from(status: &RecordedRunStatus) -> Self {
383        match status {
384            RecordedRunStatus::Incomplete => Self::Incomplete,
385            RecordedRunStatus::Unknown => Self::Unknown,
386            RecordedRunStatus::Completed(stats) => Self::Completed {
387                initial_run_count: stats.initial_run_count,
388                passed: stats.passed,
389                failed: stats.failed,
390                exit_code: stats.exit_code,
391            },
392            RecordedRunStatus::Cancelled(stats) => Self::Cancelled {
393                initial_run_count: stats.initial_run_count,
394                passed: stats.passed,
395                failed: stats.failed,
396                exit_code: stats.exit_code,
397            },
398            RecordedRunStatus::StressCompleted(stats) => Self::StressCompleted {
399                initial_iteration_count: stats.initial_iteration_count,
400                success_count: stats.success_count,
401                failed_count: stats.failed_count,
402                exit_code: stats.exit_code,
403            },
404            RecordedRunStatus::StressCancelled(stats) => Self::StressCancelled {
405                initial_iteration_count: stats.initial_iteration_count,
406                success_count: stats.success_count,
407                failed_count: stats.failed_count,
408                exit_code: stats.exit_code,
409            },
410        }
411    }
412}
413
414// ---
415// Rerun types
416// ---
417
418/// Rerun-specific metadata stored in `meta/rerun-info.json`.
419///
420/// This is only present for reruns (runs with a parent run).
421#[derive(Clone, Debug, Deserialize, Serialize)]
422#[serde(rename_all = "kebab-case")]
423pub struct RerunInfo {
424    /// The immediate parent run ID.
425    pub parent_run_id: ReportUuid,
426
427    /// Root information from the original run.
428    pub root_info: RerunRootInfo,
429
430    /// The set of outstanding and passing test cases.
431    pub test_suites: IdOrdMap<RerunTestSuiteInfo>,
432}
433
434/// For a rerun, information obtained from the root of the rerun chain.
435#[derive(Clone, Debug, Deserialize, Serialize)]
436#[serde(rename_all = "kebab-case")]
437pub struct RerunRootInfo {
438    /// The run ID.
439    pub run_id: ReportUuid,
440
441    /// Build scope args from the original run.
442    pub build_scope_args: Vec<String>,
443}
444
445impl RerunRootInfo {
446    /// Creates a new `RerunRootInfo` for a root of a rerun chain.
447    ///
448    /// `build_scope_args` should be the build scope arguments extracted from
449    /// the original run's CLI args. Use `extract_build_scope_args` from
450    /// `cargo-nextest` to extract these.
451    pub fn new(run_id: ReportUuid, build_scope_args: Vec<String>) -> Self {
452        Self {
453            run_id,
454            build_scope_args,
455        }
456    }
457}
458
459/// A test suite's outstanding and passing test cases.
460#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
461pub struct RerunTestSuiteInfo {
462    /// The binary ID.
463    pub binary_id: RustBinaryId,
464
465    /// The set of passing test cases.
466    pub passing: BTreeSet<TestCaseName>,
467
468    /// The set of outstanding test cases.
469    pub outstanding: BTreeSet<TestCaseName>,
470}
471
472impl RerunTestSuiteInfo {
473    pub(super) fn new(binary_id: RustBinaryId) -> Self {
474        Self {
475            binary_id,
476            passing: BTreeSet::new(),
477            outstanding: BTreeSet::new(),
478        }
479    }
480}
481
482impl IdOrdItem for RerunTestSuiteInfo {
483    type Key<'a> = &'a RustBinaryId;
484    fn key(&self) -> Self::Key<'_> {
485        &self.binary_id
486    }
487    id_upcast!();
488}
489
490// ---
491// Archive format types
492// ---
493
494/// The current format version for recorded test runs.
495///
496/// Increment this when making breaking changes to the archive structure or
497/// event format. Readers should check this version and refuse to read archives
498/// with a different version.
499pub const RECORD_FORMAT_VERSION: u32 = 1;
500
501// Archive file names.
502pub(super) static STORE_ZIP_FILE_NAME: &str = "store.zip";
503pub(super) static RUN_LOG_FILE_NAME: &str = "run.log.zst";
504
505// Paths within the zip archive.
506pub(super) static CARGO_METADATA_JSON_PATH: &str = "meta/cargo-metadata.json";
507pub(super) static TEST_LIST_JSON_PATH: &str = "meta/test-list.json";
508pub(super) static RECORD_OPTS_JSON_PATH: &str = "meta/record-opts.json";
509pub(super) static RERUN_INFO_JSON_PATH: &str = "meta/rerun-info.json";
510pub(super) static STDOUT_DICT_PATH: &str = "meta/stdout.dict";
511pub(super) static STDERR_DICT_PATH: &str = "meta/stderr.dict";
512
513/// Which dictionary to use for compressing/decompressing a file.
514#[derive(Clone, Copy, Debug, PartialEq, Eq)]
515pub enum OutputDict {
516    /// Use the stdout dictionary (for stdout and combined output).
517    Stdout,
518    /// Use the stderr dictionary.
519    Stderr,
520    /// Use standard zstd compression (for metadata files).
521    None,
522}
523
524impl OutputDict {
525    /// Determines which dictionary to use based on the file path.
526    ///
527    /// Output files in `out/` use dictionaries based on their suffix:
528    /// - `-stdout` and `-combined` use the stdout dictionary.
529    /// - `-stderr` uses the stderr dictionary.
530    ///
531    /// All other files (metadata in `meta/`) use standard zstd.
532    pub fn for_path(path: &Utf8Path) -> Self {
533        let mut iter = path.iter();
534        let Some(first_component) = iter.next() else {
535            return Self::None;
536        };
537        // Output files are always in the out/ directory.
538        if first_component != "out" {
539            return Self::None;
540        }
541
542        Self::for_output_file_name(iter.as_path().as_str())
543    }
544
545    /// Determines which dictionary to use based on the output file name.
546    ///
547    /// The file name should be the basename without the `out/` prefix,
548    /// e.g., `test-abc123-1-stdout`.
549    pub fn for_output_file_name(file_name: &str) -> Self {
550        if file_name.ends_with("-stdout") || file_name.ends_with("-combined") {
551            Self::Stdout
552        } else if file_name.ends_with("-stderr") {
553            Self::Stderr
554        } else {
555            // Unknown output type, use standard compression.
556            Self::None
557        }
558    }
559
560    /// Returns the dictionary bytes for this output type (for writing new archives).
561    ///
562    /// Returns `None` for `OutputDict::None`.
563    pub fn dict_bytes(self) -> Option<&'static [u8]> {
564        match self {
565            Self::Stdout => Some(super::dicts::STDOUT),
566            Self::Stderr => Some(super::dicts::STDERR),
567            Self::None => None,
568        }
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    #[test]
577    fn test_output_dict_for_path() {
578        // Metadata files should not use dictionaries.
579        assert_eq!(
580            OutputDict::for_path("meta/cargo-metadata.json".as_ref()),
581            OutputDict::None
582        );
583        assert_eq!(
584            OutputDict::for_path("meta/test-list.json".as_ref()),
585            OutputDict::None
586        );
587
588        // Content-addressed output files should use appropriate dictionaries.
589        assert_eq!(
590            OutputDict::for_path("out/0123456789abcdef-stdout".as_ref()),
591            OutputDict::Stdout
592        );
593        assert_eq!(
594            OutputDict::for_path("out/0123456789abcdef-stderr".as_ref()),
595            OutputDict::Stderr
596        );
597        assert_eq!(
598            OutputDict::for_path("out/0123456789abcdef-combined".as_ref()),
599            OutputDict::Stdout
600        );
601    }
602
603    #[test]
604    fn test_output_dict_for_output_file_name() {
605        // Content-addressed file names.
606        assert_eq!(
607            OutputDict::for_output_file_name("0123456789abcdef-stdout"),
608            OutputDict::Stdout
609        );
610        assert_eq!(
611            OutputDict::for_output_file_name("0123456789abcdef-stderr"),
612            OutputDict::Stderr
613        );
614        assert_eq!(
615            OutputDict::for_output_file_name("0123456789abcdef-combined"),
616            OutputDict::Stdout
617        );
618        assert_eq!(
619            OutputDict::for_output_file_name("0123456789abcdef-unknown"),
620            OutputDict::None
621        );
622    }
623
624    #[test]
625    fn test_dict_bytes() {
626        assert!(OutputDict::Stdout.dict_bytes().is_some());
627        assert!(OutputDict::Stderr.dict_bytes().is_some());
628        assert!(OutputDict::None.dict_bytes().is_none());
629    }
630
631    #[test]
632    fn test_runs_json_missing_version() {
633        // runs.json.zst without format-version should fail to deserialize.
634        let json = r#"{"runs": []}"#;
635        let result: Result<RecordedRunList, _> = serde_json::from_str(json);
636        assert!(result.is_err(), "expected error for missing format-version");
637    }
638
639    #[test]
640    fn test_runs_json_current_version() {
641        // runs.json.zst with current version should deserialize and allow writes.
642        let json = format!(
643            r#"{{"format-version": {}, "runs": []}}"#,
644            RUNS_JSON_FORMAT_VERSION
645        );
646        let list: RecordedRunList = serde_json::from_str(&json).expect("should deserialize");
647        assert_eq!(list.write_permission(), RunsJsonWritePermission::Allowed);
648    }
649
650    #[test]
651    fn test_runs_json_older_version() {
652        // runs.json.zst with older version (if any existed) should allow writes.
653        // Since we only have version 1, test version 0 if we supported it.
654        // For now, this test just ensures version 1 allows writes.
655        let json = r#"{"format-version": 1, "runs": []}"#;
656        let list: RecordedRunList = serde_json::from_str(json).expect("should deserialize");
657        assert_eq!(list.write_permission(), RunsJsonWritePermission::Allowed);
658    }
659
660    #[test]
661    fn test_runs_json_newer_version() {
662        // runs.json.zst with newer version should deserialize but deny writes.
663        let json = r#"{"format-version": 99, "runs": []}"#;
664        let list: RecordedRunList = serde_json::from_str(json).expect("should deserialize");
665        assert_eq!(
666            list.write_permission(),
667            RunsJsonWritePermission::Denied {
668                file_version: 99,
669                max_supported_version: RUNS_JSON_FORMAT_VERSION,
670            }
671        );
672    }
673
674    #[test]
675    fn test_runs_json_serialization_includes_version() {
676        // Serialized runs.json.zst should always include format-version.
677        let list = RecordedRunList::from_data(&[], None);
678        let json = serde_json::to_string(&list).expect("should serialize");
679        assert!(
680            json.contains("format-version"),
681            "serialized runs.json.zst should include format-version"
682        );
683
684        // Verify it's the current version.
685        let parsed: serde_json::Value = serde_json::from_str(&json).expect("should parse");
686        assert_eq!(
687            parsed["format-version"], RUNS_JSON_FORMAT_VERSION,
688            "format-version should be current version"
689        );
690    }
691
692    #[test]
693    fn test_runs_json_new() {
694        // RecordedRunList::new() should create with current version.
695        let list = RecordedRunList::new();
696        assert_eq!(list.format_version, RUNS_JSON_FORMAT_VERSION);
697        assert!(list.runs.is_empty());
698        assert_eq!(list.write_permission(), RunsJsonWritePermission::Allowed);
699    }
700
701    // --- RecordedRun serialization snapshot tests ---
702
703    fn make_test_run(status: RecordedRunStatusFormat) -> RecordedRun {
704        RecordedRun {
705            run_id: ReportUuid::from_u128(0x550e8400_e29b_41d4_a716_446655440000),
706            store_format_version: RECORD_FORMAT_VERSION,
707            nextest_version: Version::new(0, 9, 111),
708            started_at: DateTime::parse_from_rfc3339("2024-12-19T14:22:33-08:00")
709                .expect("valid timestamp"),
710            last_written_at: DateTime::parse_from_rfc3339("2024-12-19T22:22:33Z")
711                .expect("valid timestamp"),
712            duration_secs: Some(12.345),
713            cli_args: vec![
714                "cargo".to_owned(),
715                "nextest".to_owned(),
716                "run".to_owned(),
717                "--workspace".to_owned(),
718            ],
719            build_scope_args: vec!["--workspace".to_owned()],
720            env_vars: BTreeMap::from([
721                ("CARGO_TERM_COLOR".to_owned(), "always".to_owned()),
722                ("NEXTEST_PROFILE".to_owned(), "ci".to_owned()),
723            ]),
724            parent_run_id: Some(ReportUuid::from_u128(
725                0x550e7400_e29b_41d4_a716_446655440000,
726            )),
727            sizes: RecordedSizesFormat {
728                log: ComponentSizesFormat {
729                    compressed: 2345,
730                    uncompressed: 5678,
731                    entries: 42,
732                },
733                store: ComponentSizesFormat {
734                    compressed: 10000,
735                    uncompressed: 40000,
736                    entries: 15,
737                },
738            },
739            status,
740        }
741    }
742
743    #[test]
744    fn test_recorded_run_serialize_incomplete() {
745        let run = make_test_run(RecordedRunStatusFormat::Incomplete);
746        let json = serde_json::to_string_pretty(&run).expect("serialization should succeed");
747        insta::assert_snapshot!(json);
748    }
749
750    #[test]
751    fn test_recorded_run_serialize_completed() {
752        let run = make_test_run(RecordedRunStatusFormat::Completed {
753            initial_run_count: 100,
754            passed: 95,
755            failed: 5,
756            exit_code: 0,
757        });
758        let json = serde_json::to_string_pretty(&run).expect("serialization should succeed");
759        insta::assert_snapshot!(json);
760    }
761
762    #[test]
763    fn test_recorded_run_serialize_cancelled() {
764        let run = make_test_run(RecordedRunStatusFormat::Cancelled {
765            initial_run_count: 100,
766            passed: 45,
767            failed: 5,
768            exit_code: 100,
769        });
770        let json = serde_json::to_string_pretty(&run).expect("serialization should succeed");
771        insta::assert_snapshot!(json);
772    }
773
774    #[test]
775    fn test_recorded_run_serialize_stress_completed() {
776        let run = make_test_run(RecordedRunStatusFormat::StressCompleted {
777            initial_iteration_count: NonZero::new(100),
778            success_count: 98,
779            failed_count: 2,
780            exit_code: 0,
781        });
782        let json = serde_json::to_string_pretty(&run).expect("serialization should succeed");
783        insta::assert_snapshot!(json);
784    }
785
786    #[test]
787    fn test_recorded_run_serialize_stress_cancelled() {
788        let run = make_test_run(RecordedRunStatusFormat::StressCancelled {
789            initial_iteration_count: NonZero::new(100),
790            success_count: 45,
791            failed_count: 5,
792            exit_code: 100,
793        });
794        let json = serde_json::to_string_pretty(&run).expect("serialization should succeed");
795        insta::assert_snapshot!(json);
796    }
797
798    #[test]
799    fn test_recorded_run_deserialize_unknown_status() {
800        // Simulate a run from a future nextest version with an unknown status.
801        // The store-format-version is set to 999 to indicate a future version.
802        let json = r#"{
803            "run-id": "550e8400-e29b-41d4-a716-446655440000",
804            "store-format-version": 999,
805            "nextest-version": "0.9.999",
806            "started-at": "2024-12-19T14:22:33-08:00",
807            "last-written-at": "2024-12-19T22:22:33Z",
808            "cli-args": ["cargo", "nextest", "run"],
809            "env-vars": {},
810            "sizes": {
811                "log": { "compressed": 2345, "uncompressed": 5678 },
812                "store": { "compressed": 10000, "uncompressed": 40000 }
813            },
814            "status": {
815                "status": "super-new-status",
816                "some-future-field": 42
817            }
818        }"#;
819        let run: RecordedRun = serde_json::from_str(json).expect("should deserialize");
820        assert!(
821            matches!(run.status, RecordedRunStatusFormat::Unknown),
822            "unknown status should deserialize to Unknown variant"
823        );
824
825        // Verify domain conversion preserves Unknown.
826        let info: RecordedRunInfo = run.into();
827        assert!(
828            matches!(info.status, RecordedRunStatus::Unknown),
829            "Unknown format should convert to Unknown domain type"
830        );
831    }
832
833    #[test]
834    fn test_recorded_run_roundtrip() {
835        let original = make_test_run(RecordedRunStatusFormat::Completed {
836            initial_run_count: 100,
837            passed: 95,
838            failed: 5,
839            exit_code: 0,
840        });
841        let json = serde_json::to_string(&original).expect("serialization should succeed");
842        let roundtripped: RecordedRun =
843            serde_json::from_str(&json).expect("deserialization should succeed");
844
845        assert_eq!(roundtripped.run_id, original.run_id);
846        assert_eq!(roundtripped.nextest_version, original.nextest_version);
847        assert_eq!(roundtripped.started_at, original.started_at);
848        assert_eq!(roundtripped.sizes, original.sizes);
849
850        // Verify status fields via domain conversion.
851        let info: RecordedRunInfo = roundtripped.into();
852        match info.status {
853            RecordedRunStatus::Completed(stats) => {
854                assert_eq!(stats.initial_run_count, 100);
855                assert_eq!(stats.passed, 95);
856                assert_eq!(stats.failed, 5);
857            }
858            _ => panic!("expected Completed variant"),
859        }
860    }
861}