Skip to main content

wyvern/extensions/
mod.rs

1//! CLI extension registry: load, merge, match, expand, and dispatch.
2//!
3//! Extensions are an argv preprocessor inside the `wyvern` crate (ADR-0022).
4//! They produce existing [`wyvern_schema::Command`] JSON only — no new host
5//! dialog types. Phase E `--interactive` reuses this module; MCP tools consume
6//! pre-expanded Command JSON.
7//!
8//! # Examples
9//!
10//! ```rust,no_run
11//! use std::path::Path;
12//! use wyvern::extensions::{build_match_context, expand_and_validate, ExtensionRegistry};
13//!
14//! let registry = ExtensionRegistry::load(Path::new("share/wyvern/extensions.json"), None)?;
15//! let argv = vec!["doc.md".to_string()];
16//! if let Some(matched) = registry.match_argv(&argv) {
17//!     let ctx = build_match_context(&matched, matched.extension());
18//!     let expanded = expand_and_validate(matched.extension(), &ctx)?;
19//!     assert_eq!(expanded.command["type"], "markdown");
20//! }
21//! # Ok::<(), wyvern::extensions::ExtensionError>(())
22//! ```
23
24mod catalog;
25mod diagnostics;
26mod expand;
27mod extends;
28mod ids;
29mod list;
30mod match_logic;
31mod preexec;
32mod share_resolve;
33
34use std::io::Read;
35use std::path::Path;
36
37use serde::Deserialize;
38use serde_json::Value;
39
40#[doc(inline)]
41pub use catalog::{
42    build_skill_record, build_skill_records, format_skill_card, skill_help_command, SkillArg,
43    SkillRecord, SkillRequire,
44};
45#[doc(inline)]
46pub use diagnostics::{
47    classify_near_miss, emit_near_miss, MatchOutcome, NearMissKind, SkippedExtension,
48};
49#[doc(inline)]
50pub use expand::{
51    build_match_context, expand_and_validate, expand_command_host, expand_preexec_args,
52    infer_wizard_root, last_created_tmpdir, relpath_from_ui_root, ExpandedInvocation,
53    HostOverrides, MatchContext,
54};
55#[doc(inline)]
56pub use ids::{ArgName, BinaryName, ExtensionId, ExtensionIdError, MatchToken};
57#[doc(inline)]
58pub use list::{
59    extensions_usage_message, format_extensions_list, run_extensions_command, ExtensionsCmdError,
60};
61#[doc(inline)]
62pub use match_logic::{
63    is_help_only_tokens, match_extension_help, match_kind_summary, ExtensionMatch,
64};
65#[doc(inline)]
66pub use preexec::{
67    binary_on_path, create_tmpdir, run_preexec, run_script, PathRequiresProbe, PreexecFailureKind,
68    RequiresProbe, ScriptError, ScriptOutput, ScriptRequest,
69};
70
71pub(crate) use match_logic::ends_with_suffix;
72
73/// Shipped defaults compiled into the binary (dev + `cargo install`).
74pub const SHIPPED_EXTENSIONS_JSON: &str = include_str!(concat!(
75    env!("CARGO_MANIFEST_DIR"),
76    "/share/wyvern/extensions.json"
77));
78
79/// Embedded `share/wyvern/**` assets (`extensions.json`, packaged UI extras).
80#[derive(rust_embed::RustEmbed)]
81#[folder = "share/wyvern/"]
82pub struct ShareAssets;
83
84/// Embedded `scripts/ext/**` preexec helpers.
85#[derive(rust_embed::RustEmbed)]
86#[folder = "scripts/ext/"]
87pub struct ScriptAssets;
88
89/// Merged, `extends`-resolved extension registry.
90#[derive(Debug, Clone)]
91pub struct ExtensionRegistry {
92    extensions: Vec<ExtensionDef>,
93}
94
95/// Whether a catalog entry came from shipped defaults or a project file.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
97#[serde(rename_all = "lowercase")]
98pub enum SkillSource {
99    /// Built-in `share/wyvern/extensions.json` (or compiled fallback).
100    #[default]
101    Shipped,
102    /// Project `.wyvern/extensions.json` (trusted preexec).
103    Project,
104}
105
106impl SkillSource {
107    /// Stable wire / help label (`shipped` or `project`).
108    #[must_use]
109    pub const fn as_str(self) -> &'static str {
110        match self {
111            Self::Shipped => "shipped",
112            Self::Project => "project",
113        }
114    }
115}
116
117impl std::fmt::Display for SkillSource {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.write_str(self.as_str())
120    }
121}
122
123/// One registry entry after merge and `extends` resolution.
124#[derive(Debug, Clone, Deserialize)]
125pub struct ExtensionDef {
126    /// Stable extension id (merge key). Must be non-empty after trim.
127    pub id: ExtensionId,
128    /// Argv match rule.
129    #[serde(rename = "match")]
130    pub match_spec: MatchSpec,
131    /// Optional parent id whose preexec/expand are reused.
132    #[serde(default)]
133    pub extends: Option<ExtensionId>,
134    /// One-line agent-facing summary (optional; recommended on shipped skills).
135    #[serde(default)]
136    pub description: Option<String>,
137    /// Copy-paste argv examples (optional; recommended on shipped skills).
138    #[serde(default)]
139    pub examples: Vec<String>,
140    /// Optional subprocess step before command expand.
141    #[serde(default)]
142    pub preexec: Option<PreexecSpec>,
143    /// Command + host template expansion.
144    #[serde(default)]
145    pub expand: Option<ExpandSpec>,
146    /// Load origin; not present in registry JSON (`shipped` by default).
147    #[serde(skip)]
148    pub source: SkillSource,
149}
150
151/// Match fields from the registry schema.
152#[derive(Debug, Clone, Default, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct MatchSpec {
155    /// Single positional ends with this suffix (`.md`).
156    #[serde(default)]
157    pub positional_suffix: Option<MatchToken>,
158    /// Exact basename match (`wizard.json`).
159    #[serde(default)]
160    pub filename: Option<MatchToken>,
161    /// First N argv tokens (`["compose", "render"]`).
162    #[serde(default)]
163    pub argv_prefix: Option<Vec<MatchToken>>,
164    /// Token after prefix matches this suffix.
165    #[serde(default)]
166    pub arg_suffix: Option<MatchToken>,
167}
168
169/// Stdout capture mode for [`PreexecSpec`].
170#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
171#[serde(rename_all = "lowercase")]
172pub enum StdoutCapture {
173    /// Capture stdout as markdown text and inject as `{preexec.stdout}`.
174    Markdown,
175}
176
177/// Preexec subprocess declaration.
178#[derive(Debug, Clone, Default, Deserialize)]
179#[serde(deny_unknown_fields)]
180pub struct PreexecSpec {
181    /// Executable name or path (phase-1 expanded).
182    #[serde(default)]
183    pub cmd: String,
184    /// Argv tokens (phase-1 expanded; `{arg:name:repeat}` splices).
185    #[serde(default)]
186    pub args: Vec<String>,
187    /// Binaries that must be on `PATH` or the extension does not match.
188    #[serde(default)]
189    pub requires: Vec<BinaryName>,
190    /// Stdout capture mode (`markdown` only in Phase F).
191    #[serde(default)]
192    pub stdout: Option<StdoutCapture>,
193}
194
195/// Expand templates for command JSON and host overrides.
196#[derive(Debug, Clone, Default, Deserialize)]
197#[serde(deny_unknown_fields)]
198pub struct ExpandSpec {
199    /// Inline Command JSON after phase-2 substitution.
200    #[serde(default)]
201    pub command: Option<Value>,
202    /// Load Command JSON from this path template.
203    #[serde(default)]
204    pub command_from_file: Option<String>,
205    /// Catalog hint for `expands_to` when `command_from_file` is a template.
206    #[serde(default)]
207    pub command_type: Option<String>,
208    /// Host overrides (`ui_root` only in Phase F).
209    #[serde(default)]
210    pub host: Option<HostExpandSpec>,
211}
212
213/// Host template object (`ui_root` only).
214#[derive(Debug, Clone, Default, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct HostExpandSpec {
217    /// Template for [`HostOverrides::ui_root`].
218    #[serde(default)]
219    pub ui_root: Option<String>,
220}
221
222/// Why [`ExtensionError::Template`] failed (RBP-F006).
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub enum TemplateErrorKind {
225    /// Template contained `{` without a matching `}`.
226    UnclosedBrace,
227    /// `{name}` is not a known template variable.
228    UnknownVariable,
229    /// Variable is valid only in the other expansion phase.
230    PhaseRestricted,
231    /// Variable is known but not available in this context.
232    Unavailable,
233    /// Expand/preexec spec is incomplete or contradictory.
234    InvalidSpec,
235}
236
237/// Structured extension-engine failure.
238#[derive(Debug)]
239pub enum ExtensionError {
240    /// Registry JSON or schema is invalid.
241    InvalidRegistry {
242        /// Human-readable load failure.
243        message: String,
244    },
245    /// One or more required `{arg:name}` flags were missing.
246    MissingArgs {
247        /// Missing flags including leading dashes (`--root`).
248        missing: Vec<String>,
249        /// All declared `{arg:*}` names (no dashes).
250        declared: std::collections::BTreeSet<String>,
251        /// Extension that required the flags.
252        extension_id: ExtensionId,
253        /// Copy-paste example from the skill card.
254        example: String,
255        /// Invocation-prefix help (`wyvern compose render --help`), not the id.
256        help_command: String,
257    },
258    /// Unexpected token after a successful prefix match.
259    UnexpectedArg {
260        /// Offending token.
261        token: String,
262        /// Declared `{arg:*}` names (no dashes).
263        declared: std::collections::BTreeSet<String>,
264        /// Extension that matched argv.
265        extension_id: ExtensionId,
266        /// Invocation-prefix help (`wyvern compose render --help`), not the id.
267        help_command: String,
268    },
269    /// Path-derived template used without a matched path.
270    PathVarWithoutPath {
271        /// Template variable name.
272        var: String,
273    },
274    /// Template expansion failure (see [`TemplateErrorKind`] for the sub-mode).
275    Template {
276        /// Discriminated substitution failure class.
277        kind: TemplateErrorKind,
278        /// Substitution failure detail.
279        message: String,
280    },
281    /// Preexec process failed or could not be spawned.
282    Preexec {
283        /// Spawn-not-found, nonzero-exit, or timeout (`None` for other spawn I/O).
284        kind: Option<PreexecFailureKind>,
285        /// Human-readable subprocess failure.
286        message: String,
287        /// Original error if available.
288        source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
289    },
290    /// Expanded command failed [`wyvern_schema::validate`].
291    InvalidCommand {
292        /// Schema validation error.
293        source: wyvern_schema::ValidationError,
294    },
295    /// Filesystem failure while loading or expanding.
296    Io {
297        /// Human-readable I/O error description.
298        message: String,
299        /// Original I/O error if available.
300        source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
301    },
302}
303
304impl std::fmt::Display for ExtensionError {
305    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306        match self {
307            Self::InvalidRegistry { message } => write!(f, "invalid extension registry: {message}"),
308            Self::MissingArgs { missing, .. } => {
309                write!(
310                    f,
311                    "missing required extension arguments {}",
312                    missing.join(", ")
313                )
314            }
315            Self::UnexpectedArg { token, .. } => {
316                write!(f, "unexpected argument after extension match: {token}")
317            }
318            Self::PathVarWithoutPath { var } => {
319                write!(f, "template {{{var}}} requires a matched file path")
320            }
321            Self::Template { message, .. } => write!(f, "extension template error: {message}"),
322            Self::Preexec { message, .. } => write!(f, "extension preexec failed: {message}"),
323            Self::InvalidCommand { source } => {
324                write!(f, "expanded command failed validation: {source}")
325            }
326            Self::Io { message, .. } => write!(f, "extension I/O error: {message}"),
327        }
328    }
329}
330
331impl std::error::Error for ExtensionError {
332    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
333        match self {
334            Self::InvalidCommand { source } => Some(source),
335            Self::Io { source, .. } => source.as_deref().map(|e| e as _),
336            Self::Preexec { source, .. } => source.as_deref().map(|e| e as _),
337            _ => None,
338        }
339    }
340}
341
342impl ExtensionError {
343    /// Stable process exit code for this failure.
344    #[must_use]
345    pub fn exit_code(&self) -> i32 {
346        match self {
347            Self::InvalidRegistry { .. } => wyvern_schema::ErrorCode::ParseError.exit_code(),
348            Self::Io { .. } | Self::Preexec { .. } => wyvern_schema::ErrorCode::IoError.exit_code(),
349            Self::InvalidCommand { source } => source.exit_code(),
350            Self::MissingArgs { .. }
351            | Self::UnexpectedArg { .. }
352            | Self::PathVarWithoutPath { .. }
353            | Self::Template { .. } => wyvern_schema::ErrorCode::ValidationError.exit_code(),
354        }
355    }
356
357    pub(crate) fn template(kind: TemplateErrorKind, message: impl Into<String>) -> Self {
358        Self::Template {
359            kind,
360            message: message.into(),
361        }
362    }
363}
364
365#[derive(Debug, Deserialize)]
366struct RegistryFile {
367    version: u32,
368    #[serde(default)]
369    extensions: Vec<ExtensionDef>,
370}
371
372impl ExtensionRegistry {
373    /// Load shipped defaults and an optional project registry, then merge.
374    ///
375    /// Later files override earlier entries by `id` (in-place). `extends` is
376    /// resolved after merge. User config (`~/.config/wyvern/extensions.json`)
377    /// is not loaded in Phase F.
378    ///
379    /// # Errors
380    ///
381    /// Returns [`ExtensionError::InvalidRegistry`] for unreadable or invalid JSON.
382    pub fn load(defaults: &Path, project: Option<&Path>) -> Result<Self, ExtensionError> {
383        let default_exts = if defaults.is_file() {
384            parse_registry_file(defaults)?
385        } else if std::env::var_os("WYVERN_SHARE").is_some() {
386            return Err(ExtensionError::InvalidRegistry {
387                message: format!(
388                    "WYVERN_SHARE is set but '{}' is missing or not a file",
389                    defaults.display()
390                ),
391            });
392        } else {
393            parse_registry_str(SHIPPED_EXTENSIONS_JSON, "shipped defaults")?
394        };
395        let project_exts = match project {
396            Some(path) if path.is_file() => {
397                let mut exts = parse_registry_file(path)?;
398                for ext in &mut exts {
399                    ext.source = SkillSource::Project;
400                }
401                exts
402            }
403            _ => Vec::new(),
404        };
405        let merged = merge_by_id(default_exts, project_exts);
406        let extensions = extends::apply_extends(merged)?;
407        Ok(Self { extensions })
408    }
409
410    /// Load shipped defaults plus `.wyvern/extensions.json` from the cwd.
411    ///
412    /// # Errors
413    ///
414    /// Returns [`ExtensionError::InvalidRegistry`] when a registry file is invalid.
415    pub fn load_default() -> Result<Self, ExtensionError> {
416        let defaults = resolve_wyvern_share().join("extensions.json");
417        let project = std::env::current_dir()
418            .ok()
419            .map(|cwd| cwd.join(".wyvern").join("extensions.json"));
420        let project = project.filter(|p| p.is_file());
421        Self::load(&defaults, project.as_deref())
422    }
423
424    /// Parse a registry from an in-memory JSON string (tests and shipped fallback).
425    ///
426    /// # Errors
427    ///
428    /// Returns [`ExtensionError::InvalidRegistry`] when `json` is not a v1 registry.
429    pub fn from_json_str(json: &str) -> Result<Self, ExtensionError> {
430        let extensions = extends::apply_extends(parse_registry_str(json, "memory")?)?;
431        Ok(Self { extensions })
432    }
433
434    /// Walk merged extensions in order; first match wins.
435    ///
436    /// After `extends` resolution, an extension whose `preexec.requires` binaries
437    /// are absent on `PATH` does not match (fallthrough).
438    #[must_use]
439    pub fn match_argv<'a>(&'a self, argv: &'a [String]) -> Option<ExtensionMatch<'a>> {
440        self.match_with_diagnostics(argv).matched
441    }
442
443    /// [`Self::match_argv`] with an injectable [`RequiresProbe`].
444    #[must_use]
445    pub fn match_argv_with<'a>(
446        &'a self,
447        argv: &'a [String],
448        probe: &dyn RequiresProbe,
449    ) -> Option<ExtensionMatch<'a>> {
450        self.match_with_diagnostics_with(argv, probe).matched
451    }
452
453    /// Match argv and record extensions skipped for missing `requires`.
454    #[must_use]
455    pub fn match_with_diagnostics<'a>(&'a self, argv: &'a [String]) -> MatchOutcome<'a> {
456        self.match_with_diagnostics_with(argv, &PathRequiresProbe)
457    }
458
459    /// [`Self::match_with_diagnostics`] with an injectable [`RequiresProbe`].
460    #[must_use]
461    pub fn match_with_diagnostics_with<'a>(
462        &'a self,
463        argv: &'a [String],
464        probe: &dyn RequiresProbe,
465    ) -> MatchOutcome<'a> {
466        let mut skipped = Vec::new();
467        for ext in &self.extensions {
468            let Some(candidate) = ext.match_spec_argv(argv) else {
469                continue;
470            };
471            let missing: Vec<BinaryName> = ext
472                .requires()
473                .iter()
474                .filter(|bin| !probe.binary_on_path(bin.as_str()))
475                .cloned()
476                .collect();
477            if missing.is_empty() {
478                return MatchOutcome {
479                    matched: Some(candidate),
480                    skipped,
481                };
482            }
483            skipped.push(SkippedExtension {
484                id: ext.id.clone(),
485                missing,
486            });
487        }
488        MatchOutcome {
489            matched: None,
490            skipped,
491        }
492    }
493
494    /// Merged extensions in match order.
495    #[must_use]
496    pub fn extensions(&self) -> &[ExtensionDef] {
497        &self.extensions
498    }
499}
500
501impl ExtensionDef {
502    /// Required binaries advertised for `extensions list` and match-time skip.
503    #[must_use]
504    pub fn requires(&self) -> &[BinaryName] {
505        self.preexec
506            .as_ref()
507            .map(|p| p.requires.as_slice())
508            .unwrap_or(&[])
509    }
510}
511
512fn parse_registry_file(path: &Path) -> Result<Vec<ExtensionDef>, ExtensionError> {
513    const MAX_REGISTRY_BYTES: usize = 1024 * 1024;
514    let file = std::fs::File::open(path).map_err(|err| ExtensionError::Io {
515        message: format!("could not read '{}': {err}", path.display()),
516        source: Some(Box::new(err)),
517    })?;
518    let mut buf = Vec::new();
519    let n = file
520        .take(MAX_REGISTRY_BYTES as u64 + 1)
521        .read_to_end(&mut buf)
522        .map_err(|err| ExtensionError::Io {
523            message: format!("could not read '{}': {err}", path.display()),
524            source: Some(Box::new(err)),
525        })?;
526    if n > MAX_REGISTRY_BYTES {
527        return Err(ExtensionError::InvalidRegistry {
528            message: format!(
529                "registry file '{}' exceeds maximum of {MAX_REGISTRY_BYTES} bytes",
530                path.display()
531            ),
532        });
533    }
534    let text = String::from_utf8(buf).map_err(|err| ExtensionError::InvalidRegistry {
535        message: format!(
536            "registry file '{}' is not valid UTF-8: {err}",
537            path.display()
538        ),
539    })?;
540    parse_registry_str(&text, &path.display().to_string())
541}
542
543fn parse_registry_str(text: &str, origin: &str) -> Result<Vec<ExtensionDef>, ExtensionError> {
544    let file: RegistryFile =
545        serde_json::from_str(text).map_err(|err| ExtensionError::InvalidRegistry {
546            message: format!("invalid JSON in {origin}: {err}"),
547        })?;
548    if file.version != 1 {
549        return Err(ExtensionError::InvalidRegistry {
550            message: format!(
551                "unsupported registry version {} in {origin} (expected 1)",
552                file.version
553            ),
554        });
555    }
556    for ext in &file.extensions {
557        if !has_match_field(&ext.match_spec) && ext.extends.is_none() {
558            return Err(ExtensionError::InvalidRegistry {
559                message: format!("extension '{}' in {origin} has no match fields", ext.id),
560            });
561        }
562    }
563    Ok(file.extensions)
564}
565
566fn has_match_field(spec: &MatchSpec) -> bool {
567    spec.positional_suffix.is_some()
568        || spec.filename.is_some()
569        || spec.argv_prefix.as_ref().is_some_and(|p| !p.is_empty())
570        || spec.arg_suffix.is_some()
571}
572
573fn merge_by_id(mut defaults: Vec<ExtensionDef>, project: Vec<ExtensionDef>) -> Vec<ExtensionDef> {
574    for ext in project {
575        if let Some(index) = defaults.iter().position(|existing| existing.id == ext.id) {
576            defaults[index] = ext;
577        } else {
578            defaults.push(ext);
579        }
580    }
581    defaults
582}
583
584#[doc(inline)]
585pub use share_resolve::{find_workspace_root, resolve_wyvern_share, resolve_wyvern_share_with};
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    struct LocalAbsentProbe;
592
593    impl RequiresProbe for LocalAbsentProbe {
594        fn binary_on_path(&self, _name: &str) -> bool {
595            false
596        }
597    }
598
599    #[test]
600    fn shipped_markdown_suffix_matches_md_path() {
601        let registry = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON).expect("shipped");
602        let argv = vec!["docs/readme.md".to_string()];
603        let matched = registry.match_argv(&argv).expect("match");
604        assert_eq!(matched.extension().id.as_str(), "markdown-suffix");
605        assert_eq!(matched.path(), Some("docs/readme.md"));
606    }
607
608    #[test]
609    fn unknown_suffix_does_not_match() {
610        let registry = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON).expect("shipped");
611        let argv = vec!["notes.txt".to_string()];
612        assert!(registry.match_argv(&argv).is_none());
613    }
614
615    #[test]
616    fn invalid_registry_json_is_structured_error() {
617        let err = ExtensionRegistry::from_json_str("{not-json").expect_err("invalid");
618        assert!(matches!(err, ExtensionError::InvalidRegistry { .. }));
619        assert_eq!(err.exit_code(), 2);
620    }
621
622    #[test]
623    fn project_override_replaces_same_id() {
624        let dir = tempfile::tempdir().expect("tmp");
625        let defaults = dir.path().join("defaults.json");
626        std::fs::write(&defaults, SHIPPED_EXTENSIONS_JSON).expect("write");
627        let project = dir.path().join("project.json");
628        std::fs::write(
629            &project,
630            r#"{
631              "version": 1,
632              "extensions": [
633                {
634                  "id": "markdown-suffix",
635                  "match": { "positional_suffix": ".markdown" },
636                  "expand": { "command": { "type": "markdown", "file": "{path}" } }
637                }
638              ]
639            }"#,
640        )
641        .expect("write project");
642        let registry = ExtensionRegistry::load(&defaults, Some(&project)).expect("load");
643        let overridden = registry
644            .extensions()
645            .iter()
646            .find(|ext| ext.id.as_str() == "markdown-suffix")
647            .expect("markdown-suffix");
648        assert_eq!(overridden.source, SkillSource::Project);
649        let shipped_len = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON)
650            .expect("shipped")
651            .extensions()
652            .len();
653        assert_eq!(
654            registry.extensions().len(),
655            shipped_len,
656            "project override must replace same id in-place, not append"
657        );
658        let markdown = registry
659            .extensions()
660            .iter()
661            .find(|ext| ext.id.as_str() == "markdown-suffix")
662            .expect("markdown-suffix");
663        assert_eq!(
664            markdown
665                .match_spec
666                .positional_suffix
667                .as_ref()
668                .map(MatchToken::as_str),
669            Some(".markdown")
670        );
671    }
672
673    #[test]
674    fn match_with_diagnostics_records_skipped_requires() {
675        let json = r#"{
676          "version": 1,
677          "extensions": [
678            {
679              "id": "needs-tool",
680              "match": { "positional_suffix": ".csv" },
681              "preexec": { "cmd": "python3", "requires": ["python3"] },
682              "expand": { "command": { "type": "markdown", "content": "x" } }
683            }
684          ]
685        }"#;
686        let registry = ExtensionRegistry::from_json_str(json).expect("parse");
687        let argv = vec!["sample.csv".into()];
688        let outcome = registry.match_with_diagnostics_with(&argv, &LocalAbsentProbe);
689        assert!(outcome.matched.is_none());
690        assert_eq!(outcome.skipped.len(), 1);
691        assert_eq!(outcome.skipped[0].id, "needs-tool");
692        assert_eq!(outcome.skipped[0].missing, ["python3"]);
693        assert!(registry.match_argv_with(&argv, &LocalAbsentProbe).is_none());
694    }
695
696    #[test]
697    fn requires_absent_skips_match() {
698        let json = r#"{
699          "version": 1,
700          "extensions": [
701            {
702              "id": "needs-tool",
703              "match": { "argv_prefix": ["compose", "render"] },
704              "preexec": { "cmd": "sc-compose", "requires": ["sc-compose"] },
705              "expand": { "command": { "type": "markdown", "content": "x" } }
706            }
707          ]
708        }"#;
709        let registry = ExtensionRegistry::from_json_str(json).expect("parse");
710        let argv = vec![
711            "compose".into(),
712            "render".into(),
713            "--root".into(),
714            "r".into(),
715        ];
716        assert!(registry.match_argv_with(&argv, &LocalAbsentProbe).is_none());
717        assert_eq!(
718            registry.extensions()[0]
719                .requires()
720                .iter()
721                .map(BinaryName::as_str)
722                .collect::<Vec<_>>(),
723            ["sc-compose"]
724        );
725    }
726
727    #[test]
728    fn extends_reuses_parent_expand() {
729        let json = r#"{
730          "version": 1,
731          "extensions": [
732            {
733              "id": "parent",
734              "match": { "positional_suffix": ".csv" },
735              "expand": { "command": { "type": "markdown", "file": "{path}" } }
736            },
737            {
738              "id": "child",
739              "extends": "parent",
740              "match": { "argv_prefix": ["md"], "arg_suffix": ".csv" }
741            }
742          ]
743        }"#;
744        let registry = ExtensionRegistry::from_json_str(json).expect("parse");
745        let child = registry
746            .extensions()
747            .iter()
748            .find(|e| e.id.as_str() == "child")
749            .expect("child");
750        assert!(child.expand.is_some());
751        let argv = vec!["md".into(), "report.csv".into()];
752        let matched = registry.match_argv(&argv).expect("match");
753        assert!(matches!(matched, ExtensionMatch::PrefixSuffix { .. }));
754        assert_eq!(matched.path(), Some("report.csv"));
755    }
756
757    #[test]
758    fn empty_extension_id_is_invalid() {
759        let json = r#"{
760          "version": 1,
761          "extensions": [{
762            "id": "   ",
763            "match": { "positional_suffix": ".md" },
764            "expand": { "command": { "type": "markdown", "file": "{path}" } }
765          }]
766        }"#;
767        let err = ExtensionRegistry::from_json_str(json).expect_err("empty id");
768        assert!(matches!(err, ExtensionError::InvalidRegistry { .. }));
769        assert!(ExtensionId::try_from(String::from("   ")).is_err());
770        assert_eq!(
771            ExtensionId::try_from(String::from("markdown-suffix"))
772                .expect("valid")
773                .as_str(),
774            "markdown-suffix"
775        );
776    }
777
778    #[test]
779    fn arg_name_rejects_empty() {
780        assert!(ArgName::new("").is_none());
781        assert!(ArgName::new("   ").is_none());
782        assert!(ArgName::try_from(String::from("   ")).is_err());
783        assert_eq!(ArgName::new("root").expect("valid").as_str(), "root");
784    }
785
786    #[test]
787    fn binary_name_rejects_empty_and_path() {
788        assert!(BinaryName::try_from(String::from("   ")).is_err());
789        assert!(BinaryName::try_from(String::from("bin/foo")).is_err());
790        assert!(BinaryName::try_from(String::from("bin\\foo")).is_err());
791        assert_eq!(
792            BinaryName::try_from(String::from("sc-compose"))
793                .expect("valid")
794                .as_str(),
795            "sc-compose"
796        );
797    }
798
799    #[test]
800    fn ends_with_suffix_does_not_panic_on_multibyte_token() {
801        assert!(ends_with_suffix("café.md", ".md"));
802        assert!(ends_with_suffix("café.MD", ".md"));
803        assert!(ends_with_suffix("ファイル.md", ".md"));
804        // "xé" is 3 bytes; a 2-byte suffix would split `é` under byte slicing.
805        assert!(!ends_with_suffix("xé", "xx"));
806        assert!(!ends_with_suffix("é", ".md"));
807        assert!(!ends_with_suffix("ab", ".md"));
808    }
809
810    #[test]
811    fn preexec_requires_rejects_empty_and_path() {
812        let empty = r#"{
813          "version": 1,
814          "extensions": [{
815            "id": "bad-empty",
816            "match": { "positional_suffix": ".md" },
817            "preexec": { "cmd": "true", "requires": ["  "] },
818            "expand": { "command": { "type": "markdown", "file": "{path}" } }
819          }]
820        }"#;
821        let err = ExtensionRegistry::from_json_str(empty).expect_err("empty requires");
822        assert!(
823            matches!(err, ExtensionError::InvalidRegistry { .. }),
824            "{err}"
825        );
826        assert!(
827            format!("{err}").contains("in memory"),
828            "empty-requires error must include origin: {err}"
829        );
830
831        let pathish = r#"{
832          "version": 1,
833          "extensions": [{
834            "id": "bad-path",
835            "match": { "positional_suffix": ".md" },
836            "preexec": { "cmd": "true", "requires": ["bin/foo"] },
837            "expand": { "command": { "type": "markdown", "file": "{path}" } }
838          }]
839        }"#;
840        let err = ExtensionRegistry::from_json_str(pathish).expect_err("path requires");
841        assert!(
842            matches!(err, ExtensionError::InvalidRegistry { .. }),
843            "{err}"
844        );
845        assert!(
846            format!("{err}").contains("in memory"),
847            "path-requires error must include origin: {err}"
848        );
849    }
850
851    #[test]
852    fn inherit_from_requires_only_keeps_parent_cmd() {
853        let json = r#"{
854          "version": 1,
855          "extensions": [
856            {
857              "id": "parent",
858              "match": { "positional_suffix": ".csv" },
859              "preexec": {
860                "cmd": "python3",
861                "args": ["--parent-flag"],
862                "requires": ["python3"]
863              },
864              "expand": { "command": { "type": "markdown", "file": "{path}" } }
865            },
866            {
867              "id": "child",
868              "extends": "parent",
869              "match": { "argv_prefix": ["md"], "arg_suffix": ".csv" },
870              "preexec": { "requires": ["python3"] }
871            }
872          ]
873        }"#;
874        let registry = ExtensionRegistry::from_json_str(json).expect("parse");
875        let child = registry
876            .extensions()
877            .iter()
878            .find(|e| e.id.as_str() == "child")
879            .expect("child");
880        let pre = child.preexec.as_ref().expect("inherited preexec");
881        assert_eq!(pre.cmd, "python3");
882        assert_eq!(pre.args, vec!["--parent-flag"]);
883        assert_eq!(pre.requires.len(), 1);
884        assert_eq!(pre.requires[0].as_str(), "python3");
885    }
886
887    #[test]
888    fn unknown_host_viewer_field_fails_load() {
889        let json = r#"{
890          "version": 1,
891          "extensions": [{
892            "id": "bad-host",
893            "match": { "positional_suffix": ".md" },
894            "expand": {
895              "command": { "type": "markdown", "file": "{path}" },
896              "host": { "ui_root": ".", "viewer": "none" }
897            }
898          }]
899        }"#;
900        let err = ExtensionRegistry::from_json_str(json).expect_err("viewer");
901        assert!(
902            matches!(err, ExtensionError::InvalidRegistry { .. }),
903            "{err}"
904        );
905        assert!(
906            format!("{err}").contains("viewer") || format!("{err}").contains("unknown"),
907            "{err}"
908        );
909    }
910
911    #[test]
912    fn help_only_tokens_require_help_flags() {
913        assert!(is_help_only_tokens(&["--help".into()]));
914        assert!(is_help_only_tokens(&["-h".into()]));
915        assert!(is_help_only_tokens(&["--help".into(), "-h".into()]));
916        assert!(!is_help_only_tokens(&[]));
917        assert!(!is_help_only_tokens(&["--help".into(), "data.csv".into()]));
918        assert!(!is_help_only_tokens(&["data.csv".into()]));
919    }
920
921    #[test]
922    fn match_extension_help_ignores_requires_and_suffix() {
923        let registry = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON).expect("shipped");
924        let compose = vec!["compose".into(), "render".into(), "--help".into()];
925        let ext = match_extension_help(&registry, &compose).expect("compose help");
926        assert_eq!(ext.id.as_str(), "compose-render");
927        assert!(registry
928            .match_argv_with(&compose, &LocalAbsentProbe)
929            .is_none());
930
931        let md = vec!["md".into(), "-h".into()];
932        let ext = match_extension_help(&registry, &md).expect("md help");
933        assert_eq!(ext.id.as_str(), "csv-md");
934
935        let table = vec!["table".into(), "--help".into()];
936        let ext = match_extension_help(&registry, &table).expect("table help");
937        assert_eq!(ext.id.as_str(), "csv-table-alias");
938
939        let incomplete = vec!["compose".into(), "--help".into()];
940        assert!(match_extension_help(&registry, &incomplete).is_none());
941        let suffix = vec!["doc.md".into(), "--help".into()];
942        let ext = match_extension_help(&registry, &suffix).expect("suffix help");
943        assert_eq!(ext.id.as_str(), "markdown-suffix");
944        let filename = vec!["path/to/wizard.json".into(), "--help".into()];
945        let ext = match_extension_help(&registry, &filename).expect("filename help");
946        assert_eq!(ext.id.as_str(), "wizard-json-suffix");
947    }
948}