Skip to main content

wyvern/extensions/
catalog.rs

1//! Skill catalog: [`SkillRecord`], list/show JSON, and the sole text formatter.
2//!
3//! `wyvern --help`, extension prefix `--help`, `extensions list`, and
4//! `extensions show` all call [`format_skill_card`] after
5//! [`build_skill_record`]. There is no second formatter.
6
7use std::collections::BTreeSet;
8
9use serde::Serialize;
10use serde_json::Value;
11
12use super::{
13    match_kind_summary, ArgName, BinaryName, ExtensionDef, ExtensionId, ExtensionRegistry,
14    MatchToken, PreexecSpec, RequiresProbe, SkillSource,
15};
16
17/// One declared `{arg:name}` / `{arg:name:repeat}` flag.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
19pub struct SkillArg {
20    /// Flag name without leading dashes.
21    pub name: ArgName,
22    /// `true` when the template uses `{arg:name}` (missing is an error).
23    pub required: bool,
24    /// `true` when the template uses `{arg:name:repeat}`.
25    pub repeat: bool,
26}
27
28/// One `preexec.requires` binary and its current PATH availability.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30pub struct SkillRequire {
31    /// Bare binary name from the registry.
32    pub binary: BinaryName,
33    /// Result of [`RequiresProbe::binary_on_path`] at build time.
34    pub available: bool,
35}
36
37/// One catalog / help record for a resolved extension (REQ-0132).
38#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
39pub struct SkillRecord {
40    /// Extension id.
41    pub id: ExtensionId,
42    /// Human match DSL (`prefix: compose render`, `prefix+suffix: md .csv`).
43    pub match_kind: String,
44    /// Copy-paste invocation pattern, including declared flags.
45    pub invocation: String,
46    /// Required binaries and whether each is on `PATH`.
47    pub requires: Vec<SkillRequire>,
48    /// Declared `{arg:*}` flags.
49    pub args: Vec<SkillArg>,
50    /// Expand command `type` from inline JSON, `command_type`, or file contents.
51    pub expands_to: String,
52    /// Present when `expands_to` was inferred or `command_from_file` was unreadable.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub catalog_warning: Option<String>,
55    /// One-line agent-facing summary from the registry, if present.
56    pub description: Option<String>,
57    /// Copy-paste example lines.
58    pub examples: Vec<String>,
59    /// Parent extension id when `extends` is set; otherwise `null` on the wire.
60    pub extends: Option<ExtensionId>,
61    /// `shipped` defaults or `project` `.wyvern/extensions.json`.
62    pub source: SkillSource,
63}
64
65/// Build a help-oriented [`SkillRecord`] from a resolved extension.
66///
67/// `probe` is evaluated at call time (not cached). Help still builds a card
68/// when required binaries are missing.
69#[must_use]
70pub fn build_skill_record(ext: &ExtensionDef, probe: &dyn RequiresProbe) -> SkillRecord {
71    let args = declared_skill_args(ext);
72    let invocation = invocation_line(ext, &args);
73    let examples = if ext.examples.is_empty() {
74        vec![example_line(ext, &invocation, &args)]
75    } else {
76        ext.examples.clone()
77    };
78    let expands = expands_to(ext);
79    SkillRecord {
80        id: ext.id.clone(),
81        match_kind: match_kind_summary(&ext.match_spec),
82        invocation,
83        requires: ext
84            .requires()
85            .iter()
86            .map(|binary| SkillRequire {
87                binary: binary.clone(),
88                available: probe.binary_on_path(binary.as_str()),
89            })
90            .collect(),
91        args,
92        expands_to: expands.type_name,
93        catalog_warning: expands.warning,
94        description: ext
95            .description
96            .as_ref()
97            .map(|text| text.trim().to_string())
98            .filter(|text| !text.is_empty()),
99        examples,
100        extends: ext.extends.clone(),
101        source: ext.source,
102    }
103}
104
105/// Recovery `--help` using the invocation prefix, not the extension id.
106///
107/// Prefix skills: `wyvern compose render --help`. Suffix/filename skills:
108/// `wyvern extensions show <id>` (path `--help` also works at match time).
109#[must_use]
110pub fn skill_help_command(ext: &ExtensionDef) -> String {
111    if let Some(prefix) = &ext.match_spec.argv_prefix {
112        if !prefix.is_empty() {
113            return format!("wyvern {} --help", join_prefix(prefix));
114        }
115    }
116    format!("wyvern extensions show {}", ext.id)
117}
118
119/// Build a [`SkillRecord`] for every merged extension, in registry order.
120#[must_use]
121pub fn build_skill_records(
122    registry: &ExtensionRegistry,
123    probe: &dyn RequiresProbe,
124) -> Vec<SkillRecord> {
125    registry
126        .extensions()
127        .iter()
128        .map(|ext| build_skill_record(ext, probe))
129        .collect()
130}
131
132/// Format one skill as the single help / list / show text card.
133///
134/// g.1 `--help` and g.3 `list` / `show` must call this function. There is no
135/// second formatter.
136#[must_use]
137pub fn format_skill_card(record: &SkillRecord) -> String {
138    let mut out = String::new();
139    out.push_str(record.id.as_str());
140    out.push('\n');
141    out.push_str(&record.match_kind);
142    out.push('\n');
143    if let Some(description) = &record.description {
144        out.push_str(description);
145        out.push('\n');
146    }
147    out.push_str("Usage: ");
148    out.push_str(&record.invocation);
149    out.push('\n');
150    out.push_str("Requires: ");
151    if record.requires.is_empty() {
152        out.push_str("(none)");
153    } else {
154        out.push_str(
155            &record
156                .requires
157                .iter()
158                .map(|req| {
159                    let status = if req.available {
160                        "available"
161                    } else {
162                        "missing"
163                    };
164                    format!("{} [{status}]", req.binary)
165                })
166                .collect::<Vec<_>>()
167                .join(", "),
168        );
169    }
170    out.push('\n');
171    out.push_str("Expands to: ");
172    out.push_str(&record.expands_to);
173    out.push('\n');
174    if let Some(warning) = &record.catalog_warning {
175        out.push_str("Warning: ");
176        out.push_str(warning);
177        out.push('\n');
178    }
179    if let Some(parent) = &record.extends {
180        out.push_str("Extends: ");
181        out.push_str(parent.as_str());
182        out.push_str(" (alias)\n");
183    }
184    out.push_str("Example: ");
185    if let Some(example) = record.examples.first() {
186        out.push_str(example);
187        for extra in record.examples.iter().skip(1) {
188            out.push('\n');
189            out.push_str("         ");
190            out.push_str(extra);
191        }
192    } else {
193        out.push_str(&record.invocation);
194    }
195    out.push('\n');
196    out
197}
198
199/// Resolved catalog `expands_to` plus an optional degraded-path warning.
200struct ExpandsToResolution {
201    type_name: String,
202    warning: Option<String>,
203}
204
205impl ExpandsToResolution {
206    fn confirmed(type_name: impl Into<String>) -> Self {
207        Self {
208            type_name: type_name.into(),
209            warning: None,
210        }
211    }
212
213    fn degraded(type_name: impl Into<String>, warning: impl Into<String>) -> Self {
214        Self {
215            type_name: type_name.into(),
216            warning: Some(warning.into()),
217        }
218    }
219}
220
221/// Structured failure when catalog cannot read `command_from_file` JSON.
222#[derive(Debug)]
223enum CatalogCommandTypeError {
224    /// Filesystem read failed.
225    Read(std::io::Error),
226    /// File contents were not JSON.
227    Parse(serde_json::Error),
228}
229
230impl std::fmt::Display for CatalogCommandTypeError {
231    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232        match self {
233            Self::Read(err) => write!(f, "could not read command_from_file: {err}"),
234            Self::Parse(err) => write!(f, "command_from_file is not valid JSON: {err}"),
235        }
236    }
237}
238
239impl std::error::Error for CatalogCommandTypeError {
240    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
241        match self {
242            Self::Read(err) => Some(err),
243            Self::Parse(err) => Some(err),
244        }
245    }
246}
247
248fn expands_to(ext: &ExtensionDef) -> ExpandsToResolution {
249    if let Some(ty) = ext
250        .expand
251        .as_ref()
252        .and_then(|spec| spec.command.as_ref())
253        .and_then(|command| command.get("type"))
254        .and_then(Value::as_str)
255        .filter(|ty| !ty.is_empty())
256    {
257        return ExpandsToResolution::confirmed(ty);
258    }
259    let spec = ext.expand.as_ref();
260    let hint = spec
261        .and_then(|expand| expand.command_type.as_deref())
262        .map(str::trim)
263        .filter(|ty| !ty.is_empty());
264    if let Some(path_tmpl) = spec.and_then(|expand| expand.command_from_file.as_deref()) {
265        return type_from_command_from_file(path_tmpl, hint);
266    }
267    match hint {
268        Some(ty) => ExpandsToResolution::confirmed(ty),
269        None => ExpandsToResolution::degraded(
270            "wizard",
271            "catalog defaulted expands_to to wizard; no command type was declared",
272        ),
273    }
274}
275
276/// Read `type` from resolvable `command_from_file` JSON, a registry hint, or
277/// a filename heuristic marked as degraded.
278///
279/// Catalog listing cannot wait for preexec to write `{tmpdir}/…`. Prefer
280/// `expand.command_type`. Read/parse failures must not silently become `wizard`
281/// or a filename guess — they surface [`SkillRecord::catalog_warning`].
282fn type_from_command_from_file(path_tmpl: &str, hint: Option<&str>) -> ExpandsToResolution {
283    if let Some(path) = resolve_static_command_path(path_tmpl) {
284        match read_command_type(&path) {
285            Ok(Some(ty)) => return ExpandsToResolution::confirmed(ty),
286            Ok(None) => {
287                tracing::warn!(
288                    path = %path.display(),
289                    "catalog command_from_file JSON has no type field"
290                );
291                return ExpandsToResolution::degraded(
292                    hint.unwrap_or("unknown"),
293                    format!("command_from_file {} has no type field", path.display()),
294                );
295            }
296            Err(err) => {
297                tracing::warn!(
298                    path = %path.display(),
299                    error = %err,
300                    "catalog could not read command_from_file type"
301                );
302                return ExpandsToResolution::degraded(hint.unwrap_or("unknown"), err.to_string());
303            }
304        }
305    }
306    if let Some(ty) = hint {
307        return ExpandsToResolution::confirmed(ty);
308    }
309    match filename_command_type(path_tmpl) {
310        Some(ty) => ExpandsToResolution::degraded(
311            ty,
312            "inferred expands_to from filename; command_from_file template is not readable yet",
313        ),
314        None => ExpandsToResolution::degraded(
315            "wizard",
316            "command_from_file type is unavailable; catalog defaulted to wizard",
317        ),
318    }
319}
320
321fn filename_command_type(path_tmpl: &str) -> Option<String> {
322    let name = path_tmpl.rsplit(['/', '\\']).next().unwrap_or(path_tmpl);
323    (name == "report-command.json").then(|| "report".to_string())
324}
325
326fn resolve_static_command_path(path_tmpl: &str) -> Option<std::path::PathBuf> {
327    const SHARE: &str = "{wyvern_share}";
328    let open_braces = path_tmpl.chars().filter(|ch| *ch == '{').count();
329    if let Some(rest) = path_tmpl.strip_prefix(SHARE) {
330        if open_braces > 1 {
331            return None;
332        }
333        let mut path = super::resolve_wyvern_share();
334        let rest = rest.trim_start_matches(['/', '\\']);
335        if !rest.is_empty() {
336            path.push(rest);
337        }
338        return path.is_file().then_some(path);
339    }
340    if open_braces > 0 {
341        return None;
342    }
343    let path = std::path::PathBuf::from(path_tmpl);
344    path.is_file().then_some(path)
345}
346
347fn read_command_type(path: &std::path::Path) -> Result<Option<String>, CatalogCommandTypeError> {
348    let text = std::fs::read_to_string(path).map_err(CatalogCommandTypeError::Read)?;
349    let value: Value = serde_json::from_str(&text).map_err(CatalogCommandTypeError::Parse)?;
350    Ok(value
351        .get("type")
352        .and_then(Value::as_str)
353        .filter(|ty| !ty.is_empty())
354        .map(ToOwned::to_owned))
355}
356
357fn invocation_line(ext: &ExtensionDef, args: &[SkillArg]) -> String {
358    let mut parts = vec!["wyvern".to_string()];
359    let spec = &ext.match_spec;
360    if let Some(prefix) = &spec.argv_prefix {
361        for token in prefix {
362            parts.push(token.as_str().to_string());
363        }
364        if let Some(suffix) = &spec.arg_suffix {
365            parts.push(format!("<file{}>", suffix.as_str()));
366        }
367        for arg in args {
368            parts.push(format_arg(arg));
369        }
370        return parts.join(" ");
371    }
372    if let Some(filename) = &spec.filename {
373        parts.push(format!("path/to/{}", filename.as_str()));
374        return parts.join(" ");
375    }
376    if let Some(suffix) = &spec.positional_suffix {
377        parts.push(format!("file{}", suffix.as_str()));
378        return parts.join(" ");
379    }
380    parts.join(" ")
381}
382
383fn example_line(ext: &ExtensionDef, invocation: &str, args: &[SkillArg]) -> String {
384    let spec = &ext.match_spec;
385    if let Some(prefix) = &spec.argv_prefix {
386        let prefix_s = join_prefix(prefix);
387        if let Some(suffix) = &spec.arg_suffix {
388            return format!("wyvern {prefix_s} data{}", suffix.as_str());
389        }
390        if !args.is_empty() {
391            return format!("wyvern {prefix_s} {}", example_args(args));
392        }
393        return format!("wyvern {prefix_s}");
394    }
395    invocation.to_string()
396}
397
398fn join_prefix(prefix: &[MatchToken]) -> String {
399    prefix
400        .iter()
401        .map(MatchToken::as_str)
402        .collect::<Vec<_>>()
403        .join(" ")
404}
405
406fn format_arg(arg: &SkillArg) -> String {
407    match (arg.name.as_str(), arg.required, arg.repeat) {
408        ("root", true, _) => "--root <DIR>".into(),
409        ("file", true, _) => "--file <FILE>".into(),
410        ("var", _, true) => "[--var k=v]".into(),
411        ("var-file", _, true) => "[--var-file vars.json]".into(),
412        ("env-prefix", _, true) => "[--env-prefix PREFIX]".into(),
413        (name, true, _) => format!("--{name} <{}>", placeholder(name)),
414        (name, false, true) => format!("[--{name} …]"),
415        (name, false, false) => format!("[--{name} <{}>]", placeholder(name)),
416    }
417}
418
419fn example_args(args: &[SkillArg]) -> String {
420    args.iter()
421        .map(|arg| match (arg.name.as_str(), arg.required, arg.repeat) {
422            ("root", true, _) => "--root DIR".into(),
423            ("file", true, _) => "--file FILE.j2".into(),
424            ("var", _, true) => "[--var k=v]".into(),
425            ("var-file", _, true) => "[--var-file vars.json]".into(),
426            ("env-prefix", _, true) => "[--env-prefix PREFIX]".into(),
427            (name, true, _) => format!("--{name} {}", placeholder(name)),
428            (name, false, true) => format!("[--{name} …]"),
429            (name, false, false) => format!("[--{name} {}]", placeholder(name)),
430        })
431        .collect::<Vec<_>>()
432        .join(" ")
433}
434
435fn placeholder(name: &str) -> String {
436    name.replace('-', "_").to_ascii_uppercase()
437}
438
439pub(crate) fn declared_skill_args(ext: &ExtensionDef) -> Vec<SkillArg> {
440    let mut vars = Vec::new();
441    let mut seen = BTreeSet::new();
442    if let Some(PreexecSpec { cmd, args, .. }) = &ext.preexec {
443        collect_template_vars(cmd, &mut vars, &mut seen);
444        for arg in args {
445            collect_template_vars(arg, &mut vars, &mut seen);
446        }
447    }
448    if let Some(exp) = &ext.expand {
449        if let Some(cmd) = &exp.command {
450            collect_value_vars(cmd, &mut vars, &mut seen);
451        }
452        if let Some(path) = &exp.command_from_file {
453            collect_template_vars(path, &mut vars, &mut seen);
454        }
455        if let Some(ui) = exp.host.as_ref().and_then(|h| h.ui_root.as_ref()) {
456            collect_template_vars(ui, &mut vars, &mut seen);
457        }
458    }
459    let mut ordered: Vec<SkillArg> = Vec::new();
460    for var in vars {
461        let Some(rest) = var.strip_prefix("arg:") else {
462            continue;
463        };
464        let (name, repeat) = match rest.strip_suffix(":repeat") {
465            Some(name) => (name, true),
466            None => (rest, false),
467        };
468        if let Some(existing) = ordered.iter_mut().find(|arg| arg.name.as_str() == name) {
469            if repeat {
470                existing.repeat = true;
471            } else {
472                existing.required = true;
473            }
474            continue;
475        }
476        let Some(arg_name) = ArgName::new(name) else {
477            continue;
478        };
479        ordered.push(SkillArg {
480            name: arg_name,
481            required: !repeat,
482            repeat,
483        });
484    }
485    ordered
486}
487
488fn collect_template_vars(template: &str, into: &mut Vec<String>, seen: &mut BTreeSet<String>) {
489    let mut rest = template;
490    while let Some(start) = rest.find('{') {
491        let after = &rest[start + 1..];
492        let Some(end) = after.find('}') else {
493            break;
494        };
495        let name = after[..end].to_string();
496        if seen.insert(name.clone()) {
497            into.push(name);
498        }
499        rest = &after[end + 1..];
500    }
501}
502
503fn collect_value_vars(value: &Value, into: &mut Vec<String>, seen: &mut BTreeSet<String>) {
504    match value {
505        Value::String(s) => collect_template_vars(s, into, seen),
506        Value::Array(items) => {
507            for item in items {
508                collect_value_vars(item, into, seen);
509            }
510        }
511        Value::Object(map) => {
512            for v in map.values() {
513                collect_value_vars(v, into, seen);
514            }
515        }
516        _ => {}
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use crate::extensions::{
524        ExtensionRegistry, RequiresProbe, SkillSource, SHIPPED_EXTENSIONS_JSON,
525    };
526
527    struct Absent;
528
529    impl RequiresProbe for Absent {
530        fn binary_on_path(&self, _name: &str) -> bool {
531            false
532        }
533    }
534
535    #[test]
536    fn compose_card_lists_flags_requires_and_example() {
537        let registry = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON).expect("shipped");
538        let ext = registry
539            .extensions()
540            .iter()
541            .find(|e| e.id.as_str() == "compose-render")
542            .expect("compose-render");
543        let record = build_skill_record(ext, &Absent);
544        assert_eq!(record.id.to_string(), "compose-render");
545        assert_eq!(record.source, SkillSource::Shipped);
546        assert_eq!(skill_help_command(ext), "wyvern compose render --help");
547        assert!(record.args.iter().any(|arg| arg.name.as_str() == "root"));
548        assert!(!record.requires.iter().any(|r| r.available));
549        let card = format_skill_card(&record);
550        let root_at = card.find("--root").expect("root");
551        let file_at = card.find("--file").expect("file");
552        assert!(root_at < file_at, "{card}");
553        assert!(card.contains("--env-prefix"), "{card}");
554        assert!(card.contains("Requires:"), "{card}");
555        assert!(card.contains("sc-compose"), "{card}");
556        assert!(card.contains("Example:"), "{card}");
557    }
558
559    #[test]
560    fn md_card_does_not_require_csv_path() {
561        let registry = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON).expect("shipped");
562        let ext = registry
563            .extensions()
564            .iter()
565            .find(|e| e.id.as_str() == "csv-md")
566            .expect("csv-md");
567        let card = format_skill_card(&build_skill_record(ext, &Absent));
568        assert_eq!(skill_help_command(ext), "wyvern md --help");
569        assert!(card.contains("wyvern md"), "{card}");
570        assert!(card.contains("Requires:"), "{card}");
571        assert!(card.contains("Example:"), "{card}");
572    }
573
574    #[test]
575    fn compose_render_shipped_preexec_uses_output_and_env_prefix() {
576        let shipped: Value = serde_json::from_str(SHIPPED_EXTENSIONS_JSON).expect("json");
577        let compose = shipped["extensions"]
578            .as_array()
579            .expect("extensions")
580            .iter()
581            .find(|ext| ext["id"] == "compose-render")
582            .expect("compose-render");
583        let args: Vec<&str> = compose["preexec"]["args"]
584            .as_array()
585            .expect("args")
586            .iter()
587            .filter_map(Value::as_str)
588            .collect();
589        assert!(args.contains(&"--output"), "{args:?}");
590        assert!(!args.contains(&"--out"), "{args:?}");
591        assert!(!args.contains(&"--env"), "{args:?}");
592        assert!(
593            args.iter().any(|token| token.contains("env-prefix")),
594            "{args:?}"
595        );
596        assert!(!args.contains(&"--format"), "{args:?}");
597        assert!(!args.contains(&"html"), "{args:?}");
598    }
599
600    #[test]
601    fn registry_accepts_missing_description_and_examples() {
602        let json = r#"{
603          "version": 1,
604          "extensions": [{
605            "id": "plain",
606            "match": { "positional_suffix": ".md" },
607            "expand": { "command": { "type": "markdown", "file": "{path}" } }
608          }]
609        }"#;
610        let registry = ExtensionRegistry::from_json_str(json).expect("parse");
611        let record = build_skill_record(&registry.extensions()[0], &Absent);
612        assert!(record.description.is_none());
613        assert_eq!(record.extends, None);
614        assert_eq!(record.examples.len(), 1);
615        assert_eq!(record.source, SkillSource::Shipped);
616        assert_eq!(
617            skill_help_command(&registry.extensions()[0]),
618            "wyvern extensions show plain"
619        );
620    }
621
622    #[test]
623    fn declared_skill_args_skips_empty_template_names() {
624        let json = r#"{
625          "version": 1,
626          "extensions": [{
627            "id": "empty-arg",
628            "match": { "positional_suffix": ".md" },
629            "preexec": { "cmd": "true", "args": ["{arg:}", "{arg:  }", "{arg:root}"] },
630            "expand": { "command": { "type": "markdown", "file": "{path}" } }
631          }]
632        }"#;
633        let registry = ExtensionRegistry::from_json_str(json).expect("parse");
634        let args = declared_skill_args(&registry.extensions()[0]);
635        assert_eq!(args.len(), 1, "{args:?}");
636        assert_eq!(args[0].name.as_str(), "root");
637    }
638
639    #[test]
640    fn expands_to_reads_type_from_command_json_file() {
641        let tmp = tempfile::tempdir().expect("tempdir");
642        let path = tmp.path().join("emitted.json");
643        std::fs::write(
644            &path,
645            r#"{"type":"report","title":"t","page":"pages/view.xhtml"}"#,
646        )
647        .expect("write");
648        let json = serde_json::json!({
649            "version": 1,
650            "extensions": [{
651                "id": "from-file",
652                "match": { "argv_prefix": ["from-file"] },
653                "expand": { "command_from_file": path }
654            }]
655        });
656        let registry = ExtensionRegistry::from_json_str(&json.to_string()).expect("parse");
657        let record = build_skill_record(&registry.extensions()[0], &Absent);
658        assert_eq!(record.expands_to, "report");
659    }
660
661    #[test]
662    fn expands_to_uses_command_type_hint_without_magic_filename() {
663        let json = r#"{
664          "version": 1,
665          "extensions": [{
666            "id": "hinted",
667            "match": { "argv_prefix": ["hinted"] },
668            "expand": {
669              "command_from_file": "{tmpdir}/custom-out.json",
670              "command_type": "report"
671            }
672          }]
673        }"#;
674        let registry = ExtensionRegistry::from_json_str(json).expect("parse");
675        let record = build_skill_record(&registry.extensions()[0], &Absent);
676        assert_eq!(record.expands_to, "report");
677    }
678
679    #[test]
680    fn expands_to_unknown_when_command_file_unreadable() {
681        let tmp = tempfile::tempdir().expect("tempdir");
682        let path = tmp.path().join("broken-command.json");
683        std::fs::write(&path, "not-json").expect("write");
684        let json = serde_json::json!({
685            "version": 1,
686            "extensions": [{
687                "id": "from-file",
688                "match": { "argv_prefix": ["from-file"] },
689                "expand": { "command_from_file": path }
690            }]
691        });
692        let registry = ExtensionRegistry::from_json_str(&json.to_string()).expect("parse");
693        let record = build_skill_record(&registry.extensions()[0], &Absent);
694        assert_eq!(record.expands_to, "unknown");
695        assert_ne!(record.expands_to, "wizard");
696        assert!(
697            record
698                .catalog_warning
699                .as_deref()
700                .is_some_and(|w| w.contains("not valid JSON")),
701            "{:?}",
702            record.catalog_warning
703        );
704        let card = format_skill_card(&record);
705        assert!(card.contains("Warning:"), "{card}");
706    }
707
708    #[test]
709    fn resolve_static_command_path_trims_windows_separators() {
710        let unix = resolve_static_command_path("{wyvern_share}/extensions.json");
711        let windows = resolve_static_command_path("{wyvern_share}\\extensions.json");
712        assert!(unix.is_some(), "unix share path should resolve");
713        assert_eq!(unix, windows);
714    }
715
716    #[test]
717    fn expands_to_report_command_json_template_is_report() {
718        let json = r#"{
719          "version": 1,
720          "extensions": [{
721            "id": "report-from-file",
722            "match": { "argv_prefix": ["report-xhtml"], "arg_suffix": ".json" },
723            "expand": { "command_from_file": "{tmpdir}/report-command.json" }
724          }]
725        }"#;
726        let registry = ExtensionRegistry::from_json_str(json).expect("parse");
727        let record = build_skill_record(&registry.extensions()[0], &Absent);
728        assert_eq!(record.expands_to, "report");
729    }
730
731    #[test]
732    fn shipped_report_xhtml_expands_to_report() {
733        let registry = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON).expect("shipped");
734        let ext = registry
735            .extensions()
736            .iter()
737            .find(|e| e.id.as_str() == "report-xhtml")
738            .expect("report-xhtml");
739        let record = build_skill_record(ext, &Absent);
740        assert_eq!(record.expands_to, "report");
741        assert_ne!(record.expands_to, "wizard");
742    }
743
744    #[test]
745    fn skill_record_includes_catalog_fields() {
746        let registry = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON).expect("shipped");
747        let records = build_skill_records(&registry, &Absent);
748        assert!(records.len() >= 7, "{}", records.len());
749        let alias = records
750            .iter()
751            .find(|record| record.id.as_str() == "csv-table-alias")
752            .expect("csv-table-alias");
753        assert_eq!(
754            alias.extends.as_ref().map(ExtensionId::as_str),
755            Some("csv-suffix")
756        );
757        assert!(alias.description.as_ref().is_some_and(|d| !d.is_empty()));
758        assert!(!alias.examples.is_empty());
759        let card = format_skill_card(alias);
760        assert!(card.contains("Extends: csv-suffix (alias)"), "{card}");
761        assert!(card.contains("[missing]"), "{card}");
762    }
763}