use anyhow::{Result, anyhow};
use pidge_core::{Contact, ContactsCache};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolveOutcome {
Literal(String),
One(String),
Unknown(String),
Ambiguous {
token: String,
candidates: Vec<Contact>,
},
}
pub fn resolve_one(token: &str, cache: &ContactsCache) -> ResolveOutcome {
let trimmed = token.trim();
let Some(query) = trimmed.strip_prefix('@') else {
return ResolveOutcome::Literal(trimmed.to_string());
};
let query_lc = query.to_lowercase();
if query_lc.is_empty() {
return ResolveOutcome::Unknown(token.to_string());
}
if let Some(c) = cache.by_email.get(&query_lc) {
return ResolveOutcome::One(c.email.clone());
}
let mut matches: Vec<Contact> = cache
.by_email
.values()
.filter(|c| contact_matches(c, &query_lc))
.cloned()
.collect();
matches.sort_by_key(|c| std::cmp::Reverse(c.last_seen));
match matches.len() {
0 => ResolveOutcome::Unknown(token.to_string()),
1 => ResolveOutcome::One(matches.remove(0).email),
_ => ResolveOutcome::Ambiguous {
token: token.to_string(),
candidates: matches.into_iter().take(8).collect(),
},
}
}
pub fn contact_matches_public(c: &Contact, q: &str) -> bool {
contact_matches(c, q)
}
fn contact_matches(c: &Contact, q: &str) -> bool {
if c.email.to_lowercase().contains(q) {
return true;
}
let local_part = c.email.split('@').next().unwrap_or("").to_lowercase();
if local_part.contains(q) {
return true;
}
c.display_name.to_lowercase().contains(q)
}
pub fn resolve_addresses(tokens: &[String], cache: &ContactsCache) -> Result<Vec<String>> {
let mut resolved: Vec<String> = Vec::with_capacity(tokens.len());
let mut problems: Vec<String> = Vec::new();
for token in tokens {
match resolve_one(token, cache) {
ResolveOutcome::Literal(s) | ResolveOutcome::One(s) => resolved.push(s),
ResolveOutcome::Unknown(t) => problems.push(format!(
" {t} — unknown (run `pidge contacts refresh` to update the index)"
)),
ResolveOutcome::Ambiguous { token, candidates } => {
let names: Vec<String> = candidates
.iter()
.map(|c| {
if c.display_name.is_empty() {
c.email.clone()
} else {
format!("{} <{}>", c.display_name, c.email)
}
})
.collect();
problems.push(format!(" {token} — ambiguous: {}", names.join(", ")));
}
}
}
if !problems.is_empty() {
return Err(anyhow!(
"Could not resolve {} attendee/recipient token{}:\n{}",
problems.len(),
if problems.len() == 1 { "" } else { "s" },
problems.join("\n")
));
}
Ok(resolved)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use pidge_core::ContactSource;
fn cache_with(entries: &[(&str, &str, i32, u32, u32)]) -> ContactsCache {
let mut c = ContactsCache::default();
for (email, name, day, _mail_count, _cal_count) in entries {
let ts = Utc
.with_ymd_and_hms(2026, 5, *day as u32, 12, 0, 0)
.unwrap();
c.upsert(email, name, ts, ContactSource::Calendar);
}
c
}
#[test]
fn token_without_at_prefix_passes_through_literally() {
let cache = ContactsCache::default();
assert_eq!(
resolve_one("alice@x.com", &cache),
ResolveOutcome::Literal("alice@x.com".into())
);
}
#[test]
fn exact_email_match_after_at_prefix_wins() {
let cache = cache_with(&[
("dino@needefy.se", "Dino Semovic", 20, 0, 1),
("dino@elsewhere.com", "Dino Other", 19, 0, 1),
]);
assert_eq!(
resolve_one("@dino@needefy.se", &cache),
ResolveOutcome::One("dino@needefy.se".into())
);
}
#[test]
fn substring_matches_display_name() {
let cache = cache_with(&[("dino@needefy.se", "Dino Semovic", 20, 0, 1)]);
assert_eq!(
resolve_one("@dino", &cache),
ResolveOutcome::One("dino@needefy.se".into())
);
}
#[test]
fn substring_matches_email_local_part() {
let cache = cache_with(&[("bob.smith@x.com", "", 20, 0, 1)]);
assert_eq!(
resolve_one("@smith", &cache),
ResolveOutcome::One("bob.smith@x.com".into())
);
}
#[test]
fn matching_is_case_insensitive() {
let cache = cache_with(&[("dino@needefy.se", "Dino Semovic", 20, 0, 1)]);
assert_eq!(
resolve_one("@DINO", &cache),
ResolveOutcome::One("dino@needefy.se".into())
);
}
#[test]
fn multiple_matches_return_ambiguous_with_recent_first() {
let cache = cache_with(&[
("john.smith@a.com", "John Smith", 18, 0, 1),
("john.doe@b.com", "John Doe", 20, 0, 1),
]);
let r = resolve_one("@john", &cache);
match r {
ResolveOutcome::Ambiguous { token, candidates } => {
assert_eq!(token, "@john");
assert_eq!(candidates.len(), 2);
assert_eq!(candidates[0].email, "john.doe@b.com");
assert_eq!(candidates[1].email, "john.smith@a.com");
}
other => panic!("expected Ambiguous, got {other:?}"),
}
}
#[test]
fn no_match_returns_unknown_with_original_token() {
let cache = ContactsCache::default();
assert_eq!(
resolve_one("@nope", &cache),
ResolveOutcome::Unknown("@nope".into())
);
}
#[test]
fn bare_at_token_is_unknown() {
let cache = cache_with(&[("a@b.com", "A", 20, 0, 1)]);
assert_eq!(
resolve_one("@", &cache),
ResolveOutcome::Unknown("@".into())
);
}
#[test]
fn resolve_addresses_aggregates_literals_and_lookups() {
let cache = cache_with(&[("dino@needefy.se", "Dino Semovic", 20, 0, 1)]);
let out = resolve_addresses(&["@dino".into(), "literal@x.com".into()], &cache).unwrap();
assert_eq!(out, vec!["dino@needefy.se", "literal@x.com"]);
}
#[test]
fn resolve_addresses_reports_every_failure_at_once() {
let cache = cache_with(&[
("john.smith@a.com", "John Smith", 18, 0, 1),
("john.doe@b.com", "John Doe", 20, 0, 1),
]);
let err = resolve_addresses(&["@john".into(), "@nope".into()], &cache)
.unwrap_err()
.to_string();
assert!(err.contains("Could not resolve 2"));
assert!(err.contains("@john — ambiguous"));
assert!(err.contains("@nope — unknown"));
}
}