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, run_script, PathRequiresProbe, PreexecFailureKind,
68 RequiresProbe, ScriptError, ScriptOutput, ScriptRequest,
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 command_type: Option<String>,
208 #[serde(default)]
210 pub host: Option<HostExpandSpec>,
211}
212
213#[derive(Debug, Clone, Default, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct HostExpandSpec {
217 #[serde(default)]
219 pub ui_root: Option<String>,
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub enum TemplateErrorKind {
225 UnclosedBrace,
227 UnknownVariable,
229 PhaseRestricted,
231 Unavailable,
233 InvalidSpec,
235}
236
237#[derive(Debug)]
239pub enum ExtensionError {
240 InvalidRegistry {
242 message: String,
244 },
245 MissingArgs {
247 missing: Vec<String>,
249 declared: std::collections::BTreeSet<String>,
251 extension_id: ExtensionId,
253 example: String,
255 help_command: String,
257 },
258 UnexpectedArg {
260 token: String,
262 declared: std::collections::BTreeSet<String>,
264 extension_id: ExtensionId,
266 help_command: String,
268 },
269 PathVarWithoutPath {
271 var: String,
273 },
274 Template {
276 kind: TemplateErrorKind,
278 message: String,
280 },
281 Preexec {
283 kind: Option<PreexecFailureKind>,
285 message: String,
287 source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
289 },
290 InvalidCommand {
292 source: wyvern_schema::ValidationError,
294 },
295 Io {
297 message: String,
299 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 #[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 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 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 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 #[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 #[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 #[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 #[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 #[must_use]
496 pub fn extensions(&self) -> &[ExtensionDef] {
497 &self.extensions
498 }
499}
500
501impl ExtensionDef {
502 #[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 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(®istry, &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(®istry, &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(®istry, &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(®istry, &incomplete).is_none());
941 let suffix = vec!["doc.md".into(), "--help".into()];
942 let ext = match_extension_help(®istry, &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(®istry, &filename).expect("filename help");
946 assert_eq!(ext.id.as_str(), "wizard-json-suffix");
947 }
948}