crates/nornir-build-thing/src/progress.rs
//! **Watching a build happen** — the live event seam over [`build_variant`].
//!
//! [`crate::provenance::build_variant`] is a *blocking* call that returns only when
//! cargo has finished. That is fine for an xtask and useless for a UI: a holger tier
//! is a multi-minute compile, and a button that appears to hang is indistinguishable
//! from a broken one. Worse, the message loop parsed cargo's
//! `--message-format=json-render-diagnostics` stream and **threw the diagnostics
//! away** — so a failed build surfaced as `outcome: Failed` with the compiler's own
//! error text nowhere to be found.
//!
//! This module adds the missing seam, and nothing else:
//!
//! * [`BuildEvent`] — what happens during a build, as data (started · a unit
//! compiled · a compiler diagnostic, **verbatim** · an artifact produced ·
//! finished).
//! * [`BuildObserver`] — one method, `on_event`. A UI pushes onto a channel; a CLI
//! prints; [`NullObserver`] (the default) drops.
//! * [`build_variant_observed`] — the one real implementation. Every other entry
//! point ([`build_variant`], [`build_variant_with_info`]) now delegates here with a
//! [`NullObserver`], so there is exactly ONE cargo-driving loop in this crate.
//!
//! ```no_run
//! use nornir_build_thing::{BuildVariantSet, BuildEvent, build_variant_observed};
//! # fn main() -> anyhow::Result<()> {
//! let set = BuildVariantSet::discover(std::path::Path::new("."))?.unwrap_or_default();
//! let (tx, rx) = std::sync::mpsc::channel::<BuildEvent>();
//! std::thread::spawn(move || {
//! // the UI thread drains `rx` while this worker blocks on cargo.
//! build_variant_observed(&set, "full", std::path::Path::new("."), &tx)
//! });
//! # Ok(()) }
//! ```
//!
//! [`build_variant`]: crate::provenance::build_variant
//! [`build_variant_with_info`]: crate::provenance::build_variant_with_info
use serde::{Deserialize, Serialize};
/// One thing that happened while a variant was building.
///
/// Serializable, so a pane's `state_json` can carry the tail of a live build and a
/// robot can assert over it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum BuildEvent {
/// The build was validated and cargo is about to be spawned.
Started {
/// The variant handle being built.
variant: String,
/// The materialized command line (`cargo build -p … --features …`).
command_line: String,
/// The repo the build runs in.
repo_root: String,
},
/// Cargo finished a compilation unit. The closest thing cargo's JSON stream
/// offers to a progress tick — one per crate compiled, so a UI can count.
Compiled {
/// The crate/target name cargo reported.
unit: String,
/// How many units have been reported so far (1-based).
nth: usize,
},
/// A compiler diagnostic, **rendered exactly as rustc printed it**. This is the
/// error text a failed build must be able to show; it is never summarized into a
/// status enum.
Diagnostic {
/// `error` / `warning` / `note` / … (rustc's own level string).
level: String,
/// The full rendered diagnostic, ANSI-free (cargo renders it for us).
rendered: String,
},
/// An executable artifact was produced.
Artifact {
/// Absolute path to the produced executable.
path: String,
},
/// The build ended. Terminal — exactly one of these per build.
Finished {
/// The variant handle.
variant: String,
/// Whether cargo exited zero.
success: bool,
/// Wall time of the whole call.
elapsed_ms: u64,
/// How many executables were produced.
artifacts: usize,
},
/// The build could not even be attempted (validation failed, cargo could not be
/// spawned). Terminal, and carries the reason verbatim.
Failed {
/// The variant handle.
variant: String,
/// The error chain, formatted.
error: String,
},
}
impl BuildEvent {
/// A one-line human rendering — what a live status strip shows.
pub fn line(&self) -> String {
match self {
BuildEvent::Started { command_line, repo_root, .. } => {
format!("$ {command_line} (in {repo_root})")
}
BuildEvent::Compiled { unit, nth } => format!("[{nth}] compiled {unit}"),
BuildEvent::Diagnostic { level, rendered } => {
let first = rendered.lines().next().unwrap_or(level);
first.to_string()
}
BuildEvent::Artifact { path } => format!("artifact {path}"),
BuildEvent::Finished { variant, success, elapsed_ms, artifacts } => format!(
"{} '{variant}' in {:.1}s — {artifacts} artifact(s)",
if *success { "BUILT" } else { "FAILED" },
*elapsed_ms as f64 / 1000.0
),
BuildEvent::Failed { variant, error } => format!("FAILED '{variant}': {error}"),
}
}
/// True for the two terminal events ([`BuildEvent::Finished`] /
/// [`BuildEvent::Failed`]) — a consumer draining a channel stops here.
pub fn is_terminal(&self) -> bool {
matches!(self, BuildEvent::Finished { .. } | BuildEvent::Failed { .. })
}
/// True for a diagnostic rustc reported at `error` level — what makes a failure
/// *explicable* rather than merely red.
pub fn is_error_diagnostic(&self) -> bool {
matches!(self, BuildEvent::Diagnostic { level, .. } if level == "error")
}
}
/// A sink for [`BuildEvent`]s. One method; implemented for
/// [`std::sync::mpsc::Sender`] (the UI case) and closures (the CLI case).
///
/// Emission is best-effort by contract: an observer that errors, blocks or has been
/// dropped must never affect the build. Implementations therefore return `()`.
pub trait BuildObserver {
/// Receive one event. Called from the building thread, in order.
fn on_event(&self, event: BuildEvent);
/// Whether this observer wants [`BuildEvent::Diagnostic`]s, and therefore which
/// cargo message format the build asks for. This is a real fork, not a hint:
///
/// * `false` (the [`NullObserver`] default) → `--message-format=json-render-diagnostics`,
/// under which **cargo itself renders diagnostics to stderr** and emits no
/// `CompilerMessage` at all. This is the historical behaviour of
/// [`build_variant`](crate::build_variant): an xtask sees rustc's errors on its
/// terminal, exactly as before.
/// * `true` (every other observer) → `--message-format=json`, under which cargo
/// emits `CompilerMessage`s carrying the `rendered` text and prints nothing
/// itself. The observer becomes responsible for showing them — which is the
/// point, because a UI has no terminal to read.
///
/// Asking for both is not possible: the two formats are mutually exclusive, and
/// `json-render-diagnostics` is precisely the one that withholds the message.
fn wants_diagnostics(&self) -> bool {
true
}
}
/// The default observer — drops every event. What [`crate::build_variant`] uses, so
/// the existing blocking API is byte-for-byte unchanged.
#[derive(Debug, Clone, Copy, Default)]
pub struct NullObserver;
impl BuildObserver for NullObserver {
fn on_event(&self, _event: BuildEvent) {}
/// No: the null observer would drop them, and asking for them would take rustc's
/// errors off the caller's terminal in exchange for nothing.
fn wants_diagnostics(&self) -> bool {
false
}
}
/// A channel is an observer: a UI thread drains the receiver while a worker thread
/// blocks in the build. A closed channel (the pane was dropped) is ignored — the
/// build finishes regardless.
impl BuildObserver for std::sync::mpsc::Sender<BuildEvent> {
fn on_event(&self, event: BuildEvent) {
let _ = self.send(event);
}
}
impl<F: Fn(BuildEvent)> BuildObserver for F {
fn on_event(&self, event: BuildEvent) {
self(event)
}
}
/// Collects every event in memory — the shape a test asserts over.
#[derive(Debug, Default)]
pub struct RecordingObserver {
events: std::sync::Mutex<Vec<BuildEvent>>,
}
impl RecordingObserver {
/// A fresh recorder.
pub fn new() -> Self {
Self::default()
}
/// Every event seen so far, in order.
pub fn events(&self) -> Vec<BuildEvent> {
self.events.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
}
impl BuildObserver for RecordingObserver {
fn on_event(&self, event: BuildEvent) {
self.events.lock().unwrap_or_else(|e| e.into_inner()).push(event);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_channel_is_an_observer_and_a_closed_one_is_harmless() {
let (tx, rx) = std::sync::mpsc::channel();
tx.on_event(BuildEvent::Compiled { unit: "holger-server-lib".into(), nth: 1 });
assert_eq!(rx.recv().unwrap(), BuildEvent::Compiled { unit: "holger-server-lib".into(), nth: 1 });
drop(rx);
// The receiver is gone; the build must not care.
tx.on_event(BuildEvent::Artifact { path: "/tmp/x".into() });
}
#[test]
fn a_failure_keeps_the_compilers_own_words() {
let rec = RecordingObserver::new();
rec.on_event(BuildEvent::Diagnostic {
level: "error".into(),
rendered: "error[E0425]: cannot find value `ENV_LOCK` in this scope\n --> src/lib.rs:9:5"
.into(),
});
rec.on_event(BuildEvent::Finished {
variant: "full".into(),
success: false,
elapsed_ms: 1234,
artifacts: 0,
});
let ev = rec.events();
assert!(ev[0].is_error_diagnostic(), "the error level survives");
assert!(
ev[0].line().contains("E0425"),
"the compiler's own text is reachable, not summarized into a status: {}",
ev[0].line()
);
assert!(ev[1].is_terminal());
assert!(ev[1].line().starts_with("FAILED"));
assert!(ev[1].line().contains("1.2s"), "the elapsed time is a RESULT, not a spinner");
}
/// The message-format fork. `NullObserver` declines diagnostics so an existing
/// `build_variant` caller keeps cargo's own stderr rendering; every real observer
/// asks for them, because a UI has no terminal to read them off.
#[test]
fn only_a_real_observer_asks_cargo_for_the_diagnostics() {
assert!(!NullObserver.wants_diagnostics(), "the historical path is unchanged");
let (tx, _rx) = std::sync::mpsc::channel::<BuildEvent>();
assert!(tx.wants_diagnostics(), "a channel-backed UI needs the rendered text");
assert!(RecordingObserver::new().wants_diagnostics());
let closure = |_e: BuildEvent| {};
assert!(BuildObserver::wants_diagnostics(&closure));
}
#[test]
fn events_round_trip_through_json_for_a_panes_state() {
let e = BuildEvent::Started {
variant: "embedded".into(),
command_line: "cargo build -p holger-server-cli --bin holger-embedded".into(),
repo_root: "/home/x/holger".into(),
};
let j = serde_json::to_string(&e).unwrap();
assert!(j.contains(r#""event":"started""#), "{j}");
assert_eq!(serde_json::from_str::<BuildEvent>(&j).unwrap(), e);
}
}