1use std::fs::File;
2use std::io::{self, Read, Write};
3use std::sync::Mutex;
4use std::sync::atomic::{AtomicBool, Ordering};
5
6#[derive(Clone, Debug, Default, PartialEq, Eq)]
8pub enum Display {
9 #[default]
11 Stream,
12 Prefixed(String),
14 Static {
18 id: String,
19 group: bool,
20 cached: bool,
21 },
22 Headed { id: String },
24 Failures { id: String },
27 Hidden,
29}
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum Shown {
34 Success,
35 Failure,
36 LocalCache,
37 RemoteCache,
38 Kept,
40}
41
42impl Display {
43 pub fn for_task(
47 style: OutputStyle,
48 id: &str,
49 project: &str,
50 continuous: bool,
51 root: bool,
52 ) -> Self {
53 match style {
54 OutputStyle::StreamWithoutPrefixes => Self::Stream,
55 OutputStyle::RunOne if root && !continuous => Self::Headed { id: id.to_owned() },
56 OutputStyle::Static | OutputStyle::RunOne if !continuous => Self::Static {
57 id: id.to_owned(),
58 group: std::env::var_os("GITHUB_ACTIONS").is_some()
59 && std::env::var_os("NX_SKIP_LOG_GROUPING").is_none_or(|value| value != "true"),
60 cached: style == OutputStyle::Static,
61 },
62 OutputStyle::Quiet => Self::Failures { id: id.to_owned() },
63 OutputStyle::Static | OutputStyle::RunOne | OutputStyle::Stream => {
64 Self::Prefixed(prefix(project))
65 }
66 }
67 }
68}
69
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72pub enum OutputStyle {
73 Stream,
74 StreamWithoutPrefixes,
75 Static,
76 RunOne,
80 Quiet,
82}
83
84fn prefix(project: &str) -> String {
87 const COLOURS: [u8; 10] = [32, 92, 34, 94, 36, 96, 33, 93, 35, 95];
90 if !colour() {
91 return format!("{project}:");
92 }
93 let code: u32 = project.encode_utf16().map(u32::from).sum();
94 let colour = COLOURS[code as usize % COLOURS.len()];
95 format!("\x1b[1m\x1b[{colour}m{project}:\x1b[39m\x1b[22m")
96}
97
98#[derive(Clone, Debug, Default, PartialEq, Eq)]
100pub struct Decoration {
101 pub prefix: Option<String>,
102 pub prefix_color: Option<String>,
103 pub color: Option<String>,
104 pub bg_color: Option<String>,
105}
106
107const COLOURS: [&str; 8] = [
108 "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
109];
110
111impl Decoration {
112 pub fn is_empty(&self) -> bool {
113 self == &Self::default()
114 }
115
116 pub fn valid(name: &str, background: bool) -> bool {
119 match name.strip_prefix("bg") {
120 Some(rest) if background => {
121 let mut chars = rest.chars();
122 chars.next().is_some_and(char::is_uppercase)
123 && COLOURS.contains(&rest.to_lowercase().as_str())
124 }
125 _ => !background && COLOURS.contains(&name),
126 }
127 }
128
129 fn apply(&self, bytes: &[u8]) -> Vec<u8> {
132 let colours = colour();
133 let paint = |text: &str, name: Option<&str>, background: bool| -> String {
134 let index = name.and_then(|name| {
135 let name = if background {
136 name.get(2..)?.to_lowercase()
137 } else {
138 name.to_owned()
139 };
140 COLOURS.iter().position(|colour| *colour == name)
141 });
142 match index {
143 Some(index) if colours => {
144 let (open, close) = if background { (40, 49) } else { (30, 39) };
145 format!("\x1b[{}m{text}\x1b[{close}m", open + index)
146 }
147 _ => text.to_owned(),
148 }
149 };
150 let mut text = String::from_utf8_lossy(bytes).into_owned();
151 if let Some(prefix) = &self.prefix {
152 let mut prefix = paint(prefix, self.prefix_color.as_deref(), false);
153 if colours {
154 prefix = format!("\x1b[1m{prefix}\x1b[22m");
155 }
156 text = text
157 .split('\n')
158 .map(|line| {
159 if line.trim().is_empty() {
160 line.to_owned()
161 } else {
162 format!("{prefix} {line}")
163 }
164 })
165 .collect::<Vec<_>>()
166 .join("\n");
167 }
168 let text = paint(&text, self.color.as_deref(), false);
169 paint(&text, self.bg_color.as_deref(), true).into_bytes()
170 }
171}
172
173fn colour() -> bool {
176 use std::io::IsTerminal;
177 let set = |name| std::env::var_os(name).is_some();
178 !set("NO_COLOR")
179 && (set("FORCE_COLOR")
180 || cfg!(windows)
181 || set("CI")
182 || (io::stdout().is_terminal()
183 && std::env::var("TERM").is_ok_and(|term| term != "dumb")))
184}
185
186struct Printer {
188 display: Display,
189 lines: Mutex<[Vec<u8>; 2]>,
191 held: Mutex<Vec<u8>>,
192}
193
194impl Printer {
195 fn new(display: Display) -> Self {
196 Self {
197 display,
198 lines: Mutex::default(),
199 held: Mutex::default(),
200 }
201 }
202
203 fn write(&self, stderr: bool, bytes: &[u8]) -> io::Result<()> {
204 match &self.display {
205 Display::Stream | Display::Headed { .. } => write_to(stderr, bytes),
206 Display::Hidden => Ok(()),
207 Display::Static { .. } | Display::Failures { .. } => {
208 self.held.lock().unwrap().extend_from_slice(bytes);
209 Ok(())
210 }
211 Display::Prefixed(prefix) => {
212 let mut lines = self.lines.lock().unwrap();
213 let buffer = &mut lines[usize::from(stderr)];
214 buffer.extend_from_slice(bytes);
215 let Some(end) = buffer
216 .iter()
217 .rposition(|byte| matches!(byte, b'\n' | b'\r'))
218 else {
219 return Ok(());
220 };
221 let complete: Vec<u8> = buffer.drain(..=end).collect();
222 write_to(stderr, &prefixed(prefix, &complete))
223 }
224 }
225 }
226
227 fn begin(&self, shown: Option<Shown>) -> io::Result<()> {
229 match &self.display {
230 Display::Headed { id } => {
231 let header = header(id, shown.unwrap_or(Shown::Success));
232 write_to(false, format!("\n{header}\n\n").as_bytes())
233 }
234 _ => Ok(()),
235 }
236 }
237
238 fn finish(&self, shown: Shown) -> io::Result<()> {
239 match &self.display {
240 Display::Stream | Display::Headed { .. } | Display::Hidden => Ok(()),
241 Display::Failures { id } => {
242 let held = std::mem::take(&mut *self.held.lock().unwrap());
243 if shown == Shown::Failure {
244 let mut text = format!("\nā qk run {id} failed\n\n").into_bytes();
245 text.extend_from_slice(&held);
246 if !held.ends_with(b"\n") {
247 text.push(b'\n');
248 }
249 write_to(false, &text)?;
250 }
251 Ok(())
252 }
253 Display::Prefixed(prefix) => {
254 let mut lines = self.lines.lock().unwrap();
255 for stderr in [false, true] {
256 let rest = std::mem::take(&mut lines[usize::from(stderr)]);
257 write_to(stderr, &prefixed(prefix, &rest))?;
258 }
259 Ok(())
260 }
261 Display::Static { id, group, cached } => {
262 let mut held = std::mem::take(&mut *self.held.lock().unwrap());
263 if !cached && matches!(shown, Shown::LocalCache | Shown::RemoteCache | Shown::Kept)
264 {
265 held.clear();
266 }
267 let mut text = Vec::new();
268 text.push(b'\n');
269 if *group {
270 let icon = match shown {
271 Shown::Success => "ā
",
272 Shown::Failure => "ā",
273 Shown::LocalCache | Shown::RemoteCache => "š",
274 Shown::Kept => "ā©",
275 };
276 text.extend_from_slice(format!("::group::{icon} ").as_bytes());
277 }
278 text.extend_from_slice(format!("{}\n\n", header(id, shown)).as_bytes());
279 text.extend_from_slice(&held);
280 if *group {
281 if !held.ends_with(b"\n") && !held.is_empty() {
282 text.push(b'\n');
283 }
284 text.extend_from_slice(b"::endgroup::\n");
285 }
286 write_to(false, &text)
287 }
288 }
289 }
290}
291
292fn header(id: &str, shown: Shown) -> String {
294 let status = match shown {
295 Shown::LocalCache => " [local cache]",
296 Shown::RemoteCache => " [remote cache]",
297 Shown::Kept => " [existing outputs match the cache, left as is]",
298 Shown::Success | Shown::Failure => "",
299 };
300 format!("> qk run {id}{status}")
301}
302
303fn prefixed(prefix: &str, bytes: &[u8]) -> Vec<u8> {
305 let mut result = Vec::new();
306 for line in bytes.split(|byte| matches!(byte, b'\n' | b'\r')) {
307 if !line.is_empty() {
308 result.extend_from_slice(prefix.as_bytes());
309 result.push(b' ');
310 result.extend_from_slice(line);
311 result.push(b'\n');
312 }
313 }
314 result
315}
316
317fn write_to(stderr: bool, bytes: &[u8]) -> io::Result<()> {
318 crate::report::output(stderr, bytes);
319 Ok(())
320}
321
322pub struct Capture {
325 file: Option<Mutex<File>>,
326 retained: Mutex<Option<RecordedLog>>,
327 printer: Printer,
328 healthy: AtomicBool,
329 readiness: Option<Readiness>,
330}
331
332pub const MAX_LOG_BYTES: usize = 4 * 1024 * 1024;
334
335#[derive(Default)]
337pub struct RecordedLog {
338 pub data: Vec<u8>,
339 pub truncated: bool,
341}
342
343impl RecordedLog {
344 fn append(&mut self, stderr: bool, bytes: &[u8]) {
346 if self.truncated {
347 return;
348 }
349 let remaining = MAX_LOG_BYTES.saturating_sub(self.data.len());
350 let count = bytes.len().min(remaining.saturating_sub(5));
351 if count > 0 {
352 self.data.push(u8::from(stderr));
353 self.data.extend_from_slice(&(count as u32).to_le_bytes());
354 self.data.extend_from_slice(&bytes[..count]);
355 }
356 self.truncated = count != bytes.len();
357 }
358}
359
360struct Readiness {
362 patterns: Vec<String>,
363 state: Mutex<(Vec<bool>, [Vec<u8>; 2])>,
366 ready: std::sync::Arc<AtomicBool>,
367}
368
369impl Readiness {
370 fn see(&self, stderr: bool, bytes: &[u8]) {
371 let Ok(mut state) = self.state.lock() else {
372 return;
373 };
374 let (found, tails) = &mut *state;
375 let tail = &mut tails[usize::from(stderr)];
376 tail.extend_from_slice(bytes);
377 let text = String::from_utf8_lossy(tail);
378 for (pattern, found) in self.patterns.iter().zip(found.iter_mut()) {
379 *found |= text.contains(pattern.as_str());
380 }
381 let keep = self.patterns.iter().map(String::len).max().unwrap_or(0);
382 if tail.len() > keep {
383 tail.drain(..tail.len() - keep);
384 }
385 if found.iter().all(|found| *found) {
386 self.ready.store(true, Ordering::SeqCst);
387 }
388 }
389}
390
391impl Capture {
392 pub fn new(file: Option<File>, display: Display) -> Self {
395 let printer = Printer::new(display);
396 let _ = printer.begin(None);
397 Self {
398 file: file.map(Mutex::new),
399 retained: Mutex::new(None),
400 printer,
401 healthy: AtomicBool::new(true),
402 readiness: None,
403 }
404 }
405
406 pub fn retain_log(self) -> Self {
408 *self.retained.lock().unwrap() = Some(RecordedLog::default());
409 self
410 }
411
412 pub fn take_log(&self) -> Option<RecordedLog> {
414 self.retained.lock().unwrap().take()
415 }
416
417 pub fn ready_when(mut self, patterns: &[String], ready: std::sync::Arc<AtomicBool>) -> Self {
419 if !patterns.is_empty() {
420 self.readiness = Some(Readiness {
421 patterns: patterns.to_vec(),
422 state: Mutex::new((vec![false; patterns.len()], [Vec::new(), Vec::new()])),
423 ready,
424 });
425 }
426 self
427 }
428
429 pub fn healthy(&self) -> bool {
430 self.healthy.load(Ordering::Relaxed)
431 }
432
433 pub(crate) fn copy(
435 &self,
436 mut reader: impl Read,
437 stderr: bool,
438 decoration: Option<&Decoration>,
439 ) -> io::Result<()> {
440 let decoration = decoration.filter(|decoration| !decoration.is_empty());
441 let mut buffer = [0; 16 * 1024];
442 let mut partial = Vec::new();
444 loop {
445 let count = reader.read(&mut buffer)?;
446 let chunk = &buffer[..count];
447 match decoration {
448 None if count == 0 => return Ok(()),
449 None => self.emit(stderr, chunk)?,
450 Some(decoration) if decoration.prefix.is_some() => {
451 partial.extend_from_slice(chunk);
452 let end = if count == 0 {
453 partial.len()
454 } else {
455 partial
456 .iter()
457 .rposition(|byte| *byte == b'\n')
458 .map_or(0, |end| end + 1)
459 };
460 if end > 0 {
461 let lines: Vec<u8> = partial.drain(..end).collect();
462 self.emit(stderr, &decoration.apply(&lines))?;
463 }
464 if count == 0 {
465 return Ok(());
466 }
467 }
468 Some(_) if count == 0 => return Ok(()),
469 Some(decoration) => self.emit(stderr, &decoration.apply(chunk))?,
470 }
471 }
472 }
473
474 fn emit(&self, stderr: bool, bytes: &[u8]) -> io::Result<()> {
476 for frame in bytes.chunks(16 * 1024) {
477 let mut retained = self
479 .retained
480 .lock()
481 .map_err(|_| io::Error::other("retained log lock poisoned"))?;
482 if let Some(log) = retained.as_mut() {
483 log.append(stderr, frame);
484 }
485 if let Some(file) = &self.file {
486 let mut file = file
487 .lock()
488 .map_err(|_| io::Error::other("output capture lock poisoned"))?;
489 if self.healthy() {
490 let result = (|| {
491 file.write_all(&[u8::from(stderr)])?;
492 file.write_all(&(frame.len() as u32).to_le_bytes())?;
493 file.write_all(frame)
494 })();
495 if result.is_err() {
496 self.healthy.store(false, Ordering::Relaxed);
497 }
498 }
499 }
500 self.printer.write(stderr, frame)?;
501 if let Some(readiness) = &self.readiness {
502 readiness.see(stderr, frame);
503 }
504 }
505 Ok(())
506 }
507
508 pub(crate) fn finish(&self, shown: Shown) -> io::Result<()> {
509 self.printer.finish(shown)
510 }
511}
512
513pub fn read_capture(
514 mut reader: impl Read,
515 mut consume: impl FnMut(bool, &[u8]) -> io::Result<()>,
516) -> io::Result<()> {
517 loop {
518 let mut stream = [0];
519 if reader.read(&mut stream)? == 0 {
520 return Ok(());
521 }
522 if stream[0] > 1 {
523 return Err(io::Error::other("invalid output capture stream"));
524 }
525 let mut length = [0; 4];
526 reader.read_exact(&mut length)?;
527 let length = u32::from_le_bytes(length) as usize;
528 if length > 16 * 1024 {
529 return Err(io::Error::other("invalid output capture frame"));
530 }
531 let mut bytes = vec![0; length];
532 reader.read_exact(&mut bytes)?;
533 consume(stream[0] == 1, &bytes)?;
534 }
535}
536
537pub fn replay(reader: impl Read, display: &Display, shown: Shown) -> io::Result<()> {
539 let printer = Printer::new(display.clone());
540 printer.begin(Some(shown))?;
541 read_capture(reader, |stderr, bytes| printer.write(stderr, bytes))?;
542 printer.finish(shown)
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548
549 #[test]
551 fn retained_logs_are_ordered_and_bounded() {
552 let capture = Capture::new(None, Display::Hidden).retain_log();
553 capture.emit(false, b"stdout\0\xff").unwrap();
554 capture.emit(true, b"stderr").unwrap();
555 let data = vec![b'x'; MAX_LOG_BYTES];
556 capture.emit(false, &data).unwrap();
557 capture.emit(true, b"omitted").unwrap();
558 let log = capture.take_log().unwrap();
559 assert!(log.truncated);
560 assert!(log.data.len() <= MAX_LOG_BYTES);
561 let mut frames = Vec::new();
562 read_capture(log.data.as_slice(), |stderr, bytes| {
563 frames.push((stderr, bytes.to_vec()));
564 Ok(())
565 })
566 .unwrap();
567 assert_eq!(frames[0], (false, b"stdout\0\xff".to_vec()));
568 assert_eq!(frames[1], (true, b"stderr".to_vec()));
569 assert!(
570 frames[2..]
571 .iter()
572 .all(|(stderr, bytes)| !stderr && bytes.iter().all(|b| *b == b'x'))
573 );
574 assert!(capture.take_log().is_none());
575 }
576
577 #[test]
578 fn prefixes_non_empty_lines_like_nx() {
579 assert_eq!(
580 prefixed("web:", b"one\n\ntwo\r\nthree"),
581 b"web: one\nweb: two\nweb: three\n"
582 );
583 }
584}