Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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);
    }
}