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

nordisk/facett

facett-confirm/src/lib.rs

//! **facett-confirm** — a destructive-action **confirmation gate**.
//!
//! An ops surface (restore a cluster, drop a DB, promote DEV→PROD) needs a gate
//! that a normal CRUD form doesn't: it must (1) name the **target** it will act
//! on, (2) require the operator to **type that target to confirm**, and (3) be
//! **hard-disabled** when a guard says the target is not allowed (e.g. prod
//! without an override). This is the reusable UI half of that pattern — no I/O,
//! no policy of its own; the host passes the guard verdict in as [`Allow`].
//!
//! Canonical facett Elm split: every input is a [`Msg`], the only mutation path
//! is [`ConfirmGate::update`], and [`ConfirmGate::view`] is a pure function of
//! `&self` that paints and returns the `Msg`s it produced. [`Effect`] is what
//! the host acts on (`Confirmed` → run the op).

use egui::{Color32, RichText, Ui};
use serde::{Deserialize, Serialize};

/// **Introspection / emit marker** — record one functional-status row for the
/// nornir test matrix. Wraps [`nornir_testmatrix::functional_status`] behind the
/// `testmatrix` feature; a compiled-out `#[inline]` no-op otherwise (and no
/// nornir dep in the default build). `component` is the reporting surface
/// (`"facett-confirm"`), `check` what it verified, `ok` the verdict, `detail` a
/// short human note. The gate's confirm/cancel surfaces call this so `nornir
/// test` SEES the gate's health.
#[inline]
pub(crate) fn functional_status(component: &str, check: &str, ok: bool, detail: &str) {
    #[cfg(feature = "testmatrix")]
    nornir_testmatrix::functional_status(component, check, ok, detail);
    #[cfg(not(feature = "testmatrix"))]
    {
        let _ = (component, check, ok, detail);
    }
}

/// The host's guard verdict for the target. `No` hard-disables confirmation.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Allow {
    /// Target is permitted (e.g. a lab namespace).
    Yes,
    /// Target is refused (e.g. prod without override) — confirm is disabled.
    No,
}

/// The gate's serialisable state (so a headless driver reads what the UI shows).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ConfirmState {
    /// The verb, e.g. `"Restore"`.
    pub action: String,
    /// What it acts on, e.g. `"njord-lab"` — shown and (by default) the phrase.
    pub target: String,
    /// Guard verdict.
    pub allow: Allow,
    /// The phrase the operator must type to arm (defaults to `target`).
    pub require_phrase: String,
    /// Current typed input.
    pub typed: String,
}

/// Inputs.
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
    Typed(String),
    Confirm,
    Cancel,
}

/// What the host acts on.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {
    /// Armed (allowed + phrase matched) and confirmed — run the op.
    Confirmed,
    Cancelled,
}

/// The confirmation-gate widget.
pub struct ConfirmGate {
    st: ConfirmState,
}

impl ConfirmGate {
    /// New gate for `action` on `target`, with the guard verdict `allow`. The
    /// confirm phrase defaults to `target`.
    pub fn new(action: impl Into<String>, target: impl Into<String>, allow: Allow) -> Self {
        let target = target.into();
        ConfirmGate {
            st: ConfirmState {
                action: action.into(),
                require_phrase: target.clone(),
                target,
                allow,
                typed: String::new(),
            },
        }
    }

    /// Override the phrase the operator must type (default = target).
    pub fn require_phrase(mut self, phrase: impl Into<String>) -> Self {
        self.st.require_phrase = phrase.into();
        self
    }

    pub fn state(&self) -> &ConfirmState {
        &self.st
    }

    /// Update the guard verdict (e.g. the operator switched target namespace).
    pub fn set_allow(&mut self, allow: Allow) {
        self.st.allow = allow;
    }

    /// Armed = allowed AND the typed phrase matches. Only then does `Confirm`
    /// yield [`Effect::Confirmed`].
    pub fn is_armed(&self) -> bool {
        self.st.allow == Allow::Yes && self.st.typed == self.st.require_phrase
    }

    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::Typed(s) => {
                self.st.typed = s;
                Vec::new()
            }
            Msg::Confirm => {
                if self.is_armed() {
                    // Armed (allowed AND phrase matched) → the op runs. A live,
                    // correctly-gated confirm surface.
                    functional_status("facett-confirm", "confirm", true, &self.st.target);
                    vec![Effect::Confirmed]
                } else {
                    // The guard correctly REFUSED an un-armed confirm (disallowed
                    // target or unmatched phrase) — the safety invariant held.
                    functional_status(
                        "facett-confirm",
                        "guard_blocks_unarmed",
                        true,
                        &format!("allow={:?} phrase_ok={}", self.st.allow, self.st.typed == self.st.require_phrase),
                    );
                    Vec::new()
                }
            }
            Msg::Cancel => {
                functional_status("facett-confirm", "cancel", true, &self.st.target);
                vec![Effect::Cancelled]
            }
        }
    }

    /// A serialisable snapshot of what the gate shows.
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "action": self.st.action,
            "target": self.st.target,
            "allow": matches!(self.st.allow, Allow::Yes),
            "armed": self.is_armed(),
            "typed_len": self.st.typed.len(),
        })
    }

    /// Paint the gate; returns any `Msg`s produced this frame.
    pub fn view(&self, ui: &mut Ui) -> Vec<Msg> {
        let mut msgs = Vec::new();
        let danger = Color32::from_rgb(0xE5, 0x48, 0x4A);

        egui::Frame::group(ui.style())
            .stroke(egui::Stroke::new(1.5_f32, danger))
            .show(ui, |ui| {
                ui.label(
                    RichText::new(format!("⚠ {} → {}", self.st.action, self.st.target))
                        .strong()
                        .color(danger),
                );

                if self.st.allow == Allow::No {
                    ui.label(
                        RichText::new(format!(
                            "Refused: '{}' is not an allowed target.",
                            self.st.target
                        ))
                        .color(danger),
                    );
                }

                ui.horizontal(|ui| {
                    ui.label(format!("Type “{}” to confirm:", self.st.require_phrase));
                    let mut buf = self.st.typed.clone();
                    let enabled = self.st.allow == Allow::Yes;
                    if ui
                        .add_enabled(enabled, egui::TextEdit::singleline(&mut buf))
                        .changed()
                    {
                        msgs.push(Msg::Typed(buf));
                    }
                });

                ui.horizontal(|ui| {
                    let armed = self.is_armed();
                    let confirm = ui.add_enabled(
                        armed,
                        egui::Button::new(RichText::new(&self.st.action).color(Color32::WHITE))
                            .fill(danger),
                    );
                    if confirm.clicked() {
                        msgs.push(Msg::Confirm);
                    }
                    if ui.button("Cancel").clicked() {
                        msgs.push(Msg::Cancel);
                    }
                });
            });

        msgs
    }
}

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

    #[test]
    fn not_armed_until_allowed_and_phrase_matches() {
        let mut g = ConfirmGate::new("Restore", "njord-lab", Allow::Yes);
        assert!(!g.is_armed());
        assert!(g.update(Msg::Confirm).is_empty(), "wrong phrase must not confirm");
        g.update(Msg::Typed("njord-lab".into()));
        assert!(g.is_armed());
        assert_eq!(g.update(Msg::Confirm), vec![Effect::Confirmed]);
    }

    #[test]
    fn disallowed_target_never_arms() {
        let mut g = ConfirmGate::new("Restore", "njord", Allow::No);
        g.update(Msg::Typed("njord".into())); // matches phrase, but not allowed
        assert!(!g.is_armed());
        assert!(g.update(Msg::Confirm).is_empty());
    }

    #[test]
    fn cancel_always_emits() {
        let mut g = ConfirmGate::new("Restore", "njord-lab", Allow::No);
        assert_eq!(g.update(Msg::Cancel), vec![Effect::Cancelled]);
    }

    #[test]
    fn set_allow_flips_armability() {
        let mut g = ConfirmGate::new("Rebirth", "lab", Allow::No);
        g.update(Msg::Typed("lab".into()));
        assert!(!g.is_armed());
        g.set_allow(Allow::Yes);
        assert!(g.is_armed());
    }
}