use std::io::{BufRead, BufReader, Read};
use std::sync::Arc;
use std::thread::JoinHandle;
use colored::{Color, Colorize};
pub(crate) trait LineSink: Send + Sync {
fn emit(&self, prefix: &str, is_stderr: bool, line: &str);
}
pub(crate) struct StdioSink;
impl LineSink for StdioSink {
fn emit(&self, prefix: &str, is_stderr: bool, line: &str) {
use std::io::Write;
if is_stderr {
let mut h = std::io::stderr().lock();
let _ = writeln!(h, "{prefix} {line}");
} else {
let mut h = std::io::stdout().lock();
let _ = writeln!(h, "{prefix} {line}");
}
}
}
pub(crate) fn prefix_width(names: &[&str]) -> usize {
names.iter().map(|n| n.chars().count()).max().unwrap_or(0)
}
pub(crate) fn color_for(name: &str) -> Color {
const PALETTE: [Color; 8] = [
Color::Cyan,
Color::Magenta,
Color::Yellow,
Color::Green,
Color::Blue,
Color::Red,
Color::BrightCyan,
Color::BrightMagenta,
];
let hash = name
.bytes()
.fold(0u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
PALETTE[hash as usize % PALETTE.len()]
}
pub(crate) fn render_prefix(name: &str, width: usize, colorize: bool) -> String {
let padded = format!("{name:<width$}");
let bracketed = format!("[{padded}]");
if colorize {
bracketed.color(color_for(name)).to_string()
} else {
bracketed
}
}
pub(crate) fn spawn_readers<R>(
streams: Vec<(String, bool, R)>,
sink: &Arc<dyn LineSink>,
) -> Vec<JoinHandle<()>>
where
R: Read + Send + 'static,
{
streams
.into_iter()
.map(|(prefix, is_stderr, reader)| {
let sink = Arc::clone(sink);
std::thread::spawn(move || {
let buf = BufReader::new(reader);
for line in buf.lines() {
let Ok(line) = line else { return };
sink.emit(&prefix, is_stderr, &line);
}
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
struct VecSink {
lines: Mutex<Vec<(String, bool, String)>>,
}
impl VecSink {
fn new() -> Self {
Self {
lines: Mutex::new(Vec::new()),
}
}
fn take(&self) -> Vec<(String, bool, String)> {
std::mem::take(&mut *self.lines.lock().unwrap())
}
}
impl LineSink for VecSink {
fn emit(&self, prefix: &str, is_stderr: bool, line: &str) {
self.lines
.lock()
.unwrap()
.push((prefix.to_string(), is_stderr, line.to_string()));
}
}
#[test]
fn prefix_width_picks_longest() {
assert_eq!(prefix_width(&["a", "build", "test"]), 5);
assert_eq!(prefix_width(&[]), 0);
}
#[test]
fn color_for_is_deterministic() {
assert_eq!(color_for("build"), color_for("build"));
}
#[test]
fn render_prefix_pads_and_brackets() {
let p = render_prefix("a", 5, false);
assert_eq!(p, "[a ]");
}
#[test]
fn render_prefix_colors_when_enabled() {
colored::control::set_override(true);
let p = render_prefix("a", 1, true);
colored::control::unset_override();
assert!(p.contains("[a]"));
assert!(p.contains("\u{1b}["), "expected ANSI escape, got: {p:?}");
}
#[test]
fn spawn_readers_streams_lines_through_sink() {
let sink = Arc::new(VecSink::new());
let stream = std::io::Cursor::new(b"hello\nworld\n".to_vec());
let handles = spawn_readers(
vec![("[t]".into(), false, stream)],
&(Arc::clone(&sink) as Arc<dyn LineSink>),
);
for h in handles {
h.join().unwrap();
}
let mut got: Vec<String> = sink.take().into_iter().map(|(_, _, line)| line).collect();
got.sort();
assert_eq!(got, vec!["hello".to_string(), "world".to_string()]);
}
#[test]
fn spawn_readers_routes_stderr_flag_through_sink() {
let sink = Arc::new(VecSink::new());
let out = std::io::Cursor::new(b"o\n".to_vec());
let err = std::io::Cursor::new(b"e\n".to_vec());
let handles = spawn_readers(
vec![("[t]".into(), false, out), ("[t]".into(), true, err)],
&(Arc::clone(&sink) as Arc<dyn LineSink>),
);
for h in handles {
h.join().unwrap();
}
let mut got = sink.take();
got.sort_by_key(|(_, is_err, _)| *is_err);
assert_eq!(got[0].2, "o");
assert!(!got[0].1);
assert_eq!(got[1].2, "e");
assert!(got[1].1);
}
}