Skip to main content

qk_executor/
capture.rs

1use std::fs::File;
2use std::io::{self, Read, Seek, SeekFrom, Write};
3use std::sync::Mutex;
4use std::sync::atomic::{AtomicBool, Ordering};
5
6/// How a task's output reaches the terminal, after Nx's output styles. Where
7/// a task is named, it is as Nx prints it after `nx run`: see [`printable`].
8#[derive(Clone, Debug, Default, PartialEq, Eq)]
9pub enum Display {
10    /// Straight through, as the task writes it: Nx's `stream-without-prefixes`.
11    #[default]
12    Stream,
13    /// Each non-empty line behind the given prefix: Nx's `stream`.
14    Prefixed(String),
15    /// Held until the task ends, then printed under `> nx run <task>`: Nx's
16    /// `static`. `group` folds each task into a GitHub Actions log group, and
17    /// `cached` shows a cache hit's log rather than only its header.
18    /// `collapse`, for Nx's `static-failures-only`, prints a task that did not
19    /// fail as one line instead.
20    Static {
21        id: String,
22        group: bool,
23        cached: bool,
24        collapse: bool,
25    },
26    /// Straight through under `> nx run <task>`: the task a static `run` is for.
27    Headed { id: String },
28    /// Held until the task ends, and printed under a header only if it
29    /// failed: for the quiet and dynamic styles. With `log`, for Nx's
30    /// `summary`, a failure's output goes to that file instead.
31    Failures {
32        id: String,
33        log: Option<std::path::PathBuf>,
34    },
35    /// Not shown at all.
36    Hidden,
37}
38
39/// How a task ended, for the header of held output.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum Shown {
42    Success,
43    Failure,
44    LocalCache,
45    RemoteCache,
46    /// A local hit whose outputs were already in place.
47    Kept,
48}
49
50impl Display {
51    /// The display for a task under an output style. Continuous tasks never end
52    /// on their own, so held output would never appear; they stream.
53    /// `id` names the task as [`printable`] does. `root` is a task the run was
54    /// asked for, rather than a dependency, and `initiating` a task of the
55    /// project a single task was asked for, which `static-failures-only`
56    /// shows in full, as Nx does. `streams` is a task whose output Nx
57    /// streams rather than holding it.
58    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        // As in Nx, a task streamed under another style is prefixed only when
74        // NX_PREFIX_OUTPUT is `true`, which `--output-style=stream` sets.
75        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
111/// A task as Nx names it after `nx run`: `project:target`, the target quoted
112/// when it has a colon, then `:configuration` and the task's arguments.
113pub 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
135/// How many tasks `static-failures-only` printed as one line, for the hint
136/// the run ends with.
137static COLLAPSED: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
138
139pub fn collapsed() -> usize {
140    COLLAPSED.load(Ordering::SeqCst)
141}
142
143/// The output styles qk renders.
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
145pub enum OutputStyle {
146    Stream,
147    StreamWithoutPrefixes,
148    Static,
149    /// `static` for one requested task, as Nx shows `nx run`: the task streams
150    /// under its header, and its dependencies are held, with only the headers
151    /// of cache hits.
152    RunOne,
153    /// Nx's `static-failures-only`: `static`, but a task that did not fail
154    /// is one line.
155    StaticFailuresOnly,
156    /// `static-failures-only` for one requested task: the tasks of its
157    /// project as `RunOne` shows them, the others as one line unless they fail.
158    RunOneFailuresOnly,
159    /// Nx's `summary`: no task output, failures' output kept in files.
160    Summary,
161    /// Only failed tasks' output, for the quiet and dynamic styles.
162    Quiet,
163}
164
165/// `project:` in bold and in the colour Nx picks for the project, when the
166/// terminal takes colour.
167fn prefix(project: &str) -> String {
168    // picocolors' green, greenBright, blue, blueBright, cyan, cyanBright,
169    // yellow, yellowBright, magenta, magentaBright, indexed like Nx's getColor.
170    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/// Nx's per-command `prefix`, `prefixColor`, `color` and `bgColor`.
180#[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    /// Whether `name` is a colour a command entry may name: `bg` names
198    /// background colours.
199    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    /// As Nx's `addColorAndPrefix`: a bold, optionally coloured prefix before
211    /// each non-blank line, then the whole text in `color` and `bgColor`.
212    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
254/// Small logs stay in memory; larger logs and unfinished lines spill to an
255/// anonymous file that is removed when dropped. Replay never copies the log.
256const 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            // A visit need not end this spool's lifetime.
296            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
343/// picocolors' rule: colour unless NO_COLOR, when forced, in CI, or on a
344/// terminal that is not dumb. `FORCE_COLOR=0` turns it off.
345fn 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
359/// Writes one task's output as its display asks.
360struct Printer {
361    display: Display,
362    /// Partial lines of stdout and stderr, for prefixing whole lines.
363    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    /// Announces a task about to show its output; `shown` for a cache hit.
404    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                // Nx's `logCommandSummary`: a tick, then the task on one line.
459                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
504/// `> nx run <task>`, marked when the output came from the cache, as Nx
505/// prints it, since tools read logs by it.
506fn header(id: &str, shown: Shown) -> String {
507    format!("> nx run {id}{}", status(shown))
508}
509
510/// Where a task's output came from, as Nx marks it after the task.
511fn 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
520/// Nx's `formatPrefixedLines`: every non-empty line behind the prefix.
521fn 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
539/// Framed stdout/stderr chunks, optionally stored on disk without buffering the
540/// task's log, and shown as the task's display asks.
541pub struct Capture {
542    file: Option<Mutex<File>>,
543    retained: Mutex<Option<RecordedLog>>,
544    printer: Printer,
545    healthy: AtomicBool,
546    readiness: Option<Readiness>,
547    /// A continuous target's `logMatches` check, which the output decides.
548    log_matches: Option<(
549        Mutex<crate::readiness::LogMatcher>,
550        std::sync::Arc<crate::readiness::Verdict>,
551    )>,
552}
553
554/// Maximum framed bytes retained for an execution, independent of result caching.
555pub const MAX_LOG_BYTES: usize = 4 * 1024 * 1024;
556
557/// Ordered, stream-tagged frames retained for run history.
558#[derive(Default)]
559pub struct RecordedLog {
560    pub data: Vec<u8>,
561    /// Further output was displayed but omitted from this bounded record.
562    pub truncated: bool,
563}
564
565impl RecordedLog {
566    /// Keeps a prefix of complete frames, stopping at the payload limit.
567    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
582/// Watches output for `readyWhen` text, on either stream and across reads.
583struct Readiness {
584    patterns: Vec<String>,
585    /// Which patterns have appeared, and each stream's last bytes, so text
586    /// split between two reads still matches.
587    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    /// Streams output through its display, optionally recording cache frames on disk.
615    /// A headed display announces the task here, as it starts.
616    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    /// Retains a bounded execution log alongside any result-cache capture.
630    pub fn retain_log(self) -> Self {
631        *self.retained.lock().unwrap() = Some(RecordedLog::default());
632        self
633    }
634
635    /// Takes the retained log after the command's output readers have finished.
636    pub fn take_log(&self) -> Option<RecordedLog> {
637        self.retained.lock().unwrap().take()
638    }
639
640    /// Sets `ready` once every one of `patterns` has appeared in the output.
641    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    /// Decides `verdict` once every one of `patterns` has appeared in the
653    /// output, control sequences left out.
654    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    /// Copies one command's stream, decorated as its command entry asks.
671    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        // With a prefix, whole lines, so a line split between reads gets one.
680        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    /// Records, shows and watches output, in frames the log reader accepts.
712    fn emit(&self, stderr: bool, bytes: &[u8]) -> io::Result<()> {
713        for frame in bytes.chunks(16 * 1024) {
714            // One order for retained frames, cache frames and displayed chunks.
715            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
780/// Shows a recorded log as a cache hit.
781pub 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        // A successful quiet task drops its spool without reading or displaying it.
814        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    /// Retention preserves binary streams and valid framing at the byte limit.
870    #[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}