Skip to main content

zlayer_builder/zimage/
parser.rs

1//! `ZImagefile` parser — YAML deserialization + semantic validation.
2//!
3//! The entry point is [`parse_zimagefile`], which takes raw YAML content,
4//! deserializes it into a [`ZImage`], and then runs validation rules that
5//! cannot be expressed through serde alone.
6
7use super::types::{ZImage, ZStep, ZWasmConfig};
8use crate::error::{BuildError, Result};
9
10/// Parse and validate a `ZImagefile` from its YAML content.
11///
12/// This performs two phases:
13/// 1. **Deserialization** — YAML string into [`ZImage`] via `serde_yaml`.
14/// 2. **Validation** — semantic rules that serde annotations cannot enforce.
15///
16/// # Errors
17///
18/// Returns [`BuildError::ZImagefileParse`] for malformed YAML and
19/// [`BuildError::ZImagefileValidation`] for semantic rule violations.
20pub fn parse_zimagefile(content: &str) -> Result<ZImage> {
21    // Phase 1: Deserialize YAML into the ZImage struct.
22    let mut image: ZImage =
23        serde_yaml::from_str(content).map_err(|e| BuildError::zimagefile_parse(e.to_string()))?;
24
25    // `runtime: wasm` is a shorthand that routes to WASM build mode. Rewrite
26    // it to an explicit `wasm:` section with defaults so the rest of the
27    // pipeline (validation + builder dispatch) treats it uniformly as WASM
28    // mode. Keeps `runtime: wasm` distinct from language runtime templates
29    // which expand into Dockerfiles.
30    if image
31        .runtime
32        .as_deref()
33        .is_some_and(|r| r.eq_ignore_ascii_case("wasm") || r.eq_ignore_ascii_case("webassembly"))
34        && image.wasm.is_none()
35    {
36        image.runtime = None;
37        image.wasm = Some(default_wasm_config());
38    }
39
40    // Phase 2: Semantic validation.
41    validate_version(&image)?;
42    validate_mode_exclusivity(&image)?;
43    validate_steps(&image)?;
44    validate_wasm(&image)?;
45
46    Ok(image)
47}
48
49/// Construct a default [`ZWasmConfig`] by routing through serde so the
50/// `#[serde(default = ...)]` attributes on [`ZWasmConfig`] populate fields
51/// like `target` and `opt_level`. This avoids needing a `Default` impl on
52/// [`ZWasmConfig`] just for this path.
53fn default_wasm_config() -> ZWasmConfig {
54    serde_yaml::from_str::<ZWasmConfig>("{}")
55        .expect("empty ZWasmConfig must deserialize from '{}' via serde defaults")
56}
57
58// ---------------------------------------------------------------------------
59// Version validation
60// ---------------------------------------------------------------------------
61
62/// The version field, if present, must be `"1"`.
63fn validate_version(image: &ZImage) -> Result<()> {
64    if let Some(ref v) = image.version {
65        if v != "1" {
66            return Err(BuildError::zimagefile_validation(format!(
67                "unsupported version '{v}', only version \"1\" is supported"
68            )));
69        }
70    }
71    Ok(())
72}
73
74// ---------------------------------------------------------------------------
75// Mode exclusivity
76// ---------------------------------------------------------------------------
77
78/// Exactly one of `runtime`, `wasm`, `stages`, `base`, or `build` must be set.
79/// `base` and `build` are treated as the same mode (single-stage) and are
80/// mutually exclusive with each other.
81fn validate_mode_exclusivity(image: &ZImage) -> Result<()> {
82    let modes_present: Vec<&str> = [
83        image.runtime.as_ref().map(|_| "runtime"),
84        image.wasm.as_ref().map(|_| "wasm"),
85        image.stages.as_ref().map(|_| "stages"),
86        image.base.as_ref().map(|_| "base"),
87        image.build.as_ref().map(|_| "build"),
88    ]
89    .into_iter()
90    .flatten()
91    .collect();
92
93    match modes_present.len() {
94        0 => Err(BuildError::zimagefile_validation(
95            "exactly one of 'runtime', 'wasm', 'stages', 'base', or 'build' must be set, \
96             but none were found"
97                .to_string(),
98        )),
99        1 => Ok(()),
100        _ => Err(BuildError::zimagefile_validation(format!(
101            "exactly one of 'runtime', 'wasm', 'stages', 'base', or 'build' must be set, \
102             but multiple were found: {}",
103            modes_present.join(", ")
104        ))),
105    }
106}
107
108// ---------------------------------------------------------------------------
109// Step validation
110// ---------------------------------------------------------------------------
111
112/// Validate all steps reachable from the current image configuration.
113fn validate_steps(image: &ZImage) -> Result<()> {
114    // Single-stage mode: validate top-level steps.
115    if image.base.is_some() || image.build.is_some() {
116        for (i, step) in image.steps.iter().enumerate() {
117            validate_step(step, i, None)?;
118        }
119    }
120
121    // Multi-stage mode: validate steps in every stage + base/build exclusivity.
122    if let Some(ref stages) = image.stages {
123        for (stage_name, stage) in stages {
124            // Exactly one of base or build must be set per stage.
125            match (&stage.base, &stage.build) {
126                (None, None) => {
127                    return Err(BuildError::zimagefile_validation(format!(
128                        "stage '{stage_name}': exactly one of 'base' or 'build' must be set, \
129                         but neither was found"
130                    )));
131                }
132                (Some(_), Some(_)) => {
133                    return Err(BuildError::zimagefile_validation(format!(
134                        "stage '{stage_name}': 'base' and 'build' are mutually exclusive, \
135                         but both were set"
136                    )));
137                }
138                _ => {}
139            }
140
141            for (i, step) in stage.steps.iter().enumerate() {
142                validate_step(step, i, Some(stage_name))?;
143            }
144        }
145    }
146
147    Ok(())
148}
149
150/// Validate a single build step.
151///
152/// Rules enforced:
153/// - Exactly one instruction type must be set (`run`, `copy`, `add`, `env`, `workdir`, `user`).
154/// - `copy` and `add` steps must have a `to` field.
155/// - `cache` is only valid on `run` steps.
156/// - `from` is only valid on `copy`/`add` steps.
157/// - `owner`/`chmod` are only valid on `copy`/`add` steps.
158fn validate_step(step: &ZStep, index: usize, stage: Option<&str>) -> Result<()> {
159    let location = match stage {
160        Some(s) => format!("stage '{s}', step {}", index + 1),
161        None => format!("step {}", index + 1),
162    };
163
164    // Count how many instruction fields are set.
165    let instructions: Vec<&str> = [
166        step.run.as_ref().map(|_| "run"),
167        step.copy.as_ref().map(|_| "copy"),
168        step.add.as_ref().map(|_| "add"),
169        step.env.as_ref().map(|_| "env"),
170        step.workdir.as_ref().map(|_| "workdir"),
171        step.user.as_ref().map(|_| "user"),
172    ]
173    .into_iter()
174    .flatten()
175    .collect();
176
177    match instructions.len() {
178        0 => {
179            return Err(BuildError::zimagefile_validation(format!(
180                "{location}: step must have exactly one instruction type \
181                 (run, copy, add, env, workdir, user), but none were found"
182            )));
183        }
184        1 => {} // good
185        _ => {
186            return Err(BuildError::zimagefile_validation(format!(
187                "{location}: step must have exactly one instruction type, \
188                 but multiple were found: {}",
189                instructions.join(", ")
190            )));
191        }
192    }
193
194    let instruction = instructions[0];
195    let is_copy_or_add = instruction == "copy" || instruction == "add";
196
197    // `to` is required on copy/add steps.
198    if is_copy_or_add && step.to.is_none() {
199        return Err(BuildError::zimagefile_validation(format!(
200            "{location}: '{instruction}' step must have a 'to' field"
201        )));
202    }
203
204    // `cache` is only valid on `run` steps.
205    if !step.cache.is_empty() && instruction != "run" {
206        return Err(BuildError::zimagefile_validation(format!(
207            "{location}: 'cache' is only valid on 'run' steps, not '{instruction}'"
208        )));
209    }
210
211    // `from` is only valid on `copy`/`add` steps.
212    if step.from.is_some() && !is_copy_or_add {
213        return Err(BuildError::zimagefile_validation(format!(
214            "{location}: 'from' is only valid on 'copy'/'add' steps, not '{instruction}'"
215        )));
216    }
217
218    // `owner` is only valid on `copy`/`add` steps.
219    if step.owner.is_some() && !is_copy_or_add {
220        return Err(BuildError::zimagefile_validation(format!(
221            "{location}: 'owner' is only valid on 'copy'/'add' steps, not '{instruction}'"
222        )));
223    }
224
225    // `chmod` is only valid on `copy`/`add` steps.
226    if step.chmod.is_some() && !is_copy_or_add {
227        return Err(BuildError::zimagefile_validation(format!(
228            "{location}: 'chmod' is only valid on 'copy'/'add' steps, not '{instruction}'"
229        )));
230    }
231
232    Ok(())
233}
234
235// ---------------------------------------------------------------------------
236// WASM validation
237// ---------------------------------------------------------------------------
238
239/// Validate WASM-specific configuration fields when the `wasm` mode is active.
240#[allow(clippy::items_after_statements)]
241fn validate_wasm(image: &ZImage) -> Result<()> {
242    let Some(ref wasm) = image.wasm else {
243        return Ok(());
244    };
245
246    // `target` must be "preview1" or "preview2".
247    const VALID_TARGETS: &[&str] = &["preview1", "preview2"];
248    if !VALID_TARGETS.contains(&wasm.target.as_str()) {
249        return Err(BuildError::zimagefile_validation(format!(
250            "wasm.target must be one of {VALID_TARGETS:?}, got '{}'",
251            wasm.target
252        )));
253    }
254
255    // `world` must be a known ZLayer world name.
256    if let Some(ref world) = wasm.world {
257        const VALID_WORLDS: &[&str] = &[
258            "zlayer-plugin",
259            "zlayer-http-handler",
260            "zlayer-transformer",
261            "zlayer-authenticator",
262            "zlayer-rate-limiter",
263            "zlayer-middleware",
264            "zlayer-router",
265        ];
266        if !VALID_WORLDS.contains(&world.as_str()) {
267            return Err(BuildError::zimagefile_validation(format!(
268                "wasm.world must be one of {VALID_WORLDS:?}, got '{world}'"
269            )));
270        }
271    }
272
273    // `opt_level` must be a valid wasm-opt optimization level.
274    if let Some(ref opt_level) = wasm.opt_level {
275        const VALID_OPT_LEVELS: &[&str] = &["O", "Os", "Oz", "O2", "O3"];
276        if !VALID_OPT_LEVELS.contains(&opt_level.as_str()) {
277            return Err(BuildError::zimagefile_validation(format!(
278                "wasm.opt_level must be one of {VALID_OPT_LEVELS:?}, got '{opt_level}'"
279            )));
280        }
281    }
282
283    // `language` must be a supported source language.
284    if let Some(ref language) = wasm.language {
285        const VALID_LANGUAGES: &[&str] = &[
286            "rust",
287            "go",
288            "python",
289            "typescript",
290            "assemblyscript",
291            "c",
292            "zig",
293        ];
294        if !VALID_LANGUAGES.contains(&language.as_str()) {
295            return Err(BuildError::zimagefile_validation(format!(
296                "wasm.language must be one of {VALID_LANGUAGES:?}, got '{language}'"
297            )));
298        }
299    }
300
301    Ok(())
302}
303
304// ---------------------------------------------------------------------------
305// Tests
306// ---------------------------------------------------------------------------
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    // -- Happy-path tests ------------------------------------------------
313
314    #[test]
315    fn test_parse_runtime_mode() {
316        let yaml = r#"
317version: "1"
318runtime: node22
319cmd: "node server.js"
320"#;
321        let img = parse_zimagefile(yaml).unwrap();
322        assert_eq!(img.runtime.as_deref(), Some("node22"));
323    }
324
325    #[test]
326    fn test_parse_single_stage() {
327        let yaml = r#"
328version: "1"
329base: "alpine:3.19"
330steps:
331  - run: "apk add --no-cache curl"
332  - copy: "app.sh"
333    to: "/usr/local/bin/app.sh"
334    chmod: "755"
335  - workdir: "/app"
336cmd: ["./app.sh"]
337"#;
338        let img = parse_zimagefile(yaml).unwrap();
339        assert_eq!(img.base.as_deref(), Some("alpine:3.19"));
340        assert_eq!(img.steps.len(), 3);
341    }
342
343    #[test]
344    fn test_parse_multi_stage() {
345        let yaml = r#"
346version: "1"
347stages:
348  builder:
349    base: "node:22-alpine"
350    steps:
351      - copy: "package.json"
352        to: "./"
353      - run: "npm ci"
354  runtime:
355    base: "node:22-alpine"
356    steps:
357      - copy: "dist"
358        from: builder
359        to: "/app"
360cmd: ["node", "dist/index.js"]
361"#;
362        let img = parse_zimagefile(yaml).unwrap();
363        let stages = img.stages.as_ref().unwrap();
364        assert_eq!(stages.len(), 2);
365    }
366
367    #[test]
368    fn test_parse_wasm_mode() {
369        let yaml = r#"
370version: "1"
371wasm:
372  target: preview2
373  optimize: true
374"#;
375        let img = parse_zimagefile(yaml).unwrap();
376        assert!(img.wasm.is_some());
377    }
378
379    #[test]
380    fn test_version_omitted_is_ok() {
381        let yaml = r"
382runtime: node22
383";
384        let img = parse_zimagefile(yaml).unwrap();
385        assert!(img.version.is_none());
386        assert_eq!(img.runtime.as_deref(), Some("node22"));
387    }
388
389    // -- Version validation -----------------------------------------------
390
391    #[test]
392    fn test_bad_version_rejected() {
393        let yaml = r#"
394version: "2"
395runtime: node22
396"#;
397        let err = parse_zimagefile(yaml).unwrap_err();
398        let msg = err.to_string();
399        assert!(msg.contains("unsupported version"), "got: {msg}");
400    }
401
402    // -- Mode exclusivity -------------------------------------------------
403
404    #[test]
405    fn test_no_mode_rejected() {
406        let yaml = r#"
407version: "1"
408cmd: "echo hi"
409"#;
410        let err = parse_zimagefile(yaml).unwrap_err();
411        let msg = err.to_string();
412        assert!(msg.contains("none were found"), "got: {msg}");
413    }
414
415    #[test]
416    fn test_multiple_modes_rejected() {
417        let yaml = r#"
418version: "1"
419runtime: node22
420base: "alpine:3.19"
421"#;
422        let err = parse_zimagefile(yaml).unwrap_err();
423        let msg = err.to_string();
424        assert!(msg.contains("multiple were found"), "got: {msg}");
425    }
426
427    // -- Step validation --------------------------------------------------
428
429    #[test]
430    fn test_step_no_instruction_rejected() {
431        let yaml = r#"
432version: "1"
433base: "alpine:3.19"
434steps:
435  - to: "/app"
436"#;
437        let err = parse_zimagefile(yaml).unwrap_err();
438        let msg = err.to_string();
439        assert!(msg.contains("none were found"), "got: {msg}");
440    }
441
442    #[test]
443    fn test_step_multiple_instructions_rejected() {
444        let yaml = r#"
445version: "1"
446base: "alpine:3.19"
447steps:
448  - run: "echo hi"
449    workdir: "/app"
450"#;
451        let err = parse_zimagefile(yaml).unwrap_err();
452        let msg = err.to_string();
453        assert!(msg.contains("multiple were found"), "got: {msg}");
454    }
455
456    #[test]
457    fn test_copy_missing_to_rejected() {
458        let yaml = r#"
459version: "1"
460base: "alpine:3.19"
461steps:
462  - copy: "file.txt"
463"#;
464        let err = parse_zimagefile(yaml).unwrap_err();
465        let msg = err.to_string();
466        assert!(msg.contains("must have a 'to' field"), "got: {msg}");
467    }
468
469    #[test]
470    fn test_add_missing_to_rejected() {
471        let yaml = r#"
472version: "1"
473base: "alpine:3.19"
474steps:
475  - add: "archive.tar.gz"
476"#;
477        let err = parse_zimagefile(yaml).unwrap_err();
478        let msg = err.to_string();
479        assert!(msg.contains("must have a 'to' field"), "got: {msg}");
480    }
481
482    #[test]
483    fn test_cache_on_non_run_rejected() {
484        let yaml = r#"
485version: "1"
486base: "alpine:3.19"
487steps:
488  - copy: "file.txt"
489    to: "/app/file.txt"
490    cache:
491      - target: /var/cache
492"#;
493        let err = parse_zimagefile(yaml).unwrap_err();
494        let msg = err.to_string();
495        assert!(msg.contains("'cache' is only valid on 'run'"), "got: {msg}");
496    }
497
498    #[test]
499    fn test_from_on_non_copy_add_rejected() {
500        let yaml = r#"
501version: "1"
502base: "alpine:3.19"
503steps:
504  - run: "echo hi"
505    from: builder
506"#;
507        let err = parse_zimagefile(yaml).unwrap_err();
508        let msg = err.to_string();
509        assert!(
510            msg.contains("'from' is only valid on 'copy'/'add'"),
511            "got: {msg}"
512        );
513    }
514
515    #[test]
516    fn test_owner_on_non_copy_add_rejected() {
517        let yaml = r#"
518version: "1"
519base: "alpine:3.19"
520steps:
521  - run: "echo hi"
522    owner: "root:root"
523"#;
524        let err = parse_zimagefile(yaml).unwrap_err();
525        let msg = err.to_string();
526        assert!(
527            msg.contains("'owner' is only valid on 'copy'/'add'"),
528            "got: {msg}"
529        );
530    }
531
532    #[test]
533    fn test_chmod_on_non_copy_add_rejected() {
534        let yaml = r#"
535version: "1"
536base: "alpine:3.19"
537steps:
538  - run: "echo hi"
539    chmod: "755"
540"#;
541        let err = parse_zimagefile(yaml).unwrap_err();
542        let msg = err.to_string();
543        assert!(
544            msg.contains("'chmod' is only valid on 'copy'/'add'"),
545            "got: {msg}"
546        );
547    }
548
549    #[test]
550    fn test_from_on_copy_allowed() {
551        let yaml = r#"
552version: "1"
553base: "alpine:3.19"
554steps:
555  - copy: "dist"
556    from: builder
557    to: "/app"
558"#;
559        parse_zimagefile(yaml).unwrap();
560    }
561
562    #[test]
563    fn test_from_on_add_allowed() {
564        let yaml = r#"
565version: "1"
566base: "alpine:3.19"
567steps:
568  - add: "https://example.com/file.tar.gz"
569    from: builder
570    to: "/app"
571"#;
572        parse_zimagefile(yaml).unwrap();
573    }
574
575    #[test]
576    fn test_cache_on_run_allowed() {
577        let yaml = r#"
578version: "1"
579base: "alpine:3.19"
580steps:
581  - run: "apt-get update"
582    cache:
583      - target: /var/cache/apt
584        id: apt-cache
585"#;
586        parse_zimagefile(yaml).unwrap();
587    }
588
589    #[test]
590    fn test_owner_chmod_on_copy_allowed() {
591        let yaml = r#"
592version: "1"
593base: "alpine:3.19"
594steps:
595  - copy: "app.sh"
596    to: "/usr/local/bin/app.sh"
597    owner: "1000:1000"
598    chmod: "755"
599"#;
600        parse_zimagefile(yaml).unwrap();
601    }
602
603    #[test]
604    fn test_multi_stage_step_validation() {
605        // Ensure validation runs on every stage, not just top-level.
606        let yaml = r#"
607version: "1"
608stages:
609  builder:
610    base: "node:22"
611    steps:
612      - copy: "package.json"
613"#;
614        let err = parse_zimagefile(yaml).unwrap_err();
615        let msg = err.to_string();
616        assert!(msg.contains("stage 'builder'"), "got: {msg}");
617        assert!(msg.contains("must have a 'to' field"), "got: {msg}");
618    }
619
620    #[test]
621    fn test_yaml_syntax_error() {
622        let yaml = ":::not valid yaml:::";
623        let err = parse_zimagefile(yaml).unwrap_err();
624        let msg = err.to_string();
625        assert!(msg.contains("parse error"), "got: {msg}");
626    }
627
628    #[test]
629    fn test_env_step_valid() {
630        let yaml = r#"
631version: "1"
632base: "alpine:3.19"
633steps:
634  - env:
635      NODE_ENV: production
636"#;
637        parse_zimagefile(yaml).unwrap();
638    }
639
640    #[test]
641    fn test_user_step_valid() {
642        let yaml = r#"
643version: "1"
644base: "alpine:3.19"
645steps:
646  - user: "nobody"
647"#;
648        parse_zimagefile(yaml).unwrap();
649    }
650
651    // -- Build directive tests ------------------------------------------------
652
653    #[test]
654    fn test_build_short_form() {
655        let yaml = r#"
656version: "1"
657build: "."
658steps:
659  - run: "echo hello"
660"#;
661        let img = parse_zimagefile(yaml).unwrap();
662        assert!(img.build.is_some());
663        assert!(img.base.is_none());
664    }
665
666    #[test]
667    fn test_build_long_form() {
668        let yaml = r#"
669version: "1"
670build:
671  context: "./subdir"
672  file: "ZImagefile.prod"
673  args:
674    RUST_VERSION: "1.90"
675steps:
676  - run: "echo hello"
677"#;
678        let img = parse_zimagefile(yaml).unwrap();
679        assert!(img.build.is_some());
680        assert!(img.base.is_none());
681    }
682
683    #[test]
684    fn test_build_and_base_rejected() {
685        let yaml = r#"
686version: "1"
687base: "alpine:3.19"
688build: "."
689steps:
690  - run: "echo hi"
691"#;
692        let err = parse_zimagefile(yaml).unwrap_err();
693        let msg = err.to_string();
694        assert!(msg.contains("multiple were found"), "got: {msg}");
695    }
696
697    #[test]
698    fn test_stage_build_directive() {
699        let yaml = r#"
700version: "1"
701stages:
702  builder:
703    build: "."
704    steps:
705      - run: "make build"
706  runtime:
707    base: "debian:bookworm-slim"
708    steps:
709      - copy: "target/release/app"
710        from: builder
711        to: "/usr/local/bin/app"
712"#;
713        let img = parse_zimagefile(yaml).unwrap();
714        let stages = img.stages.as_ref().unwrap();
715        assert!(stages["builder"].build.is_some());
716        assert!(stages["builder"].base.is_none());
717        assert!(stages["runtime"].base.is_some());
718        assert!(stages["runtime"].build.is_none());
719    }
720
721    #[test]
722    fn test_stage_build_and_base_rejected() {
723        let yaml = r#"
724version: "1"
725stages:
726  builder:
727    base: "rust:1.90"
728    build: "."
729    steps:
730      - run: "cargo build"
731"#;
732        let err = parse_zimagefile(yaml).unwrap_err();
733        let msg = err.to_string();
734        assert!(msg.contains("mutually exclusive"), "got: {msg}");
735    }
736
737    #[test]
738    fn test_stage_neither_base_nor_build_rejected() {
739        let yaml = r#"
740version: "1"
741stages:
742  builder:
743    steps:
744      - run: "echo hi"
745"#;
746        let err = parse_zimagefile(yaml).unwrap_err();
747        let msg = err.to_string();
748        assert!(msg.contains("neither was found"), "got: {msg}");
749    }
750
751    // -- WASM validation tests ------------------------------------------------
752
753    #[test]
754    fn test_wasm_valid_full_config() {
755        let yaml = r#"
756version: "1"
757wasm:
758  target: "preview2"
759  optimize: true
760  opt_level: "Oz"
761  language: "rust"
762  world: "zlayer-http-handler"
763  wit: "./wit"
764  output: "./output.wasm"
765  features: [json, metrics]
766  build_args:
767    CARGO_PROFILE_RELEASE_LTO: "true"
768  pre_build:
769    - "wit-bindgen tiny-go --world zlayer-http-handler --out-dir bindings/"
770  post_build:
771    - "wasm-tools component embed --world zlayer-http-handler wit/ output.wasm -o output.wasm"
772  adapter: "./wasi_snapshot_preview1.reactor.wasm"
773"#;
774        parse_zimagefile(yaml).unwrap();
775    }
776
777    #[test]
778    fn test_wasm_preview1_target_valid() {
779        let yaml = r#"
780version: "1"
781wasm:
782  target: "preview1"
783"#;
784        parse_zimagefile(yaml).unwrap();
785    }
786
787    #[test]
788    fn test_wasm_invalid_target_rejected() {
789        let yaml = r#"
790version: "1"
791wasm:
792  target: "preview3"
793"#;
794        let err = parse_zimagefile(yaml).unwrap_err();
795        let msg = err.to_string();
796        assert!(msg.contains("wasm.target"), "got: {msg}");
797        assert!(msg.contains("preview3"), "got: {msg}");
798    }
799
800    #[test]
801    fn test_wasm_invalid_world_rejected() {
802        let yaml = r#"
803version: "1"
804wasm:
805  world: "unknown-world"
806"#;
807        let err = parse_zimagefile(yaml).unwrap_err();
808        let msg = err.to_string();
809        assert!(msg.contains("wasm.world"), "got: {msg}");
810        assert!(msg.contains("unknown-world"), "got: {msg}");
811    }
812
813    #[test]
814    fn test_wasm_all_valid_worlds() {
815        for world in &[
816            "zlayer-plugin",
817            "zlayer-http-handler",
818            "zlayer-transformer",
819            "zlayer-authenticator",
820            "zlayer-rate-limiter",
821            "zlayer-middleware",
822            "zlayer-router",
823        ] {
824            let yaml = format!(
825                r#"
826version: "1"
827wasm:
828  world: "{world}"
829"#
830            );
831            parse_zimagefile(&yaml).unwrap_or_else(|e| {
832                panic!("world '{world}' should be valid, got: {e}");
833            });
834        }
835    }
836
837    #[test]
838    fn test_wasm_invalid_opt_level_rejected() {
839        let yaml = r#"
840version: "1"
841wasm:
842  opt_level: "O4"
843"#;
844        let err = parse_zimagefile(yaml).unwrap_err();
845        let msg = err.to_string();
846        assert!(msg.contains("wasm.opt_level"), "got: {msg}");
847        assert!(msg.contains("O4"), "got: {msg}");
848    }
849
850    #[test]
851    fn test_wasm_all_valid_opt_levels() {
852        for level in &["O", "Os", "Oz", "O2", "O3"] {
853            let yaml = format!(
854                r#"
855version: "1"
856wasm:
857  opt_level: "{level}"
858"#
859            );
860            parse_zimagefile(&yaml).unwrap_or_else(|e| {
861                panic!("opt_level '{level}' should be valid, got: {e}");
862            });
863        }
864    }
865
866    #[test]
867    fn test_wasm_invalid_language_rejected() {
868        let yaml = r#"
869version: "1"
870wasm:
871  language: "java"
872"#;
873        let err = parse_zimagefile(yaml).unwrap_err();
874        let msg = err.to_string();
875        assert!(msg.contains("wasm.language"), "got: {msg}");
876        assert!(msg.contains("java"), "got: {msg}");
877    }
878
879    #[test]
880    fn test_runtime_wasm_rewrites_to_wasm_mode() {
881        // `runtime: wasm` is shorthand: it should be transparently rewritten
882        // to the `wasm:` mode with serde-default values so the rest of the
883        // pipeline dispatches it through the WASM build path.
884        let yaml = r#"
885version: "1"
886runtime: wasm
887"#;
888        let img = parse_zimagefile(yaml).unwrap();
889        assert!(img.runtime.is_none(), "runtime should be cleared");
890        let wasm = img.wasm.expect("wasm mode should be set");
891        assert_eq!(wasm.target, "preview2");
892        assert_eq!(wasm.opt_level.as_deref(), Some("Oz"));
893    }
894
895    #[test]
896    fn test_runtime_webassembly_alias_rewrites_to_wasm_mode() {
897        let yaml = r#"
898version: "1"
899runtime: WebAssembly
900"#;
901        let img = parse_zimagefile(yaml).unwrap();
902        assert!(img.runtime.is_none());
903        assert!(img.wasm.is_some());
904    }
905
906    #[test]
907    fn test_runtime_wasm_does_not_override_explicit_wasm_mode() {
908        // If the user sets both `runtime: wasm` and an explicit `wasm:`
909        // block... actually, the mode exclusivity check should reject that.
910        let yaml = r#"
911version: "1"
912runtime: wasm
913wasm:
914  target: preview1
915"#;
916        // Since we only rewrite when `image.wasm.is_none()`, both fields
917        // remain set and mode exclusivity rejects the combination.
918        let err = parse_zimagefile(yaml).unwrap_err();
919        let msg = err.to_string();
920        assert!(msg.contains("multiple were found"), "got: {msg}");
921    }
922
923    #[test]
924    fn test_wasm_all_valid_languages() {
925        for lang in &[
926            "rust",
927            "go",
928            "python",
929            "typescript",
930            "assemblyscript",
931            "c",
932            "zig",
933        ] {
934            let yaml = format!(
935                r#"
936version: "1"
937wasm:
938  language: "{lang}"
939"#
940            );
941            parse_zimagefile(&yaml).unwrap_or_else(|e| {
942                panic!("language '{lang}' should be valid, got: {e}");
943            });
944        }
945    }
946}