use crate::entities::EntityInfo;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Mention {
None,
Open { slug: String },
Message { slug: String, text: String },
Unknown { name: String },
}
pub fn parse(text: &str, roster: &[EntityInfo]) -> Mention {
let t = text.trim_start();
let Some(rest) = t.strip_prefix('@') else {
return Mention::None;
};
let mut parts = rest.splitn(2, char::is_whitespace);
let name = parts.next().unwrap_or("").trim();
if name.is_empty() {
return Mention::None;
}
let body = parts.next().unwrap_or("").trim().to_string();
let lower = name.to_lowercase();
match roster
.iter()
.find(|e| e.error.is_empty() && e.slug == lower)
{
Some(e) => {
if body.is_empty() {
Mention::Open {
slug: e.slug.clone(),
}
} else {
Mention::Message {
slug: e.slug.clone(),
text: body,
}
}
}
None => Mention::Unknown {
name: name.to_string(),
},
}
}
pub type Candidate = (String, String, String);
pub fn candidates(query: &str, roster: &[EntityInfo]) -> Vec<Candidate> {
let q = query.trim().to_lowercase();
if roster.iter().any(|e| e.error.is_empty() && e.slug == q) {
return Vec::new(); }
roster
.iter()
.filter(|e| e.error.is_empty() && e.slug.starts_with(&q))
.map(|e| {
let mut detail = e.state.clone();
if let Some(n) = e.pending_tasks {
if n > 0 {
detail.push_str(&format!(" · {n} task(s) pending"));
}
}
(e.slug.clone(), format!("@{} ", e.slug), detail)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn roster() -> Vec<EntityInfo> {
let mk = |slug: &str, state: &str, tasks: Option<u64>| EntityInfo {
slug: slug.into(),
name: slug.into(),
state: state.into(),
pending_tasks: tasks,
..Default::default()
};
let mut r = vec![
mk("castor", "asleep", None),
mk("doorcheck", "asleep", Some(2)),
];
r.push(EntityInfo {
slug: "lost-home".into(),
error: "home unreadable".into(),
..Default::default()
});
r
}
#[test]
fn leading_mention_routes_and_bare_opens() {
let r = roster();
assert_eq!(
parse("@castor", &r),
Mention::Open {
slug: "castor".into()
}
);
assert_eq!(
parse("@Castor hello there", &r),
Mention::Message {
slug: "castor".into(),
text: "hello there".into()
},
"case-insensitive match routes to the canonical slug"
);
assert_eq!(
parse(" @doorcheck hi", &r),
Mention::Message {
slug: "doorcheck".into(),
text: "hi".into()
},
"leading whitespace does not defeat the parse"
);
}
#[test]
fn mid_prompt_and_bare_at_are_plain_text() {
let r = roster();
assert_eq!(parse("ask @castor about doors", &r), Mention::None);
assert_eq!(parse("@", &r), Mention::None);
assert_eq!(parse("mail me at x@y.z", &r), Mention::None);
}
#[test]
fn adversarial_drafts_parse_predictably() {
let r = roster();
assert_eq!(
parse("@doorcheck\n", &r),
Mention::Open {
slug: "doorcheck".into()
},
"trailing newline still opens"
);
assert_eq!(
parse("@doorcheck\nhello\nworld", &r),
Mention::Message {
slug: "doorcheck".into(),
text: "hello\nworld".into()
},
"multiline bodies route whole"
);
assert_eq!(
parse("@doorcheck\u{a0}hi", &r),
Mention::Message {
slug: "doorcheck".into(),
text: "hi".into()
}
);
assert_eq!(
parse("@doorcheck\thi", &r),
Mention::Message {
slug: "doorcheck".into(),
text: "hi".into()
}
);
let mut r2 = r.clone();
r2.push(EntityInfo {
slug: "ünïcorn".into(),
name: "ünïcorn".into(),
state: "awake".into(),
..Default::default()
});
assert_eq!(
parse("@Ünïcorn hi", &r2),
Mention::Message {
slug: "ünïcorn".into(),
text: "hi".into()
}
);
assert_eq!(
parse("@doorcheck, hi", &r),
Mention::Unknown {
name: "doorcheck,".into()
}
);
assert_eq!(
parse("@castor@10.0.0.215 hi", &r),
Mention::Unknown {
name: "castor@10.0.0.215".into()
}
);
}
#[test]
fn unknown_name_is_reported_not_swallowed() {
let r = roster();
assert_eq!(
parse("@ghost hello", &r),
Mention::Unknown {
name: "ghost".into()
}
);
assert_eq!(
parse("@lost-home hi", &r),
Mention::Unknown {
name: "lost-home".into()
}
);
}
#[test]
fn provider_prefix_matches_and_details() {
let r = roster();
let cands = candidates("c", &r);
assert_eq!(cands.len(), 1);
assert_eq!(cands[0].0, "castor");
assert_eq!(cands[0].1, "@castor ");
assert_eq!(cands[0].2, "asleep");
let with_tasks = candidates("door", &r);
assert!(with_tasks[0].2.contains("2 task(s) pending"));
assert_eq!(candidates("CAS", &r).len(), 1);
}
#[test]
fn provider_exact_slug_yields_no_candidates() {
let r = roster();
assert!(
candidates("castor", &r).is_empty(),
"a fully-typed @castor submits on the first Enter"
);
assert!(candidates("CASTOR", &r).is_empty());
}
#[test]
fn provider_empty_cache_and_broken_rows_stay_silent() {
assert!(candidates("c", &[]).is_empty());
let r = roster();
assert!(
candidates("lost", &r).is_empty(),
"error rows never complete"
);
}
}