1use super::spec::CommandSpec;
5use crate::diagnostic::Diagnostic;
6use crate::event::EventView;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10pub enum CliErrorKind {
11 UnknownArgument,
12 MissingValue,
13 InvalidValue,
14 MissingRequired,
15 UnexpectedValue,
16 Conflict,
17 UnknownSubcommand,
18 Other,
19}
20
21#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct CliError {
38 pub kind: CliErrorKind,
39 pub message: String,
41 pub argument: Option<String>,
43 pub value: Option<String>,
45 pub suggestions: Vec<String>,
47 pub possible_values: Vec<String>,
49 pub usage: Option<String>,
51 pub help_flag: Option<String>,
53}
54
55impl CliError {
56 pub fn new(kind: CliErrorKind, message: impl Into<String>) -> Self {
57 CliError {
58 kind,
59 message: message.into(),
60 argument: None,
61 value: None,
62 suggestions: Vec::new(),
63 possible_values: Vec::new(),
64 usage: None,
65 help_flag: None,
66 }
67 }
68
69 pub fn unknown_argument<S: AsRef<str>>(
71 argument: impl Into<String>,
72 candidates: impl IntoIterator<Item = S>,
73 ) -> Self {
74 let argument = argument.into();
75 let suggestions = suggest(&argument, candidates);
76 CliError::new(CliErrorKind::UnknownArgument, "")
77 .argument(argument)
78 .suggestions(suggestions)
79 }
80
81 pub fn unknown_subcommand<S: AsRef<str>>(
83 name: impl Into<String>,
84 candidates: impl IntoIterator<Item = S>,
85 ) -> Self {
86 let name = name.into();
87 let suggestions = suggest(&name, candidates);
88 CliError::new(CliErrorKind::UnknownSubcommand, "")
89 .argument(name)
90 .suggestions(suggestions)
91 }
92
93 pub fn unknown_in(spec: &CommandSpec, argument: &str) -> Self {
97 let error = if argument.starts_with('-') {
98 CliError::unknown_argument(argument, spec.switch_names())
99 } else {
100 CliError::unknown_subcommand(argument, spec.subcommand_names())
101 };
102 error.usage(spec.usage_lines().join("\n"))
103 }
104
105 pub fn invalid_value<S: AsRef<str>>(
107 argument: impl Into<String>,
108 value: impl Into<String>,
109 possible: impl IntoIterator<Item = S>,
110 ) -> Self {
111 let value = value.into();
112 let possible: Vec<String> = possible
113 .into_iter()
114 .map(|s| s.as_ref().to_string())
115 .collect();
116 let mut error = CliError::new(CliErrorKind::InvalidValue, "")
117 .argument(argument)
118 .suggestions(suggest(&value, &possible));
119 error.value = Some(value);
120 error.possible_values = possible;
121 error
122 }
123
124 pub fn missing_value(argument: impl Into<String>) -> Self {
126 CliError::new(CliErrorKind::MissingValue, "").argument(argument)
127 }
128
129 pub fn missing_required<S: AsRef<str>>(arguments: impl IntoIterator<Item = S>) -> Self {
131 let joined: Vec<String> = arguments
132 .into_iter()
133 .map(|s| s.as_ref().to_string())
134 .collect();
135 CliError::new(CliErrorKind::MissingRequired, "").argument(joined.join(", "))
136 }
137
138 pub fn argument(mut self, argument: impl Into<String>) -> Self {
139 self.argument = Some(argument.into());
140 self
141 }
142 pub fn value(mut self, value: impl Into<String>) -> Self {
143 self.value = Some(value.into());
144 self
145 }
146 pub fn suggestion(mut self, suggestion: impl Into<String>) -> Self {
147 self.suggestions.push(suggestion.into());
148 self
149 }
150 pub fn suggestions(mut self, suggestions: impl IntoIterator<Item = String>) -> Self {
151 self.suggestions.extend(suggestions);
152 self
153 }
154 pub fn usage(mut self, usage: impl Into<String>) -> Self {
155 self.usage = Some(usage.into());
156 self
157 }
158 pub fn help_flag(mut self, flag: impl Into<String>) -> Self {
160 self.help_flag = Some(flag.into());
161 self
162 }
163
164 pub fn exit_code(&self) -> i32 {
167 2
168 }
169
170 pub fn headline(&self) -> String {
172 if !self.message.is_empty() {
173 return self.message.clone();
174 }
175 let arg = self.argument.as_deref().unwrap_or("");
176 let value = self.value.as_deref().unwrap_or("");
177 match self.kind {
178 CliErrorKind::UnknownArgument => format!("unexpected argument '{arg}'"),
179 CliErrorKind::MissingValue => {
180 format!("a value is required for '{arg}' but none was supplied")
181 }
182 CliErrorKind::InvalidValue => format!("invalid value '{value}' for '{arg}'"),
183 CliErrorKind::MissingRequired => {
184 format!("the following required arguments were not provided: {arg}")
185 }
186 CliErrorKind::UnexpectedValue => format!("unexpected value '{value}' for '{arg}'"),
187 CliErrorKind::Conflict if !value.is_empty() => {
188 format!("the argument '{arg}' cannot be used with '{value}'")
189 }
190 CliErrorKind::Conflict => format!("the argument '{arg}' cannot be used here"),
191 CliErrorKind::UnknownSubcommand => format!("unrecognized subcommand '{arg}'"),
192 CliErrorKind::Other => "invalid command line".to_string(),
193 }
194 }
195
196 pub fn to_diagnostic(&self) -> Diagnostic {
201 let mut diagnostic =
202 Diagnostic::error(escape_controls(&self.headline())).view(EventView::Expanded);
203 if let Some(usage) = &self.usage {
204 for line in usage.lines() {
205 diagnostic = diagnostic.note(escape_controls(&format!("usage: {line}")));
206 }
207 }
208 if !self.possible_values.is_empty() {
209 diagnostic = diagnostic.note(escape_controls(&format!(
210 "possible values: {}",
211 self.possible_values.join(", ")
212 )));
213 }
214 if !self.suggestions.is_empty() {
215 let noun = match self.kind {
216 CliErrorKind::UnknownSubcommand => ("subcommand", "subcommands"),
217 CliErrorKind::InvalidValue => ("value", "values"),
218 _ => ("argument", "arguments"),
219 };
220 let quoted: Vec<String> = self.suggestions.iter().map(|s| format!("'{s}'")).collect();
221 diagnostic = diagnostic.help(escape_controls(&match quoted.len() {
222 1 => format!("a similar {} exists: {}", noun.0, quoted[0]),
223 _ => format!("similar {} exist: {}", noun.1, quoted.join(", ")),
224 }));
225 }
226 if let Some(flag) = &self.help_flag {
227 diagnostic = diagnostic.help(escape_controls(&format!(
228 "for more information, try '{flag}'"
229 )));
230 }
231 diagnostic
232 }
233}
234
235fn escape_controls(text: &str) -> String {
238 if !text.chars().any(char::is_control) {
239 return text.to_string();
240 }
241 text.chars()
242 .map(|c| match c {
243 '\n' => r"\n".to_string(),
244 '\t' => r"\t".to_string(),
245 '\r' => r"\r".to_string(),
246 c if c.is_control() => format!("\\u{{{:x}}}", c as u32),
247 c => c.to_string(),
248 })
249 .collect()
250}
251
252impl std::fmt::Display for CliError {
253 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254 f.write_str(&escape_controls(&self.headline()))
255 }
256}
257
258impl std::error::Error for CliError {}
259
260pub fn suggest<S: AsRef<str>>(input: &str, candidates: impl IntoIterator<Item = S>) -> Vec<String> {
277 let needle = input.trim_start_matches('-');
278 let kind = dashes(input);
279 let mut scored: Vec<(f64, String)> = candidates
280 .into_iter()
281 .map(|c| c.as_ref().to_string())
282 .filter(|c| c != input && dashes(c) == kind)
283 .map(|c| (jaro_winkler(needle, c.trim_start_matches('-')), c))
284 .filter(|(score, _)| *score > 0.7)
285 .collect();
286 scored.sort_by(|a, b| b.0.total_cmp(&a.0));
288 scored.dedup_by(|a, b| a.1 == b.1);
289 scored.into_iter().take(3).map(|(_, c)| c).collect()
290}
291
292fn dashes(word: &str) -> usize {
295 word.bytes().take(2).take_while(|&b| b == b'-').count()
296}
297
298fn jaro_winkler(a: &str, b: &str) -> f64 {
300 let a: Vec<char> = a.chars().collect();
301 let b: Vec<char> = b.chars().collect();
302 if a.is_empty() && b.is_empty() {
303 return 1.0;
304 }
305 if a.is_empty() || b.is_empty() {
306 return 0.0;
307 }
308 let window = (a.len().max(b.len()) / 2).saturating_sub(1);
309 let mut a_hit = vec![false; a.len()];
310 let mut b_hit = vec![false; b.len()];
311 let mut matches = 0usize;
312 for (i, ca) in a.iter().enumerate() {
313 let lo = i.saturating_sub(window);
314 let hi = (i + window + 1).min(b.len());
315 for j in lo..hi {
316 if !b_hit[j] && b[j] == *ca {
317 a_hit[i] = true;
318 b_hit[j] = true;
319 matches += 1;
320 break;
321 }
322 }
323 }
324 if matches == 0 {
325 return 0.0;
326 }
327 let a_matched = a
328 .iter()
329 .zip(&a_hit)
330 .filter(|(_, hit)| **hit)
331 .map(|(c, _)| c);
332 let b_matched = b
333 .iter()
334 .zip(&b_hit)
335 .filter(|(_, hit)| **hit)
336 .map(|(c, _)| c);
337 let transpositions = a_matched.zip(b_matched).filter(|(x, y)| x != y).count() / 2;
338 let m = matches as f64;
339 let jaro = (m / a.len() as f64 + m / b.len() as f64 + (m - transpositions as f64) / m) / 3.0;
340 let prefix = a.iter().zip(&b).take(4).take_while(|(x, y)| x == y).count();
341 jaro + prefix as f64 * 0.1 * (1.0 - jaro)
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn jaro_winkler_reference_values() {
350 assert!((jaro_winkler("MARTHA", "MARHTA") - 0.9611).abs() < 1e-3);
352 assert!((jaro_winkler("DIXON", "DICKSONX") - 0.8133).abs() < 1e-3);
353 assert_eq!(jaro_winkler("same", "same"), 1.0);
354 assert_eq!(jaro_winkler("abc", "xyz"), 0.0);
355 }
356
357 #[test]
358 fn subcommand_suggestions() {
359 assert_eq!(
360 suggest("confg", ["config", "print", "markdown"]),
361 ["config"]
362 );
363 }
364
365 #[test]
366 fn suggestions_compare_like_with_like() {
367 let names = ["--parallel", "-r", "--dry-run", "-n", "report", "-p"];
368 assert_eq!(suggest("--paralel", names), ["--parallel"]);
369 assert_eq!(suggest("--dry-rn", names), ["--dry-run"]);
370 assert!(!suggest("--r", names).contains(&"-r".to_string()));
371 assert!(suggest("-x", ["--x-ray", "--xx"]).is_empty());
372 assert_eq!(suggest("reprt", names), ["report"]);
373 assert!(suggest("paralel", names).is_empty());
374 }
375
376 #[test]
377 fn suggestions_keep_the_top_three_and_the_cutoff() {
378 let names = ["--color", "--colour", "--colors", "--colored", "--width"];
379 assert_eq!(suggest("--colr", names).len(), 3);
380 assert!(suggest("--zzz", names).is_empty());
381 }
382}