1use wyvern_schema::{ErrorCode, StderrError};
8
9use crate::error::EmitError;
10
11use super::{
12 build_skill_record, ends_with_suffix, format_skill_card, BinaryName, ExtensionId,
13 ExtensionRegistry, PathRequiresProbe,
14};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct SkippedExtension {
19 pub id: ExtensionId,
21 pub missing: Vec<BinaryName>,
23}
24
25#[derive(Debug)]
27pub struct MatchOutcome<'a> {
28 pub matched: Option<super::ExtensionMatch<'a>>,
30 pub skipped: Vec<SkippedExtension>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum NearMissKind {
37 UnknownInput {
39 token: String,
41 },
42 IncompletePrefix {
44 extension_id: ExtensionId,
46 hint: String,
48 },
49 BarePrefix {
51 extension_id: ExtensionId,
53 usage: String,
55 },
56 SkippedRequires {
58 path: String,
60 skipped: Vec<SkippedExtension>,
62 },
63}
64
65impl NearMissKind {
66 #[must_use]
68 pub fn exit_code(&self) -> i32 {
69 match self {
70 Self::UnknownInput { .. } => ErrorCode::ParseError.exit_code(),
71 Self::IncompletePrefix { .. }
72 | Self::BarePrefix { .. }
73 | Self::SkippedRequires { .. } => ErrorCode::ValidationError.exit_code(),
74 }
75 }
76}
77
78#[must_use]
84pub fn classify_near_miss(
85 registry: &ExtensionRegistry,
86 argv: &[String],
87 skipped: &[SkippedExtension],
88) -> Option<NearMissKind> {
89 if argv.is_empty() {
90 return None;
91 }
92 if argv.len() == 1 {
93 let token = argv[0].as_str();
94 if token.starts_with('{') || token.starts_with('[') {
95 return None;
96 }
97 if token.starts_with('-') {
98 return None;
99 }
100 if is_json_command_file(token) {
101 return None;
102 }
103 }
104 if !skipped.is_empty() {
105 let path = argv
106 .iter()
107 .find(|token| looks_path_like(token))
108 .cloned()
109 .unwrap_or_else(|| argv[0].clone());
110 return Some(NearMissKind::SkippedRequires {
111 path,
112 skipped: skipped.to_vec(),
113 });
114 }
115 if let Some(kind) = find_bare_prefix(registry, argv) {
116 return Some(kind);
117 }
118 if let Some(kind) = find_incomplete_prefix(registry, argv) {
119 return Some(kind);
120 }
121 Some(NearMissKind::UnknownInput {
122 token: argv[0].clone(),
123 })
124}
125
126pub fn emit_near_miss(kind: &NearMissKind) -> Result<String, EmitError> {
132 let (code, message, cause, recovery) = match kind {
133 NearMissKind::UnknownInput { token } => (
134 ErrorCode::ParseError,
135 format!("unknown input '{token}'"),
136 format!("No shipped extension matches '{token}'"),
137 vec![
138 "Use a supported suffix such as .md, .html, .csv, or wizard.json".into(),
139 "Or a prefix such as md <file.csv>, table <file.csv>, or compose render".into(),
140 "Run wyvern --help to list skills".into(),
141 "Run wyvern extensions list".into(),
142 ],
143 ),
144 NearMissKind::IncompletePrefix { extension_id, hint } => (
145 ErrorCode::ValidationError,
146 format!("incomplete prefix for '{extension_id}'"),
147 format!("'{extension_id}' expects `{hint}`"),
148 vec![
149 format!("Continue with: wyvern {hint}"),
150 format!("Run wyvern {hint} --help"),
151 "Run wyvern --help to list skills".into(),
152 ],
153 ),
154 NearMissKind::BarePrefix {
155 extension_id,
156 usage,
157 } => (
158 ErrorCode::ValidationError,
159 format!("extension '{extension_id}' requires a matching path"),
160 format!("Usage: {usage}"),
161 vec![
162 format!("Pass a path as in: {usage}"),
163 format!("Run wyvern {} --help", prefix_from_usage(usage)),
164 "Run wyvern --help to list skills".into(),
165 ],
166 ),
167 NearMissKind::SkippedRequires { path, skipped } => {
168 let (id_summary, missing) = skipped_requires_summary(skipped);
169 let mut recovery = vec![format!("Install {missing} and retry")];
170 for skipped_ext in skipped {
171 let example = skill_example_line(skipped_ext.id.as_str())
172 .unwrap_or_else(|| format!("wyvern {path}"));
173 recovery.push(format!("Example ({}): {example}", skipped_ext.id));
174 }
175 recovery.push("Run wyvern extensions list to see requires".into());
176 recovery.push("Run wyvern --help to list skills".into());
177 (
178 ErrorCode::ValidationError,
179 format!("extension(s) '{id_summary}' skipped; missing {missing}"),
180 format!(
181 "'{path}' matched skipped extension(s) but required binaries are not on PATH"
182 ),
183 recovery,
184 )
185 }
186 };
187 let mut envelope = StderrError::new(code, message)
188 .cause(cause)
189 .docs("docs/wyvern/requirements.md (REQ-0136)");
190 for step in recovery {
191 envelope = envelope.recovery(step);
192 }
193 envelope.to_json_string().map_err(EmitError::Serialize)
194}
195
196fn is_json_command_file(token: &str) -> bool {
197 std::path::Path::new(token)
198 .extension()
199 .and_then(|ext| ext.to_str())
200 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
201}
202
203fn looks_path_like(token: &str) -> bool {
204 token.contains('/') || token.contains('\\') || std::path::Path::new(token).extension().is_some()
205}
206
207fn find_bare_prefix(registry: &ExtensionRegistry, argv: &[String]) -> Option<NearMissKind> {
208 let mut best: Option<(&super::ExtensionDef, usize)> = None;
209 for ext in registry.extensions() {
210 let spec = &ext.match_spec;
211 let Some(prefix) = &spec.argv_prefix else {
212 continue;
213 };
214 let Some(suffix) = &spec.arg_suffix else {
215 continue;
216 };
217 if !prefix_tokens_match(prefix, argv) {
218 continue;
219 }
220 let rest = &argv[prefix.len()..];
221 if rest
222 .iter()
223 .any(|token| ends_with_suffix(token, suffix.as_str()))
224 {
225 continue;
226 }
227 if best.is_none_or(|(_, len)| prefix.len() > len) {
228 best = Some((ext, prefix.len()));
229 }
230 }
231 best.map(|(ext, _)| {
232 let record = build_skill_record(ext, &PathRequiresProbe);
233 NearMissKind::BarePrefix {
234 extension_id: ext.id.clone(),
235 usage: record.invocation,
236 }
237 })
238}
239
240fn find_incomplete_prefix(registry: &ExtensionRegistry, argv: &[String]) -> Option<NearMissKind> {
241 let mut best: Option<(&super::ExtensionDef, usize)> = None;
242 for ext in registry.extensions() {
243 let Some(prefix) = &ext.match_spec.argv_prefix else {
244 continue;
245 };
246 if prefix.is_empty() || argv.is_empty() || argv.len() >= prefix.len() {
247 continue;
248 }
249 if !prefix
250 .iter()
251 .zip(argv.iter())
252 .all(|(expected, got)| expected.as_str() == got)
253 {
254 continue;
255 }
256 if best.is_none_or(|(_, len)| prefix.len() > len) {
257 best = Some((ext, prefix.len()));
258 }
259 }
260 best.map(|(ext, _)| {
261 let hint = ext
262 .match_spec
263 .argv_prefix
264 .as_ref()
265 .map(|prefix| {
266 prefix
267 .iter()
268 .map(super::MatchToken::as_str)
269 .collect::<Vec<_>>()
270 .join(" ")
271 })
272 .unwrap_or_default();
273 NearMissKind::IncompletePrefix {
274 extension_id: ext.id.clone(),
275 hint,
276 }
277 })
278}
279
280fn prefix_tokens_match(prefix: &[super::MatchToken], argv: &[String]) -> bool {
281 argv.len() >= prefix.len()
282 && prefix
283 .iter()
284 .zip(argv.iter())
285 .all(|(expected, got)| expected.as_str() == got)
286}
287
288fn prefix_from_usage(usage: &str) -> String {
289 usage
290 .strip_prefix("wyvern ")
291 .unwrap_or(usage)
292 .split_whitespace()
293 .take_while(|part| {
294 !part.starts_with('<') && !part.starts_with('[') && !part.starts_with('-')
295 })
296 .collect::<Vec<_>>()
297 .join(" ")
298}
299
300const MAX_SKIPPED_SUMMARY: usize = 4;
302
303fn skipped_requires_summary(skipped: &[SkippedExtension]) -> (String, String) {
304 let shown = skipped.len().min(MAX_SKIPPED_SUMMARY);
305 let ids = skipped[..shown]
306 .iter()
307 .map(|s| s.id.to_string())
308 .collect::<Vec<_>>()
309 .join(", ");
310 let id_summary = if skipped.len() > MAX_SKIPPED_SUMMARY {
311 format!("{ids} (+{} more)", skipped.len() - MAX_SKIPPED_SUMMARY)
312 } else if ids.is_empty() {
313 "extension".into()
314 } else {
315 ids
316 };
317 let mut missing = Vec::new();
318 for skipped in skipped {
319 for bin in &skipped.missing {
320 let name = bin.as_str();
321 if !missing.iter().any(|seen: &String| seen == name) {
322 missing.push(name.to_string());
323 }
324 }
325 }
326 let missing = if missing.is_empty() {
327 "required binaries".into()
328 } else {
329 missing.join(", ")
330 };
331 (id_summary, missing)
332}
333
334fn skill_example_line(id: &str) -> Option<String> {
335 let registry = ExtensionRegistry::from_json_str(super::SHIPPED_EXTENSIONS_JSON).ok()?;
336 let ext = registry
337 .extensions()
338 .iter()
339 .find(|ext| ext.id.as_str() == id)?;
340 let record = build_skill_record(ext, &PathRequiresProbe);
341 let card = format_skill_card(&record);
342 card.lines()
343 .find_map(|line| line.strip_prefix("Example: "))
344 .map(ToOwned::to_owned)
345 .or_else(|| record.examples.first().cloned())
346 .or(Some(record.invocation))
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::extensions::{ExtensionRegistry, RequiresProbe, SHIPPED_EXTENSIONS_JSON};
353
354 struct Absent;
355
356 impl RequiresProbe for Absent {
357 fn binary_on_path(&self, _name: &str) -> bool {
358 false
359 }
360 }
361
362 fn shipped() -> ExtensionRegistry {
363 ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON).expect("shipped")
364 }
365
366 #[test]
367 fn unknown_txt_is_parse_error_not_json() {
368 let registry = shipped();
369 let argv = vec!["notes.txt".into()];
370 let outcome = registry.match_with_diagnostics(&argv);
371 let kind = classify_near_miss(®istry, &argv, &outcome.skipped).expect("near-miss");
372 assert!(matches!(kind, NearMissKind::UnknownInput { .. }));
373 let json = emit_near_miss(&kind).expect("emit");
374 assert!(json.contains("PARSE_ERROR"), "{json}");
375 assert!(json.contains("unknown input"), "{json}");
376 assert!(!json.contains("not valid JSON"), "{json}");
377 assert_eq!(kind.exit_code(), 2);
378 }
379
380 #[test]
381 fn inline_json_is_not_a_near_miss() {
382 let registry = shipped();
383 let argv = vec![r#"{"type":"message"}"#.into()];
384 assert!(classify_near_miss(®istry, &argv, &[]).is_none());
385 }
386
387 #[test]
388 fn json_file_falls_through() {
389 let registry = shipped();
390 let argv = vec!["cmd.json".into()];
391 assert!(classify_near_miss(®istry, &argv, &[]).is_none());
392 }
393
394 #[test]
395 fn md_bare_prefix_names_csv_md_and_file_csv() {
396 let registry = shipped();
397 let argv = vec!["md".into()];
398 let kind = classify_near_miss(®istry, &argv, &[]).expect("bare");
399 match &kind {
400 NearMissKind::BarePrefix {
401 extension_id,
402 usage,
403 } => {
404 assert_eq!(extension_id.as_str(), "csv-md");
405 assert!(usage.contains("<file.csv>"), "{usage}");
406 }
407 other => panic!("expected BarePrefix, got {other:?}"),
408 }
409 let json = emit_near_miss(&kind).expect("emit");
410 assert!(json.contains("VALIDATION_ERROR"), "{json}");
411 assert!(json.contains("<file.csv>"), "{json}");
412 assert_eq!(kind.exit_code(), 4);
413 }
414
415 #[test]
416 fn compose_incomplete_prefix_hints_compose_render() {
417 let registry = shipped();
418 let argv = vec!["compose".into()];
419 let kind = classify_near_miss(®istry, &argv, &[]).expect("incomplete");
420 match &kind {
421 NearMissKind::IncompletePrefix { extension_id, hint } => {
422 assert_eq!(extension_id.as_str(), "compose-render");
423 assert_eq!(hint, "compose render");
424 }
425 other => panic!("expected IncompletePrefix, got {other:?}"),
426 }
427 let json = emit_near_miss(&kind).expect("emit");
428 assert!(json.contains("compose render"), "{json}");
429 assert!(json.contains("compose-render"), "{json}");
430 }
431
432 #[test]
433 fn csv_skipped_requires_names_python3() {
434 let registry = shipped();
435 let argv = vec!["sample.csv".into()];
436 let outcome = registry.match_with_diagnostics_with(&argv, &Absent);
437 assert!(outcome.matched.is_none());
438 assert!(
439 outcome
440 .skipped
441 .iter()
442 .any(|s| s.id == "csv-suffix" && s.missing.iter().any(|b| b == "python3")),
443 "{:?}",
444 outcome.skipped
445 );
446 let kind = classify_near_miss(®istry, &argv, &outcome.skipped).expect("skipped");
447 let json = emit_near_miss(&kind).expect("emit");
448 assert!(json.contains("csv-suffix"), "{json}");
449 assert!(json.contains("python3"), "{json}");
450 assert!(json.contains("wyvern"), "{json}");
451 let _ = format_skill_card(&build_skill_record(
452 registry
453 .extensions()
454 .iter()
455 .find(|e| e.id.as_str() == "csv-suffix")
456 .expect("csv"),
457 &Absent,
458 ));
459 }
460
461 #[test]
462 fn skipped_requires_lists_all_skipped_extensions() {
463 let json = r#"{
464 "version": 1,
465 "extensions": [
466 {
467 "id": "one-csv",
468 "match": { "positional_suffix": ".csv" },
469 "preexec": { "cmd": "python3", "requires": ["python3"] },
470 "expand": { "command": { "type": "markdown", "file": "{path}" } }
471 },
472 {
473 "id": "two-csv",
474 "match": { "positional_suffix": ".csv" },
475 "preexec": { "cmd": "ruby", "requires": ["ruby"] },
476 "expand": { "command": { "type": "markdown", "file": "{path}" } }
477 }
478 ]
479 }"#;
480 let registry = ExtensionRegistry::from_json_str(json).expect("parse");
481 let argv = vec!["sample.csv".into()];
482 let outcome = registry.match_with_diagnostics_with(&argv, &Absent);
483 assert_eq!(outcome.skipped.len(), 2, "{:?}", outcome.skipped);
484 let kind = classify_near_miss(®istry, &argv, &outcome.skipped).expect("skipped");
485 let json = emit_near_miss(&kind).expect("emit");
486 assert!(json.contains("one-csv"), "{json}");
487 assert!(json.contains("two-csv"), "{json}");
488 assert!(json.contains("python3"), "{json}");
489 assert!(json.contains("ruby"), "{json}");
490 assert!(json.contains("Example (one-csv):"), "{json}");
491 assert!(json.contains("Example (two-csv):"), "{json}");
492 }
493}