crates/autonom/src/sentinel.rs
//! The reusable **sentinel-ban** scan.
//!
//! A rendered surface (a pane's `state_json`, an AccessKit label, a viz slice)
//! that carries any of these forbidden phrases is, by definition, an *unwired
//! stub* that shipped without an RPC / data path. Banning them turns "a button
//! that says TODO" into a RED test row instead of a bug someone eyeballs later.
//!
//! This logic was copy-pasted (identically) into nornir's test files —
//! `nornir/tests/viz_surface.rs` and `nornir/tests/viz_ops_buttons.rs`. It now
//! lives once, so nornir, robotui, and dwarves all ban the *same* set the *same*
//! way.
//!
//! (This list used to name a third file, `viz_functional_matrix.rs`, which does
//! not exist in nornir and has not existed in any repo — a reader who went looking
//! for it found nothing, and a reader who did not went on believing in a third
//! copy. `crates/nornir-testmatrix/tests/citation_guard.rs` now fails on that.)
/// The forbidden phrases. A rendered surface carrying any of these is an unwired
/// stub. Matched case-insensitively as whole substrings.
pub const SENTINELS: [&str; 4] = ["todo", "not wired", "not yet", "coming soon"];
/// A sentinel match: which phrase was found, and a short surrounding context
/// window so the failure points precisely at the offending surface.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SentinelHit {
/// The forbidden phrase that matched (lower-case, as in [`SENTINELS`]).
pub phrase: String,
/// ±40 chars of context around the hit, newlines flattened to spaces.
pub context: String,
}
/// Scan a rendered text blob for any sentinel phrase. Returns the first hit
/// (phrase + a short context window), or `None` if the blob is clean.
///
/// Matching is case-insensitive and char-boundary-safe (won't panic on UTF-8).
pub fn find_sentinel(haystack: &str) -> Option<SentinelHit> {
let lower = haystack.to_ascii_lowercase();
for s in SENTINELS {
if let Some(idx) = lower.find(s) {
let raw_start = idx.saturating_sub(40);
let raw_end = (idx + s.len() + 40).min(haystack.len());
let start = (raw_start..=idx)
.find(|i| haystack.is_char_boundary(*i))
.unwrap_or(idx);
let end = (raw_end..=haystack.len())
.find(|i| haystack.is_char_boundary(*i))
.unwrap_or(haystack.len());
return Some(SentinelHit {
phrase: s.to_string(),
context: haystack[start..end].replace('\n', " "),
});
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clean_blob_has_no_hit() {
assert!(find_sentinel("everything is wired and rendered").is_none());
}
#[test]
fn case_insensitive_match_with_context() {
let hit = find_sentinel("panel: Coming Soon — check back").unwrap();
assert_eq!(hit.phrase, "coming soon");
assert!(hit.context.to_ascii_lowercase().contains("coming soon"));
}
#[test]
fn multibyte_haystack_does_not_panic() {
let hit = find_sentinel("räksmörgås — TODO fixa detta här ölölöl").unwrap();
assert_eq!(hit.phrase, "todo");
}
}