1use super::types::{ZImage, ZStep, ZWasmConfig};
8use crate::error::{BuildError, Result};
9
10pub fn parse_zimagefile(content: &str) -> Result<ZImage> {
21 let mut image: ZImage =
23 serde_yaml::from_str(content).map_err(|e| BuildError::zimagefile_parse(e.to_string()))?;
24
25 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 validate_version(&image)?;
42 validate_mode_exclusivity(&image)?;
43 validate_steps(&image)?;
44 validate_wasm(&image)?;
45
46 Ok(image)
47}
48
49fn default_wasm_config() -> ZWasmConfig {
54 serde_yaml::from_str::<ZWasmConfig>("{}")
55 .expect("empty ZWasmConfig must deserialize from '{}' via serde defaults")
56}
57
58fn 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
74fn 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
108fn validate_steps(image: &ZImage) -> Result<()> {
114 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 if let Some(ref stages) = image.stages {
123 for (stage_name, stage) in stages {
124 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
150fn 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 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 => {} _ => {
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 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 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 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 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 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#[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 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 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 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 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#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[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 #[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 #[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 #[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 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 #[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 #[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 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 let yaml = r#"
911version: "1"
912runtime: wasm
913wasm:
914 target: preview1
915"#;
916 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}