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 `> qk 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 `> qk 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            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
117/// A task as Nx names it after `nx run`: `project:target`, the target quoted
118/// when it has a colon, then `:configuration` and the task's arguments.
119pub 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
141/// How many tasks `static-failures-only` printed as one line, for the hint
142/// the run ends with.
143static COLLAPSED: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
144
145pub fn collapsed() -> usize {
146    COLLAPSED.load(Ordering::SeqCst)
147}
148
149/// The output styles qk renders.
150#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub enum OutputStyle {
152    Stream,
153    StreamWithoutPrefixes,
154    Static,
155    /// `static` for one requested task, as Nx shows `nx run`: the task streams
156    /// under its header, and its dependencies are held, with only the headers
157    /// of cache hits.
158    RunOne,
159    /// Nx's `static-failures-only`: `static`, but a task that did not fail
160    /// is one line.
161    StaticFailuresOnly,
162    /// `static-failures-only` for one requested task: the tasks of its
163    /// project as `RunOne` shows them, the others as one line unless they fail.
164    RunOneFailuresOnly,
165    /// Nx's `summary`: no task output, failures' output kept in files.
166    Summary,
167    /// Only failed tasks' output, for the quiet and dynamic styles.
168    Quiet,
169    /// The live panel for one requested task: it streams once its
170    /// dependencies are done, they show only failures, and continuous ones
171    /// stream behind their project's prefix, since they never end.
172    DynamicRunOne,
173}
174
175/// `project:` in bold and in the colour Nx picks for the project, when the
176/// terminal takes colour.
177fn prefix(project: &str) -> String {
178    // picocolors' green, greenBright, blue, blueBright, cyan, cyanBright,
179    // yellow, yellowBright, magenta, magentaBright, indexed like Nx's getColor.
180    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/// Nx's per-command `prefix`, `prefixColor`, `color` and `bgColor`.
190#[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    /// Whether `name` is a colour a command entry may name: `bg` names
208    /// background colours.
209    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    /// As Nx's `addColorAndPrefix`: a bold, optionally coloured prefix before
221    /// each non-blank line, then the whole text in `color` and `bgColor`.
222    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
264/// Small logs stay in memory; larger logs and unfinished lines spill to an
265/// anonymous file that is removed when dropped. Replay never copies the log.
266const 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            // A visit need not end this spool's lifetime.
306            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
353/// picocolors' rule: colour unless NO_COLOR, when forced, in CI, or on a
354/// terminal that is not dumb. `FORCE_COLOR=0` turns it off.
355fn 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
369/// Writes one task's output as its display asks.
370struct Printer {
371    display: Display,
372    /// Partial lines of stdout and stderr, for prefixing whole lines.
373    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    /// Announces a task about to show its output; `shown` for a cache hit.
414    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                // Nx's `logCommandSummary`: a tick, then the task on one line.
469                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
514/// `> qk run <task>`, marked when the output came from the cache: Nx's
515/// header under qk's name, with the cache marks tools read logs by.
516fn header(id: &str, shown: Shown) -> String {
517    format!("> qk run {id}{}", status(shown))
518}
519
520/// Where a task's output came from, as Nx marks it after the task.
521fn 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
530/// Nx's `formatPrefixedLines`: every non-empty line behind the prefix.
531fn 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
549/// Framed stdout/stderr chunks, optionally stored on disk without buffering the
550/// task's log, and shown as the task's display asks.
551pub struct Capture {
552    file: Option<Mutex<File>>,
553    retained: Mutex<Option<RecordedLog>>,
554    printer: Printer,
555    healthy: AtomicBool,
556    readiness: Option<Readiness>,
557    /// A continuous target's `logMatches` check, which the output decides.
558    log_matches: Option<(
559        Mutex<crate::readiness::LogMatcher>,
560        std::sync::Arc<crate::readiness::Verdict>,
561    )>,
562}
563
564/// Maximum framed bytes retained for an execution, independent of result caching.
565pub const MAX_LOG_BYTES: usize = 4 * 1024 * 1024;
566
567/// Ordered, stream-tagged frames retained for run history.
568#[derive(Default)]
569pub struct RecordedLog {
570    pub data: Vec<u8>,
571    /// Further output was displayed but omitted from this bounded record.
572    pub truncated: bool,
573}
574
575impl RecordedLog {
576    /// Keeps a prefix of complete frames, stopping at the payload limit.
577    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
592/// Watches output for `readyWhen` text, on either stream and across reads:
593/// as printed, so text that holds a control sequence matches, and as shown,
594/// so text a control sequence interrupts, such as a bold port, does too.
595struct Readiness {
596    patterns: Vec<String>,
597    seen: Mutex<Seen>,
598    ready: std::sync::Arc<AtomicBool>,
599}
600
601struct Seen {
602    /// Which patterns have appeared.
603    found: Vec<bool>,
604    /// Each stream's last bytes, so text split between two reads matches.
605    tails: [Vec<u8>; 2],
606    /// Each pattern's matcher of the text as shown.
607    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    /// Streams output through its display, optionally recording cache frames on disk.
640    /// A headed display announces the task here, as it starts.
641    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    /// Retains a bounded execution log alongside any result-cache capture.
655    pub fn retain_log(self) -> Self {
656        *self.retained.lock().unwrap() = Some(RecordedLog::default());
657        self
658    }
659
660    /// Takes the retained log after the command's output readers have finished.
661    pub fn take_log(&self) -> Option<RecordedLog> {
662        self.retained.lock().unwrap().take()
663    }
664
665    /// Sets `ready` once every one of `patterns` has appeared in the output.
666    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    /// Decides `verdict` once every one of `patterns` has appeared in the
685    /// output, control sequences left out.
686    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    /// The `readyWhen` text that has not appeared, if any was asked for.
699    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    /// Adds a line of qk's own to the task's error output. It is no output
714    /// of the task's, so readiness checks never see it: a note quoting the
715    /// text one waits for must not make the task ready.
716    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    /// Copies one command's stream, decorated as its command entry asks.
725    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        // With a prefix, whole lines, so a line split between reads gets one.
734        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    /// Records, shows and watches output, in frames the log reader accepts.
766    fn emit(&self, stderr: bool, bytes: &[u8]) -> io::Result<()> {
767        self.write(stderr, bytes, true)
768    }
769
770    /// Records and shows output, and with `watched`, checks it for readiness.
771    fn write(&self, stderr: bool, bytes: &[u8], watched: bool) -> io::Result<()> {
772        for frame in bytes.chunks(16 * 1024) {
773            // One order for retained frames, cache frames and displayed chunks.
774            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
842/// Shows a recorded log as a cache hit.
843pub 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        // A successful quiet task drops its spool without reading or displaying it.
905        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    /// Retention preserves binary streams and valid framing at the byte limit.
961    #[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}