#![allow(dead_code)]
pub const BLOCKED: &str = "BLOCKED";
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum Kind {
Blocked,
Until,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct Hit {
pub token: String,
pub kind: Kind,
}
#[derive(Debug, Clone)]
pub struct Scanner {
until: Vec<String>,
}
impl Scanner {
pub fn new<S: AsRef<str>>(tokens: &[S]) -> Self {
let mut until: Vec<String> = Vec::with_capacity(tokens.len());
for t in tokens {
let trimmed = t.as_ref().trim();
if trimmed.is_empty() || trimmed == BLOCKED {
continue;
}
let owned = trimmed.to_string();
if !until.contains(&owned) {
until.push(owned);
}
}
Self { until }
}
pub fn feed(&self, line: &str) -> Option<Hit> {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
if trimmed == BLOCKED {
return Some(Hit {
token: BLOCKED.to_string(),
kind: Kind::Blocked,
});
}
for u in &self.until {
if trimmed == u.as_str() {
return Some(Hit {
token: u.clone(),
kind: Kind::Until,
});
}
}
None
}
pub fn scan<L, I>(&self, lines: I) -> Option<Hit>
where
L: AsRef<str>,
I: IntoIterator<Item = L>,
{
let mut current: Option<Hit> = None;
for line in lines {
let Some(hit) = self.feed(line.as_ref()) else {
continue;
};
match hit.kind {
Kind::Blocked => return Some(hit),
Kind::Until if current.is_none() => current = Some(hit),
Kind::Until => { }
}
}
current
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn feed_hits_on_standalone_reserved_token() {
let s = Scanner::new::<&str>(&[]);
let hit = s.feed("BLOCKED").expect("standalone BLOCKED line hits");
assert_eq!(hit.token, "BLOCKED");
assert_eq!(hit.kind, Kind::Blocked);
}
#[test]
fn feed_hits_on_whitespace_padded_reserved_token() {
let s = Scanner::new::<&str>(&[]);
let hit = s.feed(" BLOCKED ").expect("trim() before compare");
assert_eq!(hit.kind, Kind::Blocked);
}
#[test]
fn feed_misses_on_substring_containing_token() {
let s = Scanner::new::<&str>(&[]);
assert!(
s.feed("I am BLOCKED by a dependency").is_none(),
"narrative mention must not match"
);
assert!(
s.feed("BLOCKED: yesterday").is_none(),
"prefix-only no match"
);
assert!(s.feed("un-BLOCKED").is_none(), "hyphen-prefixed no match");
}
#[test]
fn feed_misses_on_empty_or_whitespace_line() {
let s = Scanner::new::<&str>(&[]);
assert!(s.feed("").is_none());
assert!(s.feed(" ").is_none());
assert!(s.feed("\t\t").is_none());
}
#[test]
fn feed_is_case_sensitive() {
let s = Scanner::new::<&str>(&[]);
assert!(s.feed("blocked").is_none(), "lowercase must not match");
assert!(s.feed("Blocked").is_none(), "mixed case must not match");
}
#[test]
fn user_until_token_hits_with_kind_until() {
let s = Scanner::new(&["ALL_TASKS_COMPLETE"]);
let hit = s.feed("ALL_TASKS_COMPLETE").expect("registered until hits");
assert_eq!(hit.token, "ALL_TASKS_COMPLETE");
assert_eq!(hit.kind, Kind::Until);
}
#[test]
fn multiple_until_tokens_register() {
let s = Scanner::new(&["READY", "APPROVED_LATER"]);
assert_eq!(s.feed("READY").unwrap().kind, Kind::Until);
assert_eq!(s.feed("APPROVED_LATER").unwrap().kind, Kind::Until);
assert!(s.feed("UNREGISTERED").is_none());
}
#[test]
fn explicit_blocked_in_tokens_is_still_classified_as_blocked() {
let s = Scanner::new(&["BLOCKED"]);
assert_eq!(s.feed("BLOCKED").unwrap().kind, Kind::Blocked);
}
#[test]
fn constructor_drops_empty_and_duplicate_until_tokens() {
let s = Scanner::new(&["READY", "", "READY", " ", "DONE"]);
assert!(s.feed("READY").is_some());
assert!(s.feed("DONE").is_some());
assert!(s.feed("").is_none());
}
#[test]
fn scan_returns_blocked_when_both_tokens_appear() {
let s = Scanner::new(&["ALL_TASKS_COMPLETE"]);
let lines = ["noise", "ALL_TASKS_COMPLETE", "more noise", "BLOCKED"];
let hit = s.scan(lines).expect("some hit");
assert_eq!(hit.kind, Kind::Blocked, "BLOCKED beats Until");
}
#[test]
fn scan_returns_blocked_short_circuit_even_if_until_follows() {
struct OnceThenPanic {
yielded: bool,
}
impl Iterator for OnceThenPanic {
type Item = &'static str;
fn next(&mut self) -> Option<&'static str> {
if !self.yielded {
self.yielded = true;
Some("BLOCKED")
} else {
panic!("scan did not short-circuit on BLOCKED");
}
}
}
let s = Scanner::new(&["ALL_TASKS_COMPLETE"]);
let hit = s
.scan(OnceThenPanic { yielded: false })
.expect("BLOCKED returned");
assert_eq!(hit.kind, Kind::Blocked);
}
#[test]
fn scan_returns_first_until_when_no_blocked_appears() {
let s = Scanner::new(&["READY", "ALL_TASKS_COMPLETE"]);
let hit = s
.scan(["noise", "READY", "ALL_TASKS_COMPLETE", "tail"])
.expect("some hit");
assert_eq!(hit.kind, Kind::Until);
assert_eq!(hit.token, "READY", "first Until wins");
}
#[test]
fn scan_returns_none_on_no_hits() {
let s = Scanner::new(&["READY"]);
assert!(s.scan(["foo", "bar", "baz"]).is_none());
}
#[test]
fn fuzz_lines_around_tokens_never_false_positive() {
let s = Scanner::new(&["READY", "ALL_TASKS_COMPLETE"]);
let perturbations = [
"{}.",
"{} now",
"now {}",
"{}?",
"({})",
" prefix {} suffix ",
"{}!",
"maybe-{}",
"{}_x",
"x_{}",
];
for seed in 0..100 {
for token in ["BLOCKED", "READY", "ALL_TASKS_COMPLETE"] {
for pat in perturbations {
let line = pat.replace("{}", token);
assert!(
s.feed(&line).is_none(),
"false positive at seed={seed} token={token} line={line:?}"
);
}
}
}
assert!(s.feed("BLOCKED").is_some());
assert!(s.feed("READY").is_some());
assert!(s.feed("ALL_TASKS_COMPLETE").is_some());
}
}