Skip to main content

wyvern/extensions/expand/
mod.rs

1//! Two-phase template substitution for preexec args and command/host expand.
2
3mod 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/// Context collected from an [`ExtensionMatch`] plus optional preexec outputs.
20#[derive(Debug, Clone)]
21pub struct MatchContext<'a> {
22    /// Matched file path (None for Prefix-only).
23    pub path: Option<&'a str>,
24    /// Tokens after an argv prefix.
25    pub args_after_prefix: &'a [String],
26    /// Captured preexec stdout when `preexec.stdout` is `"markdown"`.
27    pub preexec_stdout: Option<String>,
28    /// Lexicographically first `*.html` under `{tmpdir}/pages/`.
29    pub rendered_basename: Option<String>,
30    /// Secure temp dir path when `{tmpdir}` is referenced.
31    pub tmpdir: Option<PathBuf>,
32    /// Unified `{wyvern_share}` root.
33    pub wyvern_share: PathBuf,
34}
35
36/// Host overrides produced by phase-2 expand (`ui_root` only in Phase F).
37#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct HostOverrides {
39    /// Replaces CLI `--ui-root` when `Some`.
40    pub ui_root: Option<PathBuf>,
41}
42
43/// Validated expansion ready for the existing host pipeline.
44#[derive(Debug)]
45pub struct ExpandedInvocation {
46    /// Command JSON that has passed [`wyvern_schema::validate`].
47    pub command: Value,
48    /// Host option overrides from the extension.
49    pub host_overrides: HostOverrides,
50    /// Temp dir kept until host exit when `ui_root` is `{tmpdir}`.
51    pub temp_guard: Option<TempDir>,
52}
53
54#[derive(Clone, Copy)]
55pub(super) enum Phase {
56    Preexec,
57    Command,
58}
59
60/// Build the initial match context (no preexec outputs yet).
61#[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
73/// Phase 1: expand `preexec.cmd` and `preexec.args` only.
74///
75/// Takes [`PreexecSpec`] so callers cannot invoke this without a preexec block.
76/// `ext` is still required so `{arg:*}` declarations on command/host templates
77/// are accepted during remainder parsing.
78///
79/// # Errors
80///
81/// Returns [`ExtensionError`] for missing args, unexpected tokens, or bad templates.
82pub 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
93/// Phase 2: expand `command` / `command_from_file` and `host`.
94///
95/// # Errors
96///
97/// Returns [`ExtensionError`] for template, I/O, or missing-path failures.
98pub 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        // Expand the path only — file contents are Command JSON as written
123        // (literal braces must survive, e.g. wizard.json).
124        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/// Walk from the file's directory until `wizard.json` or `pages/` is found.
148#[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/// `{path}` relative to `{wizard_root}` using `/` separators.
166#[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}