use strsim::jaro_winkler;
const SIMILARITY_THRESHOLD: f64 = 0.7;
const MAX_SUGGESTIONS: usize = 3;
pub fn find_similar<'a>(
input: &str,
candidates: impl IntoIterator<Item = &'a str>,
) -> Vec<&'a str> {
let input_lower = input.to_lowercase();
let mut scored: Vec<_> = candidates
.into_iter()
.map(|candidate| {
let score = jaro_winkler(&input_lower, &candidate.to_lowercase());
(candidate, score)
})
.filter(|(_, score)| *score >= SIMILARITY_THRESHOLD)
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored
.into_iter()
.take(MAX_SUGGESTIONS)
.map(|(s, _)| s)
.collect()
}
pub fn format_suggestions(suggestions: &[&str]) -> Option<String> {
match suggestions.len() {
0 => None,
1 => Some(format!("Did you mean '{}'?", suggestions[0])),
_ => {
let quoted: Vec<_> = suggestions.iter().map(|s| format!("'{}'", s)).collect();
Some(format!("Did you mean one of: {}?", quoted.join(", ")))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_similar_exact_match() {
let candidates = ["DATABASE_URL", "API_KEY", "SECRET_TOKEN"];
let result = find_similar("DATABASE_URL", candidates.iter().copied());
assert_eq!(result, vec!["DATABASE_URL"]);
}
#[test]
fn test_find_similar_typo() {
let candidates = ["DATABASE_URL", "API_KEY", "SECRET_TOKEN"];
let result = find_similar("DATABSE_URL", candidates.iter().copied());
assert_eq!(result, vec!["DATABASE_URL"]);
}
#[test]
fn test_find_similar_case_insensitive() {
let candidates = ["DATABASE_URL", "API_KEY", "SECRET_TOKEN"];
let result = find_similar("database_url", candidates.iter().copied());
assert_eq!(result, vec!["DATABASE_URL"]);
}
#[test]
fn test_find_similar_no_match() {
let candidates = ["DATABASE_URL", "API_KEY", "SECRET_TOKEN"];
let result = find_similar("COMPLETELY_DIFFERENT", candidates.iter().copied());
assert!(result.is_empty());
}
#[test]
fn test_find_similar_provider_names() {
let candidates = ["age", "1password", "aws-kms", "aws-sm", "bitwarden"];
let result = find_similar("1passwrd", candidates.iter().copied());
assert_eq!(result, vec!["1password"]);
}
#[test]
fn test_format_suggestions_none() {
assert_eq!(format_suggestions(&[]), None);
}
#[test]
fn test_format_suggestions_single() {
assert_eq!(
format_suggestions(&["DATABASE_URL"]),
Some("Did you mean 'DATABASE_URL'?".to_string())
);
}
#[test]
fn test_format_suggestions_multiple() {
assert_eq!(
format_suggestions(&["DATABASE_URL", "DATABASE_URI"]),
Some("Did you mean one of: 'DATABASE_URL', 'DATABASE_URI'?".to_string())
);
}
}