1use 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
22pub(super) const RUNS_JSON_FORMAT_VERSION: u32 = 1;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum RunsJsonWritePermission {
36 Allowed,
38 Denied {
40 file_version: u32,
42 max_supported_version: u32,
44 },
45}
46
47#[derive(Debug, Deserialize, Serialize)]
49#[serde(rename_all = "kebab-case")]
50pub(super) struct RecordedRunList {
51 pub(super) format_version: u32,
53
54 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub(super) last_pruned_at: Option<DateTime<Utc>>,
60
61 #[serde(default)]
63 pub(super) runs: Vec<RecordedRun>,
64}
65
66pub(super) struct RunListData {
68 pub(super) runs: Vec<RecordedRunInfo>,
69 pub(super) last_pruned_at: Option<DateTime<Utc>>,
70}
71
72impl RecordedRunList {
73 #[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 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 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 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#[derive(Clone, Debug, Deserialize, Serialize)]
124#[serde(rename_all = "kebab-case")]
125pub(super) struct RecordedRun {
126 pub(super) run_id: ReportUuid,
128 pub(super) store_format_version: u32,
133 pub(super) nextest_version: Version,
135 pub(super) started_at: DateTime<FixedOffset>,
137 pub(super) last_written_at: DateTime<FixedOffset>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub(super) duration_secs: Option<f64>,
146 #[serde(default)]
148 pub(super) cli_args: Vec<String>,
149 #[serde(default)]
154 pub(super) build_scope_args: Vec<String>,
155 #[serde(default)]
159 pub(super) env_vars: BTreeMap<String, String>,
160 #[serde(default)]
162 pub(super) parent_run_id: Option<ReportUuid>,
163 pub(super) sizes: RecordedSizesFormat,
167 pub(super) status: RecordedRunStatusFormat,
169}
170
171#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
173#[serde(rename_all = "kebab-case")]
174pub(super) struct RecordedSizesFormat {
175 pub(super) log: ComponentSizesFormat,
177 pub(super) store: ComponentSizesFormat,
179}
180
181#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
183#[serde(rename_all = "kebab-case")]
184pub(super) struct ComponentSizesFormat {
185 pub(super) compressed: u64,
187 pub(super) uncompressed: u64,
189 #[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#[derive(Clone, Debug, Deserialize, Serialize)]
230#[serde(tag = "status", rename_all = "kebab-case")]
231pub(super) enum RecordedRunStatusFormat {
232 Incomplete,
234 #[serde(rename_all = "kebab-case")]
236 Completed {
237 initial_run_count: usize,
239 passed: usize,
241 failed: usize,
243 exit_code: i32,
245 },
246 #[serde(rename_all = "kebab-case")]
248 Cancelled {
249 initial_run_count: usize,
251 passed: usize,
253 failed: usize,
255 exit_code: i32,
257 },
258 #[serde(rename_all = "kebab-case")]
260 StressCompleted {
261 initial_iteration_count: Option<NonZero<u32>>,
263 success_count: u32,
265 failed_count: u32,
267 exit_code: i32,
269 },
270 #[serde(rename_all = "kebab-case")]
272 StressCancelled {
273 initial_iteration_count: Option<NonZero<u32>>,
275 success_count: u32,
277 failed_count: u32,
279 exit_code: i32,
281 },
282 #[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#[derive(Clone, Debug, Deserialize, Serialize)]
422#[serde(rename_all = "kebab-case")]
423pub struct RerunInfo {
424 pub parent_run_id: ReportUuid,
426
427 pub root_info: RerunRootInfo,
429
430 pub test_suites: IdOrdMap<RerunTestSuiteInfo>,
432}
433
434#[derive(Clone, Debug, Deserialize, Serialize)]
436#[serde(rename_all = "kebab-case")]
437pub struct RerunRootInfo {
438 pub run_id: ReportUuid,
440
441 pub build_scope_args: Vec<String>,
443}
444
445impl RerunRootInfo {
446 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#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
461pub struct RerunTestSuiteInfo {
462 pub binary_id: RustBinaryId,
464
465 pub passing: BTreeSet<TestCaseName>,
467
468 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
490pub const RECORD_FORMAT_VERSION: u32 = 1;
500
501pub(super) static STORE_ZIP_FILE_NAME: &str = "store.zip";
503pub(super) static RUN_LOG_FILE_NAME: &str = "run.log.zst";
504
505pub(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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
515pub enum OutputDict {
516 Stdout,
518 Stderr,
520 None,
522}
523
524impl OutputDict {
525 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 if first_component != "out" {
539 return Self::None;
540 }
541
542 Self::for_output_file_name(iter.as_path().as_str())
543 }
544
545 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 Self::None
557 }
558 }
559
560 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}