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