scythe_codegen/
backend_options.rs1use std::collections::HashMap;
2
3use scythe_core::errors::{ErrorCode, ScytheError};
4
5pub fn reject_unknown_options(known: &[&str], options: &HashMap<String, String>) -> Result<(), ScytheError> {
25 let mut keys: Vec<&String> = options.keys().collect();
26 keys.sort();
27
28 for key in keys {
29 if known.contains(&key.as_str()) {
30 continue;
31 }
32
33 let suggestion = known
34 .iter()
35 .map(|&candidate| (candidate, levenshtein_distance(key, candidate)))
36 .filter(|&(_, distance)| distance <= 2)
37 .min_by_key(|&(_, distance)| distance)
38 .map(|(candidate, _)| candidate);
39
40 let message = match suggestion {
41 Some(suggestion) => format!(
42 "unknown option '{key}' (did you mean '{suggestion}'?): valid options are {}",
43 known.join(", ")
44 ),
45 None => format!("unknown option '{key}': valid options are {}", known.join(", ")),
46 };
47 return Err(ScytheError::new(ErrorCode::InvalidConfig, message));
48 }
49
50 Ok(())
51}
52
53fn levenshtein_distance(a: &str, b: &str) -> usize {
56 let a: Vec<char> = a.chars().collect();
57 let b: Vec<char> = b.chars().collect();
58
59 let mut prev_row: Vec<usize> = (0..=b.len()).collect();
60 let mut curr_row = vec![0usize; b.len() + 1];
61
62 for (i, &char_a) in a.iter().enumerate() {
63 curr_row[0] = i + 1;
64 for (j, &char_b) in b.iter().enumerate() {
65 let substitution_cost = usize::from(char_a != char_b);
66 curr_row[j + 1] = (prev_row[j + 1] + 1)
67 .min(curr_row[j] + 1)
68 .min(prev_row[j] + substitution_cost);
69 }
70 std::mem::swap(&mut prev_row, &mut curr_row);
71 }
72
73 prev_row[b.len()]
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79
80 fn known_options() -> &'static [&'static str] {
81 &["row_type", "outer_join_unions", "structs_only", "field_case"]
82 }
83
84 #[test]
85 fn test_reject_unknown_options_accepts_known_keys() {
86 let mut options = HashMap::new();
87 options.insert("row_type".to_string(), "zod".to_string());
88 options.insert("field_case".to_string(), "camelCase".to_string());
89 reject_unknown_options(known_options(), &options).unwrap();
90 }
91
92 #[test]
93 fn test_reject_unknown_options_accepts_empty_map() {
94 reject_unknown_options(known_options(), &HashMap::new()).unwrap();
95 }
96
97 #[test]
101 fn test_reject_unknown_options_rejects_unrecognized_key() {
102 let mut options = HashMap::new();
103 options.insert("row_typ".to_string(), "zod".to_string());
104 let err = reject_unknown_options(known_options(), &options).expect_err("row_typ is not a known option");
105 let message = err.to_string();
106 assert!(message.contains("row_typ"), "{message}");
107 assert!(
108 message.contains("row_type"),
109 "error should list valid options: {message}"
110 );
111 }
112
113 #[test]
114 fn test_reject_unknown_options_suggests_close_typo() {
115 let mut options = HashMap::new();
116 options.insert("row_typ".to_string(), "zod".to_string());
117 let err = reject_unknown_options(known_options(), &options).expect_err("row_typ is not a known option");
118 assert!(
119 err.to_string().contains("did you mean 'row_type'?"),
120 "expected a did-you-mean suggestion: {err}"
121 );
122 }
123
124 #[test]
125 fn test_reject_unknown_options_no_suggestion_when_too_far() {
126 let mut options = HashMap::new();
127 options.insert("completely_unrelated_option".to_string(), "x".to_string());
128 let err = reject_unknown_options(known_options(), &options).expect_err("not a known option");
129 assert!(
130 !err.to_string().contains("did you mean"),
131 "should not suggest anything this far off: {err}"
132 );
133 }
134
135 #[test]
136 fn test_levenshtein_distance_basic_cases() {
137 assert_eq!(levenshtein_distance("row_type", "row_type"), 0);
138 assert_eq!(levenshtein_distance("row_typ", "row_type"), 1);
139 assert_eq!(levenshtein_distance("", "abc"), 3);
140 assert_eq!(levenshtein_distance("abc", ""), 3);
141 assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
142 }
143
144 #[test]
149 fn should_classify_unknown_option_as_invalid_config_not_internal() {
150 let mut options = HashMap::new();
151 options.insert("row_typ".to_string(), "zod".to_string());
152
153 let err = reject_unknown_options(&["row_type", "field_case"], &options)
154 .expect_err("a misspelled option key must be rejected");
155
156 assert_eq!(err.code, ErrorCode::InvalidConfig);
157 assert_eq!(err.code.to_string(), "INVALID_CONFIG");
158 assert!(
159 err.message.contains("did you mean 'row_type'?"),
160 "the suggestion is the useful half of the message: {}",
161 err.message
162 );
163 }
164
165 #[test]
169 fn test_reject_unknown_options_rejects_any_key_against_an_empty_known_set() {
170 let mut options = HashMap::new();
171 options.insert("row_type".to_string(), "zod".to_string());
172 let err = reject_unknown_options(&[], &options).expect_err("a backend with no options accepts no keys");
173 assert!(err.to_string().contains("row_type"), "{err}");
174 assert_eq!(err.code, ErrorCode::InvalidConfig);
175 }
176}