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, PathRequiresProbe, PreexecFailureKind,
68    RequiresProbe,
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    /// Host overrides (`ui_root` only in Phase F).
206    #[serde(default)]
207    pub host: Option<HostExpandSpec>,
208}
209
210/// Host template object (`ui_root` only).
211#[derive(Debug, Clone, Default, Deserialize)]
212#[serde(deny_unknown_fields)]
213pub struct HostExpandSpec {
214    /// Template for [`HostOverrides::ui_root`].
215    #[serde(default)]
216    pub ui_root: Option<String>,
217}
218
219/// Why [`ExtensionError::Template`] failed (RBP-F006).
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub enum TemplateErrorKind {
222    /// Template contained `{` without a matching `}`.
223    UnclosedBrace,
224    /// `{name}` is not a known template variable.
225    UnknownVariable,
226    /// Variable is valid only in the other expansion phase.
227    PhaseRestricted,
228    /// Variable is known but not available in this context.
229    Unavailable,
230    /// Expand/preexec spec is incomplete or contradictory.
231    InvalidSpec,
232}
233
234/// Structured extension-engine failure.
235#[derive(Debug)]
236pub enum ExtensionError {
237    /// Registry JSON or schema is invalid.
238    InvalidRegistry {
239        /// Human-readable load failure.
240        message: String,
241    },
242    /// One or more required `{arg:name}` flags were missing.
243    MissingArgs {
244        /// Missing flags including leading dashes (`--root`).
245        missing: Vec<String>,
246        /// All declared `{arg:*}` names (no dashes).
247        declared: std::collections::BTreeSet<String>,
248        /// Extension that required the flags.
249        extension_id: ExtensionId,
250        /// Copy-paste example from the skill card.
251        example: String,
252        /// Invocation-prefix help (`wyvern compose render --help`), not the id.
253        help_command: String,
254    },
255    /// Unexpected token after a successful prefix match.
256    UnexpectedArg {
257        /// Offending token.
258        token: String,
259        /// Declared `{arg:*}` names (no dashes).
260        declared: std::collections::BTreeSet<String>,
261        /// Extension that matched argv.
262        extension_id: ExtensionId,
263        /// Invocation-prefix help (`wyvern compose render --help`), not the id.
264        help_command: String,
265    },
266    /// Path-derived template used without a matched path.
267    PathVarWithoutPath {
268        /// Template variable name.
269        var: String,
270    },
271    /// Template expansion failure (see [`TemplateErrorKind`] for the sub-mode).
272    Template {
273        /// Discriminated substitution failure class.
274        kind: TemplateErrorKind,
275        /// Substitution failure detail.
276        message: String,
277    },
278    /// Preexec process failed or could not be spawned.
279    Preexec {
280        /// Spawn-not-found, nonzero-exit, or timeout (`None` for other spawn I/O).
281        kind: Option<PreexecFailureKind>,
282        /// Human-readable subprocess failure.
283        message: String,
284        /// Original error if available.
285        source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
286    },
287    /// Expanded command failed [`wyvern_schema::validate`].
288    InvalidCommand {
289        /// Schema validation error.
290        source: wyvern_schema::ValidationError,
291    },
292    /// Filesystem failure while loading or expanding.
293    Io {
294        /// Human-readable I/O error description.
295        message: String,
296        /// Original I/O error if available.
297        source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
298    },
299}
300
301impl std::fmt::Display for ExtensionError {
302    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
303        match self {
304            Self::InvalidRegistry { message } => write!(f, "invalid extension registry: {message}"),
305            Self::MissingArgs { missing, .. } => {
306                write!(
307                    f,
308                    "missing required extension arguments {}",
309                    missing.join(", ")
310                )
311            }
312            Self::UnexpectedArg { token, .. } => {
313                write!(f, "unexpected argument after extension match: {token}")
314            }
315            Self::PathVarWithoutPath { var } => {
316                write!(f, "template {{{var}}} requires a matched file path")
317            }
318            Self::Template { message, .. } => write!(f, "extension template error: {message}"),
319            Self::Preexec { message, .. } => write!(f, "extension preexec failed: {message}"),
320            Self::InvalidCommand { source } => {
321                write!(f, "expanded command failed validation: {source}")
322            }
323            Self::Io { message, .. } => write!(f, "extension I/O error: {message}"),
324        }
325    }
326}
327
328impl std::error::Error for ExtensionError {
329    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
330        match self {
331            Self::InvalidCommand { source } => Some(source),
332            Self::Io { source, .. } => source.as_deref().map(|e| e as _),
333            Self::Preexec { source, .. } => source.as_deref().map(|e| e as _),
334            _ => None,
335        }
336    }
337}
338
339impl ExtensionError {
340    /// Stable process exit code for this failure.
341    #[must_use]
342    pub fn exit_code(&self) -> i32 {
343        match self {
344            Self::InvalidRegistry { .. } => wyvern_schema::ErrorCode::ParseError.exit_code(),
345            Self::Io { .. } | Self::Preexec { .. } => wyvern_schema::ErrorCode::IoError.exit_code(),
346            Self::InvalidCommand { source } => source.exit_code(),
347            Self::MissingArgs { .. }
348            | Self::UnexpectedArg { .. }
349            | Self::PathVarWithoutPath { .. }
350            | Self::Template { .. } => wyvern_schema::ErrorCode::ValidationError.exit_code(),
351        }
352    }
353
354    pub(crate) fn template(kind: TemplateErrorKind, message: impl Into<String>) -> Self {
355        Self::Template {
356            kind,
357            message: message.into(),
358        }
359    }
360}
361
362#[derive(Debug, Deserialize)]
363struct RegistryFile {
364    version: u32,
365    #[serde(default)]
366    extensions: Vec<ExtensionDef>,
367}
368
369impl ExtensionRegistry {
370    /// Load shipped defaults and an optional project registry, then merge.
371    ///
372    /// Later files override earlier entries by `id` (in-place). `extends` is
373    /// resolved after merge. User config (`~/.config/wyvern/extensions.json`)
374    /// is not loaded in Phase F.
375    ///
376    /// # Errors
377    ///
378    /// Returns [`ExtensionError::InvalidRegistry`] for unreadable or invalid JSON.
379    pub fn load(defaults: &Path, project: Option<&Path>) -> Result<Self, ExtensionError> {
380        let default_exts = if defaults.is_file() {
381            parse_registry_file(defaults)?
382        } else if std::env::var_os("WYVERN_SHARE").is_some() {
383            return Err(ExtensionError::InvalidRegistry {
384                message: format!(
385                    "WYVERN_SHARE is set but '{}' is missing or not a file",
386                    defaults.display()
387                ),
388            });
389        } else {
390            parse_registry_str(SHIPPED_EXTENSIONS_JSON, "shipped defaults")?
391        };
392        let project_exts = match project {
393            Some(path) if path.is_file() => {
394                let mut exts = parse_registry_file(path)?;
395                for ext in &mut exts {
396                    ext.source = SkillSource::Project;
397                }
398                exts
399            }
400            _ => Vec::new(),
401        };
402        let merged = merge_by_id(default_exts, project_exts);
403        let extensions = extends::apply_extends(merged)?;
404        Ok(Self { extensions })
405    }
406
407    /// Load shipped defaults plus `.wyvern/extensions.json` from the cwd.
408    ///
409    /// # Errors
410    ///
411    /// Returns [`ExtensionError::InvalidRegistry`] when a registry file is invalid.
412    pub fn load_default() -> Result<Self, ExtensionError> {
413        let defaults = resolve_wyvern_share().join("extensions.json");
414        let project = std::env::current_dir()
415            .ok()
416            .map(|cwd| cwd.join(".wyvern").join("extensions.json"));
417        let project = project.filter(|p| p.is_file());
418        Self::load(&defaults, project.as_deref())
419    }
420
421    /// Parse a registry from an in-memory JSON string (tests and shipped fallback).
422    ///
423    /// # Errors
424    ///
425    /// Returns [`ExtensionError::InvalidRegistry`] when `json` is not a v1 registry.
426    pub fn from_json_str(json: &str) -> Result<Self, ExtensionError> {
427        let extensions = extends::apply_extends(parse_registry_str(json, "memory")?)?;
428        Ok(Self { extensions })
429    }
430
431    /// Walk merged extensions in order; first match wins.
432    ///
433    /// After `extends` resolution, an extension whose `preexec.requires` binaries
434    /// are absent on `PATH` does not match (fallthrough).
435    #[must_use]
436    pub fn match_argv<'a>(&'a self, argv: &'a [String]) -> Option<ExtensionMatch<'a>> {
437        self.match_with_diagnostics(argv).matched
438    }
439
440    /// [`Self::match_argv`] with an injectable [`RequiresProbe`].
441    #[must_use]
442    pub fn match_argv_with<'a>(
443        &'a self,
444        argv: &'a [String],
445        probe: &dyn RequiresProbe,
446    ) -> Option<ExtensionMatch<'a>> {
447        self.match_with_diagnostics_with(argv, probe).matched
448    }
449
450    /// Match argv and record extensions skipped for missing `requires`.
451    #[must_use]
452    pub fn match_with_diagnostics<'a>(&'a self, argv: &'a [String]) -> MatchOutcome<'a> {
453        self.match_with_diagnostics_with(argv, &PathRequiresProbe)
454    }
455
456    /// [`Self::match_with_diagnostics`] with an injectable [`RequiresProbe`].
457    #[must_use]
458    pub fn match_with_diagnostics_with<'a>(
459        &'a self,
460        argv: &'a [String],
461        probe: &dyn RequiresProbe,
462    ) -> MatchOutcome<'a> {
463        let mut skipped = Vec::new();
464        for ext in &self.extensions {
465            let Some(candidate) = ext.match_spec_argv(argv) else {
466                continue;
467            };
468            let missing: Vec<BinaryName> = ext
469                .requires()
470                .iter()
471                .filter(|bin| !probe.binary_on_path(bin.as_str()))
472                .cloned()
473                .collect();
474            if missing.is_empty() {
475                return MatchOutcome {
476                    matched: Some(candidate),
477                    skipped,
478                };
479            }
480            skipped.push(SkippedExtension {
481                id: ext.id.clone(),
482                missing,
483            });
484        }
485        MatchOutcome {
486            matched: None,
487            skipped,
488        }
489    }
490
491    /// Merged extensions in match order.
492    #[must_use]
493    pub fn extensions(&self) -> &[ExtensionDef] {
494        &self.extensions
495    }
496}
497
498impl ExtensionDef {
499    /// Required binaries advertised for `extensions list` and match-time skip.
500    #[must_use]
501    pub fn requires(&self) -> &[BinaryName] {
502        self.preexec
503            .as_ref()
504            .map(|p| p.requires.as_slice())
505            .unwrap_or(&[])
506    }
507}
508
509fn parse_registry_file(path: &Path) -> Result<Vec<ExtensionDef>, ExtensionError> {
510    const MAX_REGISTRY_BYTES: usize = 1024 * 1024;
511    let file = std::fs::File::open(path).map_err(|err| ExtensionError::Io {
512        message: format!("could not read '{}': {err}", path.display()),
513        source: Some(Box::new(err)),
514    })?;
515    let mut buf = Vec::new();
516    let n = file
517        .take(MAX_REGISTRY_BYTES as u64 + 1)
518        .read_to_end(&mut buf)
519        .map_err(|err| ExtensionError::Io {
520            message: format!("could not read '{}': {err}", path.display()),
521            source: Some(Box::new(err)),
522        })?;
523    if n > MAX_REGISTRY_BYTES {
524        return Err(ExtensionError::InvalidRegistry {
525            message: format!(
526                "registry file '{}' exceeds maximum of {MAX_REGISTRY_BYTES} bytes",
527                path.display()
528            ),
529        });
530    }
531    let text = String::from_utf8(buf).map_err(|err| ExtensionError::InvalidRegistry {
532        message: format!(
533            "registry file '{}' is not valid UTF-8: {err}",
534            path.display()
535        ),
536    })?;
537    parse_registry_str(&text, &path.display().to_string())
538}
539
540fn parse_registry_str(text: &str, origin: &str) -> Result<Vec<ExtensionDef>, ExtensionError> {
541    let file: RegistryFile =
542        serde_json::from_str(text).map_err(|err| ExtensionError::InvalidRegistry {
543            message: format!("invalid JSON in {origin}: {err}"),
544        })?;
545    if file.version != 1 {
546        return Err(ExtensionError::InvalidRegistry {
547            message: format!(
548                "unsupported registry version {} in {origin} (expected 1)",
549                file.version
550            ),
551        });
552    }
553    for ext in &file.extensions {
554        if !has_match_field(&ext.match_spec) && ext.extends.is_none() {
555            return Err(ExtensionError::InvalidRegistry {
556                message: format!("extension '{}' in {origin} has no match fields", ext.id),
557            });
558        }
559    }
560    Ok(file.extensions)
561}
562
563fn has_match_field(spec: &MatchSpec) -> bool {
564    spec.positional_suffix.is_some()
565        || spec.filename.is_some()
566        || spec.argv_prefix.as_ref().is_some_and(|p| !p.is_empty())
567        || spec.arg_suffix.is_some()
568}
569
570fn merge_by_id(mut defaults: Vec<ExtensionDef>, project: Vec<ExtensionDef>) -> Vec<ExtensionDef> {
571    for ext in project {
572        if let Some(index) = defaults.iter().position(|existing| existing.id == ext.id) {
573            defaults[index] = ext;
574        } else {
575            defaults.push(ext);
576        }
577    }
578    defaults
579}
580
581#[doc(inline)]
582pub use share_resolve::{find_workspace_root, resolve_wyvern_share, resolve_wyvern_share_with};
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587
588    struct LocalAbsentProbe;
589
590    impl RequiresProbe for LocalAbsentProbe {
591        fn binary_on_path(&self, _name: &str) -> bool {
592            false
593        }
594    }
595
596    #[test]
597    fn shipped_markdown_suffix_matches_md_path() {
598        let registry = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON).expect("shipped");
599        let argv = vec!["docs/readme.md".to_string()];
600        let matched = registry.match_argv(&argv).expect("match");
601        assert_eq!(matched.extension().id.as_str(), "markdown-suffix");
602        assert_eq!(matched.path(), Some("docs/readme.md"));
603    }
604
605    #[test]
606    fn unknown_suffix_does_not_match() {
607        let registry = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON).expect("shipped");
608        let argv = vec!["notes.txt".to_string()];
609        assert!(registry.match_argv(&argv).is_none());
610    }
611
612    #[test]
613    fn invalid_registry_json_is_structured_error() {
614        let err = ExtensionRegistry::from_json_str("{not-json").expect_err("invalid");
615        assert!(matches!(err, ExtensionError::InvalidRegistry { .. }));
616        assert_eq!(err.exit_code(), 2);
617    }
618
619    #[test]
620    fn project_override_replaces_same_id() {
621        let dir = tempfile::tempdir().expect("tmp");
622        let defaults = dir.path().join("defaults.json");
623        std::fs::write(&defaults, SHIPPED_EXTENSIONS_JSON).expect("write");
624        let project = dir.path().join("project.json");
625        std::fs::write(
626            &project,
627            r#"{
628              "version": 1,
629              "extensions": [
630                {
631                  "id": "markdown-suffix",
632                  "match": { "positional_suffix": ".markdown" },
633                  "expand": { "command": { "type": "markdown", "file": "{path}" } }
634                }
635              ]
636            }"#,
637        )
638        .expect("write project");
639        let registry = ExtensionRegistry::load(&defaults, Some(&project)).expect("load");
640        let overridden = registry
641            .extensions()
642            .iter()
643            .find(|ext| ext.id.as_str() == "markdown-suffix")
644            .expect("markdown-suffix");
645        assert_eq!(overridden.source, SkillSource::Project);
646        let shipped_len = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON)
647            .expect("shipped")
648            .extensions()
649            .len();
650        assert_eq!(
651            registry.extensions().len(),
652            shipped_len,
653            "project override must replace same id in-place, not append"
654        );
655        let markdown = registry
656            .extensions()
657            .iter()
658            .find(|ext| ext.id.as_str() == "markdown-suffix")
659            .expect("markdown-suffix");
660        assert_eq!(
661            markdown
662                .match_spec
663                .positional_suffix
664                .as_ref()
665                .map(MatchToken::as_str),
666            Some(".markdown")
667        );
668    }
669
670    #[test]
671    fn match_with_diagnostics_records_skipped_requires() {
672        let json = r#"{
673          "version": 1,
674          "extensions": [
675            {
676              "id": "needs-tool",
677              "match": { "positional_suffix": ".csv" },
678              "preexec": { "cmd": "python3", "requires": ["python3"] },
679              "expand": { "command": { "type": "markdown", "content": "x" } }
680            }
681          ]
682        }"#;
683        let registry = ExtensionRegistry::from_json_str(json).expect("parse");
684        let argv = vec!["sample.csv".into()];
685        let outcome = registry.match_with_diagnostics_with(&argv, &LocalAbsentProbe);
686        assert!(outcome.matched.is_none());
687        assert_eq!(outcome.skipped.len(), 1);
688        assert_eq!(outcome.skipped[0].id, "needs-tool");
689        assert_eq!(outcome.skipped[0].missing, ["python3"]);
690        assert!(registry.match_argv_with(&argv, &LocalAbsentProbe).is_none());
691    }
692
693    #[test]
694    fn requires_absent_skips_match() {
695        let json = r#"{
696          "version": 1,
697          "extensions": [
698            {
699              "id": "needs-tool",
700              "match": { "argv_prefix": ["compose", "render"] },
701              "preexec": { "cmd": "sc-compose", "requires": ["sc-compose"] },
702              "expand": { "command": { "type": "markdown", "content": "x" } }
703            }
704          ]
705        }"#;
706        let registry = ExtensionRegistry::from_json_str(json).expect("parse");
707        let argv = vec![
708            "compose".into(),
709            "render".into(),
710            "--root".into(),
711            "r".into(),
712        ];
713        assert!(registry.match_argv_with(&argv, &LocalAbsentProbe).is_none());
714        assert_eq!(
715            registry.extensions()[0]
716                .requires()
717                .iter()
718                .map(BinaryName::as_str)
719                .collect::<Vec<_>>(),
720            ["sc-compose"]
721        );
722    }
723
724    #[test]
725    fn extends_reuses_parent_expand() {
726        let json = r#"{
727          "version": 1,
728          "extensions": [
729            {
730              "id": "parent",
731              "match": { "positional_suffix": ".csv" },
732              "expand": { "command": { "type": "markdown", "file": "{path}" } }
733            },
734            {
735              "id": "child",
736              "extends": "parent",
737              "match": { "argv_prefix": ["md"], "arg_suffix": ".csv" }
738            }
739          ]
740        }"#;
741        let registry = ExtensionRegistry::from_json_str(json).expect("parse");
742        let child = registry
743            .extensions()
744            .iter()
745            .find(|e| e.id.as_str() == "child")
746            .expect("child");
747        assert!(child.expand.is_some());
748        let argv = vec!["md".into(), "report.csv".into()];
749        let matched = registry.match_argv(&argv).expect("match");
750        assert!(matches!(matched, ExtensionMatch::PrefixSuffix { .. }));
751        assert_eq!(matched.path(), Some("report.csv"));
752    }
753
754    #[test]
755    fn empty_extension_id_is_invalid() {
756        let json = r#"{
757          "version": 1,
758          "extensions": [{
759            "id": "   ",
760            "match": { "positional_suffix": ".md" },
761            "expand": { "command": { "type": "markdown", "file": "{path}" } }
762          }]
763        }"#;
764        let err = ExtensionRegistry::from_json_str(json).expect_err("empty id");
765        assert!(matches!(err, ExtensionError::InvalidRegistry { .. }));
766        assert!(ExtensionId::try_from(String::from("   ")).is_err());
767        assert_eq!(
768            ExtensionId::try_from(String::from("markdown-suffix"))
769                .expect("valid")
770                .as_str(),
771            "markdown-suffix"
772        );
773    }
774
775    #[test]
776    fn arg_name_rejects_empty() {
777        assert!(ArgName::new("").is_none());
778        assert!(ArgName::new("   ").is_none());
779        assert!(ArgName::try_from(String::from("   ")).is_err());
780        assert_eq!(ArgName::new("root").expect("valid").as_str(), "root");
781    }
782
783    #[test]
784    fn binary_name_rejects_empty_and_path() {
785        assert!(BinaryName::try_from(String::from("   ")).is_err());
786        assert!(BinaryName::try_from(String::from("bin/foo")).is_err());
787        assert!(BinaryName::try_from(String::from("bin\\foo")).is_err());
788        assert_eq!(
789            BinaryName::try_from(String::from("sc-compose"))
790                .expect("valid")
791                .as_str(),
792            "sc-compose"
793        );
794    }
795
796    #[test]
797    fn ends_with_suffix_does_not_panic_on_multibyte_token() {
798        assert!(ends_with_suffix("café.md", ".md"));
799        assert!(ends_with_suffix("café.MD", ".md"));
800        assert!(ends_with_suffix("ファイル.md", ".md"));
801        // "xé" is 3 bytes; a 2-byte suffix would split `é` under byte slicing.
802        assert!(!ends_with_suffix("xé", "xx"));
803        assert!(!ends_with_suffix("é", ".md"));
804        assert!(!ends_with_suffix("ab", ".md"));
805    }
806
807    #[test]
808    fn preexec_requires_rejects_empty_and_path() {
809        let empty = r#"{
810          "version": 1,
811          "extensions": [{
812            "id": "bad-empty",
813            "match": { "positional_suffix": ".md" },
814            "preexec": { "cmd": "true", "requires": ["  "] },
815            "expand": { "command": { "type": "markdown", "file": "{path}" } }
816          }]
817        }"#;
818        let err = ExtensionRegistry::from_json_str(empty).expect_err("empty requires");
819        assert!(
820            matches!(err, ExtensionError::InvalidRegistry { .. }),
821            "{err}"
822        );
823        assert!(
824            format!("{err}").contains("in memory"),
825            "empty-requires error must include origin: {err}"
826        );
827
828        let pathish = r#"{
829          "version": 1,
830          "extensions": [{
831            "id": "bad-path",
832            "match": { "positional_suffix": ".md" },
833            "preexec": { "cmd": "true", "requires": ["bin/foo"] },
834            "expand": { "command": { "type": "markdown", "file": "{path}" } }
835          }]
836        }"#;
837        let err = ExtensionRegistry::from_json_str(pathish).expect_err("path requires");
838        assert!(
839            matches!(err, ExtensionError::InvalidRegistry { .. }),
840            "{err}"
841        );
842        assert!(
843            format!("{err}").contains("in memory"),
844            "path-requires error must include origin: {err}"
845        );
846    }
847
848    #[test]
849    fn inherit_from_requires_only_keeps_parent_cmd() {
850        let json = r#"{
851          "version": 1,
852          "extensions": [
853            {
854              "id": "parent",
855              "match": { "positional_suffix": ".csv" },
856              "preexec": {
857                "cmd": "python3",
858                "args": ["--parent-flag"],
859                "requires": ["python3"]
860              },
861              "expand": { "command": { "type": "markdown", "file": "{path}" } }
862            },
863            {
864              "id": "child",
865              "extends": "parent",
866              "match": { "argv_prefix": ["md"], "arg_suffix": ".csv" },
867              "preexec": { "requires": ["python3"] }
868            }
869          ]
870        }"#;
871        let registry = ExtensionRegistry::from_json_str(json).expect("parse");
872        let child = registry
873            .extensions()
874            .iter()
875            .find(|e| e.id.as_str() == "child")
876            .expect("child");
877        let pre = child.preexec.as_ref().expect("inherited preexec");
878        assert_eq!(pre.cmd, "python3");
879        assert_eq!(pre.args, vec!["--parent-flag"]);
880        assert_eq!(pre.requires.len(), 1);
881        assert_eq!(pre.requires[0].as_str(), "python3");
882    }
883
884    #[test]
885    fn unknown_host_viewer_field_fails_load() {
886        let json = r#"{
887          "version": 1,
888          "extensions": [{
889            "id": "bad-host",
890            "match": { "positional_suffix": ".md" },
891            "expand": {
892              "command": { "type": "markdown", "file": "{path}" },
893              "host": { "ui_root": ".", "viewer": "none" }
894            }
895          }]
896        }"#;
897        let err = ExtensionRegistry::from_json_str(json).expect_err("viewer");
898        assert!(
899            matches!(err, ExtensionError::InvalidRegistry { .. }),
900            "{err}"
901        );
902        assert!(
903            format!("{err}").contains("viewer") || format!("{err}").contains("unknown"),
904            "{err}"
905        );
906    }
907
908    #[test]
909    fn help_only_tokens_require_help_flags() {
910        assert!(is_help_only_tokens(&["--help".into()]));
911        assert!(is_help_only_tokens(&["-h".into()]));
912        assert!(is_help_only_tokens(&["--help".into(), "-h".into()]));
913        assert!(!is_help_only_tokens(&[]));
914        assert!(!is_help_only_tokens(&["--help".into(), "data.csv".into()]));
915        assert!(!is_help_only_tokens(&["data.csv".into()]));
916    }
917
918    #[test]
919    fn match_extension_help_ignores_requires_and_suffix() {
920        let registry = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON).expect("shipped");
921        let compose = vec!["compose".into(), "render".into(), "--help".into()];
922        let ext = match_extension_help(&registry, &compose).expect("compose help");
923        assert_eq!(ext.id.as_str(), "compose-render");
924        assert!(registry
925            .match_argv_with(&compose, &LocalAbsentProbe)
926            .is_none());
927
928        let md = vec!["md".into(), "-h".into()];
929        let ext = match_extension_help(&registry, &md).expect("md help");
930        assert_eq!(ext.id.as_str(), "csv-md");
931
932        let table = vec!["table".into(), "--help".into()];
933        let ext = match_extension_help(&registry, &table).expect("table help");
934        assert_eq!(ext.id.as_str(), "csv-table-alias");
935
936        let incomplete = vec!["compose".into(), "--help".into()];
937        assert!(match_extension_help(&registry, &incomplete).is_none());
938        let suffix = vec!["doc.md".into(), "--help".into()];
939        let ext = match_extension_help(&registry, &suffix).expect("suffix help");
940        assert_eq!(ext.id.as_str(), "markdown-suffix");
941        let filename = vec!["path/to/wizard.json".into(), "--help".into()];
942        let ext = match_extension_help(&registry, &filename).expect("filename help");
943        assert_eq!(ext.id.as_str(), "wizard-json-suffix");
944    }
945}