use std::io::{self, Write};
use std::time::Duration;
use alba_engine::{BeamStatus, RunEvent, RunSummary};
mod grouped;
mod interleaved;
mod json;
pub use grouped::GroupedRenderer;
pub use interleaved::InterleavedRenderer;
pub use json::JsonRenderer;
pub trait Renderer: Send {
fn handle(&mut self, event: &RunEvent);
}
pub struct LineSink<W: Write> {
writer: W,
open: bool,
}
impl<W: Write> LineSink<W> {
pub fn new(writer: W) -> Self {
Self { writer, open: true }
}
pub fn line(&mut self, text: &str) {
if !self.open {
return;
}
if writeln!(self.writer, "{text}").is_err() {
self.open = false;
}
}
pub fn raw(&mut self, text: &str) {
if !self.open {
return;
}
if write!(self.writer, "{text}")
.and_then(|()| self.writer.flush())
.is_err()
{
self.open = false;
}
}
}
impl LineSink<io::Stdout> {
pub fn stdout() -> Self {
Self::new(io::stdout())
}
}
impl LineSink<io::Stderr> {
pub fn stderr() -> Self {
Self::new(io::stderr())
}
}
pub fn format_duration(duration: Duration) -> String {
format!("{:.1}s", duration.as_secs_f64())
}
pub fn status_label(status: &BeamStatus) -> String {
match status {
BeamStatus::Succeeded => "ok".to_string(),
BeamStatus::Cached => "cached".to_string(),
BeamStatus::Failed { exit_code } => format!("failed (exit {exit_code})"),
BeamStatus::FailedAllowed { .. } => "failed (allowed)".to_string(),
BeamStatus::Cancelled => "cancelled".to_string(),
}
}
pub(super) fn watch_line(event: &RunEvent) -> Option<String> {
match event {
RunEvent::WatchWaiting { files } => {
let plural = if *files == 1 { "" } else { "s" };
Some(format!(
"watching — {files} file{plural}, waiting for changes"
))
}
RunEvent::WatchTriggered { paths } if paths.is_empty() => {
Some("changes detected — running".to_string())
}
RunEvent::WatchTriggered { paths } => {
let named = paths.iter().take(3).cloned().collect::<Vec<_>>().join(", ");
let rest = paths.len().saturating_sub(3);
if rest == 0 {
Some(format!("change detected in {named} — running"))
} else {
Some(format!(
"change detected in {named} and {rest} more — running"
))
}
}
_ => None,
}
}
pub(super) fn handle_watch_event(
event: &RunEvent,
out: &mut LineSink<io::Stdout>,
err: &mut LineSink<io::Stderr>,
clear_between_runs: bool,
) -> bool {
let Some(line) = watch_line(event) else {
return false;
};
if matches!(event, RunEvent::WatchTriggered { .. }) && clear_between_runs {
out.raw("\u{1b}[2J\u{1b}[3J\u{1b}[H");
}
err.line(&line);
true
}
pub fn print_summary<W: Write>(
sink: &mut LineSink<W>,
summary: &RunSummary,
affected_by: Option<&str>,
) {
sink.line(&summary_line(summary, affected_by));
}
pub fn nothing_affected_line(reference: &str) -> String {
format!("\u{2713} nothing affected by {reference}")
}
fn summary_line(summary: &RunSummary, affected_by: Option<&str>) -> String {
if let Some(reference) = affected_by
&& summary.succeeded.is_empty()
&& summary.cached.is_empty()
&& summary.failed.is_empty()
&& summary.failed_allowed.is_empty()
&& summary.cancelled.is_empty()
{
return nothing_affected_line(reference);
}
let counts = [
("\u{2713}", summary.succeeded.len(), "succeeded"),
("\u{21ba}", summary.cached.len(), "cached"),
("\u{2717}", summary.failed.len(), "failed"),
("\u{26a0}", summary.failed_allowed.len(), "failed (allowed)"),
("\u{2298}", summary.cancelled.len(), "cancelled"),
];
let mut parts: Vec<String> = counts
.iter()
.filter(|(_, count, _)| *count > 0)
.map(|(mark, count, label)| format!("{mark} {count} {label}"))
.collect();
parts.push(format_duration(summary.duration));
parts.join(" \u{b7} ")
}
#[cfg(test)]
mod tests {
use super::*;
use alba_core::BeamId;
#[test]
fn formats_durations_with_one_decimal() {
assert_eq!(format_duration(Duration::from_millis(1234)), "1.2s");
assert_eq!(format_duration(Duration::ZERO), "0.0s");
}
#[test]
fn labels_a_failure_with_its_exit_code_but_an_allowed_one_without() {
assert_eq!(
status_label(&BeamStatus::Failed { exit_code: 7 }),
"failed (exit 7)"
);
assert_eq!(
status_label(&BeamStatus::FailedAllowed { exit_code: 1 }),
"failed (allowed)"
);
}
#[test]
fn a_cached_beam_is_labelled_cached() {
assert_eq!(status_label(&BeamStatus::Cached), "cached");
}
#[test]
fn the_summary_counts_cached_beams() {
let summary = RunSummary {
succeeded: vec![BeamId("a".to_string())],
cached: vec![BeamId("b".to_string()), BeamId("c".to_string())],
duration: Duration::from_millis(4100),
..RunSummary::default()
};
assert_eq!(
summary_line(&summary, None),
"\u{2713} 1 succeeded \u{b7} \u{21ba} 2 cached \u{b7} 4.1s"
);
}
#[test]
fn the_summary_omits_empty_buckets_and_always_ends_with_the_duration() {
let summary = RunSummary {
succeeded: vec![BeamId("a".to_string())],
cancelled: vec![BeamId("b".to_string()), BeamId("c".to_string())],
duration: Duration::from_millis(4100),
..RunSummary::default()
};
assert_eq!(
summary_line(&summary, None),
"\u{2713} 1 succeeded \u{b7} \u{2298} 2 cancelled \u{b7} 4.1s"
);
}
#[test]
fn an_affected_run_with_no_targets_says_nothing_was_affected() {
assert_eq!(
summary_line(&RunSummary::default(), Some("HEAD")),
"\u{2713} nothing affected by HEAD"
);
}
#[test]
fn an_affected_run_with_targets_reports_its_counts_as_usual() {
let summary = RunSummary {
succeeded: vec![BeamId("a".to_string())],
duration: Duration::from_millis(4100),
..RunSummary::default()
};
assert_eq!(
summary_line(&summary, Some("HEAD")),
"\u{2713} 1 succeeded \u{b7} 4.1s"
);
}
struct BrokenWriter {
attempts: usize,
}
impl Write for BrokenWriter {
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
self.attempts += 1;
Err(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe"))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn a_broken_sink_swallows_the_failure_and_stops_writing() {
let mut sink = LineSink::new(BrokenWriter { attempts: 0 });
sink.line("first");
sink.line("second");
sink.line("third");
assert_eq!(
sink.writer.attempts, 1,
"the sink must latch closed after the first failure"
);
}
#[test]
fn an_open_sink_writes_every_line() {
let mut sink = LineSink::new(Vec::new());
sink.line("first");
sink.line("second");
assert_eq!(String::from_utf8(sink.writer).unwrap(), "first\nsecond\n");
}
#[test]
fn watch_lines_read_as_status_not_as_beam_output() {
assert_eq!(
watch_line(&RunEvent::WatchWaiting { files: 3 }),
Some("watching — 3 files, waiting for changes".to_string())
);
assert_eq!(
watch_line(&RunEvent::WatchWaiting { files: 1 }),
Some("watching — 1 file, waiting for changes".to_string())
);
assert_eq!(
watch_line(&RunEvent::WatchWaiting { files: 0 }),
Some("watching — 0 files, waiting for changes".to_string())
);
assert_eq!(
watch_line(&RunEvent::WatchTriggered {
paths: vec!["a.rs".into(), "b.rs".into()],
}),
Some("change detected in a.rs, b.rs — running".to_string())
);
assert_eq!(
watch_line(&RunEvent::WatchTriggered {
paths: vec!["a".into(), "b".into(), "c".into(), "d".into(), "e".into()],
}),
Some("change detected in a, b, c and 2 more — running".to_string())
);
assert_eq!(
watch_line(&RunEvent::WatchTriggered { paths: vec![] }),
Some("changes detected — running".to_string())
);
assert_eq!(
watch_line(&RunEvent::RunFinished {
summary: RunSummary::default()
}),
None
);
}
}