1mod env;
4mod preexec_orchestration;
5mod template;
6
7use std::path::{Path, PathBuf};
8
9use serde_json::Value;
10use tempfile::TempDir;
11
12use super::{ExtensionDef, ExtensionError, ExtensionMatch, PreexecSpec, TemplateErrorKind};
13use env::ExpandEnv;
14use template::read_command_from_file;
15
16#[doc(inline)]
17pub use preexec_orchestration::{expand_and_validate, last_created_tmpdir};
18
19#[derive(Debug, Clone)]
21pub struct MatchContext<'a> {
22 pub path: Option<&'a str>,
24 pub args_after_prefix: &'a [String],
26 pub preexec_stdout: Option<String>,
28 pub rendered_basename: Option<String>,
30 pub tmpdir: Option<PathBuf>,
32 pub wyvern_share: PathBuf,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct HostOverrides {
39 pub ui_root: Option<PathBuf>,
41}
42
43#[derive(Debug)]
45pub struct ExpandedInvocation {
46 pub command: Value,
48 pub host_overrides: HostOverrides,
50 pub temp_guard: Option<TempDir>,
52}
53
54#[derive(Clone, Copy)]
55pub(super) enum Phase {
56 Preexec,
57 Command,
58}
59
60#[must_use]
62pub fn build_match_context<'a>(m: &'a ExtensionMatch<'a>, _ext: &ExtensionDef) -> MatchContext<'a> {
63 MatchContext {
64 path: m.path(),
65 args_after_prefix: m.args_after_prefix(),
66 preexec_stdout: None,
67 rendered_basename: None,
68 tmpdir: None,
69 wyvern_share: super::resolve_wyvern_share(),
70 }
71}
72
73pub fn expand_preexec_args(
83 pre: &PreexecSpec,
84 ext: &ExtensionDef,
85 ctx: &MatchContext<'_>,
86) -> Result<(String, Vec<String>), ExtensionError> {
87 let env = ExpandEnv::from_context(ext, ctx, Phase::Preexec)?;
88 let cmd = env.expand_string(&pre.cmd)?;
89 let args = env.expand_argv(&pre.args)?;
90 Ok((cmd, args))
91}
92
93pub fn expand_command_host(
99 ext: &ExtensionDef,
100 ctx: &MatchContext<'_>,
101) -> Result<(Value, HostOverrides), ExtensionError> {
102 let spec = ext.expand.as_ref().ok_or_else(|| {
103 ExtensionError::template(
104 TemplateErrorKind::InvalidSpec,
105 format!("extension '{}' has no expand block", ext.id),
106 )
107 })?;
108 if spec.command.is_some() && spec.command_from_file.is_some() {
109 return Err(ExtensionError::template(
110 TemplateErrorKind::InvalidSpec,
111 format!(
112 "extension '{}' sets both command and command_from_file",
113 ext.id
114 ),
115 ));
116 }
117 let env = ExpandEnv::from_context(ext, ctx, Phase::Command)?;
118 let command = if let Some(template) = &spec.command {
119 env.expand_value(template)?
120 } else if let Some(path_tmpl) = &spec.command_from_file {
121 let path = env.expand_string(path_tmpl)?;
122 let text = read_command_from_file(&path)?;
125 serde_json::from_str(&text).map_err(|err| ExtensionError::Io {
126 message: format!("command_from_file '{path}' is not JSON: {err}"),
127 source: Some(Box::new(err)),
128 })?
129 } else {
130 return Err(ExtensionError::template(
131 TemplateErrorKind::InvalidSpec,
132 format!(
133 "extension '{}' expand has neither command nor command_from_file",
134 ext.id
135 ),
136 ));
137 };
138 let host_overrides = match spec.host.as_ref().and_then(|h| h.ui_root.as_ref()) {
139 Some(tmpl) => HostOverrides {
140 ui_root: Some(PathBuf::from(env.expand_string(tmpl)?)),
141 },
142 None => HostOverrides::default(),
143 };
144 Ok((command, host_overrides))
145}
146
147#[must_use]
149pub fn infer_wizard_root(path: &Path) -> PathBuf {
150 let start = path
151 .parent()
152 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
153 let mut current = start.clone();
154 loop {
155 if current.join("wizard.json").is_file() || current.join("pages").is_dir() {
156 return current;
157 }
158 match current.parent() {
159 Some(parent) if parent != current => current = parent.to_path_buf(),
160 _ => return start,
161 }
162 }
163}
164
165#[must_use]
167pub fn relpath_from_ui_root(path: &Path, wizard_root: &Path) -> String {
168 path.strip_prefix(wizard_root)
169 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
170 .unwrap_or_else(|_| path.to_string_lossy().replace('\\', "/"))
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use crate::extensions::{ExtensionRegistry, SHIPPED_EXTENSIONS_JSON};
177 use template::MAX_COMMAND_FROM_FILE_BYTES;
178
179 #[test]
180 fn md_suffix_expands_path_parts() {
181 let registry = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON).expect("shipped");
182 let argv = vec!["docs/readme.md".to_string()];
183 let matched = registry.match_argv(&argv).expect("match");
184 let ctx = build_match_context(&matched, matched.extension());
185 let (cmd, host) = expand_command_host(matched.extension(), &ctx).expect("expand");
186 assert_eq!(cmd["type"], "markdown");
187 assert_eq!(cmd["file"], "docs/readme.md");
188 assert!(host.ui_root.is_none());
189 wyvern_schema::validate(&cmd).expect("validate");
190 }
191
192 #[test]
193 fn wizard_root_walk_unit() {
194 let tmp = tempfile::tempdir().expect("tmp");
195 let root = tmp.path().join("single-page");
196 let pages = root.join("pages");
197 std::fs::create_dir_all(&pages).expect("mkdir");
198 std::fs::write(root.join("wizard.json"), "{}").expect("wizard.json");
199 let html = pages.join("only.html");
200 std::fs::write(&html, "<p>x</p>").expect("html");
201 let inferred = infer_wizard_root(&html);
202 assert_eq!(inferred, root);
203 assert_eq!(relpath_from_ui_root(&html, &inferred), "pages/only.html");
204 }
205
206 #[test]
207 fn arg_repeat_splices_tokens() {
208 let json = r#"{
209 "version": 1,
210 "extensions": [
211 {
212 "id": "compose-render",
213 "match": { "argv_prefix": ["compose", "render"] },
214 "preexec": {
215 "cmd": "true",
216 "args": ["--root", "{arg:root}", "{arg:var-file:repeat}"]
217 },
218 "expand": {
219 "command": { "type": "markdown", "content": "{arg:root}" }
220 }
221 }
222 ]
223 }"#;
224 let registry = ExtensionRegistry::from_json_str(json).expect("parse");
225 let argv = vec![
226 "compose".into(),
227 "render".into(),
228 "--root".into(),
229 "test-root".into(),
230 "--var-file".into(),
231 "a.j2".into(),
232 "--var-file".into(),
233 "b.j2".into(),
234 ];
235 let matched = registry.match_argv(&argv).expect("match");
236 let ctx = build_match_context(&matched, matched.extension());
237 let pre = matched.extension().preexec.as_ref().expect("preexec");
238 let (cmd, args) = expand_preexec_args(pre, matched.extension(), &ctx).expect("preexec");
239 assert_eq!(cmd, "true");
240 assert_eq!(
241 args,
242 vec![
243 "--root",
244 "test-root",
245 "--var-file",
246 "a.j2",
247 "--var-file",
248 "b.j2"
249 ]
250 );
251 }
252
253 #[test]
254 fn missing_required_arg_errors() {
255 let json = r#"{
256 "version": 1,
257 "extensions": [
258 {
259 "id": "needs-root",
260 "match": { "argv_prefix": ["compose", "render"] },
261 "expand": {
262 "command": { "type": "markdown", "content": "{arg:root}" }
263 }
264 }
265 ]
266 }"#;
267 let registry = ExtensionRegistry::from_json_str(json).expect("parse");
268 let argv = vec!["compose".into(), "render".into()];
269 let matched = registry.match_argv(&argv).expect("match");
270 let ctx = build_match_context(&matched, matched.extension());
271 let err = expand_command_host(matched.extension(), &ctx).expect_err("missing");
272 assert!(
273 matches!(err, crate::extensions::ExtensionError::MissingArgs { ref missing, ref example, .. } if missing.iter().any(|m| m == "--root") && !example.is_empty()),
274 "{err:?}"
275 );
276 }
277
278 #[test]
279 fn missing_flag_value_populates_example() {
280 let json = r#"{
281 "version": 1,
282 "extensions": [
283 {
284 "id": "needs-root",
285 "examples": ["wyvern compose render --root DIR"],
286 "match": { "argv_prefix": ["compose", "render"] },
287 "expand": {
288 "command": { "type": "markdown", "content": "{arg:root}" }
289 }
290 }
291 ]
292 }"#;
293 let registry = ExtensionRegistry::from_json_str(json).expect("parse");
294 let argv = vec!["compose".into(), "render".into(), "--root".into()];
295 let matched = registry.match_argv(&argv).expect("match");
296 let ctx = build_match_context(&matched, matched.extension());
297 let err = expand_command_host(matched.extension(), &ctx).expect_err("missing value");
298 match err {
299 crate::extensions::ExtensionError::MissingArgs {
300 missing,
301 example,
302 extension_id,
303 help_command,
304 ..
305 } => {
306 assert!(missing.iter().any(|m| m == "--root"), "{missing:?}");
307 assert!(
308 !example.is_empty() && example.contains("compose render"),
309 "{example}"
310 );
311 assert_eq!(extension_id.as_str(), "needs-root");
312 assert_eq!(help_command, "wyvern compose render --help");
313 }
314 other => panic!("expected MissingArgs, got {other:?}"),
315 }
316 }
317
318 #[test]
319 fn path_parts_expand() {
320 let json = r#"{
321 "version": 1,
322 "extensions": [{
323 "id": "parts",
324 "match": { "positional_suffix": ".md" },
325 "expand": {
326 "command": {
327 "type": "markdown",
328 "content": "{path}|{basename}|{stem}|{parent_dir}"
329 }
330 }
331 }]
332 }"#;
333 let registry = ExtensionRegistry::from_json_str(json).expect("parse");
334 let argv = vec!["docs/readme.md".to_string()];
335 let matched = registry.match_argv(&argv).expect("match");
336 let ctx = build_match_context(&matched, matched.extension());
337 let (cmd, _) = expand_command_host(matched.extension(), &ctx).expect("expand");
338 assert_eq!(cmd["content"], "docs/readme.md|readme.md|readme|docs");
339 }
340
341 #[test]
342 fn unexpected_arg_records_invocation_help_command() {
343 let json = r#"{
344 "version": 1,
345 "extensions": [{
346 "id": "needs-root",
347 "match": { "argv_prefix": ["compose", "render"] },
348 "expand": {
349 "command": { "type": "markdown", "content": "{arg:root}" }
350 }
351 }]
352 }"#;
353 let registry = ExtensionRegistry::from_json_str(json).expect("parse");
354 let argv = vec![
355 "compose".into(),
356 "render".into(),
357 "--root".into(),
358 "dir".into(),
359 "--undeclared".into(),
360 ];
361 let matched = registry.match_argv(&argv).expect("match");
362 let ctx = build_match_context(&matched, matched.extension());
363 let err = expand_command_host(matched.extension(), &ctx).expect_err("unexpected");
364 match err {
365 crate::extensions::ExtensionError::UnexpectedArg {
366 token,
367 help_command,
368 extension_id,
369 ..
370 } => {
371 assert_eq!(token, "--undeclared");
372 assert_eq!(extension_id.as_str(), "needs-root");
373 assert_eq!(help_command, "wyvern compose render --help");
374 }
375 other => panic!("expected UnexpectedArg, got {other:?}"),
376 }
377 }
378
379 #[test]
380 fn arg_equals_form_parses() {
381 let json = r#"{
382 "version": 1,
383 "extensions": [{
384 "id": "compose-render",
385 "match": { "argv_prefix": ["compose", "render"] },
386 "expand": {
387 "command": { "type": "markdown", "content": "{arg:root}" }
388 }
389 }]
390 }"#;
391 let registry = ExtensionRegistry::from_json_str(json).expect("parse");
392 let argv = vec!["compose".into(), "render".into(), "--root=test-root".into()];
393 let matched = registry.match_argv(&argv).expect("match");
394 let ctx = build_match_context(&matched, matched.extension());
395 let (cmd, _) = expand_command_host(matched.extension(), &ctx).expect("expand");
396 assert_eq!(cmd["content"], "test-root");
397 }
398
399 #[test]
400 fn arg_repeat_zero_occurrences_splices_empty() {
401 let json = r#"{
402 "version": 1,
403 "extensions": [{
404 "id": "compose-render",
405 "match": { "argv_prefix": ["compose", "render"] },
406 "preexec": {
407 "cmd": "true",
408 "args": ["--root", "{arg:root}", "{arg:var-file:repeat}"]
409 },
410 "expand": {
411 "command": { "type": "markdown", "content": "{arg:root}" }
412 }
413 }]
414 }"#;
415 let registry = ExtensionRegistry::from_json_str(json).expect("parse");
416 let argv = vec![
417 "compose".into(),
418 "render".into(),
419 "--root".into(),
420 "test-root".into(),
421 ];
422 let matched = registry.match_argv(&argv).expect("match");
423 let ctx = build_match_context(&matched, matched.extension());
424 let pre = matched.extension().preexec.as_ref().expect("preexec");
425 let (_, args) = expand_preexec_args(pre, matched.extension(), &ctx).expect("preexec");
426 assert_eq!(args, vec!["--root", "test-root"]);
427 }
428
429 #[test]
430 fn path_var_on_prefix_only_is_error() {
431 let json = r#"{
432 "version": 1,
433 "extensions": [{
434 "id": "compose-render",
435 "match": { "argv_prefix": ["compose", "render"] },
436 "expand": {
437 "command": { "type": "markdown", "file": "{path}" }
438 }
439 }]
440 }"#;
441 let registry = ExtensionRegistry::from_json_str(json).expect("parse");
442 let argv = vec!["compose".into(), "render".into()];
443 let matched = registry.match_argv(&argv).expect("match");
444 let ctx = build_match_context(&matched, matched.extension());
445 let err = expand_command_host(matched.extension(), &ctx).expect_err("path");
446 assert!(matches!(
447 err,
448 crate::extensions::ExtensionError::PathVarWithoutPath { .. }
449 ));
450 }
451
452 #[test]
453 fn expand_and_validate_markdown_suffix() {
454 let registry = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON).expect("shipped");
455 let argv = vec!["doc.md".to_string()];
456 let matched = registry.match_argv(&argv).expect("match");
457 let ctx = build_match_context(&matched, matched.extension());
458 let expanded = expand_and_validate(matched.extension(), &ctx).expect("expand");
459 assert_eq!(expanded.command["type"], "markdown");
460 assert_eq!(expanded.command["file"], "doc.md");
461 assert!(expanded.temp_guard.is_none());
462 }
463
464 #[test]
465 fn tmpdir_guard_present_then_dropped() {
466 let json = r#"{
467 "version": 1,
468 "extensions": [{
469 "id": "tmp-host",
470 "match": { "positional_suffix": ".md" },
471 "expand": {
472 "command": { "type": "markdown", "file": "{path}" },
473 "host": { "ui_root": "{tmpdir}" }
474 }
475 }]
476 }"#;
477 let registry = ExtensionRegistry::from_json_str(json).expect("parse");
478 let argv = vec!["doc.md".to_string()];
479 let matched = registry.match_argv(&argv).expect("match");
480 let ctx = build_match_context(&matched, matched.extension());
481 let expanded = expand_and_validate(matched.extension(), &ctx).expect("expand");
482 let tmp = expanded
483 .temp_guard
484 .as_ref()
485 .expect("temp_guard")
486 .path()
487 .to_path_buf();
488 assert!(tmp.is_dir());
489 drop(expanded);
490 assert!(!tmp.exists());
491 }
492
493 #[test]
494 fn rendered_basename_substitutes_foo_html() {
495 let json = r#"{
496 "version": 1,
497 "extensions": [{
498 "id": "rendered",
499 "match": { "positional_suffix": ".md" },
500 "expand": {
501 "command": { "type": "markdown", "content": "{rendered_basename}" }
502 }
503 }]
504 }"#;
505 let registry = ExtensionRegistry::from_json_str(json).expect("parse");
506 let argv = vec!["doc.md".to_string()];
507 let matched = registry.match_argv(&argv).expect("match");
508 let mut ctx = build_match_context(&matched, matched.extension());
509 ctx.rendered_basename = Some("foo.html".to_string());
510 let (cmd, _) = expand_command_host(matched.extension(), &ctx).expect("expand");
511 assert_eq!(cmd["content"], "foo.html");
512 assert!(cmd["content"]
513 .as_str()
514 .is_some_and(|s| s.contains("foo.html")));
515 }
516
517 #[cfg(unix)]
518 #[test]
519 fn rendered_basename_end_to_end_from_tmpdir_pages() {
520 let json = r#"{
521 "version": 1,
522 "extensions": [{
523 "id": "rendered-e2e",
524 "match": { "positional_suffix": ".md" },
525 "preexec": {
526 "cmd": "sh",
527 "args": [
528 "-c",
529 "mkdir -p \"$1/pages\" && printf '<p>x</p>' > \"$1/pages/foo.html\"",
530 "preexec",
531 "{tmpdir}"
532 ]
533 },
534 "expand": {
535 "command": { "type": "markdown", "content": "{rendered_basename}" }
536 }
537 }]
538 }"#;
539 let registry = ExtensionRegistry::from_json_str(json).expect("parse");
540 let argv = vec!["doc.md".to_string()];
541 let matched = registry.match_argv(&argv).expect("match");
542 let ctx = build_match_context(&matched, matched.extension());
543 let expanded = expand_and_validate(matched.extension(), &ctx).expect("expand");
544 assert_eq!(expanded.command["content"], "foo.html");
545 }
546
547 #[test]
548 fn command_from_file_does_not_expand_literal_braces() {
549 let tmp = tempfile::tempdir().expect("tmp");
550 let path = tmp.path().join("cmd.json");
551 std::fs::write(
552 &path,
553 r#"{"type":"markdown","content":"use {braces} literally"}"#,
554 )
555 .expect("write");
556 let json = format!(
557 r#"{{
558 "version": 1,
559 "extensions": [{{
560 "id": "from-file",
561 "match": {{ "positional_suffix": ".md" }},
562 "expand": {{ "command_from_file": "{}" }}
563 }}]
564 }}"#,
565 path.display().to_string().replace('\\', "/")
566 );
567 let registry = ExtensionRegistry::from_json_str(&json).expect("parse");
568 let argv = vec!["doc.md".to_string()];
569 let matched = registry.match_argv(&argv).expect("match");
570 let ctx = build_match_context(&matched, matched.extension());
571 let (cmd, _) = expand_command_host(matched.extension(), &ctx).expect("expand");
572 assert_eq!(cmd["content"], "use {braces} literally");
573 }
574
575 #[test]
576 fn command_from_file_rejects_oversize() {
577 let tmp = tempfile::tempdir().expect("tmp");
578 let path = tmp.path().join("huge.json");
579 let body = format!(
580 r#"{{"type":"markdown","content":"{}"}}"#,
581 "x".repeat(MAX_COMMAND_FROM_FILE_BYTES)
582 );
583 std::fs::write(&path, &body).expect("write");
584 let json = format!(
585 r#"{{
586 "version": 1,
587 "extensions": [{{
588 "id": "from-file",
589 "match": {{ "positional_suffix": ".md" }},
590 "expand": {{ "command_from_file": "{}" }}
591 }}]
592 }}"#,
593 path.display().to_string().replace('\\', "/")
594 );
595 let registry = ExtensionRegistry::from_json_str(&json).expect("parse");
596 let argv = vec!["doc.md".to_string()];
597 let matched = registry.match_argv(&argv).expect("match");
598 let ctx = build_match_context(&matched, matched.extension());
599 let err = expand_command_host(matched.extension(), &ctx).expect_err("oversize");
600 assert!(
601 matches!(err, crate::extensions::ExtensionError::Io { ref message, .. } if message.contains("exceeds")),
602 "{err}"
603 );
604 }
605}