1use super::{
12 dicts,
13 format::{
14 BUILD_SCOPE_JSON_PATH, CARGO_METADATA_JSON_PATH, OutputDict, RECORD_OPTS_JSON_PATH,
15 RUN_LOG_FILE_NAME, STDERR_DICT_PATH, STDOUT_DICT_PATH, STORE_ZIP_FILE_NAME,
16 TEST_LIST_JSON_PATH, stored_file_options, zstd_file_options,
17 },
18 summary::{
19 OutputEventKind, OutputFileName, OutputKind, RecordOpts, TestEventKindSummary,
20 TestEventSummary, ZipStoreOutput, ZipStoreOutputDescription,
21 },
22};
23use crate::{
24 errors::{RunStoreError, StoreWriterError},
25 output_spec::{LiveSpec, RecordingSpec},
26 record::format::{RERUN_INFO_JSON_PATH, RecordedBuildScope, RerunInfo},
27 reporter::events::{
28 ChildExecutionOutputDescription, ChildOutputDescription, ExecuteStatus, ExecutionStatuses,
29 SetupScriptExecuteStatus,
30 },
31 test_output::ChildSingleOutput,
32};
33use camino::{Utf8Path, Utf8PathBuf};
34use countio::Counter;
35use debug_ignore::DebugIgnore;
36use eazip::ArchiveWriter;
37use nextest_metadata::TestListSummary;
38use std::{
39 borrow::Cow,
40 collections::HashSet,
41 fs::File,
42 io::{self, Write},
43};
44
45struct LogEncoder {
56 inner: Option<Counter<zstd::stream::Encoder<'static, Counter<File>>>>,
59}
60
61impl std::fmt::Debug for LogEncoder {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 f.debug_struct("LogEncoder").finish_non_exhaustive()
64 }
65}
66
67impl LogEncoder {
68 fn new(encoder: zstd::stream::Encoder<'static, Counter<File>>) -> Self {
69 Self {
70 inner: Some(Counter::new(encoder)),
71 }
72 }
73
74 fn finish(mut self, entries: u64) -> io::Result<ComponentSizes> {
78 let counter = self.inner.take().expect("encoder already finished");
79 let uncompressed = counter.writer_bytes() as u64;
80 let file_counter = counter.into_inner().finish()?;
81 let compressed = file_counter.writer_bytes() as u64;
82 Ok(ComponentSizes {
83 compressed,
84 uncompressed,
85 entries,
86 })
87 }
88}
89
90impl Write for LogEncoder {
91 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
92 self.inner
93 .as_mut()
94 .expect("encoder already finished")
95 .write(buf)
96 }
97
98 fn flush(&mut self) -> io::Result<()> {
99 self.inner
100 .as_mut()
101 .expect("encoder already finished")
102 .flush()
103 }
104}
105
106impl Drop for LogEncoder {
107 fn drop(&mut self) {
108 if let Some(counter) = self.inner.take() {
109 let _ = counter.into_inner().finish();
114 }
115 }
116}
117
118#[derive(Debug)]
123pub struct RunRecorder {
124 store_path: Utf8PathBuf,
125 store_writer: StoreWriter,
126 log_path: Utf8PathBuf,
127 log: DebugIgnore<LogEncoder>,
128 log_entries: u64,
130 max_output_size: usize,
131}
132
133impl RunRecorder {
134 pub(super) fn new(
139 run_dir: Utf8PathBuf,
140 max_output_size: bytesize::ByteSize,
141 ) -> Result<Self, RunStoreError> {
142 std::fs::create_dir_all(&run_dir).map_err(|error| RunStoreError::RunDirCreate {
143 run_dir: run_dir.clone(),
144 error,
145 })?;
146
147 let store_path = run_dir.join(STORE_ZIP_FILE_NAME);
148 let store_writer =
149 StoreWriter::new(&store_path).map_err(|error| RunStoreError::StoreWrite {
150 store_path: store_path.clone(),
151 error,
152 })?;
153
154 let log_path = run_dir.join(RUN_LOG_FILE_NAME);
155 let file = std::fs::OpenOptions::new()
156 .create(true)
157 .truncate(true)
158 .write(true)
159 .open(&log_path)
160 .map_err(|error| RunStoreError::RunLogCreate {
161 path: log_path.clone(),
162 error,
163 })?;
164
165 let encoder = zstd::stream::Encoder::new(Counter::new(file), 3).map_err(|error| {
169 RunStoreError::RunLogCreate {
170 path: log_path.clone(),
171 error,
172 }
173 })?;
174 let log = LogEncoder::new(encoder);
175
176 Ok(Self {
177 store_path,
178 store_writer,
179 log_path,
180 log: DebugIgnore(log),
181 log_entries: 0,
182 max_output_size: usize::try_from(max_output_size.as_u64()).unwrap_or(usize::MAX),
185 })
186 }
187
188 pub(crate) fn write_meta(
195 &mut self,
196 cargo_metadata_json: &str,
197 test_list: &TestListSummary,
198 opts: &RecordOpts,
199 ) -> Result<(), RunStoreError> {
200 let test_list_json = serde_json::to_string(test_list)
201 .map_err(|error| RunStoreError::TestListSerialize { error })?;
202
203 let opts_json = serde_json::to_string(opts)
204 .map_err(|error| RunStoreError::RecordOptionsSerialize { error })?;
205
206 self.write_archive_file(TEST_LIST_JSON_PATH, test_list_json.as_bytes())?;
207 self.write_archive_file(CARGO_METADATA_JSON_PATH, cargo_metadata_json.as_bytes())?;
208 self.write_archive_file(RECORD_OPTS_JSON_PATH, opts_json.as_bytes())?;
209
210 self.write_archive_file(STDOUT_DICT_PATH, dicts::STDOUT)?;
212 self.write_archive_file(STDERR_DICT_PATH, dicts::STDERR)?;
213
214 Ok(())
215 }
216
217 pub(crate) fn write_build_scope(
219 &mut self,
220 build_scope: &RecordedBuildScope,
221 ) -> Result<(), RunStoreError> {
222 let build_scope_json = serde_json::to_string(build_scope)
223 .map_err(|error| RunStoreError::BuildScopeSerialize { error })?;
224
225 self.write_archive_file(BUILD_SCOPE_JSON_PATH, build_scope_json.as_bytes())?;
226
227 Ok(())
228 }
229
230 pub(crate) fn write_rerun_info(&mut self, rerun_info: &RerunInfo) -> Result<(), RunStoreError> {
234 let rerun_info_json = serde_json::to_string(rerun_info)
235 .map_err(|error| RunStoreError::RerunInfoSerialize { error })?;
236
237 self.write_archive_file(RERUN_INFO_JSON_PATH, rerun_info_json.as_bytes())?;
238
239 Ok(())
240 }
241
242 fn write_archive_file(&mut self, path: &str, bytes: &[u8]) -> Result<(), RunStoreError> {
243 self.store_writer
244 .add_file(Utf8PathBuf::from(path), bytes)
245 .map_err(|error| RunStoreError::StoreWrite {
246 store_path: self.store_path.clone(),
247 error,
248 })
249 }
250
251 pub(crate) fn write_event(
256 &mut self,
257 event: TestEventSummary<LiveSpec>,
258 ) -> Result<(), RunStoreError> {
259 let mut cx = SerializeTestEventContext {
260 store_writer: &mut self.store_writer,
261 max_output_size: self.max_output_size,
262 };
263
264 let event = cx
265 .convert_event(event)
266 .map_err(|error| RunStoreError::StoreWrite {
267 store_path: self.store_path.clone(),
268 error,
269 })?;
270
271 let json = serde_json::to_string(&event)
272 .map_err(|error| RunStoreError::TestEventSerialize { error })?;
273 self.write_log_impl(json.as_bytes())?;
274 self.write_log_impl(b"\n")?;
275
276 self.log_entries += 1;
277
278 Ok(())
279 }
280
281 fn write_log_impl(&mut self, bytes: &[u8]) -> Result<(), RunStoreError> {
282 self.log
283 .write_all(bytes)
284 .map_err(|error| RunStoreError::RunLogWrite {
285 path: self.log_path.clone(),
286 error,
287 })
288 }
289
290 pub(crate) fn finish(self) -> Result<StoreSizes, RunStoreError> {
295 let log_sizes =
296 self.log
297 .0
298 .finish(self.log_entries)
299 .map_err(|error| RunStoreError::RunLogFlush {
300 path: self.log_path.clone(),
301 error,
302 })?;
303
304 let store_sizes =
305 self.store_writer
306 .finish()
307 .map_err(|error| RunStoreError::StoreWrite {
308 store_path: self.store_path.clone(),
309 error,
310 })?;
311
312 Ok(StoreSizes {
313 log: log_sizes,
314 store: store_sizes,
315 })
316 }
317}
318
319#[derive(Debug)]
321pub(crate) struct StoreWriter {
322 writer: DebugIgnore<ArchiveWriter<Counter<File>>>,
323 added_files: HashSet<Utf8PathBuf>,
324 uncompressed_size: u64,
326}
327
328impl StoreWriter {
329 fn new(store_path: &Utf8Path) -> Result<Self, StoreWriterError> {
331 let zip_file = std::fs::OpenOptions::new()
332 .create(true)
333 .truncate(true)
334 .write(true)
335 .open(store_path)
336 .map_err(|error| StoreWriterError::Create { error })?;
337 let writer = ArchiveWriter::new(Counter::new(zip_file));
338
339 Ok(Self {
340 writer: DebugIgnore(writer),
341 added_files: HashSet::new(),
342 uncompressed_size: 0,
343 })
344 }
345
346 fn add_file(&mut self, path: Utf8PathBuf, contents: &[u8]) -> Result<(), StoreWriterError> {
353 if self.added_files.contains(&path) {
354 return Ok(());
355 }
356
357 self.uncompressed_size += contents.len() as u64;
359
360 let dict = OutputDict::for_path(&path);
361 match dict.dict_bytes() {
362 Some(dict_bytes) => {
363 let compressed = compress_with_dict(contents, dict_bytes)
366 .map_err(|error| StoreWriterError::Compress { error })?;
367
368 let options = stored_file_options();
369 self.writer
370 .add_file(path.as_str(), &compressed[..], &options)
371 .map_err(|error| StoreWriterError::Write {
372 path: path.clone(),
373 error,
374 })?;
375 }
376 None => {
377 let options = zstd_file_options();
379 self.writer
380 .add_file(path.as_str(), contents, &options)
381 .map_err(|error| StoreWriterError::Write {
382 path: path.clone(),
383 error,
384 })?;
385 }
386 }
387
388 self.added_files.insert(path);
389
390 Ok(())
391 }
392
393 fn finish(self) -> Result<ComponentSizes, StoreWriterError> {
397 let entries = self.added_files.len() as u64;
398 let mut counter = self
399 .writer
400 .0
401 .finish()
402 .map_err(|error| StoreWriterError::Finish { error })?;
403
404 counter
405 .flush()
406 .map_err(|error| StoreWriterError::Flush { error })?;
407
408 Ok(ComponentSizes {
409 compressed: counter.writer_bytes() as u64,
410 uncompressed: self.uncompressed_size,
411 entries,
412 })
413 }
414}
415
416#[derive(Clone, Copy, Debug, Default)]
418pub struct ComponentSizes {
419 pub compressed: u64,
421 pub uncompressed: u64,
423 pub entries: u64,
425}
426
427#[derive(Clone, Copy, Debug, Default)]
429pub struct StoreSizes {
430 pub log: ComponentSizes,
432 pub store: ComponentSizes,
434}
435
436impl StoreSizes {
437 pub fn total_compressed(&self) -> u64 {
439 self.log.compressed + self.store.compressed
440 }
441
442 pub fn total_uncompressed(&self) -> u64 {
444 self.log.uncompressed + self.store.uncompressed
445 }
446}
447
448fn compress_with_dict(data: &[u8], dict_bytes: &[u8]) -> io::Result<Vec<u8>> {
450 let dict = zstd::dict::EncoderDictionary::copy(dict_bytes, 3);
453 let mut encoder = zstd::stream::Encoder::with_prepared_dictionary(Vec::new(), &dict)?;
454 encoder.write_all(data)?;
455 encoder.finish()
456}
457
458struct SerializeTestEventContext<'a> {
463 store_writer: &'a mut StoreWriter,
464 max_output_size: usize,
465}
466
467impl SerializeTestEventContext<'_> {
468 fn convert_event(
470 &mut self,
471 event: TestEventSummary<LiveSpec>,
472 ) -> Result<TestEventSummary<RecordingSpec>, StoreWriterError> {
473 Ok(TestEventSummary {
474 timestamp: event.timestamp,
475 elapsed: event.elapsed,
476 kind: self.convert_event_kind(event.kind)?,
477 })
478 }
479
480 fn convert_event_kind(
481 &mut self,
482 kind: TestEventKindSummary<LiveSpec>,
483 ) -> Result<TestEventKindSummary<RecordingSpec>, StoreWriterError> {
484 match kind {
485 TestEventKindSummary::Core(core) => Ok(TestEventKindSummary::Core(core)),
486 TestEventKindSummary::Output(output) => Ok(TestEventKindSummary::Output(
487 self.convert_output_event(output)?,
488 )),
489 }
490 }
491
492 fn convert_output_event(
493 &mut self,
494 event: OutputEventKind<LiveSpec>,
495 ) -> Result<OutputEventKind<RecordingSpec>, StoreWriterError> {
496 match event {
497 OutputEventKind::SetupScriptFinished {
498 stress_index,
499 index,
500 total,
501 script_id,
502 program,
503 args,
504 no_capture,
505 run_status,
506 } => {
507 let run_status = self.convert_setup_script_status(&run_status)?;
508 Ok(OutputEventKind::SetupScriptFinished {
509 stress_index,
510 index,
511 total,
512 script_id,
513 program,
514 args,
515 no_capture,
516 run_status,
517 })
518 }
519 OutputEventKind::TestAttemptFailedWillRetry {
520 stress_index,
521 test_instance,
522 run_status,
523 delay_before_next_attempt,
524 failure_output,
525 running,
526 } => {
527 let run_status = self.convert_execute_status(run_status)?;
528 Ok(OutputEventKind::TestAttemptFailedWillRetry {
529 stress_index,
530 test_instance,
531 run_status,
532 delay_before_next_attempt,
533 failure_output,
534 running,
535 })
536 }
537 OutputEventKind::TestFinished {
538 stress_index,
539 test_instance,
540 success_output,
541 failure_output,
542 junit_store_success_output,
543 junit_store_failure_output,
544 junit_flaky_fail_status,
545 run_statuses,
546 current_stats,
547 running,
548 } => {
549 let run_statuses = self.convert_execution_statuses(run_statuses)?;
550 Ok(OutputEventKind::TestFinished {
551 stress_index,
552 test_instance,
553 success_output,
554 failure_output,
555 junit_store_success_output,
556 junit_store_failure_output,
557 junit_flaky_fail_status,
558 run_statuses,
559 current_stats,
560 running,
561 })
562 }
563 }
564 }
565
566 fn convert_setup_script_status(
567 &mut self,
568 status: &SetupScriptExecuteStatus<LiveSpec>,
569 ) -> Result<SetupScriptExecuteStatus<RecordingSpec>, StoreWriterError> {
570 Ok(SetupScriptExecuteStatus {
571 output: self.convert_child_execution_output(&status.output)?,
572 result: status.result.clone(),
573 start_time: status.start_time,
574 time_taken: status.time_taken,
575 is_slow: status.is_slow,
576 env_map: status.env_map.clone(),
577 error_summary: status.error_summary.clone(),
578 })
579 }
580
581 fn convert_execution_statuses(
582 &mut self,
583 statuses: ExecutionStatuses<LiveSpec>,
584 ) -> Result<ExecutionStatuses<RecordingSpec>, StoreWriterError> {
585 let flaky_result = statuses.flaky_result();
586 let statuses = statuses
587 .into_iter()
588 .map(|status| self.convert_execute_status(status))
589 .collect::<Result<Vec<_>, _>>()?;
590 Ok(ExecutionStatuses::new(statuses, flaky_result))
591 }
592
593 fn convert_execute_status(
594 &mut self,
595 status: ExecuteStatus<LiveSpec>,
596 ) -> Result<ExecuteStatus<RecordingSpec>, StoreWriterError> {
597 let output = self.convert_child_execution_output(&status.output)?;
598
599 Ok(ExecuteStatus {
600 retry_data: status.retry_data,
601 output,
602 result: status.result,
603 start_time: status.start_time,
604 time_taken: status.time_taken,
605 is_slow: status.is_slow,
606 delay_before_start: status.delay_before_start,
607 error_summary: status.error_summary,
608 output_error_slice: status.output_error_slice,
609 })
610 }
611
612 fn convert_child_execution_output(
613 &mut self,
614 output: &ChildExecutionOutputDescription<LiveSpec>,
615 ) -> Result<ChildExecutionOutputDescription<RecordingSpec>, StoreWriterError> {
616 match output {
617 ChildExecutionOutputDescription::Output {
618 result,
619 output,
620 errors,
621 } => {
622 let output = self.convert_child_output(output)?;
623 Ok(ChildExecutionOutputDescription::Output {
624 result: result.clone(),
625 output,
626 errors: errors.clone(),
627 })
628 }
629 ChildExecutionOutputDescription::StartError(err) => {
630 Ok(ChildExecutionOutputDescription::StartError(err.clone()))
631 }
632 }
633 }
634
635 fn convert_child_output(
636 &mut self,
637 output: &ChildOutputDescription,
638 ) -> Result<ZipStoreOutputDescription, StoreWriterError> {
639 match output {
640 ChildOutputDescription::Split { stdout, stderr } => {
641 Ok(ZipStoreOutputDescription::Split {
642 stdout: stdout
644 .as_ref()
645 .map(|o| self.write_single_output(Some(o), OutputKind::Stdout))
646 .transpose()?,
647 stderr: stderr
648 .as_ref()
649 .map(|o| self.write_single_output(Some(o), OutputKind::Stderr))
650 .transpose()?,
651 })
652 }
653 ChildOutputDescription::Combined { output } => {
654 Ok(ZipStoreOutputDescription::Combined {
655 output: self.write_single_output(Some(output), OutputKind::Combined)?,
656 })
657 }
658 ChildOutputDescription::NotLoaded => {
659 unreachable!(
660 "NotLoaded output should never be present during recording \
661 (NotLoaded is only produced during replay conversion)"
662 );
663 }
664 }
665 }
666
667 fn write_single_output(
672 &mut self,
673 output: Option<&ChildSingleOutput>,
674 kind: OutputKind,
675 ) -> Result<ZipStoreOutput, StoreWriterError> {
676 let Some(output) = output else {
677 return Ok(ZipStoreOutput::Empty);
678 };
679
680 if output.buf().is_empty() {
681 return Ok(ZipStoreOutput::Empty);
682 }
683
684 let original_len = output.buf().len();
685 let (data, truncated): (Cow<'_, [u8]>, bool) = if original_len <= self.max_output_size {
686 (Cow::Borrowed(output.buf()), false)
687 } else {
688 (truncate_output(output.buf(), self.max_output_size), true)
689 };
690
691 let file_name = OutputFileName::from_content(&data, kind);
692 let file_path = Utf8PathBuf::from(format!("out/{file_name}"));
693
694 self.store_writer.add_file(file_path, &data)?;
695
696 if truncated {
697 Ok(ZipStoreOutput::Truncated {
698 file_name,
699 original_size: original_len as u64,
700 })
701 } else {
702 Ok(ZipStoreOutput::Full { file_name })
703 }
704 }
705}
706
707fn truncate_output(buf: &[u8], max_size: usize) -> Cow<'_, [u8]> {
714 if buf.len() <= max_size {
715 return Cow::Borrowed(buf);
716 }
717
718 let truncated_bytes = buf.len() - max_size;
719 let marker = format!("\n\n... [truncated {truncated_bytes} bytes] ...\n\n");
720 let marker_bytes = marker.as_bytes();
721
722 let content_space = max_size.saturating_sub(marker_bytes.len());
723 let head_size = content_space / 2;
724 let tail_size = content_space - head_size;
725
726 let mut result = Vec::with_capacity(max_size);
727 result.extend_from_slice(&buf[..head_size]);
728 result.extend_from_slice(marker_bytes);
729 result.extend_from_slice(&buf[buf.len() - tail_size..]);
730
731 Cow::Owned(result)
732}
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737 use crate::record::dicts;
738
739 #[test]
740 fn test_truncate_output_no_truncation_needed() {
741 let input = b"hello world";
742 let result = truncate_output(input, 100);
743 assert_eq!(&*result, input);
744 assert!(matches!(result, Cow::Borrowed(_)), "should be borrowed");
745 }
746
747 #[test]
748 fn test_truncate_output_exact_size() {
749 let input = b"exactly100bytes";
750 let result = truncate_output(input, input.len());
751 assert_eq!(&*result, input);
752 assert!(matches!(result, Cow::Borrowed(_)), "should be borrowed");
753 }
754
755 #[test]
756 fn test_truncate_output_basic() {
757 let input: Vec<u8> = (0..200).collect();
759 let max_size = 100;
760
761 let result = truncate_output(&input, max_size);
762
763 assert!(matches!(result, Cow::Owned(_)), "should be owned");
765
766 assert!(
768 result.len() <= max_size,
769 "result len {} should be <= max_size {}",
770 result.len(),
771 max_size
772 );
773
774 let result_str = String::from_utf8_lossy(&result);
776 assert!(
777 result_str.contains("[truncated"),
778 "should contain truncation marker: {result_str:?}"
779 );
780 assert!(
781 result_str.contains("bytes]"),
782 "should contain 'bytes]': {result_str:?}"
783 );
784
785 assert!(
787 result.starts_with(&[0, 1, 2]),
788 "should start with beginning of input"
789 );
790
791 assert!(
793 result.ends_with(&[197, 198, 199]),
794 "should end with end of input"
795 );
796 }
797
798 #[test]
799 fn test_truncate_output_preserves_head_and_tail() {
800 let head = b"HEAD_CONTENT_";
801 let middle = vec![b'x'; 1000];
802 let tail = b"_TAIL_CONTENT";
803
804 let mut input = Vec::new();
805 input.extend_from_slice(head);
806 input.extend_from_slice(&middle);
807 input.extend_from_slice(tail);
808
809 let max_size = 200;
810 let result = truncate_output(&input, max_size);
811
812 assert!(result.len() <= max_size);
813
814 assert!(
816 result.starts_with(b"HEAD"),
817 "should preserve head: {:?}",
818 String::from_utf8_lossy(&result[..20])
819 );
820
821 assert!(
823 result.ends_with(b"CONTENT"),
824 "should preserve tail: {:?}",
825 String::from_utf8_lossy(&result[result.len() - 20..])
826 );
827 }
828
829 #[test]
830 fn test_truncate_output_marker_shows_correct_count() {
831 let input: Vec<u8> = vec![b'a'; 1000];
832 let max_size = 100;
833
834 let result = truncate_output(&input, max_size);
835 let result_str = String::from_utf8_lossy(&result);
836
837 assert!(
839 result_str.contains("[truncated 900 bytes]"),
840 "should show correct truncation count: {result_str:?}"
841 );
842 }
843
844 #[test]
845 fn test_truncate_output_large_input() {
846 let input: Vec<u8> = vec![b'x'; 20 * 1024 * 1024]; let max_size = 10 * 1024 * 1024; let result = truncate_output(&input, max_size);
851
852 assert!(
853 result.len() <= max_size,
854 "result {} should be <= max_size {}",
855 result.len(),
856 max_size
857 );
858
859 let result_str = String::from_utf8_lossy(&result);
860 assert!(
861 result_str.contains("[truncated"),
862 "should contain truncation marker"
863 );
864 }
865
866 #[test]
867 fn test_truncate_output_max_size_smaller_than_marker() {
868 let input: Vec<u8> = vec![b'x'; 100];
872 let max_size = 10; let result = truncate_output(&input, max_size);
875
876 let result_str = String::from_utf8_lossy(&result);
880 assert!(
881 result_str.contains("[truncated"),
882 "should still contain truncation marker: {result_str:?}"
883 );
884
885 assert!(
887 result_str.starts_with("\n\n..."),
888 "should start with marker prefix"
889 );
890 assert!(
891 result_str.ends_with("...\n\n"),
892 "should end with marker suffix"
893 );
894 }
895
896 #[test]
897 fn test_truncate_output_max_size_zero() {
898 let input: Vec<u8> = vec![b'x'; 50];
900 let max_size = 0;
901
902 let result = truncate_output(&input, max_size);
903
904 let result_str = String::from_utf8_lossy(&result);
906 assert!(
907 result_str.contains("[truncated 50 bytes]"),
908 "should show correct truncation count: {result_str:?}"
909 );
910 }
911
912 #[test]
913 fn test_compress_with_dict_stdout() {
914 let test_output = b"running 1 test\ntest tests::my_test ... ok\n\ntest result: ok. 1 passed; 0 failed; 0 ignored\n";
916
917 let compressed =
919 compress_with_dict(test_output, dicts::STDOUT).expect("compression failed");
920
921 let dict = zstd::dict::DecoderDictionary::copy(dicts::STDOUT);
923 let mut decoder = zstd::stream::Decoder::with_prepared_dictionary(&compressed[..], &dict)
924 .expect("decoder creation failed");
925 let mut decompressed = Vec::new();
926 io::Read::read_to_end(&mut decoder, &mut decompressed).expect("decompression failed");
927
928 assert_eq!(decompressed, test_output, "round-trip should preserve data");
929 }
930}