1use std::fs::File;
2use std::io::{self, Read, Seek, SeekFrom, Write};
3use std::sync::Mutex;
4use std::sync::atomic::{AtomicBool, Ordering};
5
6#[derive(Clone, Debug, Default, PartialEq, Eq)]
9pub enum Display {
10 #[default]
12 Stream,
13 Prefixed(String),
15 Static {
21 id: String,
22 group: bool,
23 cached: bool,
24 collapse: bool,
25 },
26 Headed { id: String },
28 Failures {
32 id: String,
33 log: Option<std::path::PathBuf>,
34 },
35 Hidden,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum Shown {
42 Success,
43 Failure,
44 LocalCache,
45 RemoteCache,
46 Kept,
48}
49
50impl Display {
51 pub fn for_task(
59 style: OutputStyle,
60 id: &str,
61 project: &str,
62 continuous: bool,
63 root: bool,
64 initiating: bool,
65 streams: bool,
66 ) -> Self {
67 use OutputStyle::*;
68 let run_one = matches!(style, RunOne | RunOneFailuresOnly);
69 let held = matches!(
70 style,
71 Static | RunOne | StaticFailuresOnly | RunOneFailuresOnly
72 );
73 let streamed = || {
76 if std::env::var_os("NX_PREFIX_OUTPUT").is_some_and(|value| value == "true") {
77 Self::Prefixed(prefix(project))
78 } else {
79 Self::Stream
80 }
81 };
82 match style {
83 StreamWithoutPrefixes => Self::Stream,
84 DynamicRunOne if root => Self::Stream,
85 DynamicRunOne if continuous => Self::Prefixed(prefix(project)),
86 DynamicRunOne => Self::Failures {
87 id: id.to_owned(),
88 log: None,
89 },
90 _ if run_one && root && !continuous => Self::Headed { id: id.to_owned() },
91 _ if held && streams && !continuous => streamed(),
92 Static | RunOne | StaticFailuresOnly | RunOneFailuresOnly if !continuous => {
93 Self::Static {
94 id: id.to_owned(),
95 group: std::env::var_os("GITHUB_ACTIONS").is_some()
96 && std::env::var_os("NX_SKIP_LOG_GROUPING")
97 .is_none_or(|value| value != "true"),
98 cached: style == Static,
99 collapse: style == StaticFailuresOnly
100 || (style == RunOneFailuresOnly && !initiating),
101 }
102 }
103 Quiet => Self::Failures {
104 id: id.to_owned(),
105 log: None,
106 },
107 Summary if !continuous => Self::Failures {
108 id: id.to_owned(),
109 log: None,
110 },
111 Stream => Self::Prefixed(prefix(project)),
112 Static | RunOne | StaticFailuresOnly | RunOneFailuresOnly | Summary => streamed(),
113 }
114 }
115}
116
117pub fn printable(
120 project: &str,
121 target: &str,
122 configuration: Option<&str>,
123 args: &[String],
124) -> String {
125 let mut text = if target.contains(':') {
126 format!("{project}:\"{target}\"")
127 } else {
128 format!("{project}:{target}")
129 };
130 if let Some(configuration) = configuration {
131 text.push(':');
132 text.push_str(configuration);
133 }
134 for arg in args {
135 text.push(' ');
136 text.push_str(arg);
137 }
138 text
139}
140
141static COLLAPSED: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
144
145pub fn collapsed() -> usize {
146 COLLAPSED.load(Ordering::SeqCst)
147}
148
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub enum OutputStyle {
152 Stream,
153 StreamWithoutPrefixes,
154 Static,
155 RunOne,
159 StaticFailuresOnly,
162 RunOneFailuresOnly,
165 Summary,
167 Quiet,
169 DynamicRunOne,
173}
174
175fn prefix(project: &str) -> String {
178 const COLOURS: [u8; 10] = [32, 92, 34, 94, 36, 96, 33, 93, 35, 95];
181 if !colour() {
182 return format!("{project}:");
183 }
184 let code: u32 = project.encode_utf16().map(u32::from).sum();
185 let colour = COLOURS[code as usize % COLOURS.len()];
186 format!("\x1b[1m\x1b[{colour}m{project}:\x1b[39m\x1b[22m")
187}
188
189#[derive(Clone, Debug, Default, PartialEq, Eq)]
191pub struct Decoration {
192 pub prefix: Option<String>,
193 pub prefix_color: Option<String>,
194 pub color: Option<String>,
195 pub bg_color: Option<String>,
196}
197
198const COLOURS: [&str; 8] = [
199 "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
200];
201
202impl Decoration {
203 pub fn is_empty(&self) -> bool {
204 self == &Self::default()
205 }
206
207 pub fn valid(name: &str, background: bool) -> bool {
210 match name.strip_prefix("bg") {
211 Some(rest) if background => {
212 let mut chars = rest.chars();
213 chars.next().is_some_and(char::is_uppercase)
214 && COLOURS.contains(&rest.to_lowercase().as_str())
215 }
216 _ => !background && COLOURS.contains(&name),
217 }
218 }
219
220 fn apply(&self, bytes: &[u8]) -> Vec<u8> {
223 let colours = colour();
224 let paint = |text: &str, name: Option<&str>, background: bool| -> String {
225 let index = name.and_then(|name| {
226 let name = if background {
227 name.get(2..)?.to_lowercase()
228 } else {
229 name.to_owned()
230 };
231 COLOURS.iter().position(|colour| *colour == name)
232 });
233 match index {
234 Some(index) if colours => {
235 let (open, close) = if background { (40, 49) } else { (30, 39) };
236 format!("\x1b[{}m{text}\x1b[{close}m", open + index)
237 }
238 _ => text.to_owned(),
239 }
240 };
241 let mut text = String::from_utf8_lossy(bytes).into_owned();
242 if let Some(prefix) = &self.prefix {
243 let mut prefix = paint(prefix, self.prefix_color.as_deref(), false);
244 if colours {
245 prefix = format!("\x1b[1m{prefix}\x1b[22m");
246 }
247 text = text
248 .split('\n')
249 .map(|line| {
250 if line.trim().is_empty() {
251 line.to_owned()
252 } else {
253 format!("{prefix} {line}")
254 }
255 })
256 .collect::<Vec<_>>()
257 .join("\n");
258 }
259 let text = paint(&text, self.color.as_deref(), false);
260 paint(&text, self.bg_color.as_deref(), true).into_bytes()
261 }
262}
263
264const MEMORY_OUTPUT_BYTES: usize = 64 * 1024;
267
268#[derive(Default)]
269struct Spool {
270 memory: Vec<u8>,
271 file: Option<File>,
272 last: Option<u8>,
273}
274
275impl Spool {
276 fn append(&mut self, bytes: &[u8]) -> io::Result<()> {
277 if self.file.is_none() && self.memory.len() + bytes.len() > MEMORY_OUTPUT_BYTES {
278 let mut file = tempfile::tempfile()?;
279 file.write_all(&self.memory)?;
280 self.file = Some(file);
281 self.memory = Vec::new();
282 }
283 if let Some(file) = &mut self.file {
284 file.write_all(bytes)?;
285 } else {
286 self.memory.extend_from_slice(bytes);
287 }
288 if let Some(last) = bytes.last() {
289 self.last = Some(*last);
290 }
291 Ok(())
292 }
293
294 fn visit(&mut self, mut consume: impl FnMut(&[u8]) -> io::Result<()>) -> io::Result<()> {
295 if let Some(file) = &mut self.file {
296 file.seek(SeekFrom::Start(0))?;
297 let mut buffer = [0; 16 * 1024];
298 loop {
299 let count = file.read(&mut buffer)?;
300 if count == 0 {
301 break;
302 }
303 consume(&buffer[..count])?;
304 }
305 file.seek(SeekFrom::End(0))?;
307 } else {
308 for bytes in self.memory.chunks(16 * 1024) {
309 consume(bytes)?;
310 }
311 }
312 Ok(())
313 }
314 fn prefixed(&mut self, stderr: bool, prefix: &str) -> io::Result<()> {
315 if self.file.is_none() {
316 return write_to(stderr, &prefixed(prefix, &self.memory));
317 }
318 crate::report::output_batch(|output| {
319 let mut started = false;
320 self.visit(|bytes| {
321 for piece in bytes.split_inclusive(|byte| matches!(byte, b'\n' | b'\r')) {
322 let complete = piece
323 .last()
324 .is_some_and(|byte| matches!(byte, b'\n' | b'\r'));
325 let content = if complete {
326 &piece[..piece.len() - 1]
327 } else {
328 piece
329 };
330 if !content.is_empty() {
331 if !started {
332 output(stderr, prefix.as_bytes());
333 output(stderr, b" ");
334 started = true;
335 }
336 output(stderr, content);
337 }
338 if complete && started {
339 output(stderr, b"\n");
340 started = false;
341 }
342 }
343 Ok(())
344 })?;
345 if started {
346 output(stderr, b"\n");
347 }
348 Ok(())
349 })
350 }
351}
352
353fn colour() -> bool {
356 use std::io::IsTerminal;
357 let set = |name| std::env::var_os(name).is_some();
358 if crate::force_colour() == Some(false) {
359 return false;
360 }
361 !set("NO_COLOR")
362 && (set("FORCE_COLOR")
363 || cfg!(windows)
364 || set("CI")
365 || (io::stdout().is_terminal()
366 && std::env::var("TERM").is_ok_and(|term| term != "dumb")))
367}
368
369struct Printer {
371 display: Display,
372 lines: Mutex<[Spool; 2]>,
374 held: Mutex<Spool>,
375}
376
377impl Printer {
378 fn new(display: Display) -> Self {
379 Self {
380 display,
381 lines: Mutex::default(),
382 held: Mutex::default(),
383 }
384 }
385
386 fn write(&self, stderr: bool, bytes: &[u8]) -> io::Result<()> {
387 match &self.display {
388 Display::Stream | Display::Headed { .. } => write_to(stderr, bytes),
389 Display::Hidden => Ok(()),
390 Display::Static { .. } | Display::Failures { .. } => {
391 self.held.lock().unwrap().append(bytes)
392 }
393 Display::Prefixed(prefix) => {
394 let mut lines = self.lines.lock().unwrap();
395 let buffer = &mut lines[usize::from(stderr)];
396 let end = bytes
397 .iter()
398 .rposition(|byte| matches!(byte, b'\n' | b'\r'))
399 .map(|end| end + 1);
400 if let Some(end) = end {
401 buffer.append(&bytes[..end])?;
402 let mut complete = std::mem::take(buffer);
403 complete.prefixed(stderr, prefix)?;
404 buffer.append(&bytes[end..])?;
405 } else {
406 buffer.append(bytes)?;
407 }
408 Ok(())
409 }
410 }
411 }
412
413 fn begin(&self, shown: Option<Shown>) -> io::Result<()> {
415 match &self.display {
416 Display::Headed { id } => {
417 let header = header(id, shown.unwrap_or(Shown::Success));
418 write_to(false, format!("\n{header}\n\n").as_bytes())
419 }
420 _ => Ok(()),
421 }
422 }
423
424 fn finish(&self, shown: Shown) -> io::Result<()> {
425 match &self.display {
426 Display::Stream | Display::Headed { .. } | Display::Hidden => Ok(()),
427 Display::Failures {
428 log: Some(path), ..
429 } => {
430 let mut held = std::mem::take(&mut *self.held.lock().unwrap());
431 if shown == Shown::Failure {
432 if let Some(directory) = path.parent() {
433 std::fs::create_dir_all(directory)?;
434 }
435 let mut file = File::create(path)?;
436 held.visit(|bytes| file.write_all(bytes))?;
437 }
438 Ok(())
439 }
440 Display::Failures { id, log: None } => {
441 let mut held = std::mem::take(&mut *self.held.lock().unwrap());
442 if shown == Shown::Failure {
443 crate::report::output_batch(|output| -> io::Result<()> {
444 output(false, format!("\nā qk run {id} failed\n\n").as_bytes());
445 held.visit(|bytes| {
446 output(false, bytes);
447 Ok(())
448 })?;
449 if held.last != Some(b'\n') {
450 output(false, b"\n");
451 }
452 Ok(())
453 })?;
454 }
455 Ok(())
456 }
457 Display::Prefixed(prefix) => {
458 let mut lines = self.lines.lock().unwrap();
459 for stderr in [false, true] {
460 let mut rest = std::mem::take(&mut lines[usize::from(stderr)]);
461 rest.prefixed(stderr, prefix)?;
462 }
463 Ok(())
464 }
465 Display::Static {
466 id, collapse: true, ..
467 } if shown != Shown::Failure => {
468 drop(std::mem::take(&mut *self.held.lock().unwrap()));
470 COLLAPSED.fetch_add(1, Ordering::SeqCst);
471 let line = format!("ā qk run {id}{}\n", status(shown));
472 crate::report::output_batch(|output| -> io::Result<()> {
473 output(false, line.as_bytes());
474 Ok(())
475 })
476 }
477 Display::Static {
478 id, group, cached, ..
479 } => {
480 let mut held = std::mem::take(&mut *self.held.lock().unwrap());
481 if !cached && matches!(shown, Shown::LocalCache | Shown::RemoteCache | Shown::Kept)
482 {
483 held = Spool::default();
484 }
485 crate::report::output_batch(|output| -> io::Result<()> {
486 output(false, b"\n");
487 if *group {
488 let icon = match shown {
489 Shown::Success => "ā
",
490 Shown::Failure => "ā",
491 Shown::LocalCache | Shown::RemoteCache => "š",
492 Shown::Kept => "ā©",
493 };
494 output(false, format!("::group::{icon} ").as_bytes());
495 }
496 output(false, format!("{}\n\n", header(id, shown)).as_bytes());
497 held.visit(|bytes| {
498 output(false, bytes);
499 Ok(())
500 })?;
501 if *group {
502 if held.last.is_some() && held.last != Some(b'\n') {
503 output(false, b"\n");
504 }
505 output(false, b"::endgroup::\n");
506 }
507 Ok(())
508 })
509 }
510 }
511 }
512}
513
514fn header(id: &str, shown: Shown) -> String {
517 format!("> qk run {id}{}", status(shown))
518}
519
520fn status(shown: Shown) -> &'static str {
522 match shown {
523 Shown::LocalCache => " [local cache]",
524 Shown::RemoteCache => " [remote cache]",
525 Shown::Kept => " [existing outputs match the cache, left as is]",
526 Shown::Success | Shown::Failure => "",
527 }
528}
529
530fn prefixed(prefix: &str, bytes: &[u8]) -> Vec<u8> {
532 let mut result = Vec::new();
533 for line in bytes.split(|byte| matches!(byte, b'\n' | b'\r')) {
534 if !line.is_empty() {
535 result.extend_from_slice(prefix.as_bytes());
536 result.push(b' ');
537 result.extend_from_slice(line);
538 result.push(b'\n');
539 }
540 }
541 result
542}
543
544fn write_to(stderr: bool, bytes: &[u8]) -> io::Result<()> {
545 crate::report::output(stderr, bytes);
546 Ok(())
547}
548
549pub struct Capture {
552 file: Option<Mutex<File>>,
553 retained: Mutex<Option<RecordedLog>>,
554 printer: Printer,
555 healthy: AtomicBool,
556 readiness: Option<Readiness>,
557 log_matches: Option<(
559 Mutex<crate::readiness::LogMatcher>,
560 std::sync::Arc<crate::readiness::Verdict>,
561 )>,
562}
563
564pub const MAX_LOG_BYTES: usize = 4 * 1024 * 1024;
566
567#[derive(Default)]
569pub struct RecordedLog {
570 pub data: Vec<u8>,
571 pub truncated: bool,
573}
574
575impl RecordedLog {
576 fn append(&mut self, stderr: bool, bytes: &[u8]) {
578 if self.truncated {
579 return;
580 }
581 let remaining = MAX_LOG_BYTES.saturating_sub(self.data.len());
582 let count = bytes.len().min(remaining.saturating_sub(5));
583 if count > 0 {
584 self.data.push(u8::from(stderr));
585 self.data.extend_from_slice(&(count as u32).to_le_bytes());
586 self.data.extend_from_slice(&bytes[..count]);
587 }
588 self.truncated = count != bytes.len();
589 }
590}
591
592struct Readiness {
594 patterns: Vec<String>,
595 state: Mutex<(Vec<bool>, [Vec<u8>; 2])>,
598 ready: std::sync::Arc<AtomicBool>,
599}
600
601impl Readiness {
602 fn see(&self, stderr: bool, bytes: &[u8]) {
603 let Ok(mut state) = self.state.lock() else {
604 return;
605 };
606 let (found, tails) = &mut *state;
607 let tail = &mut tails[usize::from(stderr)];
608 tail.extend_from_slice(bytes);
609 let text = String::from_utf8_lossy(tail);
610 for (pattern, found) in self.patterns.iter().zip(found.iter_mut()) {
611 *found |= text.contains(pattern.as_str());
612 }
613 let keep = self.patterns.iter().map(String::len).max().unwrap_or(0);
614 if tail.len() > keep {
615 tail.drain(..tail.len() - keep);
616 }
617 if found.iter().all(|found| *found) {
618 self.ready.store(true, Ordering::SeqCst);
619 }
620 }
621}
622
623impl Capture {
624 pub fn new(file: Option<File>, display: Display) -> Self {
627 let printer = Printer::new(display);
628 let _ = printer.begin(None);
629 Self {
630 file: file.map(Mutex::new),
631 retained: Mutex::new(None),
632 printer,
633 healthy: AtomicBool::new(true),
634 readiness: None,
635 log_matches: None,
636 }
637 }
638
639 pub fn retain_log(self) -> Self {
641 *self.retained.lock().unwrap() = Some(RecordedLog::default());
642 self
643 }
644
645 pub fn take_log(&self) -> Option<RecordedLog> {
647 self.retained.lock().unwrap().take()
648 }
649
650 pub fn ready_when(mut self, patterns: &[String], ready: std::sync::Arc<AtomicBool>) -> Self {
652 if !patterns.is_empty() {
653 self.readiness = Some(Readiness {
654 patterns: patterns.to_vec(),
655 state: Mutex::new((vec![false; patterns.len()], [Vec::new(), Vec::new()])),
656 ready,
657 });
658 }
659 self
660 }
661
662 pub fn log_matches(
665 mut self,
666 patterns: &[String],
667 verdict: std::sync::Arc<crate::readiness::Verdict>,
668 ) -> Self {
669 self.log_matches = Some((
670 Mutex::new(crate::readiness::LogMatcher::new(patterns.to_vec())),
671 verdict,
672 ));
673 self
674 }
675
676 pub fn healthy(&self) -> bool {
677 self.healthy.load(Ordering::Relaxed)
678 }
679
680 pub(crate) fn copy(
682 &self,
683 mut reader: impl Read,
684 stderr: bool,
685 decoration: Option<&Decoration>,
686 ) -> io::Result<()> {
687 let decoration = decoration.filter(|decoration| !decoration.is_empty());
688 let mut buffer = [0; 16 * 1024];
689 let mut partial = Vec::new();
691 loop {
692 let count = reader.read(&mut buffer)?;
693 let chunk = &buffer[..count];
694 match decoration {
695 None if count == 0 => return Ok(()),
696 None => self.emit(stderr, chunk)?,
697 Some(decoration) if decoration.prefix.is_some() => {
698 partial.extend_from_slice(chunk);
699 let end = if count == 0 {
700 partial.len()
701 } else {
702 partial
703 .iter()
704 .rposition(|byte| *byte == b'\n')
705 .map_or(0, |end| end + 1)
706 };
707 if end > 0 {
708 let lines: Vec<u8> = partial.drain(..end).collect();
709 self.emit(stderr, &decoration.apply(&lines))?;
710 }
711 if count == 0 {
712 return Ok(());
713 }
714 }
715 Some(_) if count == 0 => return Ok(()),
716 Some(decoration) => self.emit(stderr, &decoration.apply(chunk))?,
717 }
718 }
719 }
720
721 fn emit(&self, stderr: bool, bytes: &[u8]) -> io::Result<()> {
723 for frame in bytes.chunks(16 * 1024) {
724 let mut retained = self
726 .retained
727 .lock()
728 .map_err(|_| io::Error::other("retained log lock poisoned"))?;
729 if let Some(log) = retained.as_mut() {
730 log.append(stderr, frame);
731 }
732 if let Some(file) = &self.file {
733 let mut file = file
734 .lock()
735 .map_err(|_| io::Error::other("output capture lock poisoned"))?;
736 if self.healthy() {
737 let result = (|| {
738 file.write_all(&[u8::from(stderr)])?;
739 file.write_all(&(frame.len() as u32).to_le_bytes())?;
740 file.write_all(frame)
741 })();
742 if result.is_err() {
743 self.healthy.store(false, Ordering::Relaxed);
744 }
745 }
746 }
747 self.printer.write(stderr, frame)?;
748 if let Some((matcher, verdict)) = &self.log_matches
749 && !verdict.is_decided()
750 && matcher.lock().unwrap().feed(stderr, frame)
751 {
752 verdict.set_ready();
753 }
754 if let Some(readiness) = &self.readiness {
755 readiness.see(stderr, frame);
756 }
757 }
758 Ok(())
759 }
760
761 pub(crate) fn finish(&self, shown: Shown) -> io::Result<()> {
762 self.printer.finish(shown)
763 }
764}
765
766pub fn read_capture(
767 mut reader: impl Read,
768 mut consume: impl FnMut(bool, &[u8]) -> io::Result<()>,
769) -> io::Result<()> {
770 loop {
771 let mut stream = [0];
772 if reader.read(&mut stream)? == 0 {
773 return Ok(());
774 }
775 if stream[0] > 1 {
776 return Err(io::Error::other("invalid output capture stream"));
777 }
778 let mut length = [0; 4];
779 reader.read_exact(&mut length)?;
780 let length = u32::from_le_bytes(length) as usize;
781 if length > 16 * 1024 {
782 return Err(io::Error::other("invalid output capture frame"));
783 }
784 let mut bytes = vec![0; length];
785 reader.read_exact(&mut bytes)?;
786 consume(stream[0] == 1, &bytes)?;
787 }
788}
789
790pub fn replay(reader: impl Read, display: &Display, shown: Shown) -> io::Result<()> {
792 let printer = Printer::new(display.clone());
793 printer.begin(Some(shown))?;
794 read_capture(reader, |stderr, bytes| printer.write(stderr, bytes))?;
795 printer.finish(shown)
796}
797
798#[cfg(test)]
799mod tests {
800 use super::*;
801
802 #[test]
803 fn held_output_spills_without_losing_binary_stream_order() {
804 let printer = Printer::new(Display::Failures {
805 id: "large".into(),
806 log: None,
807 });
808 let chunk = [0, 255, b'x', b'\n'].repeat(4096);
809 for index in 0..32 {
810 printer.write(index % 2 == 1, &chunk).unwrap();
811 }
812 let mut held = printer.held.lock().unwrap();
813 assert!(held.file.is_some());
814 assert!(held.memory.is_empty());
815 let mut count = 0;
816 held.visit(|bytes| {
817 assert_eq!(bytes, chunk.as_slice());
818 count += 1;
819 Ok(())
820 })
821 .unwrap();
822 assert_eq!(count, 32);
823 drop(held);
825 printer.finish(Shown::Success).unwrap();
826 assert!(printer.held.lock().unwrap().file.is_none());
827 }
828
829 #[test]
830 fn unfinished_prefixed_lines_spill_and_keep_streams_separate() {
831 let printer = Printer::new(Display::Prefixed("app:".into()));
832 let bytes = vec![b'x'; MEMORY_OUTPUT_BYTES + 1];
833 printer.write(false, &bytes).unwrap();
834 printer.write(true, b"partial stderr").unwrap();
835 let mut lines = printer.lines.lock().unwrap();
836 assert!(lines[0].file.is_some());
837 assert!(lines[0].memory.is_empty());
838 let mut length = 0;
839 lines[0]
840 .visit(|chunk| {
841 assert!(chunk.len() <= 16 * 1024);
842 assert!(chunk.iter().all(|byte| *byte == b'x'));
843 length += chunk.len();
844 Ok(())
845 })
846 .unwrap();
847 assert_eq!(length, bytes.len());
848 assert_eq!(lines[1].memory, b"partial stderr");
849 }
850
851 #[test]
852 fn small_spools_stay_in_memory_and_can_replay_then_append() {
853 let mut spool = Spool::default();
854 spool.append(b"small").unwrap();
855 assert!(spool.file.is_none());
856 let mut output = Vec::new();
857 spool
858 .visit(|chunk| {
859 output.extend_from_slice(chunk);
860 Ok(())
861 })
862 .unwrap();
863 assert_eq!(output, b"small");
864 spool.append(&vec![b'x'; MEMORY_OUTPUT_BYTES]).unwrap();
865 assert!(spool.file.is_some());
866 spool.visit(|_| Ok(())).unwrap();
867 spool.append(b"last").unwrap();
868 output.clear();
869 spool
870 .visit(|chunk| {
871 output.extend_from_slice(chunk);
872 Ok(())
873 })
874 .unwrap();
875 assert!(output.starts_with(b"small"));
876 assert!(output.ends_with(b"last"));
877 }
878
879 #[test]
881 fn retained_logs_are_ordered_and_bounded() {
882 let capture = Capture::new(None, Display::Hidden).retain_log();
883 capture.emit(false, b"stdout\0\xff").unwrap();
884 capture.emit(true, b"stderr").unwrap();
885 let data = vec![b'x'; MAX_LOG_BYTES];
886 capture.emit(false, &data).unwrap();
887 capture.emit(true, b"omitted").unwrap();
888 let log = capture.take_log().unwrap();
889 assert!(log.truncated);
890 assert!(log.data.len() <= MAX_LOG_BYTES);
891 let mut frames = Vec::new();
892 read_capture(log.data.as_slice(), |stderr, bytes| {
893 frames.push((stderr, bytes.to_vec()));
894 Ok(())
895 })
896 .unwrap();
897 assert_eq!(frames[0], (false, b"stdout\0\xff".to_vec()));
898 assert_eq!(frames[1], (true, b"stderr".to_vec()));
899 assert!(
900 frames[2..]
901 .iter()
902 .all(|(stderr, bytes)| !stderr && bytes.iter().all(|b| *b == b'x'))
903 );
904 assert!(capture.take_log().is_none());
905 }
906
907 #[test]
908 fn prefixes_non_empty_lines_like_nx() {
909 assert_eq!(
910 prefixed("web:", b"one\n\ntwo\r\nthree"),
911 b"web: one\nweb: two\nweb: three\n"
912 );
913 }
914}