use crate::scanner::{
Direction, Disposition, HoldBack, ScanCtx, ScanResult, Scanner, ScannerId, Verdict,
};
const HIT_RISK: f32 = 1.0;
#[derive(Debug, Clone)]
pub struct Topic {
name: String,
keywords: Vec<String>,
}
impl Topic {
#[must_use]
pub fn new(name: impl Into<String>, keywords: impl IntoIterator<Item = String>) -> Self {
Self {
name: name.into(),
keywords: keywords
.into_iter()
.map(|k| k.to_ascii_lowercase())
.collect(),
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
fn hits(&self, lowered: &str) -> bool {
self.keywords
.iter()
.any(|kw| !kw.is_empty() && contains_word(lowered, kw))
}
}
fn contains_word(haystack: &str, keyword: &str) -> bool {
let mut from = 0;
while let Some(rel) = haystack[from..].find(keyword) {
let start = from + rel;
let end = start + keyword.len();
let left_ok = start == 0 || !is_word_byte(haystack.as_bytes()[start - 1]);
let right_ok = end == haystack.len() || !is_word_byte(haystack.as_bytes()[end]);
if left_ok && right_ok {
return true;
}
from = start + 1;
}
false
}
fn is_word_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
#[derive(Debug, Clone)]
pub struct BanTopicsScanner {
topics: Vec<Topic>,
direction: Direction,
}
impl BanTopicsScanner {
pub const ID: ScannerId = ScannerId("native:ban-topics");
#[must_use]
pub fn new(topics: impl IntoIterator<Item = Topic>) -> Self {
Self {
topics: topics.into_iter().collect(),
direction: Direction::Input,
}
}
#[must_use]
pub fn with_direction(mut self, direction: Direction) -> Self {
self.direction = direction;
self
}
#[must_use]
pub fn matched_topic(&self, text: &str) -> Option<&str> {
let lowered = text.to_ascii_lowercase();
self.topics
.iter()
.find(|t| t.hits(&lowered))
.map(Topic::name)
}
}
impl Scanner for BanTopicsScanner {
fn id(&self) -> ScannerId {
Self::ID
}
fn direction(&self) -> Direction {
self.direction
}
fn disposition(&self) -> Disposition {
Disposition::Block
}
fn scan<'a>(&self, text: &'a str, _ctx: &mut ScanCtx) -> ScanResult<'a> {
let risk = if self.matched_topic(text).is_some() {
HIT_RISK
} else {
0.0
};
Ok(Verdict::detected(text, risk, HIT_RISK))
}
fn hold_back(&self) -> HoldBack {
HoldBack::WholeStream
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "tests assert on known-good values"
)]
use super::*;
fn scanner() -> BanTopicsScanner {
BanTopicsScanner::new([
Topic::new("violence", ["weapon".to_owned(), "attack".to_owned()]),
Topic::new(
"medical",
["diagnosis".to_owned(), "prescription".to_owned()],
),
])
}
#[test]
fn blocks_on_keyword_and_reports_topic() {
let scanner = scanner();
let mut ctx = ScanCtx::new();
let text = "how do I build a weapon";
let verdict = scanner.scan(text, &mut ctx).unwrap();
assert!(!verdict.valid);
assert_eq!(scanner.matched_topic(text), Some("violence"));
}
#[test]
fn passes_clean_text() {
let scanner = scanner();
let mut ctx = ScanCtx::new();
let text = "what is the weather today";
assert!(scanner.scan(text, &mut ctx).unwrap().valid);
assert_eq!(scanner.matched_topic(text), None);
}
#[test]
fn keyword_match_is_case_insensitive() {
let scanner = scanner();
let mut ctx = ScanCtx::new();
assert!(!scanner.scan("need a PRESCRIPTION", &mut ctx).unwrap().valid);
}
#[test]
fn keyword_matched_on_word_boundary() {
let scanner = BanTopicsScanner::new([Topic::new("ban", ["ass".to_owned()])]);
let mut ctx = ScanCtx::new();
assert!(scanner.scan("the class begins", &mut ctx).unwrap().valid);
assert!(!scanner.scan("what an ass", &mut ctx).unwrap().valid);
}
#[test]
fn first_matching_topic_is_reported() {
let scanner = scanner();
assert_eq!(
scanner.matched_topic("a weapon and a diagnosis"),
Some("violence")
);
}
}