nornir 0.4.19

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! 🚦 Pre-flight checks — run once at launch, surfaced as a popup right after
//! the splash fades. They catch a host that is missing a capability nornir
//! needs at runtime: today that's a **rust toolchain** (`cargo`/`rustup`),
//! because the 🛡 Security SBOM scan and the bench runner shell out to `cargo`.
//!
//! nornir is otherwise 100% Rust / no-shell. Shelling out to the rustup
//! installer is the ONE sanctioned exception (Rickard, 2026-06-13), and it is
//! gated behind an explicit user click — we never install anything unasked.
//!
//! LAW #6: [`Preflight::state_json`] folds the check results + install state
//! into the viz dump so a test/agent sees exactly what the user sees.

use std::process::Command;
use std::sync::{Arc, Mutex};

use eframe::egui::{self, RichText};

use super::facett_theme::{Theme, GREEN, RED};

/// One capability probe and its outcome.
#[derive(Clone)]
pub struct Check {
    /// Display name, e.g. "rust toolchain (cargo)".
    pub name: String,
    /// Did the probe succeed?
    pub ok: bool,
    /// Human detail — the version string when OK, the failure reason otherwise.
    pub detail: String,
    /// Why it matters (shown when failing).
    pub why: String,
    /// True when we know how to fix it with a click (rustup install).
    pub fixable: bool,
    /// Whether a failure RAISES the popup. Only genuine capability blockers for
    /// THIS process block: the local rust toolchain in fat/embedded mode (the
    /// viz runs scans/benches itself). In thin mode the SERVER runs them, so a
    /// missing local cargo is irrelevant → non-blocking. The MCP-usage notice is
    /// always non-blocking (informational; surfaced in state_json + the 📞 tab),
    /// so it never nags over the content.
    pub blocking: bool,
    /// Copy-paste instructions shown when failing and NOT click-fixable
    /// (e.g. the MCP-usage check: a command to run in your own shell).
    pub instructions: Option<String>,
}

/// Background rustup-install progress (shared with the spawned thread).
struct Install {
    running: bool,
    log: String,
    /// `None` while running, `Some(true/false)` once finished.
    done: Option<bool>,
}

pub struct Preflight {
    checks: Vec<Check>,
    /// User clicked "Continue anyway" / closed the popup.
    dismissed: bool,
    /// Thin/remote client — local toolchain checks are non-blocking (see `run`).
    thin: bool,
    theme: Theme,
    install: Option<Arc<Mutex<Install>>>,
}

/// Probe a CLI tool by running `<bin> --version`. Returns (ok, detail).
fn probe(bin: &str) -> (bool, String) {
    match Command::new(bin).arg("--version").output() {
        Ok(o) if o.status.success() => {
            let v = String::from_utf8_lossy(&o.stdout);
            (true, v.lines().next().unwrap_or("").trim().to_string())
        }
        Ok(o) => (false, format!("`{bin} --version` exited {}", o.status)),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            (false, format!("`{bin}` not found on PATH"))
        }
        Err(e) => (false, format!("`{bin}`: {e}")),
    }
}

impl Preflight {
    /// Run the checks once (cheap: two `--version` spawns). `thin` = this viz is
    /// a remote/thin client (scans + benches run on the SERVER, not here), so a
    /// missing LOCAL cargo is informational, not a blocker.
    pub fn run(thin: bool) -> Self {
        let (cargo_ok, cargo_detail) = probe("cargo");
        let (rustup_ok, rustup_detail) = probe("rustup");
        let blocking = !thin; // only block on the local toolchain in fat/embedded mode
        let why_tail = if thin {
            " (this client is thin — the SERVER runs them; local cargo is optional here)"
        } else {
            ""
        };
        let checks = vec![
            Check {
                name: "rust toolchain (cargo)".into(),
                ok: cargo_ok,
                detail: cargo_detail,
                why: format!(
                    "the 🛡 Security SBOM scan and the bench runner invoke `cargo`{why_tail}."
                ),
                fixable: true,
                blocking,
                instructions: None,
            },
            Check {
                name: "rustup".into(),
                ok: rustup_ok,
                detail: rustup_detail,
                why: "installs/updates the toolchain above.".into(),
                fixable: true,
                blocking,
                instructions: None,
            },
        ];
        Self { checks, dismissed: false, thin, theme: Theme::default(), install: None }
    }

    pub fn set_palette(&mut self, t: Theme) {
        self.theme = t;
    }

    /// Feed the warehouse MCP call count in. Once telemetry is `loaded`, a count
    /// of **zero** raises a warning: no agent has ever called nornir's MCP, so
    /// the operator probably hasn't wired the server into their Claude agent.
    /// The "fix" isn't a shell-out — it's a command to paste into Claude Code,
    /// shown with a 📋 copy button. Idempotent: updates the check in place.
    pub fn set_mcp_calls(&mut self, calls: u64, loaded: bool, add_cmd: String) {
        if !loaded {
            return; // don't false-fire before the telemetry is scanned
        }
        let check = Check {
            name: "MCP integration (Claude agent)".into(),
            ok: calls > 0,
            detail: if calls > 0 {
                format!("{calls} MCP call(s) recorded")
            } else {
                "0 MCP calls recorded — no agent has used nornir's tools".into()
            },
            why: "nornir exposes all 56 tools over MCP; if your Claude agent isn't \
                  wired to it you're flying blind to the warehouse."
                .into(),
            fixable: false,
            blocking: false, // informational — never nags over the content
            instructions: Some(add_cmd),
        };
        // Replace an existing MCP check, else append.
        if let Some(slot) = self.checks.iter_mut().find(|c| c.name == check.name) {
            *slot = check;
        } else {
            self.checks.push(check);
        }
    }

    /// Any failing BLOCKING check the user hasn't dismissed → the popup shows.
    /// Non-blocking notices (the MCP-usage hint, or local cargo in thin mode)
    /// never raise it — they live in `state_json` / the 📞 tab instead.
    pub fn needs_attention(&self) -> bool {
        !self.dismissed && self.checks.iter().any(|c| !c.ok && c.blocking)
    }

    /// Re-run the probes (after an install finishes), preserving thin-ness.
    fn recheck(&mut self) {
        let fresh = Self::run(self.thin);
        self.checks = fresh.checks;
    }

    /// Kick off the rustup install in a background thread (the no-shell
    /// exception). The official one-liner; `-y` for non-interactive, minimal
    /// profile + stable toolchain.
    fn start_install(&mut self) {
        let state = Arc::new(Mutex::new(Install { running: true, log: String::new(), done: None }));
        self.install = Some(state.clone());
        std::thread::spawn(move || {
            let out = Command::new("sh")
                .arg("-c")
                .arg("curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
                      | sh -s -- -y --profile minimal --default-toolchain stable")
                .output();
            let mut s = state.lock().unwrap();
            match out {
                Ok(o) => {
                    s.log = format!(
                        "{}{}",
                        String::from_utf8_lossy(&o.stdout),
                        String::from_utf8_lossy(&o.stderr)
                    );
                    s.done = Some(o.status.success());
                }
                Err(e) => {
                    s.log = format!("failed to launch installer: {e}");
                    s.done = Some(false);
                }
            }
            s.running = false;
        });
    }

    /// Draw the popup when [`needs_attention`](Self::needs_attention). Returns
    /// nothing; mutates dismiss/install state. Call after the splash overlay.
    pub fn draw(&mut self, ctx: &egui::Context) {
        // Drain a finished install: append PATH note + re-probe so a now-present
        // cargo flips the check green without a restart.
        let mut just_finished = None;
        if let Some(inst) = &self.install {
            let g = inst.lock().unwrap();
            if let Some(ok) = g.done {
                if !g.running {
                    just_finished = Some(ok);
                }
            }
        }
        if let Some(ok) = just_finished {
            if ok {
                self.recheck();
            }
            // Keep `install` so the log stays visible; only clear on dismiss.
            if let Some(inst) = &self.install {
                inst.lock().unwrap().done = Some(ok);
            }
        }

        if !self.needs_attention() {
            return;
        }
        let theme = self.theme;
        egui::Window::new(RichText::new("🚦 Pre-flight").size(18.0).color(theme.text))
            .collapsible(false)
            .resizable(false)
            .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
            .frame(egui::Frame::window(&ctx.style()).fill(theme.panel_bg))
            .show(ctx, |ui| {
                ui.set_max_width(520.0);
                ui.label(
                    RichText::new(super::app::client_build())
                        .monospace()
                        .color(theme.text_dim),
                );
                ui.separator();
                for c in &self.checks {
                    ui.horizontal(|ui| {
                        if c.ok {
                            ui.colored_label(GREEN, "✓");
                        } else {
                            ui.colored_label(RED, "✗");
                        }
                        ui.label(RichText::new(&c.name).strong().color(theme.text));
                    });
                    ui.label(RichText::new(&c.detail).small().color(theme.text_dim));
                    if !c.ok {
                        ui.label(RichText::new(&c.why).small().italics().color(theme.text_dim));
                        // Copy-paste instructions (e.g. the MCP wire-up command).
                        if let Some(instr) = &c.instructions {
                            // Selectable monospace (Ctrl+C works) + an explicit
                            // one-click 📋 Copy via the egui clipboard.
                            ui.add(
                                egui::Label::new(RichText::new(instr).monospace().small())
                                    .selectable(true),
                            );
                            if ui.small_button("📋 Copy").clicked() {
                                ui.ctx().copy_text(instr.clone());
                            }
                        }
                    }
                    ui.add_space(4.0);
                }
                ui.separator();

                // Install panel.
                let installing = self
                    .install
                    .as_ref()
                    .map(|i| i.lock().unwrap().running)
                    .unwrap_or(false);
                let finished = self
                    .install
                    .as_ref()
                    .and_then(|i| i.lock().unwrap().done);

                if installing {
                    ui.horizontal(|ui| {
                        ui.spinner();
                        ui.label("installing rustup… (this can take a minute)");
                    });
                    ctx.request_repaint_after(std::time::Duration::from_millis(300));
                } else if let Some(ok) = finished {
                    if ok {
                        ui.colored_label(
                            GREEN,
                            "✓ rustup installed. Restart the server/CLI shell so cargo is on PATH.",
                        );
                    } else {
                        ui.colored_label(RED, "✗ install failed — see log below.");
                    }
                }
                if let Some(inst) = &self.install {
                    let log = inst.lock().unwrap().log.clone();
                    if !log.is_empty() {
                        egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| {
                            ui.label(RichText::new(log).monospace().small());
                        });
                    }
                }

                ui.horizontal(|ui| {
                    let can_install =
                        !installing && self.checks.iter().any(|c| !c.ok && c.fixable);
                    if ui
                        .add_enabled(can_install, egui::Button::new("⬇ Install rustup for me"))
                        .on_hover_text(
                            "Runs the official rustup installer (the one sanctioned shell-out).",
                        )
                        .clicked()
                    {
                        self.start_install();
                    }
                    if ui.button("Continue anyway").clicked() {
                        self.dismissed = true;
                    }
                });
            });
    }

    /// LAW #6 introspection.
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "needs_attention": self.needs_attention(),
            "dismissed": self.dismissed,
            "checks": self.checks.iter().map(|c| serde_json::json!({
                "name": c.name, "ok": c.ok, "detail": c.detail, "fixable": c.fixable,
            })).collect::<Vec<_>>(),
            "installing": self.install.as_ref().map(|i| i.lock().unwrap().running).unwrap_or(false),
            "install_done": self.install.as_ref().and_then(|i| i.lock().unwrap().done),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn probe_reports_missing_tool_cleanly() {
        let (ok, detail) = probe("definitely-not-a-real-binary-xyz");
        assert!(!ok, "a missing binary must probe as not-ok");
        assert!(detail.contains("not found"), "detail should say not found, got: {detail}");
    }

    #[test]
    fn needs_attention_tracks_failing_checks_and_dismiss() {
        // Construct a Preflight with one failing, fixable check.
        let mut pf = Preflight {
            checks: vec![Check {
                name: "rust toolchain (cargo)".into(),
                ok: false,
                detail: "`cargo` not found on PATH".into(),
                why: "scans/benches need it".into(),
                fixable: true,
            blocking: true,
            instructions: None,
            }],
            dismissed: false,
            thin: false,
            theme: Theme::default(),
            install: None,
        };
        assert!(pf.needs_attention(), "a failing check must raise the popup");
        let j = pf.state_json();
        assert_eq!(j["needs_attention"], serde_json::json!(true));
        assert_eq!(j["checks"][0]["ok"], serde_json::json!(false));
        assert_eq!(j["checks"][0]["fixable"], serde_json::json!(true));

        // Dismiss → popup goes away even though the check still fails.
        pf.dismissed = true;
        assert!(!pf.needs_attention());
        assert_eq!(pf.state_json()["dismissed"], serde_json::json!(true));
    }

    #[test]
    fn all_green_means_no_popup() {
        let pf = Preflight {
            checks: vec![Check {
                name: "rust toolchain (cargo)".into(),
                ok: true,
                detail: "cargo 1.96.0".into(),
                why: String::new(),
                fixable: true,
            blocking: true,
            instructions: None,
            }],
            dismissed: false,
            thin: false,
            theme: Theme::default(),
            install: None,
        };
        assert!(!pf.needs_attention(), "all-OK checks must NOT raise the popup");
    }
}