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