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

nordisk/edda

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