#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionRisk {
Benign,
Consequential,
Irreversible,
}
impl ActionRisk {
pub fn requires_confirmation(self) -> bool {
matches!(self, ActionRisk::Consequential | ActionRisk::Irreversible)
}
}
#[derive(Debug, Clone)]
pub struct ConfirmPolicy {
pub irreversible_keywords: Vec<String>,
pub consequential_keywords: Vec<String>,
}
impl Default for ConfirmPolicy {
fn default() -> Self {
let owned = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect();
Self {
irreversible_keywords: owned(&[
"delete",
"remove",
"permanently",
"destroy",
"wipe",
"erase",
"deactivate",
"close account",
]),
consequential_keywords: owned(&[
"buy",
"purchase",
"pay",
"checkout",
"place order",
"order now",
"send",
"transfer",
"publish",
"subscribe",
]),
}
}
}
pub fn classify_label(policy: &ConfirmPolicy, label: &str) -> ActionRisk {
let label = label.to_lowercase();
if label.trim().is_empty() {
return ActionRisk::Benign;
}
if policy
.irreversible_keywords
.iter()
.any(|k| label.contains(&k.to_lowercase()))
{
return ActionRisk::Irreversible;
}
if policy
.consequential_keywords
.iter()
.any(|k| label.contains(&k.to_lowercase()))
{
return ActionRisk::Consequential;
}
ActionRisk::Benign
}
pub fn label_for_uid(snapshot: &str, uid: &str) -> Option<String> {
for line in snapshot.lines() {
let body = line.trim_start();
let Some(rest) = body.strip_prefix("uid=") else {
continue;
};
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
if &rest[..end] != uid {
continue;
}
let after = &rest[end..];
let start = after.find('"')?;
let q = &after[start + 1..];
let close = q.find('"')?;
return Some(q[..close].to_string());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_benign_proceeds_autonomously() {
assert!(!ActionRisk::Benign.requires_confirmation());
assert!(ActionRisk::Consequential.requires_confirmation());
assert!(ActionRisk::Irreversible.requires_confirmation());
}
#[test]
fn benign_label_is_benign() {
let p = ConfirmPolicy::default();
assert_eq!(classify_label(&p, "Read more"), ActionRisk::Benign);
assert_eq!(classify_label(&p, "Next page"), ActionRisk::Benign);
assert_eq!(classify_label(&p, "Search"), ActionRisk::Benign);
}
#[test]
fn consequential_buttons_are_flagged() {
let p = ConfirmPolicy::default();
assert_eq!(classify_label(&p, "Buy now"), ActionRisk::Consequential);
assert_eq!(classify_label(&p, "Place order"), ActionRisk::Consequential);
assert_eq!(classify_label(&p, "Pay $42.00"), ActionRisk::Consequential);
assert_eq!(
classify_label(&p, "Send message"),
ActionRisk::Consequential
);
}
#[test]
fn irreversible_buttons_are_flagged() {
let p = ConfirmPolicy::default();
assert_eq!(
classify_label(&p, "Delete account"),
ActionRisk::Irreversible
);
assert_eq!(
classify_label(&p, "Permanently remove"),
ActionRisk::Irreversible
);
}
#[test]
fn classification_is_case_insensitive() {
let p = ConfirmPolicy::default();
assert_eq!(classify_label(&p, "DELETE"), ActionRisk::Irreversible);
assert_eq!(classify_label(&p, "bUy NoW"), ActionRisk::Consequential);
}
#[test]
fn irreversible_takes_priority_over_consequential() {
let p = ConfirmPolicy::default();
assert_eq!(
classify_label(&p, "Delete and pay"),
ActionRisk::Irreversible
);
}
#[test]
fn empty_label_is_benign() {
let p = ConfirmPolicy::default();
assert_eq!(classify_label(&p, ""), ActionRisk::Benign);
assert_eq!(classify_label(&p, " "), ActionRisk::Benign);
}
#[test]
fn generic_submit_is_benign_by_default_but_policy_extensible() {
let p = ConfirmPolicy::default();
assert_eq!(classify_label(&p, "Submit"), ActionRisk::Benign);
let strict = ConfirmPolicy {
consequential_keywords: vec!["submit".to_string()],
..ConfirmPolicy::default()
};
assert_eq!(classify_label(&strict, "Submit"), ActionRisk::Consequential);
}
const SNAP: &str = r#"uid=1_0 RootWebArea "Checkout" url="https://shop.test/cart"
uid=1_1 StaticText "Total: $42"
uid=1_2 button "Pay now"
uid=1_3 link "Keep shopping""#;
#[test]
fn label_for_uid_resolves_target_label() {
assert_eq!(label_for_uid(SNAP, "1_2").as_deref(), Some("Pay now"));
assert_eq!(label_for_uid(SNAP, "1_3").as_deref(), Some("Keep shopping"));
}
#[test]
fn label_for_uid_missing_uid_is_none() {
assert_eq!(label_for_uid(SNAP, "9_9"), None);
}
#[test]
fn label_for_uid_then_classify_flags_the_pay_button() {
let p = ConfirmPolicy::default();
let label = label_for_uid(SNAP, "1_2").expect("uid present");
assert_eq!(classify_label(&p, &label), ActionRisk::Consequential);
assert!(classify_label(&p, &label).requires_confirmation());
}
}