Skip to main content

qk_executor/
capture.rs

1use std::fs::File;
2use std::io::{self, Read, 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.
7#[derive(Clone, Debug, Default, PartialEq, Eq)]
8pub enum Display {
9    /// Straight through, as the task writes it: Nx's `stream-without-prefixes`.
10    #[default]
11    Stream,
12    /// Each non-empty line behind the given prefix: Nx's `stream`.
13    Prefixed(String),
14    /// Held until the task ends, then printed under `> qk run <id>`: Nx's
15    /// `static`. `group` folds each task into a GitHub Actions log group.
16    Static { id: String, group: bool },
17    /// Held until the task ends, and printed under a header only if it
18    /// failed: for the quiet and dynamic styles.
19    Failures { id: String },
20    /// Not shown at all.
21    Hidden,
22}
23
24/// How a task ended, for the header of held output.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum Shown {
27    Success,
28    Failure,
29    LocalCache,
30    RemoteCache,
31    /// A local hit whose outputs were already in place.
32    Kept,
33}
34
35impl Display {
36    /// The display for a task under an output style. Continuous tasks never end
37    /// on their own, so held output would never appear; they stream prefixed.
38    pub fn for_task(style: OutputStyle, id: &str, project: &str, continuous: bool) -> Self {
39        match style {
40            OutputStyle::StreamWithoutPrefixes => Self::Stream,
41            OutputStyle::Static if !continuous => Self::Static {
42                id: id.to_owned(),
43                group: std::env::var_os("GITHUB_ACTIONS").is_some()
44                    && std::env::var_os("NX_SKIP_LOG_GROUPING").is_none_or(|value| value != "true"),
45            },
46            OutputStyle::Quiet => Self::Failures { id: id.to_owned() },
47            OutputStyle::Static | OutputStyle::Stream => Self::Prefixed(prefix(project)),
48        }
49    }
50}
51
52/// The output styles qk renders.
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum OutputStyle {
55    Stream,
56    StreamWithoutPrefixes,
57    Static,
58    /// Only failed tasks' output, for the quiet and dynamic styles.
59    Quiet,
60}
61
62/// `project:` in bold and in the colour Nx picks for the project, when the
63/// terminal takes colour.
64fn prefix(project: &str) -> String {
65    // picocolors' green, greenBright, blue, blueBright, cyan, cyanBright,
66    // yellow, yellowBright, magenta, magentaBright, indexed like Nx's getColor.
67    const COLOURS: [u8; 10] = [32, 92, 34, 94, 36, 96, 33, 93, 35, 95];
68    if !colour() {
69        return format!("{project}:");
70    }
71    let code: u32 = project.encode_utf16().map(u32::from).sum();
72    let colour = COLOURS[code as usize % COLOURS.len()];
73    format!("\x1b[1m\x1b[{colour}m{project}:\x1b[39m\x1b[22m")
74}
75
76/// Nx's per-command `prefix`, `prefixColor`, `color` and `bgColor`.
77#[derive(Clone, Debug, Default, PartialEq, Eq)]
78pub struct Decoration {
79    pub prefix: Option<String>,
80    pub prefix_color: Option<String>,
81    pub color: Option<String>,
82    pub bg_color: Option<String>,
83}
84
85const COLOURS: [&str; 8] = [
86    "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
87];
88
89impl Decoration {
90    pub fn is_empty(&self) -> bool {
91        self == &Self::default()
92    }
93
94    /// Whether `name` is a colour a command entry may name: `bg` names
95    /// background colours.
96    pub fn valid(name: &str, background: bool) -> bool {
97        match name.strip_prefix("bg") {
98            Some(rest) if background => {
99                let mut chars = rest.chars();
100                chars.next().is_some_and(char::is_uppercase)
101                    && COLOURS.contains(&rest.to_lowercase().as_str())
102            }
103            _ => !background && COLOURS.contains(&name),
104        }
105    }
106
107    /// As Nx's `addColorAndPrefix`: a bold, optionally coloured prefix before
108    /// each non-blank line, then the whole text in `color` and `bgColor`.
109    fn apply(&self, bytes: &[u8]) -> Vec<u8> {
110        let colours = colour();
111        let paint = |text: &str, name: Option<&str>, background: bool| -> String {
112            let index = name.and_then(|name| {
113                let name = if background {
114                    name.get(2..)?.to_lowercase()
115                } else {
116                    name.to_owned()
117                };
118                COLOURS.iter().position(|colour| *colour == name)
119            });
120            match index {
121                Some(index) if colours => {
122                    let (open, close) = if background { (40, 49) } else { (30, 39) };
123                    format!("\x1b[{}m{text}\x1b[{close}m", open + index)
124                }
125                _ => text.to_owned(),
126            }
127        };
128        let mut text = String::from_utf8_lossy(bytes).into_owned();
129        if let Some(prefix) = &self.prefix {
130            let mut prefix = paint(prefix, self.prefix_color.as_deref(), false);
131            if colours {
132                prefix = format!("\x1b[1m{prefix}\x1b[22m");
133            }
134            text = text
135                .split('\n')
136                .map(|line| {
137                    if line.trim().is_empty() {
138                        line.to_owned()
139                    } else {
140                        format!("{prefix} {line}")
141                    }
142                })
143                .collect::<Vec<_>>()
144                .join("\n");
145        }
146        let text = paint(&text, self.color.as_deref(), false);
147        paint(&text, self.bg_color.as_deref(), true).into_bytes()
148    }
149}
150
151/// picocolors' rule: colour unless NO_COLOR, when forced, in CI, or on a
152/// terminal that is not dumb.
153fn colour() -> bool {
154    use std::io::IsTerminal;
155    let set = |name| std::env::var_os(name).is_some();
156    !set("NO_COLOR")
157        && (set("FORCE_COLOR")
158            || cfg!(windows)
159            || set("CI")
160            || (io::stdout().is_terminal()
161                && std::env::var("TERM").is_ok_and(|term| term != "dumb")))
162}
163
164/// Writes one task's output as its display asks.
165struct Printer {
166    display: Display,
167    /// Partial lines of stdout and stderr, for prefixing whole lines.
168    lines: Mutex<[Vec<u8>; 2]>,
169    held: Mutex<Vec<u8>>,
170}
171
172impl Printer {
173    fn new(display: Display) -> Self {
174        Self {
175            display,
176            lines: Mutex::default(),
177            held: Mutex::default(),
178        }
179    }
180
181    fn write(&self, stderr: bool, bytes: &[u8]) -> io::Result<()> {
182        match &self.display {
183            Display::Stream => write_to(stderr, bytes),
184            Display::Hidden => Ok(()),
185            Display::Static { .. } | Display::Failures { .. } => {
186                self.held.lock().unwrap().extend_from_slice(bytes);
187                Ok(())
188            }
189            Display::Prefixed(prefix) => {
190                let mut lines = self.lines.lock().unwrap();
191                let buffer = &mut lines[usize::from(stderr)];
192                buffer.extend_from_slice(bytes);
193                let Some(end) = buffer
194                    .iter()
195                    .rposition(|byte| matches!(byte, b'\n' | b'\r'))
196                else {
197                    return Ok(());
198                };
199                let complete: Vec<u8> = buffer.drain(..=end).collect();
200                write_to(stderr, &prefixed(prefix, &complete))
201            }
202        }
203    }
204
205    fn finish(&self, shown: Shown) -> io::Result<()> {
206        match &self.display {
207            Display::Stream | Display::Hidden => Ok(()),
208            Display::Failures { id } => {
209                let held = std::mem::take(&mut *self.held.lock().unwrap());
210                if shown == Shown::Failure {
211                    let mut text = format!("\nāœ– qk run {id} failed\n\n").into_bytes();
212                    text.extend_from_slice(&held);
213                    if !held.ends_with(b"\n") {
214                        text.push(b'\n');
215                    }
216                    write_to(false, &text)?;
217                }
218                Ok(())
219            }
220            Display::Prefixed(prefix) => {
221                let mut lines = self.lines.lock().unwrap();
222                for stderr in [false, true] {
223                    let rest = std::mem::take(&mut lines[usize::from(stderr)]);
224                    write_to(stderr, &prefixed(prefix, &rest))?;
225                }
226                Ok(())
227            }
228            Display::Static { id, group } => {
229                let held = std::mem::take(&mut *self.held.lock().unwrap());
230                let status = match shown {
231                    Shown::LocalCache => "  [local cache]",
232                    Shown::RemoteCache => "  [remote cache]",
233                    Shown::Kept => "  [existing outputs match the cache, left as is]",
234                    Shown::Success | Shown::Failure => "",
235                };
236                let mut text = Vec::new();
237                text.push(b'\n');
238                if *group {
239                    let icon = match shown {
240                        Shown::Success => "āœ…",
241                        Shown::Failure => "āŒ",
242                        Shown::LocalCache | Shown::RemoteCache => "šŸ”",
243                        Shown::Kept => "ā©",
244                    };
245                    text.extend_from_slice(format!("::group::{icon} ").as_bytes());
246                }
247                text.extend_from_slice(format!("> qk run {id}{status}\n\n").as_bytes());
248                text.extend_from_slice(&held);
249                if *group {
250                    if !held.ends_with(b"\n") && !held.is_empty() {
251                        text.push(b'\n');
252                    }
253                    text.extend_from_slice(b"::endgroup::\n");
254                }
255                write_to(false, &text)
256            }
257        }
258    }
259}
260
261/// Nx's `formatPrefixedLines`: every non-empty line behind the prefix.
262fn prefixed(prefix: &str, bytes: &[u8]) -> Vec<u8> {
263    let mut result = Vec::new();
264    for line in bytes.split(|byte| matches!(byte, b'\n' | b'\r')) {
265        if !line.is_empty() {
266            result.extend_from_slice(prefix.as_bytes());
267            result.push(b' ');
268            result.extend_from_slice(line);
269            result.push(b'\n');
270        }
271    }
272    result
273}
274
275fn write_to(stderr: bool, bytes: &[u8]) -> io::Result<()> {
276    crate::report::output(stderr, bytes);
277    Ok(())
278}
279
280/// Framed stdout/stderr chunks, optionally stored on disk without buffering the
281/// task's log, and shown as the task's display asks.
282pub struct Capture {
283    file: Option<Mutex<File>>,
284    retained: Mutex<Option<RecordedLog>>,
285    printer: Printer,
286    healthy: AtomicBool,
287    readiness: Option<Readiness>,
288}
289
290/// Maximum framed bytes retained for an execution, independent of result caching.
291pub const MAX_LOG_BYTES: usize = 4 * 1024 * 1024;
292
293/// Ordered, stream-tagged frames retained for run history.
294#[derive(Default)]
295pub struct RecordedLog {
296    pub data: Vec<u8>,
297    /// Further output was displayed but omitted from this bounded record.
298    pub truncated: bool,
299}
300
301impl RecordedLog {
302    /// Keeps a prefix of complete frames, stopping at the payload limit.
303    fn append(&mut self, stderr: bool, bytes: &[u8]) {
304        if self.truncated {
305            return;
306        }
307        let remaining = MAX_LOG_BYTES.saturating_sub(self.data.len());
308        let count = bytes.len().min(remaining.saturating_sub(5));
309        if count > 0 {
310            self.data.push(u8::from(stderr));
311            self.data.extend_from_slice(&(count as u32).to_le_bytes());
312            self.data.extend_from_slice(&bytes[..count]);
313        }
314        self.truncated = count != bytes.len();
315    }
316}
317
318/// Watches output for `readyWhen` text, on either stream and across reads.
319struct Readiness {
320    patterns: Vec<String>,
321    /// Which patterns have appeared, and each stream's last bytes, so text
322    /// split between two reads still matches.
323    state: Mutex<(Vec<bool>, [Vec<u8>; 2])>,
324    ready: std::sync::Arc<AtomicBool>,
325}
326
327impl Readiness {
328    fn see(&self, stderr: bool, bytes: &[u8]) {
329        let Ok(mut state) = self.state.lock() else {
330            return;
331        };
332        let (found, tails) = &mut *state;
333        let tail = &mut tails[usize::from(stderr)];
334        tail.extend_from_slice(bytes);
335        let text = String::from_utf8_lossy(tail);
336        for (pattern, found) in self.patterns.iter().zip(found.iter_mut()) {
337            *found |= text.contains(pattern.as_str());
338        }
339        let keep = self.patterns.iter().map(String::len).max().unwrap_or(0);
340        if tail.len() > keep {
341            tail.drain(..tail.len() - keep);
342        }
343        if found.iter().all(|found| *found) {
344            self.ready.store(true, Ordering::SeqCst);
345        }
346    }
347}
348
349impl Capture {
350    /// Streams output through its display, optionally recording cache frames on disk.
351    pub fn new(file: Option<File>, display: Display) -> Self {
352        Self {
353            file: file.map(Mutex::new),
354            retained: Mutex::new(None),
355            printer: Printer::new(display),
356            healthy: AtomicBool::new(true),
357            readiness: None,
358        }
359    }
360
361    /// Retains a bounded execution log alongside any result-cache capture.
362    pub fn retain_log(self) -> Self {
363        *self.retained.lock().unwrap() = Some(RecordedLog::default());
364        self
365    }
366
367    /// Takes the retained log after the command's output readers have finished.
368    pub fn take_log(&self) -> Option<RecordedLog> {
369        self.retained.lock().unwrap().take()
370    }
371
372    /// Sets `ready` once every one of `patterns` has appeared in the output.
373    pub fn ready_when(mut self, patterns: &[String], ready: std::sync::Arc<AtomicBool>) -> Self {
374        if !patterns.is_empty() {
375            self.readiness = Some(Readiness {
376                patterns: patterns.to_vec(),
377                state: Mutex::new((vec![false; patterns.len()], [Vec::new(), Vec::new()])),
378                ready,
379            });
380        }
381        self
382    }
383
384    pub fn healthy(&self) -> bool {
385        self.healthy.load(Ordering::Relaxed)
386    }
387
388    /// Copies one command's stream, decorated as its command entry asks.
389    pub(crate) fn copy(
390        &self,
391        mut reader: impl Read,
392        stderr: bool,
393        decoration: Option<&Decoration>,
394    ) -> io::Result<()> {
395        let decoration = decoration.filter(|decoration| !decoration.is_empty());
396        let mut buffer = [0; 16 * 1024];
397        // With a prefix, whole lines, so a line split between reads gets one.
398        let mut partial = Vec::new();
399        loop {
400            let count = reader.read(&mut buffer)?;
401            let chunk = &buffer[..count];
402            match decoration {
403                None if count == 0 => return Ok(()),
404                None => self.emit(stderr, chunk)?,
405                Some(decoration) if decoration.prefix.is_some() => {
406                    partial.extend_from_slice(chunk);
407                    let end = if count == 0 {
408                        partial.len()
409                    } else {
410                        partial
411                            .iter()
412                            .rposition(|byte| *byte == b'\n')
413                            .map_or(0, |end| end + 1)
414                    };
415                    if end > 0 {
416                        let lines: Vec<u8> = partial.drain(..end).collect();
417                        self.emit(stderr, &decoration.apply(&lines))?;
418                    }
419                    if count == 0 {
420                        return Ok(());
421                    }
422                }
423                Some(_) if count == 0 => return Ok(()),
424                Some(decoration) => self.emit(stderr, &decoration.apply(chunk))?,
425            }
426        }
427    }
428
429    /// Records, shows and watches output, in frames the log reader accepts.
430    fn emit(&self, stderr: bool, bytes: &[u8]) -> io::Result<()> {
431        for frame in bytes.chunks(16 * 1024) {
432            // One order for retained frames, cache frames and displayed chunks.
433            let mut retained = self
434                .retained
435                .lock()
436                .map_err(|_| io::Error::other("retained log lock poisoned"))?;
437            if let Some(log) = retained.as_mut() {
438                log.append(stderr, frame);
439            }
440            if let Some(file) = &self.file {
441                let mut file = file
442                    .lock()
443                    .map_err(|_| io::Error::other("output capture lock poisoned"))?;
444                if self.healthy() {
445                    let result = (|| {
446                        file.write_all(&[u8::from(stderr)])?;
447                        file.write_all(&(frame.len() as u32).to_le_bytes())?;
448                        file.write_all(frame)
449                    })();
450                    if result.is_err() {
451                        self.healthy.store(false, Ordering::Relaxed);
452                    }
453                }
454            }
455            self.printer.write(stderr, frame)?;
456            if let Some(readiness) = &self.readiness {
457                readiness.see(stderr, frame);
458            }
459        }
460        Ok(())
461    }
462
463    pub(crate) fn finish(&self, shown: Shown) -> io::Result<()> {
464        self.printer.finish(shown)
465    }
466}
467
468pub fn read_capture(
469    mut reader: impl Read,
470    mut consume: impl FnMut(bool, &[u8]) -> io::Result<()>,
471) -> io::Result<()> {
472    loop {
473        let mut stream = [0];
474        if reader.read(&mut stream)? == 0 {
475            return Ok(());
476        }
477        if stream[0] > 1 {
478            return Err(io::Error::other("invalid output capture stream"));
479        }
480        let mut length = [0; 4];
481        reader.read_exact(&mut length)?;
482        let length = u32::from_le_bytes(length) as usize;
483        if length > 16 * 1024 {
484            return Err(io::Error::other("invalid output capture frame"));
485        }
486        let mut bytes = vec![0; length];
487        reader.read_exact(&mut bytes)?;
488        consume(stream[0] == 1, &bytes)?;
489    }
490}
491
492/// Shows a recorded log as a cache hit.
493pub fn replay(reader: impl Read, display: &Display, shown: Shown) -> io::Result<()> {
494    let printer = Printer::new(display.clone());
495    read_capture(reader, |stderr, bytes| printer.write(stderr, bytes))?;
496    printer.finish(shown)
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    /// Retention preserves binary streams and valid framing at the byte limit.
504    #[test]
505    fn retained_logs_are_ordered_and_bounded() {
506        let capture = Capture::new(None, Display::Hidden).retain_log();
507        capture.emit(false, b"stdout\0\xff").unwrap();
508        capture.emit(true, b"stderr").unwrap();
509        let data = vec![b'x'; MAX_LOG_BYTES];
510        capture.emit(false, &data).unwrap();
511        capture.emit(true, b"omitted").unwrap();
512        let log = capture.take_log().unwrap();
513        assert!(log.truncated);
514        assert!(log.data.len() <= MAX_LOG_BYTES);
515        let mut frames = Vec::new();
516        read_capture(log.data.as_slice(), |stderr, bytes| {
517            frames.push((stderr, bytes.to_vec()));
518            Ok(())
519        })
520        .unwrap();
521        assert_eq!(frames[0], (false, b"stdout\0\xff".to_vec()));
522        assert_eq!(frames[1], (true, b"stderr".to_vec()));
523        assert!(
524            frames[2..]
525                .iter()
526                .all(|(stderr, bytes)| !stderr && bytes.iter().all(|b| *b == b'x'))
527        );
528        assert!(capture.take_log().is_none());
529    }
530
531    #[test]
532    fn prefixes_non_empty_lines_like_nx() {
533        assert_eq!(
534            prefixed("web:", b"one\n\ntwo\r\nthree"),
535            b"web: one\nweb: two\nweb: three\n"
536        );
537    }
538}