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 example = skipped
170 .first()
171 .and_then(|s| skill_example_line(s.id.as_str()))
172 .unwrap_or_else(|| format!("wyvern {path}"));
173 (
174 ErrorCode::ValidationError,
175 format!("extension(s) '{id_summary}' skipped; missing {missing}"),
176 format!(
177 "'{path}' matched skipped extension(s) but required binaries are not on PATH"
178 ),
179 vec![
180 format!("Install {missing} and retry"),
181 format!("Example: {example}"),
182 "Run wyvern extensions list to see requires".into(),
183 "Run wyvern --help to list skills".into(),
184 ],
185 )
186 }
187 };
188 let mut envelope = StderrError::new(code, message)
189 .cause(cause)
190 .docs("docs/wyvern/requirements.md (REQ-0136)");
191 for step in recovery {
192 envelope = envelope.recovery(step);
193 }
194 envelope.to_json_string().map_err(EmitError::Serialize)
195}
196
197fn is_json_command_file(token: &str) -> bool {
198 std::path::Path::new(token)
199 .extension()
200 .and_then(|ext| ext.to_str())
201 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
202}
203
204fn looks_path_like(token: &str) -> bool {
205 token.contains('/') || token.contains('\\') || std::path::Path::new(token).extension().is_some()
206}
207
208fn find_bare_prefix(registry: &ExtensionRegistry, argv: &[String]) -> Option<NearMissKind> {
209 let mut best: Option<(&super::ExtensionDef, usize)> = None;
210 for ext in registry.extensions() {
211 let spec = &ext.match_spec;
212 let Some(prefix) = &spec.argv_prefix else {
213 continue;
214 };
215 let Some(suffix) = &spec.arg_suffix else {
216 continue;
217 };
218 if !prefix_tokens_match(prefix, argv) {
219 continue;
220 }
221 let rest = &argv[prefix.len()..];
222 if rest
223 .iter()
224 .any(|token| ends_with_suffix(token, suffix.as_str()))
225 {
226 continue;
227 }
228 if best.is_none_or(|(_, len)| prefix.len() > len) {
229 best = Some((ext, prefix.len()));
230 }
231 }
232 best.map(|(ext, _)| {
233 let record = build_skill_record(ext, &PathRequiresProbe);
234 NearMissKind::BarePrefix {
235 extension_id: ext.id.clone(),
236 usage: record.invocation,
237 }
238 })
239}
240
241fn find_incomplete_prefix(registry: &ExtensionRegistry, argv: &[String]) -> Option<NearMissKind> {
242 let mut best: Option<(&super::ExtensionDef, usize)> = None;
243 for ext in registry.extensions() {
244 let Some(prefix) = &ext.match_spec.argv_prefix else {
245 continue;
246 };
247 if prefix.is_empty() || argv.is_empty() || argv.len() >= prefix.len() {
248 continue;
249 }
250 if !prefix
251 .iter()
252 .zip(argv.iter())
253 .all(|(expected, got)| expected.as_str() == got)
254 {
255 continue;
256 }
257 if best.is_none_or(|(_, len)| prefix.len() > len) {
258 best = Some((ext, prefix.len()));
259 }
260 }
261 best.map(|(ext, _)| {
262 let hint = ext
263 .match_spec
264 .argv_prefix
265 .as_ref()
266 .map(|prefix| {
267 prefix
268 .iter()
269 .map(super::MatchToken::as_str)
270 .collect::<Vec<_>>()
271 .join(" ")
272 })
273 .unwrap_or_default();
274 NearMissKind::IncompletePrefix {
275 extension_id: ext.id.clone(),
276 hint,
277 }
278 })
279}
280
281fn prefix_tokens_match(prefix: &[super::MatchToken], argv: &[String]) -> bool {
282 argv.len() >= prefix.len()
283 && prefix
284 .iter()
285 .zip(argv.iter())
286 .all(|(expected, got)| expected.as_str() == got)
287}
288
289fn prefix_from_usage(usage: &str) -> String {
290 usage
291 .strip_prefix("wyvern ")
292 .unwrap_or(usage)
293 .split_whitespace()
294 .take_while(|part| {
295 !part.starts_with('<') && !part.starts_with('[') && !part.starts_with('-')
296 })
297 .collect::<Vec<_>>()
298 .join(" ")
299}
300
301const MAX_SKIPPED_SUMMARY: usize = 4;
303
304fn skipped_requires_summary(skipped: &[SkippedExtension]) -> (String, String) {
305 let shown = skipped.len().min(MAX_SKIPPED_SUMMARY);
306 let ids = skipped[..shown]
307 .iter()
308 .map(|s| s.id.to_string())
309 .collect::<Vec<_>>()
310 .join(", ");
311 let id_summary = if skipped.len() > MAX_SKIPPED_SUMMARY {
312 format!("{ids} (+{} more)", skipped.len() - MAX_SKIPPED_SUMMARY)
313 } else if ids.is_empty() {
314 "extension".into()
315 } else {
316 ids
317 };
318 let mut missing = Vec::new();
319 for skipped in skipped {
320 for bin in &skipped.missing {
321 let name = bin.as_str();
322 if !missing.iter().any(|seen: &String| seen == name) {
323 missing.push(name.to_string());
324 }
325 }
326 }
327 let missing = if missing.is_empty() {
328 "required binaries".into()
329 } else {
330 missing.join(", ")
331 };
332 (id_summary, missing)
333}
334
335fn skill_example_line(id: &str) -> Option<String> {
336 let registry = ExtensionRegistry::from_json_str(super::SHIPPED_EXTENSIONS_JSON).ok()?;
337 let ext = registry
338 .extensions()
339 .iter()
340 .find(|ext| ext.id.as_str() == id)?;
341 let record = build_skill_record(ext, &PathRequiresProbe);
342 let card = format_skill_card(&record);
343 card.lines()
344 .find_map(|line| line.strip_prefix("Example: "))
345 .map(ToOwned::to_owned)
346 .or_else(|| record.examples.first().cloned())
347 .or(Some(record.invocation))
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use crate::extensions::{ExtensionRegistry, RequiresProbe, SHIPPED_EXTENSIONS_JSON};
354
355 struct Absent;
356
357 impl RequiresProbe for Absent {
358 fn binary_on_path(&self, _name: &str) -> bool {
359 false
360 }
361 }
362
363 fn shipped() -> ExtensionRegistry {
364 ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON).expect("shipped")
365 }
366
367 #[test]
368 fn unknown_txt_is_parse_error_not_json() {
369 let registry = shipped();
370 let argv = vec!["notes.txt".into()];
371 let outcome = registry.match_with_diagnostics(&argv);
372 let kind = classify_near_miss(®istry, &argv, &outcome.skipped).expect("near-miss");
373 assert!(matches!(kind, NearMissKind::UnknownInput { .. }));
374 let json = emit_near_miss(&kind).expect("emit");
375 assert!(json.contains("PARSE_ERROR"), "{json}");
376 assert!(json.contains("unknown input"), "{json}");
377 assert!(!json.contains("not valid JSON"), "{json}");
378 assert_eq!(kind.exit_code(), 2);
379 }
380
381 #[test]
382 fn inline_json_is_not_a_near_miss() {
383 let registry = shipped();
384 let argv = vec![r#"{"type":"message"}"#.into()];
385 assert!(classify_near_miss(®istry, &argv, &[]).is_none());
386 }
387
388 #[test]
389 fn json_file_falls_through() {
390 let registry = shipped();
391 let argv = vec!["cmd.json".into()];
392 assert!(classify_near_miss(®istry, &argv, &[]).is_none());
393 }
394
395 #[test]
396 fn md_bare_prefix_names_csv_md_and_file_csv() {
397 let registry = shipped();
398 let argv = vec!["md".into()];
399 let kind = classify_near_miss(®istry, &argv, &[]).expect("bare");
400 match &kind {
401 NearMissKind::BarePrefix {
402 extension_id,
403 usage,
404 } => {
405 assert_eq!(extension_id.as_str(), "csv-md");
406 assert!(usage.contains("<file.csv>"), "{usage}");
407 }
408 other => panic!("expected BarePrefix, got {other:?}"),
409 }
410 let json = emit_near_miss(&kind).expect("emit");
411 assert!(json.contains("VALIDATION_ERROR"), "{json}");
412 assert!(json.contains("<file.csv>"), "{json}");
413 assert_eq!(kind.exit_code(), 4);
414 }
415
416 #[test]
417 fn compose_incomplete_prefix_hints_compose_render() {
418 let registry = shipped();
419 let argv = vec!["compose".into()];
420 let kind = classify_near_miss(®istry, &argv, &[]).expect("incomplete");
421 match &kind {
422 NearMissKind::IncompletePrefix { extension_id, hint } => {
423 assert_eq!(extension_id.as_str(), "compose-render");
424 assert_eq!(hint, "compose render");
425 }
426 other => panic!("expected IncompletePrefix, got {other:?}"),
427 }
428 let json = emit_near_miss(&kind).expect("emit");
429 assert!(json.contains("compose render"), "{json}");
430 assert!(json.contains("compose-render"), "{json}");
431 }
432
433 #[test]
434 fn csv_skipped_requires_names_python3() {
435 let registry = shipped();
436 let argv = vec!["sample.csv".into()];
437 let outcome = registry.match_with_diagnostics_with(&argv, &Absent);
438 assert!(outcome.matched.is_none());
439 assert!(
440 outcome
441 .skipped
442 .iter()
443 .any(|s| s.id == "csv-suffix" && s.missing.iter().any(|b| b == "python3")),
444 "{:?}",
445 outcome.skipped
446 );
447 let kind = classify_near_miss(®istry, &argv, &outcome.skipped).expect("skipped");
448 let json = emit_near_miss(&kind).expect("emit");
449 assert!(json.contains("csv-suffix"), "{json}");
450 assert!(json.contains("python3"), "{json}");
451 assert!(json.contains("wyvern"), "{json}");
452 let _ = format_skill_card(&build_skill_record(
453 registry
454 .extensions()
455 .iter()
456 .find(|e| e.id.as_str() == "csv-suffix")
457 .expect("csv"),
458 &Absent,
459 ));
460 }
461
462 #[test]
463 fn skipped_requires_lists_all_skipped_extensions() {
464 let json = r#"{
465 "version": 1,
466 "extensions": [
467 {
468 "id": "one-csv",
469 "match": { "positional_suffix": ".csv" },
470 "preexec": { "cmd": "python3", "requires": ["python3"] },
471 "expand": { "command": { "type": "markdown", "file": "{path}" } }
472 },
473 {
474 "id": "two-csv",
475 "match": { "positional_suffix": ".csv" },
476 "preexec": { "cmd": "ruby", "requires": ["ruby"] },
477 "expand": { "command": { "type": "markdown", "file": "{path}" } }
478 }
479 ]
480 }"#;
481 let registry = ExtensionRegistry::from_json_str(json).expect("parse");
482 let argv = vec!["sample.csv".into()];
483 let outcome = registry.match_with_diagnostics_with(&argv, &Absent);
484 assert_eq!(outcome.skipped.len(), 2, "{:?}", outcome.skipped);
485 let kind = classify_near_miss(®istry, &argv, &outcome.skipped).expect("skipped");
486 let json = emit_near_miss(&kind).expect("emit");
487 assert!(json.contains("one-csv"), "{json}");
488 assert!(json.contains("two-csv"), "{json}");
489 assert!(json.contains("python3"), "{json}");
490 assert!(json.contains("ruby"), "{json}");
491 }
492}