use std::io;
use alba_engine::{BeamStatus, RunEvent, RunSummary};
use alba_executors::Stream;
use serde::Serialize;
use super::LineSink;
pub struct JsonRenderer {
out: LineSink<io::Stdout>,
err: LineSink<io::Stderr>,
}
impl JsonRenderer {
pub fn new() -> Self {
Self {
out: LineSink::stdout(),
err: LineSink::stderr(),
}
}
}
impl super::Renderer for JsonRenderer {
fn handle(&mut self, event: &RunEvent) {
if let RunEvent::ProjectBroken { diagnostic } = event {
self.err.line(diagnostic.trim_end());
}
let wire = WireEvent::from(event);
if let Ok(line) = serde_json::to_string(&wire) {
self.out.line(&line);
}
}
}
#[derive(Serialize)]
#[serde(tag = "event", rename_all = "snake_case")]
enum WireEvent<'a> {
RunStarted {
targets: Vec<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
affected_by: Option<&'a str>,
beams: Vec<&'a str>,
edges: Vec<(&'a str, &'a str)>,
},
BeamStarted {
beam: &'a str,
},
BeamCached {
beam: &'a str,
},
BeamOutput {
beam: &'a str,
stream: WireStream,
text: &'a str,
replayed: bool,
},
BeamFinished {
beam: &'a str,
status: &'static str,
exit_code: Option<i32>,
duration_ms: u64,
},
RunFinished {
succeeded: Vec<&'a str>,
cached: Vec<&'a str>,
failed: Vec<&'a str>,
failed_allowed: Vec<&'a str>,
cancelled: Vec<&'a str>,
duration_ms: u64,
exit_code: i32,
},
WatchWaiting {
files: usize,
},
WatchTriggered {
paths: &'a [String],
},
ProjectBroken {
diagnostic: &'a str,
},
}
#[derive(Serialize)]
#[serde(rename_all = "snake_case")]
enum WireStream {
Stdout,
Stderr,
}
impl<'a> From<&'a RunEvent> for WireEvent<'a> {
fn from(event: &'a RunEvent) -> Self {
match event {
RunEvent::RunStarted {
targets,
affected_by,
beams,
edges,
} => WireEvent::RunStarted {
targets: targets.iter().map(|id| id.0.as_str()).collect(),
affected_by: affected_by.as_deref(),
beams: beams.iter().map(|id| id.0.as_str()).collect(),
edges: edges
.iter()
.map(|(from, to)| (from.0.as_str(), to.0.as_str()))
.collect(),
},
RunEvent::BeamStarted { id } => WireEvent::BeamStarted { beam: &id.0 },
RunEvent::BeamCached { id } => WireEvent::BeamCached { beam: &id.0 },
RunEvent::BeamOutput { id, line, replayed } => WireEvent::BeamOutput {
beam: &id.0,
stream: match line.stream {
Stream::Stdout => WireStream::Stdout,
Stream::Stderr => WireStream::Stderr,
},
text: &line.text,
replayed: *replayed,
},
RunEvent::BeamFinished {
id,
status,
duration,
} => WireEvent::BeamFinished {
beam: &id.0,
status: status_name(status),
exit_code: exit_code(status),
duration_ms: millis(duration),
},
RunEvent::RunFinished { summary } => WireEvent::from_summary(summary),
RunEvent::WatchWaiting { files } => WireEvent::WatchWaiting { files: *files },
RunEvent::WatchTriggered { paths } => WireEvent::WatchTriggered { paths },
RunEvent::ProjectBroken { diagnostic } => WireEvent::ProjectBroken { diagnostic },
}
}
}
impl<'a> WireEvent<'a> {
fn from_summary(summary: &'a RunSummary) -> Self {
let ids = |bucket: &'a [alba_core::BeamId]| -> Vec<&'a str> {
bucket.iter().map(|id| id.0.as_str()).collect()
};
WireEvent::RunFinished {
succeeded: ids(&summary.succeeded),
cached: ids(&summary.cached),
failed: ids(&summary.failed),
failed_allowed: ids(&summary.failed_allowed),
cancelled: ids(&summary.cancelled),
duration_ms: millis(&summary.duration),
exit_code: summary.exit_code(),
}
}
}
fn status_name(status: &BeamStatus) -> &'static str {
match status {
BeamStatus::Succeeded => "succeeded",
BeamStatus::Cached => "cached",
BeamStatus::Failed { .. } => "failed",
BeamStatus::FailedAllowed { .. } => "failed_allowed",
BeamStatus::Cancelled => "cancelled",
}
}
fn exit_code(status: &BeamStatus) -> Option<i32> {
match status {
BeamStatus::Failed { exit_code } | BeamStatus::FailedAllowed { exit_code } => {
Some(*exit_code)
}
BeamStatus::Succeeded | BeamStatus::Cached | BeamStatus::Cancelled => None,
}
}
fn millis(duration: &std::time::Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
use alba_core::BeamId;
use alba_executors::OutputLine;
fn json(event: &RunEvent) -> serde_json::Value {
serde_json::from_str(&serde_json::to_string(&WireEvent::from(event)).unwrap()).unwrap()
}
#[test]
fn output_carries_the_stream_it_came_from() {
let event = RunEvent::BeamOutput {
id: BeamId("build".to_string()),
line: OutputLine {
stream: Stream::Stderr,
text: "warning".to_string(),
},
replayed: false,
};
let value = json(&event);
assert_eq!(value["event"], "beam_output");
assert_eq!(value["beam"], "build");
assert_eq!(value["stream"], "stderr");
assert_eq!(value["text"], "warning");
}
#[test]
fn a_failure_reports_its_exit_code_and_a_success_reports_none() {
let finished = |status| RunEvent::BeamFinished {
id: BeamId("build".to_string()),
status,
duration: std::time::Duration::from_millis(1500),
};
let failed = json(&finished(BeamStatus::Failed { exit_code: 7 }));
assert_eq!(failed["status"], "failed");
assert_eq!(failed["exit_code"], 7);
assert_eq!(failed["duration_ms"], 1500);
let succeeded = json(&finished(BeamStatus::Succeeded));
assert_eq!(succeeded["status"], "succeeded");
assert!(succeeded["exit_code"].is_null());
}
#[test]
fn a_cached_beam_emits_its_own_event_and_status() {
let cached = json(&RunEvent::BeamCached {
id: BeamId("gen".to_string()),
});
assert_eq!(cached["event"], "beam_cached");
assert_eq!(cached["beam"], "gen");
let finished = json(&RunEvent::BeamFinished {
id: BeamId("gen".to_string()),
status: BeamStatus::Cached,
duration: std::time::Duration::from_millis(2100),
});
assert_eq!(finished["status"], "cached");
assert!(finished["exit_code"].is_null());
}
#[test]
fn output_lines_carry_the_replayed_marker() {
let event = |replayed| RunEvent::BeamOutput {
id: BeamId("gen".to_string()),
line: OutputLine {
stream: Stream::Stdout,
text: "generated".to_string(),
},
replayed,
};
assert_eq!(json(&event(true))["replayed"], true);
assert_eq!(json(&event(false))["replayed"], false);
}
#[test]
fn the_final_event_carries_the_cached_bucket() {
let event = RunEvent::RunFinished {
summary: RunSummary {
cached: vec![BeamId("gen".to_string())],
..RunSummary::default()
},
};
assert_eq!(json(&event)["cached"][0], "gen");
}
#[test]
fn the_final_event_carries_every_bucket_and_the_exit_code() {
let event = RunEvent::RunFinished {
summary: RunSummary {
succeeded: vec![BeamId("a".to_string())],
failed: vec![BeamId("b".to_string())],
..RunSummary::default()
},
};
let value = json(&event);
assert_eq!(value["event"], "run_finished");
assert_eq!(value["succeeded"][0], "a");
assert_eq!(value["failed"][0], "b");
assert_eq!(value["exit_code"], 1);
}
#[test]
fn run_started_is_emitted_on_the_wire() {
let event = RunEvent::RunStarted {
targets: vec![BeamId("build".to_string())],
affected_by: None,
beams: vec![BeamId("codegen".to_string()), BeamId("build".to_string())],
edges: vec![(BeamId("build".to_string()), BeamId("codegen".to_string()))],
};
let line = serde_json::to_string(&WireEvent::from(&event)).unwrap();
assert_eq!(
line,
r#"{"event":"run_started","targets":["build"],"beams":["codegen","build"],"edges":[["build","codegen"]]}"#
);
}
#[test]
fn watch_events_serialize_with_their_own_tags() {
let waiting = RunEvent::WatchWaiting { files: 42 };
let triggered = RunEvent::WatchTriggered {
paths: vec!["src/lib.rs".to_string()],
};
let waiting = serde_json::to_string(&WireEvent::from(&waiting)).unwrap();
let triggered = serde_json::to_string(&WireEvent::from(&triggered)).unwrap();
assert_eq!(waiting, r#"{"event":"watch_waiting","files":42}"#);
assert_eq!(
triggered,
r#"{"event":"watch_triggered","paths":["src/lib.rs"]}"#
);
}
#[test]
fn project_broken_is_emitted_on_the_wire() {
let event = RunEvent::ProjectBroken {
diagnostic: "error: oh no\n".to_string(),
};
let line = serde_json::to_string(&WireEvent::from(&event)).unwrap();
assert_eq!(
line,
r#"{"event":"project_broken","diagnostic":"error: oh no\n"}"#
);
}
}