Skip to main content

wyvern/extensions/
diagnostics.rs

1//! Near-miss classification and structured stderr JSON (REQ-0136).
2//!
3//! Called from `main` after [`super::ExtensionRegistry::match_with_diagnostics`]
4//! returns no match and before [`crate::load_command_input`]. Do not parse
5//! path-like tokens as inline JSON.
6
7use 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/// Extension that would have matched argv but was skipped for `requires`.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct SkippedExtension {
19    /// Extension id that was skipped.
20    pub id: ExtensionId,
21    /// Required binaries that were not on `PATH`.
22    pub missing: Vec<BinaryName>,
23}
24
25/// Result of walking the registry with skip diagnostics.
26#[derive(Debug)]
27pub struct MatchOutcome<'a> {
28    /// First extension that matched argv and had all `requires` present.
29    pub matched: Option<super::ExtensionMatch<'a>>,
30    /// Spec matches skipped because required binaries were absent.
31    pub skipped: Vec<SkippedExtension>,
32}
33
34/// Why remainder argv did not match an extension (REQ-0136).
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum NearMissKind {
37    /// Path or token is not a known suffix, filename, or prefix.
38    UnknownInput {
39        /// Offending argv token.
40        token: String,
41    },
42    /// First prefix tokens matched; later prefix tokens are missing.
43    IncompletePrefix {
44        /// Extension that owns the full prefix.
45        extension_id: ExtensionId,
46        /// Remaining argv to type (for example `compose render`).
47        hint: String,
48    },
49    /// Full prefix matched; required suffix path is absent or wrong.
50    BarePrefix {
51        /// Extension that owns the prefix.
52        extension_id: ExtensionId,
53        /// Invocation line including the missing suffix placeholder.
54        usage: String,
55    },
56    /// Path would match, but every candidate was skipped for `requires`.
57    SkippedRequires {
58        /// Path token that would have matched.
59        path: String,
60        /// Skipped candidates and their missing binaries.
61        skipped: Vec<SkippedExtension>,
62    },
63}
64
65impl NearMissKind {
66    /// Process exit code for this near-miss (`2` parse / `4` validation).
67    #[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/// Classify a no-match remainder using the Phase G near-miss table.
79///
80/// Returns `None` for inline JSON (`{` / `[`) and `.json` file fallthrough so
81/// [`crate::load_command_input`] can run. Path-like unknown tokens become
82/// [`NearMissKind::UnknownInput`] instead of a JSON parse error.
83#[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
126/// Serialize a near-miss as the existing [`StderrError`] envelope.
127///
128/// # Errors
129///
130/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized.
131pub 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
300/// Bound how many skipped ids appear in the human/JSON summary.
301const 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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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}