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 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
301/// Bound how many skipped ids appear in the human/JSON summary.
302const 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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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}