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 {
596 patterns: Vec<String>,
597 seen: Mutex<Seen>,
598 ready: std::sync::Arc<AtomicBool>,
599}
600
601struct Seen {
602 found: Vec<bool>,
604 tails: [Vec<u8>; 2],
606 shown: Vec<crate::readiness::LogMatcher>,
608}
609
610impl Readiness {
611 fn see(&self, stderr: bool, bytes: &[u8]) {
612 let Ok(mut seen) = self.seen.lock() else {
613 return;
614 };
615 let Seen {
616 found,
617 tails,
618 shown,
619 } = &mut *seen;
620 let tail = &mut tails[usize::from(stderr)];
621 tail.extend_from_slice(bytes);
622 let text = String::from_utf8_lossy(tail);
623 for ((pattern, found), shown) in self.patterns.iter().zip(found.iter_mut()).zip(shown) {
624 if !*found {
625 *found = text.contains(pattern.as_str()) | shown.feed(stderr, bytes);
626 }
627 }
628 let keep = self.patterns.iter().map(String::len).max().unwrap_or(0);
629 if tail.len() > keep {
630 tail.drain(..tail.len() - keep);
631 }
632 if found.iter().all(|found| *found) {
633 self.ready.store(true, Ordering::SeqCst);
634 }
635 }
636}
637
638impl Capture {
639 pub fn new(file: Option<File>, display: Display) -> Self {
642 let printer = Printer::new(display);
643 let _ = printer.begin(None);
644 Self {
645 file: file.map(Mutex::new),
646 retained: Mutex::new(None),
647 printer,
648 healthy: AtomicBool::new(true),
649 readiness: None,
650 log_matches: None,
651 }
652 }
653
654 pub fn retain_log(self) -> Self {
656 *self.retained.lock().unwrap() = Some(RecordedLog::default());
657 self
658 }
659
660 pub fn take_log(&self) -> Option<RecordedLog> {
662 self.retained.lock().unwrap().take()
663 }
664
665 pub fn ready_when(mut self, patterns: &[String], ready: std::sync::Arc<AtomicBool>) -> Self {
667 if !patterns.is_empty() {
668 self.readiness = Some(Readiness {
669 patterns: patterns.to_vec(),
670 seen: Mutex::new(Seen {
671 found: vec![false; patterns.len()],
672 tails: [Vec::new(), Vec::new()],
673 shown: patterns
674 .iter()
675 .map(|pattern| crate::readiness::LogMatcher::new(vec![pattern.clone()]))
676 .collect(),
677 }),
678 ready,
679 });
680 }
681 self
682 }
683
684 pub fn log_matches(
687 mut self,
688 patterns: &[String],
689 verdict: std::sync::Arc<crate::readiness::Verdict>,
690 ) -> Self {
691 self.log_matches = Some((
692 Mutex::new(crate::readiness::LogMatcher::new(patterns.to_vec())),
693 verdict,
694 ));
695 self
696 }
697
698 pub fn unready(&self) -> Vec<String> {
700 let Some(readiness) = &self.readiness else {
701 return Vec::new();
702 };
703 let found = readiness.seen.lock().map(|seen| seen.found.clone());
704 readiness
705 .patterns
706 .iter()
707 .zip(found.unwrap_or_default())
708 .filter(|(_, found)| !found)
709 .map(|(pattern, _)| pattern.clone())
710 .collect()
711 }
712
713 pub fn note(&self, text: &str) -> io::Result<()> {
717 self.write(true, format!("{text}\n").as_bytes(), false)
718 }
719
720 pub fn healthy(&self) -> bool {
721 self.healthy.load(Ordering::Relaxed)
722 }
723
724 pub(crate) fn copy(
726 &self,
727 mut reader: impl Read,
728 stderr: bool,
729 decoration: Option<&Decoration>,
730 ) -> io::Result<()> {
731 let decoration = decoration.filter(|decoration| !decoration.is_empty());
732 let mut buffer = [0; 16 * 1024];
733 let mut partial = Vec::new();
735 loop {
736 let count = reader.read(&mut buffer)?;
737 let chunk = &buffer[..count];
738 match decoration {
739 None if count == 0 => return Ok(()),
740 None => self.emit(stderr, chunk)?,
741 Some(decoration) if decoration.prefix.is_some() => {
742 partial.extend_from_slice(chunk);
743 let end = if count == 0 {
744 partial.len()
745 } else {
746 partial
747 .iter()
748 .rposition(|byte| *byte == b'\n')
749 .map_or(0, |end| end + 1)
750 };
751 if end > 0 {
752 let lines: Vec<u8> = partial.drain(..end).collect();
753 self.emit(stderr, &decoration.apply(&lines))?;
754 }
755 if count == 0 {
756 return Ok(());
757 }
758 }
759 Some(_) if count == 0 => return Ok(()),
760 Some(decoration) => self.emit(stderr, &decoration.apply(chunk))?,
761 }
762 }
763 }
764
765 fn emit(&self, stderr: bool, bytes: &[u8]) -> io::Result<()> {
767 self.write(stderr, bytes, true)
768 }
769
770 fn write(&self, stderr: bool, bytes: &[u8], watched: bool) -> io::Result<()> {
772 for frame in bytes.chunks(16 * 1024) {
773 let mut retained = self
775 .retained
776 .lock()
777 .map_err(|_| io::Error::other("retained log lock poisoned"))?;
778 if let Some(log) = retained.as_mut() {
779 log.append(stderr, frame);
780 }
781 if let Some(file) = &self.file {
782 let mut file = file
783 .lock()
784 .map_err(|_| io::Error::other("output capture lock poisoned"))?;
785 if self.healthy() {
786 let result = (|| {
787 file.write_all(&[u8::from(stderr)])?;
788 file.write_all(&(frame.len() as u32).to_le_bytes())?;
789 file.write_all(frame)
790 })();
791 if result.is_err() {
792 self.healthy.store(false, Ordering::Relaxed);
793 }
794 }
795 }
796 self.printer.write(stderr, frame)?;
797 if !watched {
798 continue;
799 }
800 if let Some((matcher, verdict)) = &self.log_matches
801 && !verdict.is_decided()
802 && matcher.lock().unwrap().feed(stderr, frame)
803 {
804 verdict.set_ready();
805 }
806 if let Some(readiness) = &self.readiness {
807 readiness.see(stderr, frame);
808 }
809 }
810 Ok(())
811 }
812
813 pub(crate) fn finish(&self, shown: Shown) -> io::Result<()> {
814 self.printer.finish(shown)
815 }
816}
817
818pub fn read_capture(
819 mut reader: impl Read,
820 mut consume: impl FnMut(bool, &[u8]) -> io::Result<()>,
821) -> io::Result<()> {
822 loop {
823 let mut stream = [0];
824 if reader.read(&mut stream)? == 0 {
825 return Ok(());
826 }
827 if stream[0] > 1 {
828 return Err(io::Error::other("invalid output capture stream"));
829 }
830 let mut length = [0; 4];
831 reader.read_exact(&mut length)?;
832 let length = u32::from_le_bytes(length) as usize;
833 if length > 16 * 1024 {
834 return Err(io::Error::other("invalid output capture frame"));
835 }
836 let mut bytes = vec![0; length];
837 reader.read_exact(&mut bytes)?;
838 consume(stream[0] == 1, &bytes)?;
839 }
840}
841
842pub fn replay(reader: impl Read, display: &Display, shown: Shown) -> io::Result<()> {
844 let printer = Printer::new(display.clone());
845 printer.begin(Some(shown))?;
846 read_capture(reader, |stderr, bytes| printer.write(stderr, bytes))?;
847 printer.finish(shown)
848}
849
850#[cfg(test)]
851mod tests {
852 use super::*;
853
854 #[test]
855 fn a_note_quoting_ready_when_text_does_not_make_the_task_ready() {
856 let ready = std::sync::Arc::new(AtomicBool::new(false));
857 let capture =
858 Capture::new(None, Display::Hidden).ready_when(&["READY".into()], ready.clone());
859 capture
860 .note(r#"Warning: command "true" exited before printing the readyWhen text "READY""#)
861 .unwrap();
862 assert!(!ready.load(Ordering::SeqCst));
863 assert_eq!(capture.unready(), ["READY"]);
864 }
865
866 #[test]
867 fn ready_when_text_matches_as_printed_or_as_shown_across_reads() {
868 let ready = std::sync::Arc::new(AtomicBool::new(false));
869 let capture = Capture::new(None, Display::Hidden).ready_when(
870 &["localhost:5173".into(), "\x1b[32mok".into(), "never".into()],
871 ready.clone(),
872 );
873 capture.emit(false, b"localhost:\x1b[1m51").unwrap();
874 capture.emit(false, b"73\x1b[22m and \x1b[3").unwrap();
875 capture.emit(false, b"2mok\n").unwrap();
876 assert_eq!(capture.unready(), ["never"]);
877 assert!(!ready.load(Ordering::SeqCst));
878 capture.emit(true, b"never").unwrap();
879 assert!(capture.unready().is_empty());
880 assert!(ready.load(Ordering::SeqCst));
881 }
882
883 #[test]
884 fn held_output_spills_without_losing_binary_stream_order() {
885 let printer = Printer::new(Display::Failures {
886 id: "large".into(),
887 log: None,
888 });
889 let chunk = [0, 255, b'x', b'\n'].repeat(4096);
890 for index in 0..32 {
891 printer.write(index % 2 == 1, &chunk).unwrap();
892 }
893 let mut held = printer.held.lock().unwrap();
894 assert!(held.file.is_some());
895 assert!(held.memory.is_empty());
896 let mut count = 0;
897 held.visit(|bytes| {
898 assert_eq!(bytes, chunk.as_slice());
899 count += 1;
900 Ok(())
901 })
902 .unwrap();
903 assert_eq!(count, 32);
904 drop(held);
906 printer.finish(Shown::Success).unwrap();
907 assert!(printer.held.lock().unwrap().file.is_none());
908 }
909
910 #[test]
911 fn unfinished_prefixed_lines_spill_and_keep_streams_separate() {
912 let printer = Printer::new(Display::Prefixed("app:".into()));
913 let bytes = vec![b'x'; MEMORY_OUTPUT_BYTES + 1];
914 printer.write(false, &bytes).unwrap();
915 printer.write(true, b"partial stderr").unwrap();
916 let mut lines = printer.lines.lock().unwrap();
917 assert!(lines[0].file.is_some());
918 assert!(lines[0].memory.is_empty());
919 let mut length = 0;
920 lines[0]
921 .visit(|chunk| {
922 assert!(chunk.len() <= 16 * 1024);
923 assert!(chunk.iter().all(|byte| *byte == b'x'));
924 length += chunk.len();
925 Ok(())
926 })
927 .unwrap();
928 assert_eq!(length, bytes.len());
929 assert_eq!(lines[1].memory, b"partial stderr");
930 }
931
932 #[test]
933 fn small_spools_stay_in_memory_and_can_replay_then_append() {
934 let mut spool = Spool::default();
935 spool.append(b"small").unwrap();
936 assert!(spool.file.is_none());
937 let mut output = Vec::new();
938 spool
939 .visit(|chunk| {
940 output.extend_from_slice(chunk);
941 Ok(())
942 })
943 .unwrap();
944 assert_eq!(output, b"small");
945 spool.append(&vec![b'x'; MEMORY_OUTPUT_BYTES]).unwrap();
946 assert!(spool.file.is_some());
947 spool.visit(|_| Ok(())).unwrap();
948 spool.append(b"last").unwrap();
949 output.clear();
950 spool
951 .visit(|chunk| {
952 output.extend_from_slice(chunk);
953 Ok(())
954 })
955 .unwrap();
956 assert!(output.starts_with(b"small"));
957 assert!(output.ends_with(b"last"));
958 }
959
960 #[test]
962 fn retained_logs_are_ordered_and_bounded() {
963 let capture = Capture::new(None, Display::Hidden).retain_log();
964 capture.emit(false, b"stdout\0\xff").unwrap();
965 capture.emit(true, b"stderr").unwrap();
966 let data = vec![b'x'; MAX_LOG_BYTES];
967 capture.emit(false, &data).unwrap();
968 capture.emit(true, b"omitted").unwrap();
969 let log = capture.take_log().unwrap();
970 assert!(log.truncated);
971 assert!(log.data.len() <= MAX_LOG_BYTES);
972 let mut frames = Vec::new();
973 read_capture(log.data.as_slice(), |stderr, bytes| {
974 frames.push((stderr, bytes.to_vec()));
975 Ok(())
976 })
977 .unwrap();
978 assert_eq!(frames[0], (false, b"stdout\0\xff".to_vec()));
979 assert_eq!(frames[1], (true, b"stderr".to_vec()));
980 assert!(
981 frames[2..]
982 .iter()
983 .all(|(stderr, bytes)| !stderr && bytes.iter().all(|b| *b == b'x'))
984 );
985 assert!(capture.take_log().is_none());
986 }
987
988 #[test]
989 fn prefixes_non_empty_lines_like_nx() {
990 assert_eq!(
991 prefixed("web:", b"one\n\ntwo\r\nthree"),
992 b"web: one\nweb: two\nweb: three\n"
993 );
994 }
995}