use regex::Regex;
use std::sync::OnceLock;
fn curated_suggestion(kind: &str, wrong: &str) -> Option<&'static str> {
match (kind, wrong) {
("command", "argv") => Some("command"),
("pair", "secondary") => Some("partner"),
("line_endings", "style") => Some("target"),
("file_starts_with", "pattern") => Some("prefix"),
("file_ends_with", "pattern") => Some("suffix"),
("json_path_equals" | "yaml_path_equals" | "toml_path_equals", "matches") => Some("equals"),
("json_path_matches" | "yaml_path_matches" | "toml_path_matches", "equals") => {
Some("matches")
}
_ => None,
}
}
fn unknown_field_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"unknown field `([^`]+)`").expect("static regex")
})
}
fn backticked_token_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"`([^`]+)`").expect("static regex"))
}
fn extract_expected_fields(msg: &str) -> Vec<&str> {
backticked_token_re()
.captures_iter(msg)
.skip(1)
.filter_map(|c| c.get(1))
.map(|m| m.as_str())
.collect()
}
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let n = a.len();
let m = b.len();
if n == 0 {
return m;
}
if m == 0 {
return n;
}
let mut dp: Vec<Vec<usize>> = vec![vec![0; m + 1]; n + 1];
for (i, row) in dp.iter_mut().enumerate().take(n + 1) {
row[0] = i;
}
for (j, cell) in dp[0].iter_mut().enumerate().take(m + 1) {
*cell = j;
}
for i in 1..=n {
for j in 1..=m {
let cost = usize::from(a[i - 1] != b[j - 1]);
dp[i][j] = (dp[i - 1][j] + 1)
.min(dp[i][j - 1] + 1)
.min(dp[i - 1][j - 1] + cost);
}
}
dp[n][m]
}
fn levenshtein_suggestion<'a>(wrong: &str, expected: &[&'a str]) -> Option<&'a str> {
expected
.iter()
.map(|&e| (e, levenshtein(wrong, e)))
.min_by_key(|&(_, d)| d)
.filter(|&(_, d)| d <= 2)
.map(|(e, _)| e)
}
pub fn enrich(kind: &str, message: &str) -> String {
let Some(wrong) = unknown_field_re()
.captures(message)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
else {
return message.to_string();
};
if let Some(suggested) = curated_suggestion(kind, &wrong) {
return format!("{message}\n did you mean: `{suggested}`?");
}
let expected = extract_expected_fields(message);
if let Some(suggested) = levenshtein_suggestion(&wrong, &expected) {
return format!("{message}\n did you mean: `{suggested}`?");
}
message.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn argv_on_command_rule_suggests_command() {
let msg = "unknown field `argv`, expected one of `command`, `paths`, `timeout`, `level`";
let out = enrich("command", msg);
assert!(out.contains("did you mean: `command`"), "out: {out}");
}
#[test]
fn secondary_on_pair_rule_suggests_partner() {
let msg = "unknown field `secondary`, expected one of `primary`, `partner`, `level`";
let out = enrich("pair", msg);
assert!(out.contains("did you mean: `partner`"), "out: {out}");
}
#[test]
fn style_on_line_endings_suggests_target() {
let msg = "unknown field `style`, expected one of `paths`, `target`, `level`";
let out = enrich("line_endings", msg);
assert!(out.contains("did you mean: `target`"), "out: {out}");
}
#[test]
fn pattern_on_file_starts_with_suggests_prefix() {
let msg = "unknown field `pattern`, expected one of `paths`, `prefix`, `level`";
let out = enrich("file_starts_with", msg);
assert!(out.contains("did you mean: `prefix`"), "out: {out}");
}
#[test]
fn pattern_on_file_ends_with_suggests_suffix() {
let msg = "unknown field `pattern`, expected one of `paths`, `suffix`, `level`";
let out = enrich("file_ends_with", msg);
assert!(out.contains("did you mean: `suffix`"), "out: {out}");
}
#[test]
fn matches_on_path_equals_suggests_equals() {
let msg = "unknown field `matches`, expected one of `path`, `equals`, `level`";
let out = enrich("json_path_equals", msg);
assert!(out.contains("did you mean: `equals`"), "out: {out}");
}
#[test]
fn equals_on_path_matches_suggests_matches() {
let msg = "unknown field `equals`, expected one of `path`, `matches`, `level`";
let out = enrich("toml_path_matches", msg);
assert!(out.contains("did you mean: `matches`"), "out: {out}");
}
#[test]
fn typo_close_to_expected_field_suggests_via_levenshtein() {
let msg = "unknown field `patths`, expected one of `paths`, `level`";
let out = enrich("file_exists", msg);
assert!(out.contains("did you mean: `paths`"), "out: {out}");
}
#[test]
fn typo_with_distance_two_still_suggests() {
let msg = "unknown field `lvel`, expected one of `paths`, `level`";
let out = enrich("file_exists", msg);
assert!(out.contains("did you mean: `level`"), "out: {out}");
}
#[test]
fn typo_too_far_does_not_suggest() {
let msg = "unknown field `completely_random`, expected one of `paths`, `level`";
let out = enrich("file_exists", msg);
assert!(!out.contains("did you mean"), "out: {out}");
}
#[test]
fn curated_override_beats_levenshtein() {
let msg = "unknown field `argv`, expected one of `command`, `paths`, `timeout`, `level`";
let out = enrich("command", msg);
assert!(out.contains("did you mean: `command`"), "out: {out}");
assert!(!out.contains("did you mean: `paths`"), "out: {out}");
}
#[test]
fn missing_field_passes_through_unchanged() {
let msg = "missing field `level`";
let out = enrich("for_each_dir", msg);
assert_eq!(out, msg);
}
#[test]
fn unrelated_error_passes_through_unchanged() {
let msg = "invalid type: integer `30`, expected a string";
let out = enrich("command", msg);
assert_eq!(out, msg);
}
#[test]
fn empty_message_passes_through() {
let out = enrich("command", "");
assert_eq!(out, "");
}
#[test]
fn unknown_field_with_no_close_match_passes_through_unchanged() {
let msg = "unknown field `xyz`, expected one of `paths`, `level`";
let out = enrich("file_exists", msg);
assert_eq!(out, msg);
}
#[test]
fn extract_expected_skips_first_backtick_group() {
let msg = "unknown field `argv`, expected one of `command`, `paths`, `level`";
let expected = extract_expected_fields(msg);
assert_eq!(expected, vec!["command", "paths", "level"]);
}
#[test]
fn extract_expected_handles_singular_form() {
let msg = "unknown field `foo`, expected `bar`";
let expected = extract_expected_fields(msg);
assert_eq!(expected, vec!["bar"]);
}
#[test]
fn levenshtein_basic_cases() {
assert_eq!(levenshtein("", ""), 0);
assert_eq!(levenshtein("", "abc"), 3);
assert_eq!(levenshtein("abc", ""), 3);
assert_eq!(levenshtein("kitten", "sitting"), 3);
assert_eq!(levenshtein("paths", "patths"), 1);
assert_eq!(levenshtein("level", "lvel"), 1);
}
}