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 { id: String, group: bool },
17 Failures { id: String },
20 Hidden,
22}
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum Shown {
27 Success,
28 Failure,
29 LocalCache,
30 RemoteCache,
31 Kept,
33}
34
35impl Display {
36 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum OutputStyle {
55 Stream,
56 StreamWithoutPrefixes,
57 Static,
58 Quiet,
60}
61
62fn prefix(project: &str) -> String {
65 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#[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 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 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
151fn 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
164struct Printer {
166 display: Display,
167 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
261fn 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
280pub struct Capture {
283 file: Option<Mutex<File>>,
284 retained: Mutex<Option<RecordedLog>>,
285 printer: Printer,
286 healthy: AtomicBool,
287 readiness: Option<Readiness>,
288}
289
290pub const MAX_LOG_BYTES: usize = 4 * 1024 * 1024;
292
293#[derive(Default)]
295pub struct RecordedLog {
296 pub data: Vec<u8>,
297 pub truncated: bool,
299}
300
301impl RecordedLog {
302 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
318struct Readiness {
320 patterns: Vec<String>,
321 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 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 pub fn retain_log(self) -> Self {
363 *self.retained.lock().unwrap() = Some(RecordedLog::default());
364 self
365 }
366
367 pub fn take_log(&self) -> Option<RecordedLog> {
369 self.retained.lock().unwrap().take()
370 }
371
372 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 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 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 fn emit(&self, stderr: bool, bytes: &[u8]) -> io::Result<()> {
431 for frame in bytes.chunks(16 * 1024) {
432 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
492pub 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 #[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}