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 pub description: Option<String>,
54 pub examples: Vec<String>,
56 pub extends: Option<ExtensionId>,
58 pub source: SkillSource,
60}
61
62#[must_use]
67pub fn build_skill_record(ext: &ExtensionDef, probe: &dyn RequiresProbe) -> SkillRecord {
68 let args = declared_skill_args(ext);
69 let invocation = invocation_line(ext, &args);
70 let examples = if ext.examples.is_empty() {
71 vec![example_line(ext, &invocation, &args)]
72 } else {
73 ext.examples.clone()
74 };
75 SkillRecord {
76 id: ext.id.clone(),
77 match_kind: match_kind_summary(&ext.match_spec),
78 invocation,
79 requires: ext
80 .requires()
81 .iter()
82 .map(|binary| SkillRequire {
83 binary: binary.clone(),
84 available: probe.binary_on_path(binary.as_str()),
85 })
86 .collect(),
87 args,
88 expands_to: expands_to(ext),
89 description: ext
90 .description
91 .as_ref()
92 .map(|text| text.trim().to_string())
93 .filter(|text| !text.is_empty()),
94 examples,
95 extends: ext.extends.clone(),
96 source: ext.source,
97 }
98}
99
100#[must_use]
105pub fn skill_help_command(ext: &ExtensionDef) -> String {
106 if let Some(prefix) = &ext.match_spec.argv_prefix {
107 if !prefix.is_empty() {
108 return format!("wyvern {} --help", join_prefix(prefix));
109 }
110 }
111 format!("wyvern extensions show {}", ext.id)
112}
113
114#[must_use]
116pub fn build_skill_records(
117 registry: &ExtensionRegistry,
118 probe: &dyn RequiresProbe,
119) -> Vec<SkillRecord> {
120 registry
121 .extensions()
122 .iter()
123 .map(|ext| build_skill_record(ext, probe))
124 .collect()
125}
126
127#[must_use]
132pub fn format_skill_card(record: &SkillRecord) -> String {
133 let mut out = String::new();
134 out.push_str(record.id.as_str());
135 out.push('\n');
136 out.push_str(&record.match_kind);
137 out.push('\n');
138 if let Some(description) = &record.description {
139 out.push_str(description);
140 out.push('\n');
141 }
142 out.push_str("Usage: ");
143 out.push_str(&record.invocation);
144 out.push('\n');
145 out.push_str("Requires: ");
146 if record.requires.is_empty() {
147 out.push_str("(none)");
148 } else {
149 out.push_str(
150 &record
151 .requires
152 .iter()
153 .map(|req| {
154 let status = if req.available {
155 "available"
156 } else {
157 "missing"
158 };
159 format!("{} [{status}]", req.binary)
160 })
161 .collect::<Vec<_>>()
162 .join(", "),
163 );
164 }
165 out.push('\n');
166 out.push_str("Expands to: ");
167 out.push_str(&record.expands_to);
168 out.push('\n');
169 if let Some(parent) = &record.extends {
170 out.push_str("Extends: ");
171 out.push_str(parent.as_str());
172 out.push_str(" (alias)\n");
173 }
174 out.push_str("Example: ");
175 if let Some(example) = record.examples.first() {
176 out.push_str(example);
177 for extra in record.examples.iter().skip(1) {
178 out.push('\n');
179 out.push_str(" ");
180 out.push_str(extra);
181 }
182 } else {
183 out.push_str(&record.invocation);
184 }
185 out.push('\n');
186 out
187}
188
189fn expands_to(ext: &ExtensionDef) -> String {
190 ext.expand
191 .as_ref()
192 .and_then(|spec| spec.command.as_ref())
193 .and_then(|command| command.get("type"))
194 .and_then(Value::as_str)
195 .unwrap_or("wizard")
196 .to_string()
197}
198
199fn invocation_line(ext: &ExtensionDef, args: &[SkillArg]) -> String {
200 let mut parts = vec!["wyvern".to_string()];
201 let spec = &ext.match_spec;
202 if let Some(prefix) = &spec.argv_prefix {
203 for token in prefix {
204 parts.push(token.as_str().to_string());
205 }
206 if let Some(suffix) = &spec.arg_suffix {
207 parts.push(format!("<file{}>", suffix.as_str()));
208 }
209 for arg in args {
210 parts.push(format_arg(arg));
211 }
212 return parts.join(" ");
213 }
214 if let Some(filename) = &spec.filename {
215 parts.push(format!("path/to/{}", filename.as_str()));
216 return parts.join(" ");
217 }
218 if let Some(suffix) = &spec.positional_suffix {
219 parts.push(format!("file{}", suffix.as_str()));
220 return parts.join(" ");
221 }
222 parts.join(" ")
223}
224
225fn example_line(ext: &ExtensionDef, invocation: &str, args: &[SkillArg]) -> String {
226 let spec = &ext.match_spec;
227 if let Some(prefix) = &spec.argv_prefix {
228 let prefix_s = join_prefix(prefix);
229 if let Some(suffix) = &spec.arg_suffix {
230 return format!("wyvern {prefix_s} data{}", suffix.as_str());
231 }
232 if !args.is_empty() {
233 return format!("wyvern {prefix_s} {}", example_args(args));
234 }
235 return format!("wyvern {prefix_s}");
236 }
237 invocation.to_string()
238}
239
240fn join_prefix(prefix: &[MatchToken]) -> String {
241 prefix
242 .iter()
243 .map(MatchToken::as_str)
244 .collect::<Vec<_>>()
245 .join(" ")
246}
247
248fn format_arg(arg: &SkillArg) -> String {
249 match (arg.name.as_str(), arg.required, arg.repeat) {
250 ("root", true, _) => "--root <DIR>".into(),
251 ("file", true, _) => "--file <FILE>".into(),
252 ("var", _, true) => "[--var k=v]".into(),
253 ("var-file", _, true) => "[--var-file vars.json]".into(),
254 ("env-prefix", _, true) => "[--env-prefix PREFIX]".into(),
255 (name, true, _) => format!("--{name} <{}>", placeholder(name)),
256 (name, false, true) => format!("[--{name} …]"),
257 (name, false, false) => format!("[--{name} <{}>]", placeholder(name)),
258 }
259}
260
261fn example_args(args: &[SkillArg]) -> String {
262 args.iter()
263 .map(|arg| match (arg.name.as_str(), arg.required, arg.repeat) {
264 ("root", true, _) => "--root DIR".into(),
265 ("file", true, _) => "--file FILE.j2".into(),
266 ("var", _, true) => "[--var k=v]".into(),
267 ("var-file", _, true) => "[--var-file vars.json]".into(),
268 ("env-prefix", _, true) => "[--env-prefix PREFIX]".into(),
269 (name, true, _) => format!("--{name} {}", placeholder(name)),
270 (name, false, true) => format!("[--{name} …]"),
271 (name, false, false) => format!("[--{name} {}]", placeholder(name)),
272 })
273 .collect::<Vec<_>>()
274 .join(" ")
275}
276
277fn placeholder(name: &str) -> String {
278 name.replace('-', "_").to_ascii_uppercase()
279}
280
281pub(crate) fn declared_skill_args(ext: &ExtensionDef) -> Vec<SkillArg> {
282 let mut vars = Vec::new();
283 let mut seen = BTreeSet::new();
284 if let Some(PreexecSpec { cmd, args, .. }) = &ext.preexec {
285 collect_template_vars(cmd, &mut vars, &mut seen);
286 for arg in args {
287 collect_template_vars(arg, &mut vars, &mut seen);
288 }
289 }
290 if let Some(exp) = &ext.expand {
291 if let Some(cmd) = &exp.command {
292 collect_value_vars(cmd, &mut vars, &mut seen);
293 }
294 if let Some(path) = &exp.command_from_file {
295 collect_template_vars(path, &mut vars, &mut seen);
296 }
297 if let Some(ui) = exp.host.as_ref().and_then(|h| h.ui_root.as_ref()) {
298 collect_template_vars(ui, &mut vars, &mut seen);
299 }
300 }
301 let mut ordered: Vec<SkillArg> = Vec::new();
302 for var in vars {
303 let Some(rest) = var.strip_prefix("arg:") else {
304 continue;
305 };
306 let (name, repeat) = match rest.strip_suffix(":repeat") {
307 Some(name) => (name, true),
308 None => (rest, false),
309 };
310 if let Some(existing) = ordered.iter_mut().find(|arg| arg.name.as_str() == name) {
311 if repeat {
312 existing.repeat = true;
313 } else {
314 existing.required = true;
315 }
316 continue;
317 }
318 let Some(arg_name) = ArgName::new(name) else {
319 continue;
320 };
321 ordered.push(SkillArg {
322 name: arg_name,
323 required: !repeat,
324 repeat,
325 });
326 }
327 ordered
328}
329
330fn collect_template_vars(template: &str, into: &mut Vec<String>, seen: &mut BTreeSet<String>) {
331 let mut rest = template;
332 while let Some(start) = rest.find('{') {
333 let after = &rest[start + 1..];
334 let Some(end) = after.find('}') else {
335 break;
336 };
337 let name = after[..end].to_string();
338 if seen.insert(name.clone()) {
339 into.push(name);
340 }
341 rest = &after[end + 1..];
342 }
343}
344
345fn collect_value_vars(value: &Value, into: &mut Vec<String>, seen: &mut BTreeSet<String>) {
346 match value {
347 Value::String(s) => collect_template_vars(s, into, seen),
348 Value::Array(items) => {
349 for item in items {
350 collect_value_vars(item, into, seen);
351 }
352 }
353 Value::Object(map) => {
354 for v in map.values() {
355 collect_value_vars(v, into, seen);
356 }
357 }
358 _ => {}
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use crate::extensions::{
366 ExtensionRegistry, RequiresProbe, SkillSource, SHIPPED_EXTENSIONS_JSON,
367 };
368
369 struct Absent;
370
371 impl RequiresProbe for Absent {
372 fn binary_on_path(&self, _name: &str) -> bool {
373 false
374 }
375 }
376
377 #[test]
378 fn compose_card_lists_flags_requires_and_example() {
379 let registry = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON).expect("shipped");
380 let ext = registry
381 .extensions()
382 .iter()
383 .find(|e| e.id.as_str() == "compose-render")
384 .expect("compose-render");
385 let record = build_skill_record(ext, &Absent);
386 assert_eq!(record.id.to_string(), "compose-render");
387 assert_eq!(record.source, SkillSource::Shipped);
388 assert_eq!(skill_help_command(ext), "wyvern compose render --help");
389 assert!(record.args.iter().any(|arg| arg.name.as_str() == "root"));
390 assert!(!record.requires.iter().any(|r| r.available));
391 let card = format_skill_card(&record);
392 let root_at = card.find("--root").expect("root");
393 let file_at = card.find("--file").expect("file");
394 assert!(root_at < file_at, "{card}");
395 assert!(card.contains("--env-prefix"), "{card}");
396 assert!(card.contains("Requires:"), "{card}");
397 assert!(card.contains("sc-compose"), "{card}");
398 assert!(card.contains("Example:"), "{card}");
399 }
400
401 #[test]
402 fn md_card_does_not_require_csv_path() {
403 let registry = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON).expect("shipped");
404 let ext = registry
405 .extensions()
406 .iter()
407 .find(|e| e.id.as_str() == "csv-md")
408 .expect("csv-md");
409 let card = format_skill_card(&build_skill_record(ext, &Absent));
410 assert_eq!(skill_help_command(ext), "wyvern md --help");
411 assert!(card.contains("wyvern md"), "{card}");
412 assert!(card.contains("Requires:"), "{card}");
413 assert!(card.contains("Example:"), "{card}");
414 }
415
416 #[test]
417 fn compose_render_shipped_preexec_uses_output_and_env_prefix() {
418 let shipped: Value = serde_json::from_str(SHIPPED_EXTENSIONS_JSON).expect("json");
419 let compose = shipped["extensions"]
420 .as_array()
421 .expect("extensions")
422 .iter()
423 .find(|ext| ext["id"] == "compose-render")
424 .expect("compose-render");
425 let args: Vec<&str> = compose["preexec"]["args"]
426 .as_array()
427 .expect("args")
428 .iter()
429 .filter_map(Value::as_str)
430 .collect();
431 assert!(args.contains(&"--output"), "{args:?}");
432 assert!(!args.contains(&"--out"), "{args:?}");
433 assert!(!args.contains(&"--env"), "{args:?}");
434 assert!(
435 args.iter().any(|token| token.contains("env-prefix")),
436 "{args:?}"
437 );
438 assert!(!args.contains(&"--format"), "{args:?}");
439 assert!(!args.contains(&"html"), "{args:?}");
440 }
441
442 #[test]
443 fn registry_accepts_missing_description_and_examples() {
444 let json = r#"{
445 "version": 1,
446 "extensions": [{
447 "id": "plain",
448 "match": { "positional_suffix": ".md" },
449 "expand": { "command": { "type": "markdown", "file": "{path}" } }
450 }]
451 }"#;
452 let registry = ExtensionRegistry::from_json_str(json).expect("parse");
453 let record = build_skill_record(®istry.extensions()[0], &Absent);
454 assert!(record.description.is_none());
455 assert_eq!(record.extends, None);
456 assert_eq!(record.examples.len(), 1);
457 assert_eq!(record.source, SkillSource::Shipped);
458 assert_eq!(
459 skill_help_command(®istry.extensions()[0]),
460 "wyvern extensions show plain"
461 );
462 }
463
464 #[test]
465 fn declared_skill_args_skips_empty_template_names() {
466 let json = r#"{
467 "version": 1,
468 "extensions": [{
469 "id": "empty-arg",
470 "match": { "positional_suffix": ".md" },
471 "preexec": { "cmd": "true", "args": ["{arg:}", "{arg: }", "{arg:root}"] },
472 "expand": { "command": { "type": "markdown", "file": "{path}" } }
473 }]
474 }"#;
475 let registry = ExtensionRegistry::from_json_str(json).expect("parse");
476 let args = declared_skill_args(®istry.extensions()[0]);
477 assert_eq!(args.len(), 1, "{args:?}");
478 assert_eq!(args[0].name.as_str(), "root");
479 }
480
481 #[test]
482 fn skill_record_includes_catalog_fields() {
483 let registry = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON).expect("shipped");
484 let records = build_skill_records(®istry, &Absent);
485 assert!(records.len() >= 7, "{}", records.len());
486 let alias = records
487 .iter()
488 .find(|record| record.id.as_str() == "csv-table-alias")
489 .expect("csv-table-alias");
490 assert_eq!(
491 alias.extends.as_ref().map(ExtensionId::as_str),
492 Some("csv-suffix")
493 );
494 assert!(alias.description.as_ref().is_some_and(|d| !d.is_empty()));
495 assert!(!alias.examples.is_empty());
496 let card = format_skill_card(alias);
497 assert!(card.contains("Extends: csv-suffix (alias)"), "{card}");
498 assert!(card.contains("[missing]"), "{card}");
499 }
500}