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