const SIMILARITY_THRESHOLD: f64 = 0.8;
pub fn find_best_match<'a>(
query: &str,
candidates: impl IntoIterator<Item = &'a str>,
) -> Option<&'a str> {
let query_lower = query.to_lowercase();
candidates
.into_iter()
.filter_map(|candidate| {
let candidate_lower = candidate.to_lowercase();
let similarity = strsim::jaro_winkler(&query_lower, &candidate_lower);
if similarity >= SIMILARITY_THRESHOLD {
Some((candidate, similarity))
} else {
None
}
})
.max_by(|(_, sim_a), (_, sim_b)| {
sim_a
.partial_cmp(sim_b)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(candidate, _)| candidate)
}
pub fn format_suggestion<'a>(query: &str, candidates: impl IntoIterator<Item = &'a str>) -> String {
match find_best_match(query, candidates) {
Some(suggestion) => format!(". Did you mean '{suggestion}'?"),
None => String::new(),
}
}
pub fn suggest_config_path(schema: &crate::schema::ConfigStructSchema, path: &[String]) -> String {
use crate::schema::ConfigValueSchema;
if path.is_empty() {
return String::new();
}
let mut current_struct = Some(schema);
let mut current_enum_variants: Option<Vec<&str>> = None;
for (i, segment) in path.iter().enumerate() {
let segment_lower = segment.to_lowercase();
if let Some(enum_variants) = ¤t_enum_variants {
let found = enum_variants
.iter()
.any(|v| v.to_lowercase() == segment_lower);
if !found {
return format_suggestion(segment, enum_variants.iter().copied());
}
current_enum_variants = None;
current_struct = None;
continue;
}
if let Some(struct_schema) = current_struct {
let fields = struct_schema.fields();
let field_names: Vec<&str> = fields.keys().map(|s| s.as_str()).collect();
let matching_field = fields
.iter()
.find(|(k, _)| k.to_lowercase() == segment_lower);
if matching_field.is_none() {
return format_suggestion(segment, field_names.iter().copied());
}
if i + 1 < path.len() {
let (_, field_schema) = matching_field.unwrap();
let value_schema = unwrap_option(field_schema.value());
match value_schema {
ConfigValueSchema::Struct(s) => {
current_struct = Some(s);
current_enum_variants = None;
}
ConfigValueSchema::Enum(e) => {
current_struct = None;
current_enum_variants =
Some(e.variants().keys().map(|s| s.as_str()).collect());
}
_ => {
return String::new();
}
}
}
} else {
return String::new();
}
}
String::new()
}
fn unwrap_option(schema: &crate::schema::ConfigValueSchema) -> &crate::schema::ConfigValueSchema {
use crate::schema::ConfigValueSchema;
match schema {
ConfigValueSchema::Option { value, .. } => unwrap_option(value),
other => other,
}
}
pub fn suggest_flag<'a>(query: &str, flag_names: impl IntoIterator<Item = &'a str>) -> String {
use heck::ToKebabCase as _;
let candidates: Vec<(String, &'a str)> = flag_names
.into_iter()
.map(|name| (name.to_kebab_case(), name))
.collect();
let query_lower = query.to_lowercase();
let best_match = candidates
.iter()
.filter_map(|(kebab, original)| {
let similarity = strsim::jaro_winkler(&query_lower, &kebab.to_lowercase());
if similarity >= SIMILARITY_THRESHOLD {
Some((kebab.as_str(), *original, similarity))
} else {
None
}
})
.max_by(|(_, _, sim_a), (_, _, sim_b)| {
sim_a
.partial_cmp(sim_b)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(kebab, _, _)| kebab);
match best_match {
Some(suggestion) => format!(". Did you mean '--{suggestion}'?"),
None => String::new(),
}
}
pub fn suggest_subcommand<'a>(
query: &str,
subcommand_names: impl IntoIterator<Item = &'a str>,
) -> String {
match find_best_match(query, subcommand_names) {
Some(suggestion) => format!(". Did you mean '{suggestion}'?"),
None => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_best_match_exact() {
let candidates = ["Debug", "Info", "Warn", "Error"];
assert_eq!(find_best_match("Debug", candidates), Some("Debug"));
}
#[test]
fn test_find_best_match_typo() {
let candidates = ["Debug", "Info", "Warn", "Error"];
assert_eq!(find_best_match("Debugg", candidates), Some("Debug"));
assert_eq!(find_best_match("Errror", candidates), Some("Error"));
}
#[test]
fn test_find_best_match_case_insensitive() {
let candidates = ["Debug", "Info", "Warn", "Error"];
assert_eq!(find_best_match("debug", candidates), Some("Debug"));
assert_eq!(find_best_match("DEBUG", candidates), Some("Debug"));
}
#[test]
fn test_find_best_match_no_match() {
let candidates = ["Debug", "Info", "Warn", "Error"];
assert_eq!(find_best_match("XYZ123", candidates), None);
}
#[test]
fn test_format_suggestion_with_match() {
let candidates = ["port", "host", "timeout"];
assert_eq!(
format_suggestion("portt", candidates),
". Did you mean 'port'?"
);
}
#[test]
fn test_format_suggestion_no_match() {
let candidates = ["port", "host", "timeout"];
assert_eq!(format_suggestion("completely_different", candidates), "");
}
}