#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PrefixOutcome<'a> {
Single { id: &'a str, via_prefix: bool },
Multiple(Vec<&'a str>),
None,
}
pub fn prefix_resolve<'a>(input: &str, candidates: &'a [String]) -> PrefixOutcome<'a> {
if input.is_empty() || candidates.is_empty() {
return PrefixOutcome::None;
}
if let Some(c) = candidates.iter().find(|c| c.as_str() == input) {
return PrefixOutcome::Single {
id: c,
via_prefix: false,
};
}
let prefix_matches: Vec<&str> = candidates
.iter()
.map(String::as_str)
.filter(|c| c.starts_with(input))
.collect();
match prefix_matches.len() {
0 => PrefixOutcome::None,
1 => PrefixOutcome::Single {
id: prefix_matches[0],
via_prefix: true,
},
_ => PrefixOutcome::Multiple(prefix_matches),
}
}
pub fn nearest_matches(needle: &str, candidates: &[String], limit: usize) -> Vec<String> {
if needle.is_empty() || candidates.is_empty() || limit == 0 {
return Vec::new();
}
let threshold = std::cmp::max(2, needle.len() / 2);
let mut scored: Vec<(usize, &String)> = candidates
.iter()
.map(|candidate| (levenshtein(needle, candidate), candidate))
.collect();
scored.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)));
scored
.into_iter()
.filter(|(distance, _)| *distance <= threshold)
.take(limit)
.map(|(_, candidate)| candidate.clone())
.collect()
}
fn levenshtein(a: &str, b: &str) -> usize {
let a_bytes = a.as_bytes();
let b_bytes = b.as_bytes();
let mut prev: Vec<usize> = (0..=b_bytes.len()).collect();
for (i, &a_ch) in a_bytes.iter().enumerate() {
let mut current = Vec::with_capacity(b_bytes.len() + 1);
current.push(i + 1);
for (j, &b_ch) in b_bytes.iter().enumerate() {
let cost = if a_ch == b_ch { 0 } else { 1 };
let insert = current[j] + 1;
let delete = prev[j + 1] + 1;
let replace = prev[j] + cost;
current.push(insert.min(delete).min(replace));
}
prev = current;
}
prev[b_bytes.len()]
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn prefix_resolve_exact_match_wins_over_prefix() {
let candidates = ids(&["c123-foo", "c123", "c456"]);
assert_eq!(
prefix_resolve("c123", &candidates),
PrefixOutcome::Single {
id: "c123",
via_prefix: false,
}
);
}
#[test]
fn prefix_resolve_unique_prefix() {
let candidates = ids(&["c123-foo", "c456-bar"]);
assert_eq!(
prefix_resolve("c123", &candidates),
PrefixOutcome::Single {
id: "c123-foo",
via_prefix: true,
}
);
}
#[test]
fn prefix_resolve_multiple_prefix_matches() {
let candidates = ids(&["c123-foo", "c123-bar", "c456"]);
let outcome = prefix_resolve("c123", &candidates);
match outcome {
PrefixOutcome::Multiple(matches) => {
assert_eq!(matches, vec!["c123-foo", "c123-bar"]);
}
_ => panic!("expected Multiple, got {outcome:?}"),
}
}
#[test]
fn prefix_resolve_no_match() {
let candidates = ids(&["c123-foo", "c456-bar"]);
assert_eq!(prefix_resolve("zzz", &candidates), PrefixOutcome::None);
}
#[test]
fn prefix_resolve_case_sensitive() {
let candidates = ids(&["C123-foo"]);
assert_eq!(prefix_resolve("c123", &candidates), PrefixOutcome::None);
assert_eq!(
prefix_resolve("C123", &candidates),
PrefixOutcome::Single {
id: "C123-foo",
via_prefix: true,
}
);
}
#[test]
fn prefix_resolve_via_prefix_flag_distinguishes_exact_from_prefix() {
let candidates = ids(&["c123-fix-bug"]);
assert_eq!(
prefix_resolve("c123-fix-bug", &candidates),
PrefixOutcome::Single {
id: "c123-fix-bug",
via_prefix: false,
}
);
assert_eq!(
prefix_resolve("c123", &candidates),
PrefixOutcome::Single {
id: "c123-fix-bug",
via_prefix: true,
}
);
}
#[test]
fn prefix_resolve_empty_input_or_candidates() {
assert_eq!(prefix_resolve("", &ids(&["c123"])), PrefixOutcome::None);
assert_eq!(prefix_resolve("c123", &[]), PrefixOutcome::None);
}
}