1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
19pub struct SkillArg {
20 pub name: ArgName,
22 pub required: bool,
24 pub repeat: bool,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30pub struct SkillRequire {
31 pub binary: BinaryName,
33 pub available: bool,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
39pub struct SkillRecord {
40 pub id: ExtensionId,
42 pub match_kind: String,
44 pub invocation: String,
46 pub requires: Vec<SkillRequire>,
48 pub args: Vec<SkillArg>,
50 pub expands_to: String,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub catalog_warning: Option<String>,
55 pub description: Option<String>,
57 pub examples: Vec<String>,
59 pub extends: Option<ExtensionId>,
61 pub source: SkillSource,
63}
64
65#[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#[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#[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#[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
199struct 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#[derive(Debug)]
223enum CatalogCommandTypeError {
224 Read(std::io::Error),
226 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
276fn 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(®istry.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(®istry.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(®istry.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(®istry.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(®istry.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(®istry.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(®istry.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(®istry, &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}