1use super::{
22 format::{
23 CARGO_METADATA_JSON_PATH, OutputDict, PORTABLE_ARCHIVE_FORMAT_VERSION,
24 PORTABLE_MANIFEST_FILE_NAME, PortableManifest, RECORD_OPTS_JSON_PATH, RERUN_INFO_JSON_PATH,
25 RUN_LOG_FILE_NAME, RerunInfo, STDERR_DICT_PATH, STDOUT_DICT_PATH, STORE_FORMAT_VERSION,
26 STORE_ZIP_FILE_NAME, TEST_LIST_JSON_PATH, has_zip_extension,
27 },
28 reader::{StoreReader, decompress_with_dict},
29 store::{RecordedRunInfo, RunFilesExist, StoreRunsDir},
30 summary::{RecordOpts, TestEventSummary, ZipStoreOutput},
31};
32use crate::{
33 errors::{PortableArchiveError, PortableArchiveReadError, RecordReadError},
34 user_config::elements::MAX_MAX_OUTPUT_SIZE,
35};
36use atomicwrites::{AtomicFile, OverwriteBehavior};
37use camino::{Utf8Path, Utf8PathBuf};
38use countio::Counter;
39use debug_ignore::DebugIgnore;
40use itertools::Either;
41use nextest_metadata::TestListSummary;
42use std::{
43 borrow::Cow,
44 fs::File,
45 io::{self, BufRead, BufReader, Cursor, Read, Write},
46};
47use zip::{
48 CompressionMethod, ZipArchive, ZipWriter, read::ZipFileSeek, result::ZipError,
49 write::SimpleFileOptions,
50};
51
52#[derive(Debug)]
54pub struct PortableArchiveResult {
55 pub path: Utf8PathBuf,
57 pub size: u64,
59}
60
61#[derive(Debug)]
63pub struct ExtractOuterFileResult {
64 pub bytes_written: u64,
66 pub exceeded_limit: Option<u64>,
71}
72
73#[derive(Debug)]
75pub struct PortableArchiveWriter<'a> {
76 run_info: &'a RecordedRunInfo,
77 run_dir: Utf8PathBuf,
78}
79
80impl<'a> PortableArchiveWriter<'a> {
81 pub fn new(
85 run_info: &'a RecordedRunInfo,
86 runs_dir: StoreRunsDir<'_>,
87 ) -> Result<Self, PortableArchiveError> {
88 let run_dir = runs_dir.run_dir(run_info.run_id);
89
90 if !run_dir.exists() {
91 return Err(PortableArchiveError::RunDirNotFound { path: run_dir });
92 }
93
94 let store_zip_path = run_dir.join(STORE_ZIP_FILE_NAME);
95 if !store_zip_path.exists() {
96 return Err(PortableArchiveError::RequiredFileMissing {
97 run_dir,
98 file_name: STORE_ZIP_FILE_NAME,
99 });
100 }
101
102 let run_log_path = run_dir.join(RUN_LOG_FILE_NAME);
103 if !run_log_path.exists() {
104 return Err(PortableArchiveError::RequiredFileMissing {
105 run_dir,
106 file_name: RUN_LOG_FILE_NAME,
107 });
108 }
109
110 Ok(Self { run_info, run_dir })
111 }
112
113 pub fn default_filename(&self) -> String {
117 format!("nextest-run-{}.zip", self.run_info.run_id)
118 }
119
120 pub fn write_to_dir(
125 &self,
126 output_dir: &Utf8Path,
127 ) -> Result<PortableArchiveResult, PortableArchiveError> {
128 let output_path = output_dir.join(self.default_filename());
129 self.write_to_path(&output_path)
130 }
131
132 pub fn write_to_path(
136 &self,
137 output_path: &Utf8Path,
138 ) -> Result<PortableArchiveResult, PortableArchiveError> {
139 let atomic_file = AtomicFile::new(output_path, OverwriteBehavior::AllowOverwrite);
140
141 let final_size = atomic_file
142 .write(|temp_file| {
143 let counter = Counter::new(temp_file);
144 let mut zip_writer = ZipWriter::new(counter);
145
146 self.write_manifest(&mut zip_writer)?;
147 self.copy_file(&mut zip_writer, RUN_LOG_FILE_NAME)?;
148 self.copy_file(&mut zip_writer, STORE_ZIP_FILE_NAME)?;
149
150 let counter = zip_writer
151 .finish()
152 .map_err(PortableArchiveError::ZipFinalize)?;
153
154 let counter_bytes = counter.writer_bytes() as u64;
159 let file = counter.into_inner();
160 let size = file.metadata().map(|m| m.len()).unwrap_or(counter_bytes);
161
162 Ok(size)
163 })
164 .map_err(|err| match err {
165 atomicwrites::Error::Internal(source) => PortableArchiveError::AtomicWrite {
166 path: output_path.to_owned(),
167 source,
168 },
169 atomicwrites::Error::User(e) => e,
170 })?;
171
172 Ok(PortableArchiveResult {
173 path: output_path.to_owned(),
174 size: final_size,
175 })
176 }
177
178 fn write_manifest<W: Write + io::Seek>(
180 &self,
181 zip_writer: &mut ZipWriter<W>,
182 ) -> Result<(), PortableArchiveError> {
183 let manifest = PortableManifest::new(self.run_info);
184 let manifest_json = serde_json::to_vec_pretty(&manifest)
185 .map_err(PortableArchiveError::SerializeManifest)?;
186
187 let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
188
189 zip_writer
190 .start_file(PORTABLE_MANIFEST_FILE_NAME, options)
191 .map_err(|source| PortableArchiveError::ZipStartFile {
192 file_name: PORTABLE_MANIFEST_FILE_NAME,
193 source,
194 })?;
195
196 zip_writer
197 .write_all(&manifest_json)
198 .map_err(|source| PortableArchiveError::ZipWrite {
199 file_name: PORTABLE_MANIFEST_FILE_NAME,
200 source,
201 })?;
202
203 Ok(())
204 }
205
206 fn copy_file<W: Write + io::Seek>(
211 &self,
212 zip_writer: &mut ZipWriter<W>,
213 file_name: &'static str,
214 ) -> Result<(), PortableArchiveError> {
215 let source_path = self.run_dir.join(file_name);
216 let mut file = File::open(&source_path)
217 .map_err(|source| PortableArchiveError::ReadFile { file_name, source })?;
218
219 let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
220
221 zip_writer
222 .start_file(file_name, options)
223 .map_err(|source| PortableArchiveError::ZipStartFile { file_name, source })?;
224
225 io::copy(&mut file, zip_writer)
226 .map_err(|source| PortableArchiveError::ZipWrite { file_name, source })?;
227
228 Ok(())
229 }
230}
231
232type ArchiveReadStorage = Either<File, Cursor<Vec<u8>>>;
241
242#[derive(Debug)]
244pub struct PortableArchive {
245 archive_path: Utf8PathBuf,
246 manifest: PortableManifest,
247 outer_archive: ZipArchive<ArchiveReadStorage>,
248}
249
250impl RunFilesExist for PortableArchive {
251 fn store_zip_exists(&self) -> bool {
252 self.outer_archive
253 .index_for_name(STORE_ZIP_FILE_NAME)
254 .is_some()
255 }
256
257 fn run_log_exists(&self) -> bool {
258 self.outer_archive
259 .index_for_name(RUN_LOG_FILE_NAME)
260 .is_some()
261 }
262}
263
264impl PortableArchive {
265 pub fn open(path: &Utf8Path) -> Result<Self, PortableArchiveReadError> {
275 let file = File::open(path).map_err(|error| PortableArchiveReadError::OpenArchive {
276 path: path.to_owned(),
277 error,
278 })?;
279
280 let mut outer_archive = ZipArchive::new(Either::Left(file)).map_err(|error| {
281 PortableArchiveReadError::ReadArchive {
282 path: path.to_owned(),
283 error,
284 }
285 })?;
286
287 if outer_archive
289 .index_for_name(PORTABLE_MANIFEST_FILE_NAME)
290 .is_some()
291 {
292 return Self::open_validated(path, outer_archive);
293 }
294
295 let mut file_count = 0;
299 let mut zip_count = 0;
300 let mut zip_file: Option<String> = None;
301 for name in outer_archive.file_names() {
302 if name.ends_with('/') || name.ends_with('\\') {
303 continue;
305 }
306 file_count += 1;
307 if has_zip_extension(Utf8Path::new(name)) {
308 zip_count += 1;
309 if zip_count == 1 {
310 zip_file = Some(name.to_owned());
311 }
312 }
313 }
314
315 if let Some(inner_name) = zip_file.filter(|_| file_count == 1 && zip_count == 1) {
316 let inner_bytes = read_outer_file(&mut outer_archive, inner_name.into(), path)?;
321 let inner_archive =
322 ZipArchive::new(Either::Right(Cursor::new(inner_bytes))).map_err(|error| {
323 PortableArchiveReadError::ReadArchive {
324 path: path.to_owned(),
325 error,
326 }
327 })?;
328 Self::open_validated(path, inner_archive)
329 } else {
330 Err(PortableArchiveReadError::NotAWrapperArchive {
331 path: path.to_owned(),
332 file_count,
333 zip_count,
334 })
335 }
336 }
337
338 fn open_validated(
340 path: &Utf8Path,
341 mut outer_archive: ZipArchive<ArchiveReadStorage>,
342 ) -> Result<Self, PortableArchiveReadError> {
343 let manifest_bytes =
345 read_outer_file(&mut outer_archive, PORTABLE_MANIFEST_FILE_NAME.into(), path)?;
346 let manifest: PortableManifest =
347 serde_json::from_slice(&manifest_bytes).map_err(|error| {
348 PortableArchiveReadError::ParseManifest {
349 path: path.to_owned(),
350 error,
351 }
352 })?;
353
354 if let Err(incompatibility) = manifest
356 .format_version
357 .check_readable_by(PORTABLE_ARCHIVE_FORMAT_VERSION)
358 {
359 return Err(PortableArchiveReadError::UnsupportedFormatVersion {
360 path: path.to_owned(),
361 found: manifest.format_version,
362 supported: PORTABLE_ARCHIVE_FORMAT_VERSION,
363 incompatibility,
364 });
365 }
366
367 let store_version = manifest.store_format_version();
369 if let Err(incompatibility) = store_version.check_readable_by(STORE_FORMAT_VERSION) {
370 return Err(PortableArchiveReadError::UnsupportedStoreFormatVersion {
371 path: path.to_owned(),
372 found: store_version,
373 supported: STORE_FORMAT_VERSION,
374 incompatibility,
375 });
376 }
377
378 Ok(Self {
379 archive_path: path.to_owned(),
380 manifest,
381 outer_archive,
382 })
383 }
384
385 pub fn archive_path(&self) -> &Utf8Path {
387 &self.archive_path
388 }
389
390 pub fn run_info(&self) -> RecordedRunInfo {
392 self.manifest.run_info()
393 }
394
395 pub fn read_run_log(&mut self) -> Result<PortableArchiveRunLog, PortableArchiveReadError> {
401 let run_log_bytes = read_outer_file(
402 &mut self.outer_archive,
403 RUN_LOG_FILE_NAME.into(),
404 &self.archive_path,
405 )?;
406 Ok(PortableArchiveRunLog {
407 archive_path: self.archive_path.clone(),
408 run_log_bytes,
409 })
410 }
411
412 pub fn extract_outer_file_to_path(
421 &mut self,
422 file_name: &'static str,
423 output_path: &Utf8Path,
424 check_limit: bool,
425 ) -> Result<ExtractOuterFileResult, PortableArchiveReadError> {
426 extract_outer_file_to_path(
427 &mut self.outer_archive,
428 file_name,
429 &self.archive_path,
430 output_path,
431 check_limit,
432 )
433 }
434
435 pub fn open_store(&mut self) -> Result<PortableStoreReader<'_>, PortableArchiveReadError> {
439 let store_handle = self
441 .outer_archive
442 .by_name_seek(STORE_ZIP_FILE_NAME)
443 .map_err(|error| match error {
444 ZipError::FileNotFound => PortableArchiveReadError::MissingFile {
445 path: self.archive_path.clone(),
446 file_name: Cow::Borrowed(STORE_ZIP_FILE_NAME),
447 },
448 _ => PortableArchiveReadError::ReadArchive {
449 path: self.archive_path.clone(),
450 error,
451 },
452 })?;
453
454 let store_archive = ZipArchive::new(store_handle).map_err(|error| {
455 PortableArchiveReadError::ReadArchive {
456 path: self.archive_path.clone(),
457 error,
458 }
459 })?;
460
461 Ok(PortableStoreReader {
462 archive_path: &self.archive_path,
463 store_archive,
464 stdout_dict: None,
465 stderr_dict: None,
466 })
467 }
468}
469
470fn read_outer_file(
472 archive: &mut ZipArchive<ArchiveReadStorage>,
473 file_name: Cow<'static, str>,
474 archive_path: &Utf8Path,
475) -> Result<Vec<u8>, PortableArchiveReadError> {
476 let limit = MAX_MAX_OUTPUT_SIZE.as_u64();
477 let file = archive.by_name(&file_name).map_err(|error| match error {
478 ZipError::FileNotFound => PortableArchiveReadError::MissingFile {
479 path: archive_path.to_owned(),
480 file_name: file_name.clone(),
481 },
482 _ => PortableArchiveReadError::ReadArchive {
483 path: archive_path.to_owned(),
484 error,
485 },
486 })?;
487
488 let claimed_size = file.size();
489 if claimed_size > limit {
490 return Err(PortableArchiveReadError::FileTooLarge {
491 path: archive_path.to_owned(),
492 file_name,
493 size: claimed_size,
494 limit,
495 });
496 }
497
498 let capacity = usize::try_from(claimed_size).unwrap_or(usize::MAX);
499 let mut contents = Vec::with_capacity(capacity);
500
501 file.take(limit)
502 .read_to_end(&mut contents)
503 .map_err(|error| PortableArchiveReadError::ReadArchive {
504 path: archive_path.to_owned(),
505 error: ZipError::Io(error),
506 })?;
507
508 Ok(contents)
509}
510
511fn extract_outer_file_to_path(
513 archive: &mut ZipArchive<ArchiveReadStorage>,
514 file_name: &'static str,
515 archive_path: &Utf8Path,
516 output_path: &Utf8Path,
517 check_limit: bool,
518) -> Result<ExtractOuterFileResult, PortableArchiveReadError> {
519 let limit = MAX_MAX_OUTPUT_SIZE.as_u64();
520 let mut file = archive.by_name(file_name).map_err(|error| match error {
521 ZipError::FileNotFound => PortableArchiveReadError::MissingFile {
522 path: archive_path.to_owned(),
523 file_name: Cow::Borrowed(file_name),
524 },
525 _ => PortableArchiveReadError::ReadArchive {
526 path: archive_path.to_owned(),
527 error,
528 },
529 })?;
530
531 let claimed_size = file.size();
532 let exceeded_limit = if check_limit && claimed_size > limit {
533 Some(claimed_size)
534 } else {
535 None
536 };
537
538 let mut output_file =
539 File::create(output_path).map_err(|error| PortableArchiveReadError::ExtractFile {
540 archive_path: archive_path.to_owned(),
541 file_name,
542 output_path: output_path.to_owned(),
543 error,
544 })?;
545
546 let bytes_written = io::copy(&mut file, &mut output_file).map_err(|error| {
547 PortableArchiveReadError::ExtractFile {
548 archive_path: archive_path.to_owned(),
549 file_name,
550 output_path: output_path.to_owned(),
551 error,
552 }
553 })?;
554
555 Ok(ExtractOuterFileResult {
556 bytes_written,
557 exceeded_limit,
558 })
559}
560
561#[derive(Debug)]
566pub struct PortableArchiveRunLog {
567 archive_path: Utf8PathBuf,
568 run_log_bytes: Vec<u8>,
569}
570
571impl PortableArchiveRunLog {
572 pub fn events(&self) -> Result<PortableArchiveEventIter<'_>, RecordReadError> {
574 let decoder =
577 zstd::stream::Decoder::with_buffer(&self.run_log_bytes[..]).map_err(|error| {
578 RecordReadError::OpenRunLog {
579 path: self.archive_path.join(RUN_LOG_FILE_NAME),
580 error,
581 }
582 })?;
583 Ok(PortableArchiveEventIter {
584 reader: DebugIgnore(BufReader::new(decoder)),
586 line_buf: String::new(),
587 line_number: 0,
588 })
589 }
590}
591
592#[derive(Debug)]
594pub struct PortableArchiveEventIter<'a> {
595 reader: DebugIgnore<BufReader<zstd::stream::Decoder<'static, &'a [u8]>>>,
596 line_buf: String,
597 line_number: usize,
598}
599
600impl Iterator for PortableArchiveEventIter<'_> {
601 type Item = Result<TestEventSummary<ZipStoreOutput>, RecordReadError>;
602
603 fn next(&mut self) -> Option<Self::Item> {
604 loop {
605 self.line_buf.clear();
606 self.line_number += 1;
607
608 match self.reader.read_line(&mut self.line_buf) {
609 Ok(0) => return None,
610 Ok(_) => {
611 let trimmed = self.line_buf.trim();
612 if trimmed.is_empty() {
613 continue;
614 }
615 return Some(serde_json::from_str(trimmed).map_err(|error| {
616 RecordReadError::ParseEvent {
617 line_number: self.line_number,
618 error,
619 }
620 }));
621 }
622 Err(error) => {
623 return Some(Err(RecordReadError::ReadRunLog {
624 line_number: self.line_number,
625 error,
626 }));
627 }
628 }
629 }
630 }
631}
632
633pub struct PortableStoreReader<'a> {
637 archive_path: &'a Utf8Path,
638 store_archive: ZipArchive<ZipFileSeek<'a, ArchiveReadStorage>>,
639 stdout_dict: Option<Vec<u8>>,
641 stderr_dict: Option<Vec<u8>>,
643}
644
645impl std::fmt::Debug for PortableStoreReader<'_> {
646 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
647 f.debug_struct("PortableStoreReader")
648 .field("archive_path", &self.archive_path)
649 .field("stdout_dict", &self.stdout_dict.as_ref().map(|d| d.len()))
650 .field("stderr_dict", &self.stderr_dict.as_ref().map(|d| d.len()))
651 .finish_non_exhaustive()
652 }
653}
654
655impl PortableStoreReader<'_> {
656 fn read_store_file(&mut self, file_name: &str) -> Result<Vec<u8>, RecordReadError> {
658 let limit = MAX_MAX_OUTPUT_SIZE.as_u64();
659 let file = self.store_archive.by_name(file_name).map_err(|error| {
660 RecordReadError::ReadArchiveFile {
661 file_name: file_name.to_string(),
662 error,
663 }
664 })?;
665
666 let claimed_size = file.size();
667 if claimed_size > limit {
668 return Err(RecordReadError::FileTooLarge {
669 file_name: file_name.to_string(),
670 size: claimed_size,
671 limit,
672 });
673 }
674
675 let capacity = usize::try_from(claimed_size).unwrap_or(usize::MAX);
676 let mut contents = Vec::with_capacity(capacity);
677
678 file.take(limit)
679 .read_to_end(&mut contents)
680 .map_err(|error| RecordReadError::Decompress {
681 file_name: file_name.to_string(),
682 error,
683 })?;
684
685 let actual_size = contents.len() as u64;
686 if actual_size != claimed_size {
687 return Err(RecordReadError::SizeMismatch {
688 file_name: file_name.to_string(),
689 claimed_size,
690 actual_size,
691 });
692 }
693
694 Ok(contents)
695 }
696
697 fn get_dict_for_output(&self, file_name: &str) -> Option<&[u8]> {
699 match OutputDict::for_output_file_name(file_name) {
700 OutputDict::Stdout => Some(
701 self.stdout_dict
702 .as_ref()
703 .expect("load_dictionaries must be called first"),
704 ),
705 OutputDict::Stderr => Some(
706 self.stderr_dict
707 .as_ref()
708 .expect("load_dictionaries must be called first"),
709 ),
710 OutputDict::None => None,
711 }
712 }
713}
714
715impl StoreReader for PortableStoreReader<'_> {
716 fn read_cargo_metadata(&mut self) -> Result<String, RecordReadError> {
717 let bytes = self.read_store_file(CARGO_METADATA_JSON_PATH)?;
718 String::from_utf8(bytes).map_err(|e| RecordReadError::Decompress {
719 file_name: CARGO_METADATA_JSON_PATH.to_string(),
720 error: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
721 })
722 }
723
724 fn read_test_list(&mut self) -> Result<TestListSummary, RecordReadError> {
725 let bytes = self.read_store_file(TEST_LIST_JSON_PATH)?;
726 serde_json::from_slice(&bytes).map_err(|error| RecordReadError::DeserializeMetadata {
727 file_name: TEST_LIST_JSON_PATH.to_string(),
728 error,
729 })
730 }
731
732 fn read_record_opts(&mut self) -> Result<RecordOpts, RecordReadError> {
733 let bytes = self.read_store_file(RECORD_OPTS_JSON_PATH)?;
734 serde_json::from_slice(&bytes).map_err(|error| RecordReadError::DeserializeMetadata {
735 file_name: RECORD_OPTS_JSON_PATH.to_string(),
736 error,
737 })
738 }
739
740 fn read_rerun_info(&mut self) -> Result<Option<RerunInfo>, RecordReadError> {
741 match self.read_store_file(RERUN_INFO_JSON_PATH) {
742 Ok(bytes) => {
743 let info = serde_json::from_slice(&bytes).map_err(|error| {
744 RecordReadError::DeserializeMetadata {
745 file_name: RERUN_INFO_JSON_PATH.to_string(),
746 error,
747 }
748 })?;
749 Ok(Some(info))
750 }
751 Err(RecordReadError::ReadArchiveFile {
752 error: ZipError::FileNotFound,
753 ..
754 }) => {
755 Ok(None)
757 }
758 Err(e) => Err(e),
759 }
760 }
761
762 fn load_dictionaries(&mut self) -> Result<(), RecordReadError> {
763 self.stdout_dict = Some(self.read_store_file(STDOUT_DICT_PATH)?);
764 self.stderr_dict = Some(self.read_store_file(STDERR_DICT_PATH)?);
765 Ok(())
766 }
767
768 fn read_output(&mut self, file_name: &str) -> Result<Vec<u8>, RecordReadError> {
769 let path = format!("out/{file_name}");
770 let compressed = self.read_store_file(&path)?;
771 let limit = MAX_MAX_OUTPUT_SIZE.as_u64();
772
773 let dict_bytes = self.get_dict_for_output(file_name).ok_or_else(|| {
774 RecordReadError::UnknownOutputType {
775 file_name: file_name.to_owned(),
776 }
777 })?;
778
779 decompress_with_dict(&compressed, dict_bytes, limit).map_err(|error| {
780 RecordReadError::Decompress {
781 file_name: path,
782 error,
783 }
784 })
785 }
786
787 fn extract_file_to_path(
788 &mut self,
789 store_path: &str,
790 output_path: &Utf8Path,
791 ) -> Result<u64, RecordReadError> {
792 let mut file = self.store_archive.by_name(store_path).map_err(|error| {
793 RecordReadError::ReadArchiveFile {
794 file_name: store_path.to_owned(),
795 error,
796 }
797 })?;
798
799 let mut output_file =
800 File::create(output_path).map_err(|error| RecordReadError::ExtractFile {
801 store_path: store_path.to_owned(),
802 output_path: output_path.to_owned(),
803 error,
804 })?;
805
806 io::copy(&mut file, &mut output_file).map_err(|error| RecordReadError::ExtractFile {
807 store_path: store_path.to_owned(),
808 output_path: output_path.to_owned(),
809 error,
810 })
811 }
812}
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817 use crate::record::{
818 format::{PORTABLE_ARCHIVE_FORMAT_VERSION, STORE_FORMAT_VERSION},
819 store::{CompletedRunStats, RecordedRunStatus, RecordedSizes},
820 };
821 use camino_tempfile::Utf8TempDir;
822 use chrono::Local;
823 use quick_junit::ReportUuid;
824 use semver::Version;
825 use std::{collections::BTreeMap, io::Read};
826 use zip::ZipArchive;
827
828 fn create_test_run_dir(run_id: ReportUuid) -> (Utf8TempDir, Utf8PathBuf) {
829 let temp_dir = camino_tempfile::tempdir().expect("create temp dir");
830 let runs_dir = temp_dir.path().to_owned();
831 let run_dir = runs_dir.join(run_id.to_string());
832 std::fs::create_dir_all(&run_dir).expect("create run dir");
833
834 let store_path = run_dir.join(STORE_ZIP_FILE_NAME);
835 let store_file = File::create(&store_path).expect("create store.zip");
836 let mut zip_writer = ZipWriter::new(store_file);
837 zip_writer
838 .start_file("test.txt", SimpleFileOptions::default())
839 .expect("start file");
840 zip_writer
841 .write_all(b"test content")
842 .expect("write content");
843 zip_writer.finish().expect("finish zip");
844
845 let log_path = run_dir.join(RUN_LOG_FILE_NAME);
846 let log_file = File::create(&log_path).expect("create run.log.zst");
847 let mut encoder = zstd::stream::Encoder::new(log_file, 3).expect("create encoder");
848 encoder.write_all(b"test log content").expect("write log");
849 encoder.finish().expect("finish encoder");
850
851 (temp_dir, runs_dir)
852 }
853
854 fn create_test_run_info(run_id: ReportUuid) -> RecordedRunInfo {
855 let now = Local::now().fixed_offset();
856 RecordedRunInfo {
857 run_id,
858 store_format_version: STORE_FORMAT_VERSION,
859 nextest_version: Version::new(0, 9, 111),
860 started_at: now,
861 last_written_at: now,
862 duration_secs: Some(12.345),
863 cli_args: vec!["cargo".to_owned(), "nextest".to_owned(), "run".to_owned()],
864 build_scope_args: vec!["--workspace".to_owned()],
865 env_vars: BTreeMap::from([("CARGO_TERM_COLOR".to_owned(), "always".to_owned())]),
866 parent_run_id: None,
867 sizes: RecordedSizes::default(),
868 status: RecordedRunStatus::Completed(CompletedRunStats {
869 initial_run_count: 10,
870 passed: 9,
871 failed: 1,
872 exit_code: 100,
873 }),
874 }
875 }
876
877 #[test]
878 fn test_default_filename() {
879 let run_id = ReportUuid::from_u128(0x550e8400_e29b_41d4_a716_446655440000);
880 let (_temp_dir, runs_dir) = create_test_run_dir(run_id);
881 let run_info = create_test_run_info(run_id);
882
883 let writer = PortableArchiveWriter::new(&run_info, StoreRunsDir::new(&runs_dir))
884 .expect("create writer");
885
886 assert_eq!(
887 writer.default_filename(),
888 "nextest-run-550e8400-e29b-41d4-a716-446655440000.zip"
889 );
890 }
891
892 #[test]
893 fn test_write_portable_archive() {
894 let run_id = ReportUuid::from_u128(0x550e8400_e29b_41d4_a716_446655440000);
895 let (_temp_dir, runs_dir) = create_test_run_dir(run_id);
896 let run_info = create_test_run_info(run_id);
897
898 let writer = PortableArchiveWriter::new(&run_info, StoreRunsDir::new(&runs_dir))
899 .expect("create writer");
900
901 let output_dir = camino_tempfile::tempdir().expect("create output dir");
902
903 let result = writer
904 .write_to_dir(output_dir.path())
905 .expect("write archive");
906
907 assert!(result.path.exists());
908 assert!(result.size > 0);
909
910 let actual_size = std::fs::metadata(&result.path)
912 .expect("get file metadata")
913 .len();
914 assert_eq!(
915 result.size, actual_size,
916 "reported size should match actual file size"
917 );
918
919 assert_eq!(
920 result.path.file_name(),
921 Some("nextest-run-550e8400-e29b-41d4-a716-446655440000.zip")
922 );
923
924 let archive_file = File::open(&result.path).expect("open archive");
925 let mut archive = ZipArchive::new(archive_file).expect("read archive");
926
927 assert_eq!(archive.len(), 3);
928
929 {
930 let mut manifest_file = archive
931 .by_name(PORTABLE_MANIFEST_FILE_NAME)
932 .expect("manifest");
933 let mut manifest_content = String::new();
934 manifest_file
935 .read_to_string(&mut manifest_content)
936 .expect("read manifest");
937 let manifest: PortableManifest =
938 serde_json::from_str(&manifest_content).expect("parse manifest");
939 assert_eq!(manifest.format_version, PORTABLE_ARCHIVE_FORMAT_VERSION);
940 assert_eq!(manifest.run.run_id, run_id);
941 }
942
943 {
944 let store_file = archive.by_name(STORE_ZIP_FILE_NAME).expect("store.zip");
945 assert!(store_file.size() > 0);
946 }
947
948 {
949 let log_file = archive.by_name(RUN_LOG_FILE_NAME).expect("run.log.zst");
950 assert!(log_file.size() > 0);
951 }
952 }
953
954 #[test]
955 fn test_missing_run_dir() {
956 let run_id = ReportUuid::from_u128(0x550e8400_e29b_41d4_a716_446655440000);
957 let temp_dir = camino_tempfile::tempdir().expect("create temp dir");
958 let runs_dir = temp_dir.path().to_owned();
959 let run_info = create_test_run_info(run_id);
960
961 let result = PortableArchiveWriter::new(&run_info, StoreRunsDir::new(&runs_dir));
962
963 assert!(matches!(
964 result,
965 Err(PortableArchiveError::RunDirNotFound { .. })
966 ));
967 }
968
969 #[test]
970 fn test_missing_store_zip() {
971 let run_id = ReportUuid::from_u128(0x550e8400_e29b_41d4_a716_446655440000);
972 let temp_dir = camino_tempfile::tempdir().expect("create temp dir");
973 let runs_dir = temp_dir.path().to_owned();
974 let run_dir = runs_dir.join(run_id.to_string());
975 std::fs::create_dir_all(&run_dir).expect("create run dir");
976
977 let log_path = run_dir.join(RUN_LOG_FILE_NAME);
978 let log_file = File::create(&log_path).expect("create run.log.zst");
979 let mut encoder = zstd::stream::Encoder::new(log_file, 3).expect("create encoder");
980 encoder.write_all(b"test").expect("write");
981 encoder.finish().expect("finish");
982
983 let run_info = create_test_run_info(run_id);
984 let result = PortableArchiveWriter::new(&run_info, StoreRunsDir::new(&runs_dir));
985
986 assert!(
987 matches!(
988 &result,
989 Err(PortableArchiveError::RequiredFileMissing { file_name, .. })
990 if *file_name == STORE_ZIP_FILE_NAME
991 ),
992 "expected RequiredFileMissing for store.zip, got {result:?}"
993 );
994 }
995
996 #[test]
997 fn test_missing_run_log() {
998 let run_id = ReportUuid::from_u128(0x550e8400_e29b_41d4_a716_446655440000);
999 let temp_dir = camino_tempfile::tempdir().expect("create temp dir");
1000 let runs_dir = temp_dir.path().to_owned();
1001 let run_dir = runs_dir.join(run_id.to_string());
1002 std::fs::create_dir_all(&run_dir).expect("create run dir");
1003
1004 let store_path = run_dir.join(STORE_ZIP_FILE_NAME);
1005 let store_file = File::create(&store_path).expect("create store.zip");
1006 let mut zip_writer = ZipWriter::new(store_file);
1007 zip_writer
1008 .start_file("test.txt", SimpleFileOptions::default())
1009 .expect("start");
1010 zip_writer.write_all(b"test").expect("write");
1011 zip_writer.finish().expect("finish");
1012
1013 let run_info = create_test_run_info(run_id);
1014 let result = PortableArchiveWriter::new(&run_info, StoreRunsDir::new(&runs_dir));
1015
1016 assert!(
1017 matches!(
1018 &result,
1019 Err(PortableArchiveError::RequiredFileMissing { file_name, .. })
1020 if *file_name == RUN_LOG_FILE_NAME
1021 ),
1022 "expected RequiredFileMissing for run.log.zst, got {result:?}"
1023 );
1024 }
1025}