1mod 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
73pub const SHIPPED_EXTENSIONS_JSON: &str = include_str!(concat!(
75 env!("CARGO_MANIFEST_DIR"),
76 "/share/wyvern/extensions.json"
77));
78
79#[derive(rust_embed::RustEmbed)]
81#[folder = "share/wyvern/"]
82pub struct ShareAssets;
83
84#[derive(rust_embed::RustEmbed)]
86#[folder = "scripts/ext/"]
87pub struct ScriptAssets;
88
89#[derive(Debug, Clone)]
91pub struct ExtensionRegistry {
92 extensions: Vec<ExtensionDef>,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
97#[serde(rename_all = "lowercase")]
98pub enum SkillSource {
99 #[default]
101 Shipped,
102 Project,
104}
105
106impl SkillSource {
107 #[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#[derive(Debug, Clone, Deserialize)]
125pub struct ExtensionDef {
126 pub id: ExtensionId,
128 #[serde(rename = "match")]
130 pub match_spec: MatchSpec,
131 #[serde(default)]
133 pub extends: Option<ExtensionId>,
134 #[serde(default)]
136 pub description: Option<String>,
137 #[serde(default)]
139 pub examples: Vec<String>,
140 #[serde(default)]
142 pub preexec: Option<PreexecSpec>,
143 #[serde(default)]
145 pub expand: Option<ExpandSpec>,
146 #[serde(skip)]
148 pub source: SkillSource,
149}
150
151#[derive(Debug, Clone, Default, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct MatchSpec {
155 #[serde(default)]
157 pub positional_suffix: Option<MatchToken>,
158 #[serde(default)]
160 pub filename: Option<MatchToken>,
161 #[serde(default)]
163 pub argv_prefix: Option<Vec<MatchToken>>,
164 #[serde(default)]
166 pub arg_suffix: Option<MatchToken>,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
171#[serde(rename_all = "lowercase")]
172pub enum StdoutCapture {
173 Markdown,
175}
176
177#[derive(Debug, Clone, Default, Deserialize)]
179#[serde(deny_unknown_fields)]
180pub struct PreexecSpec {
181 #[serde(default)]
183 pub cmd: String,
184 #[serde(default)]
186 pub args: Vec<String>,
187 #[serde(default)]
189 pub requires: Vec<BinaryName>,
190 #[serde(default)]
192 pub stdout: Option<StdoutCapture>,
193}
194
195#[derive(Debug, Clone, Default, Deserialize)]
197#[serde(deny_unknown_fields)]
198pub struct ExpandSpec {
199 #[serde(default)]
201 pub command: Option<Value>,
202 #[serde(default)]
204 pub command_from_file: Option<String>,
205 #[serde(default)]
207 pub host: Option<HostExpandSpec>,
208}
209
210#[derive(Debug, Clone, Default, Deserialize)]
212#[serde(deny_unknown_fields)]
213pub struct HostExpandSpec {
214 #[serde(default)]
216 pub ui_root: Option<String>,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub enum TemplateErrorKind {
222 UnclosedBrace,
224 UnknownVariable,
226 PhaseRestricted,
228 Unavailable,
230 InvalidSpec,
232}
233
234#[derive(Debug)]
236pub enum ExtensionError {
237 InvalidRegistry {
239 message: String,
241 },
242 MissingArgs {
244 missing: Vec<String>,
246 declared: std::collections::BTreeSet<String>,
248 extension_id: ExtensionId,
250 example: String,
252 help_command: String,
254 },
255 UnexpectedArg {
257 token: String,
259 declared: std::collections::BTreeSet<String>,
261 extension_id: ExtensionId,
263 help_command: String,
265 },
266 PathVarWithoutPath {
268 var: String,
270 },
271 Template {
273 kind: TemplateErrorKind,
275 message: String,
277 },
278 Preexec {
280 kind: Option<PreexecFailureKind>,
282 message: String,
284 source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
286 },
287 InvalidCommand {
289 source: wyvern_schema::ValidationError,
291 },
292 Io {
294 message: String,
296 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 #[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 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 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 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 #[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 #[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 #[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 #[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 #[must_use]
493 pub fn extensions(&self) -> &[ExtensionDef] {
494 &self.extensions
495 }
496}
497
498impl ExtensionDef {
499 #[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 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(®istry, &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(®istry, &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(®istry, &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(®istry, &incomplete).is_none());
938 let suffix = vec!["doc.md".into(), "--help".into()];
939 let ext = match_extension_help(®istry, &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(®istry, &filename).expect("filename help");
943 assert_eq!(ext.id.as_str(), "wizard-json-suffix");
944 }
945}