Skip to main content

nextest_runner/record/
recorder.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Recording logic for individual test runs.
5//!
6//! The [`RunRecorder`] handles writing a single test run to disk, including:
7//!
8//! - A zstd-compressed zip archive (`store.zip`) containing metadata and outputs.
9//! - A zstd-compressed JSON Lines log file (`run.log.zst`) containing test events.
10
11use 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
45/// Zstd encoder that auto-finishes on drop but also supports explicit finish.
46///
47/// Unlike `zstd::stream::AutoFinishEncoder`, this wrapper allows calling
48/// `finish()` explicitly to get error handling and the underlying writer back.
49/// If dropped without calling `finish()`, the stream is finalized and errors
50/// are ignored.
51///
52/// The encoder is wrapped in `Counter<Encoder<Counter<File>>>`:
53/// - Outer Counter tracks uncompressed bytes written to the encoder.
54/// - Inner Counter tracks compressed bytes written to the file.
55struct LogEncoder {
56    /// The inner encoder, wrapped in Option so we can take it in finish().
57    /// Counter<Encoder<Counter<File>>> tracks both uncompressed and compressed sizes.
58    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    /// Finishes the encoder and returns the compressed and uncompressed sizes.
75    ///
76    /// The `entries` parameter is the number of log entries written.
77    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            // Intentionally ignore errors here. This Drop impl only runs if
110            // finish() wasn't called, which only happens during a panic. In
111            // that situation, logging or other side effects could make things
112            // worse.
113            let _ = counter.into_inner().finish();
114        }
115    }
116}
117
118/// Records a single test run to disk.
119///
120/// Created by `ExclusiveLockedRunStore::create_run_recorder`. Writes both a zip
121/// archive with metadata and outputs, and a zstd-compressed JSON Lines log.
122#[derive(Debug)]
123pub struct RunRecorder {
124    store_path: Utf8PathBuf,
125    store_writer: StoreWriter,
126    log_path: Utf8PathBuf,
127    log: DebugIgnore<LogEncoder>,
128    /// Number of log entries (records) written.
129    log_entries: u64,
130    max_output_size: usize,
131}
132
133impl RunRecorder {
134    /// Creates a new `RunRecorder` in the given directory.
135    ///
136    /// `max_output_size` specifies the maximum size of a single output (stdout/stderr)
137    /// before truncation. Outputs exceeding this size will have the middle portion removed.
138    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        // Compression level 3 is a good balance of speed and ratio. The zstd
166        // library has its own internal buffer (~128KB), so no additional
167        // buffering is needed.
168        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            // Saturate to usize::MAX on 32-bit platforms. This is fine because
183            // you can't allocate more than usize::MAX bytes anyway.
184            max_output_size: usize::try_from(max_output_size.as_u64()).unwrap_or(usize::MAX),
185        })
186    }
187
188    /// Writes metadata (cargo metadata, test list, options, and dictionaries) to the archive.
189    ///
190    /// This should be called once at the beginning of a test run.
191    ///
192    /// Note: The store format version is stored in runs.json.zst, not in the archive itself.
193    /// This allows checking replayability without opening the archive.
194    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        // Write dictionaries to make the archive self-contained.
211        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    /// Writes the build scope to the archive.
218    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    /// Writes rerun-specific metadata to the archive.
231    ///
232    /// This should be called once at the beginning of a rerun (after setup).
233    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    /// Writes a test event to the archive and log.
252    ///
253    /// The event's outputs are written to the zip archive, and the event
254    /// (with file references) is written to the JSON Lines log.
255    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    /// Finishes writing and closes all files.
291    ///
292    /// This must be called to ensure all data is flushed to disk.
293    /// Returns the compressed and uncompressed sizes for both log and store.
294    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/// Writes files to a zstd-compressed zip archive.
320#[derive(Debug)]
321pub(crate) struct StoreWriter {
322    writer: DebugIgnore<ArchiveWriter<Counter<File>>>,
323    added_files: HashSet<Utf8PathBuf>,
324    /// Total uncompressed size of all files added to the archive.
325    uncompressed_size: u64,
326}
327
328impl StoreWriter {
329    /// Creates a new `StoreWriter` at the given path.
330    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    /// Adds a file to the archive.
347    ///
348    /// Output files (in `out/`) are pre-compressed with zstd dictionaries for
349    /// better compression. Metadata files use standard zstd compression.
350    ///
351    /// If a file with the same path has already been added, this is a no-op.
352    fn add_file(&mut self, path: Utf8PathBuf, contents: &[u8]) -> Result<(), StoreWriterError> {
353        if self.added_files.contains(&path) {
354            return Ok(());
355        }
356
357        // Track the uncompressed size of the file.
358        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                // Output files: pre-compress with zstd dictionary, store as-is
364                // in the archive.
365                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                // Metadata files: let the archive handle zstd compression.
378                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    /// Finishes writing and closes the archive.
394    ///
395    /// Returns the compressed and uncompressed sizes and entry count.
396    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/// Compressed and uncompressed sizes for a single component (log or store).
417#[derive(Clone, Copy, Debug, Default)]
418pub struct ComponentSizes {
419    /// Compressed size in bytes.
420    pub compressed: u64,
421    /// Uncompressed size in bytes.
422    pub uncompressed: u64,
423    /// Number of entries (records for log, files for store).
424    pub entries: u64,
425}
426
427/// Compressed and uncompressed sizes for storage, broken down by component.
428#[derive(Clone, Copy, Debug, Default)]
429pub struct StoreSizes {
430    /// Sizes for the run log (run.log.zst).
431    pub log: ComponentSizes,
432    /// Sizes for the store archive (store.zip).
433    pub store: ComponentSizes,
434}
435
436impl StoreSizes {
437    /// Returns the total compressed size (log + store).
438    pub fn total_compressed(&self) -> u64 {
439        self.log.compressed + self.store.compressed
440    }
441
442    /// Returns the total uncompressed size (log + store).
443    pub fn total_uncompressed(&self) -> u64 {
444        self.log.uncompressed + self.store.uncompressed
445    }
446}
447
448/// Compresses data using a pre-trained zstd dictionary.
449fn compress_with_dict(data: &[u8], dict_bytes: &[u8]) -> io::Result<Vec<u8>> {
450    // Compression level 3 is a good balance of speed and ratio for
451    // dictionaries.
452    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
458/// Context for serializing test events to the zip store.
459///
460/// Handles writing output buffers to the zip and converting in-memory
461/// references to file references.
462struct SerializeTestEventContext<'a> {
463    store_writer: &'a mut StoreWriter,
464    max_output_size: usize,
465}
466
467impl SerializeTestEventContext<'_> {
468    /// Converts an in-memory event to a zip store event.
469    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                    // Preserve None (not captured) vs Some (captured, possibly empty).
643                    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    /// Writes a single output to the archive using content-addressed naming.
668    ///
669    /// The file name is a hash of the content, enabling deduplication of
670    /// identical outputs across stress iterations, retries, and tests.
671    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
707/// Truncates output to fit within `max_size` by keeping the start and end.
708///
709/// If `buf` is already within `max_size`, returns a borrowed reference.
710/// Otherwise, returns an owned buffer with approximately equal portions from
711/// the start and end, with a marker in the middle indicating how many bytes
712/// were removed.
713fn 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        // Create input that exceeds max_size.
758        let input: Vec<u8> = (0..200).collect();
759        let max_size = 100;
760
761        let result = truncate_output(&input, max_size);
762
763        // Should be owned since truncation occurred.
764        assert!(matches!(result, Cow::Owned(_)), "should be owned");
765
766        // Result should be at or under max_size.
767        assert!(
768            result.len() <= max_size,
769            "result len {} should be <= max_size {}",
770            result.len(),
771            max_size
772        );
773
774        // Should contain the truncation marker.
775        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        // Should start with beginning of original input.
786        assert!(
787            result.starts_with(&[0, 1, 2]),
788            "should start with beginning of input"
789        );
790
791        // Should end with end of original input.
792        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        // Head should be preserved.
815        assert!(
816            result.starts_with(b"HEAD"),
817            "should preserve head: {:?}",
818            String::from_utf8_lossy(&result[..20])
819        );
820
821        // Tail should be preserved.
822        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        // Should show 900 bytes truncated (1000 - 100 = 900).
838        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        // Simulate a more realistic scenario with larger input.
847        let input: Vec<u8> = vec![b'x'; 20 * 1024 * 1024]; // 20 MB
848        let max_size = 10 * 1024 * 1024; // 10 MB
849
850        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        // When max_size is smaller than the marker itself, the function should
869        // still produce a valid result. The marker is approximately 35+ bytes:
870        // "\n\n... [truncated N bytes] ...\n\n".
871        let input: Vec<u8> = vec![b'x'; 100];
872        let max_size = 10; // Much smaller than the marker.
873
874        let result = truncate_output(&input, max_size);
875
876        // The result will be just the marker since there's no room for content.
877        // This means result.len() > max_size, which is acceptable because the
878        // marker is the minimum output when truncation occurs.
879        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        // The result should be the marker with no content bytes.
886        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        // Edge case: max_size of 0 should still produce the marker.
899        let input: Vec<u8> = vec![b'x'; 50];
900        let max_size = 0;
901
902        let result = truncate_output(&input, max_size);
903
904        // With max_size = 0, content_space = 0, so result is just the marker.
905        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        // Test data that looks like typical test output.
915        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        // Compress with stdout dictionary.
918        let compressed =
919            compress_with_dict(test_output, dicts::STDOUT).expect("compression failed");
920
921        // Decompress with the same dictionary.
922        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}