Skip to main content

zlayer_builder/zimage/
types.rs

1//! `ZImagefile` types - YAML-based image build format
2//!
3//! This module defines all serde-deserializable types for the `ZImagefile` format,
4//! an alternative to Dockerfiles using YAML syntax. The format supports four
5//! mutually exclusive build modes:
6//!
7//! 1. **Runtime template** - shorthand like `runtime: node22`
8//! 2. **Single-stage** - `base:` or `build:` + `steps:` at top level
9//! 3. **Multi-stage** - `stages:` map (`IndexMap` for insertion order, last = output)
10//! 4. **WASM** - `wasm:` configuration for WebAssembly builds
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use indexmap::IndexMap;
16use serde::{Deserialize, Serialize};
17
18// ---------------------------------------------------------------------------
19// Build context (for `build:` directive)
20// ---------------------------------------------------------------------------
21
22/// Build context for building a base image from a local Dockerfile or `ZImagefile`.
23///
24/// Supports two forms:
25///
26/// ```yaml
27/// # Short form: just a path (defaults to auto-detecting build file)
28/// build: "."
29///
30/// # Long form: explicit configuration
31/// build:
32///   context: "./subdir"     # or use `workdir:` (context is an alias)
33///   file: "ZImagefile.prod" # specific build file
34///   args:
35///     RUST_VERSION: "1.90"
36/// ```
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(untagged)]
39pub enum ZBuildContext {
40    /// Short form: just a path to the build context directory.
41    Short(String),
42    /// Long form: explicit build configuration.
43    Full {
44        /// Build context directory. Defaults to the current working directory.
45        /// `context` is accepted as an alias for Docker Compose compatibility.
46        #[serde(alias = "context", default)]
47        workdir: Option<String>,
48        /// Path to the build file (Dockerfile or `ZImagefile`).
49        /// Auto-detected if omitted (prefers `ZImagefile` over Dockerfile).
50        #[serde(default, skip_serializing_if = "Option::is_none")]
51        file: Option<String>,
52        /// Build arguments passed to the nested build.
53        #[serde(default, skip_serializing_if = "HashMap::is_empty")]
54        args: HashMap<String, String>,
55    },
56}
57
58impl ZBuildContext {
59    /// Resolve the build context directory relative to a base path.
60    #[must_use]
61    pub fn context_dir(&self, base: &Path) -> PathBuf {
62        match self {
63            Self::Short(path) => base.join(path),
64            Self::Full { workdir, .. } => match workdir {
65                Some(dir) => base.join(dir),
66                None => base.to_path_buf(),
67            },
68        }
69    }
70
71    /// Get the explicit build file path, if specified.
72    #[must_use]
73    pub fn file(&self) -> Option<&str> {
74        match self {
75            Self::Short(_) => None,
76            Self::Full { file, .. } => file.as_deref(),
77        }
78    }
79
80    /// Get build arguments.
81    #[must_use]
82    pub fn args(&self) -> HashMap<String, String> {
83        match self {
84            Self::Short(_) => HashMap::new(),
85            Self::Full { args, .. } => args.clone(),
86        }
87    }
88}
89
90// ---------------------------------------------------------------------------
91// Top-level ZImage
92// ---------------------------------------------------------------------------
93
94/// Top-level `ZImagefile` representation.
95///
96/// Exactly one of the four mode fields must be set:
97/// - `runtime` for runtime template shorthand
98/// - `base` + `steps` for single-stage builds
99/// - `stages` for multi-stage builds
100/// - `wasm` for WebAssembly component builds
101///
102/// Common image metadata fields (env, workdir, expose, cmd, etc.) apply to
103/// the final output image regardless of mode.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct ZImage {
107    /// `ZImagefile` format version (currently must be "1")
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub version: Option<String>,
110
111    // -- Mode 1: runtime template shorthand --
112    /// Runtime template name, e.g. "node22", "python313", "rust"
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub runtime: Option<String>,
115
116    // -- Mode 2: single-stage --
117    /// Base image for single-stage builds (e.g. "alpine:3.19").
118    /// Mutually exclusive with `build`.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub base: Option<String>,
121
122    /// Build a base image from a local Dockerfile/ZImagefile context.
123    /// Mutually exclusive with `base`.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub build: Option<ZBuildContext>,
126
127    /// Build steps for single-stage mode
128    #[serde(default, skip_serializing_if = "Vec::is_empty")]
129    pub steps: Vec<ZStep>,
130
131    /// Target platform for single-stage mode (e.g. "linux/amd64")
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub platform: Option<String>,
134
135    // -- Mode 3: multi-stage --
136    /// Named stages for multi-stage builds. Insertion order is preserved;
137    /// the last stage is the output image.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub stages: Option<IndexMap<String, ZStage>>,
140
141    // -- Mode 4: WASM --
142    /// WebAssembly build configuration
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub wasm: Option<ZWasmConfig>,
145
146    // -- Common image metadata --
147    /// Environment variables applied to the final image
148    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
149    pub env: HashMap<String, String>,
150
151    /// Working directory for the final image
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub workdir: Option<String>,
154
155    /// Ports to expose
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub expose: Option<ZExpose>,
158
159    /// Default command (CMD equivalent)
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub cmd: Option<ZCommand>,
162
163    /// Entrypoint command
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub entrypoint: Option<ZCommand>,
166
167    /// User to run as in the final image
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub user: Option<String>,
170
171    /// Image labels / metadata
172    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
173    pub labels: HashMap<String, String>,
174
175    /// Volume mount points
176    #[serde(default, skip_serializing_if = "Vec::is_empty")]
177    pub volumes: Vec<String>,
178
179    /// Healthcheck configuration
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub healthcheck: Option<ZHealthcheck>,
182
183    /// Signal to send when stopping the container
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub stopsignal: Option<String>,
186
187    /// Build arguments (name -> default value)
188    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
189    pub args: HashMap<String, String>,
190}
191
192// ---------------------------------------------------------------------------
193// Stage
194// ---------------------------------------------------------------------------
195
196/// A single build stage in a multi-stage `ZImagefile`.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198#[serde(deny_unknown_fields)]
199pub struct ZStage {
200    /// Base image for this stage (e.g. "node:22-alpine").
201    /// Mutually exclusive with `build`. One of `base` or `build` must be set.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub base: Option<String>,
204
205    /// Build a base image from a local Dockerfile/ZImagefile context.
206    /// Mutually exclusive with `base`. One of `base` or `build` must be set.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub build: Option<ZBuildContext>,
209
210    /// Target platform override (e.g. "linux/arm64")
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub platform: Option<String>,
213
214    /// Build arguments scoped to this stage
215    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
216    pub args: HashMap<String, String>,
217
218    /// Environment variables for this stage
219    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
220    pub env: HashMap<String, String>,
221
222    /// Working directory for this stage
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub workdir: Option<String>,
225
226    /// Ordered build steps
227    #[serde(default, skip_serializing_if = "Vec::is_empty")]
228    pub steps: Vec<ZStep>,
229
230    /// Labels for this stage
231    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
232    pub labels: HashMap<String, String>,
233
234    /// Ports to expose
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub expose: Option<ZExpose>,
237
238    /// User to run as
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub user: Option<String>,
241
242    /// Entrypoint command
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub entrypoint: Option<ZCommand>,
245
246    /// Default command
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub cmd: Option<ZCommand>,
249
250    /// Volume mount points
251    #[serde(default, skip_serializing_if = "Vec::is_empty")]
252    pub volumes: Vec<String>,
253
254    /// Healthcheck configuration
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub healthcheck: Option<ZHealthcheck>,
257
258    /// Signal to send when stopping the container
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub stopsignal: Option<String>,
261}
262
263// ---------------------------------------------------------------------------
264// Step
265// ---------------------------------------------------------------------------
266
267/// A single build instruction within a stage.
268///
269/// Exactly one of the action fields (`run`, `copy`, `add`, `env`, `workdir`,
270/// `user`) should be set. The remaining fields are modifiers that apply to
271/// the chosen action.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273#[serde(deny_unknown_fields)]
274pub struct ZStep {
275    // -- Mutually exclusive action fields --
276    /// Shell command or exec-form command to run
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub run: Option<ZCommand>,
279
280    /// Source path(s) to copy into the image
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub copy: Option<ZCopySources>,
283
284    /// Source path(s) to add (supports URLs and auto-extraction)
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub add: Option<ZCopySources>,
287
288    /// Environment variables to set
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub env: Option<HashMap<String, String>>,
291
292    /// Change working directory
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub workdir: Option<String>,
295
296    /// Change user
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub user: Option<String>,
299
300    // -- Shared modifier fields --
301    /// Destination path (for copy/add actions)
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub to: Option<String>,
304
305    /// Source stage name for cross-stage copy (replaces `--from`)
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub from: Option<String>,
308
309    /// File ownership (replaces `--chown`)
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub owner: Option<String>,
312
313    /// File permissions (replaces `--chmod`)
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub chmod: Option<String>,
316
317    /// Cache mounts for RUN steps
318    #[serde(default, skip_serializing_if = "Vec::is_empty")]
319    pub cache: Vec<ZCacheMount>,
320}
321
322// ---------------------------------------------------------------------------
323// Cache mount
324// ---------------------------------------------------------------------------
325
326/// A cache mount specification for RUN steps.
327///
328/// Maps to `--mount=type=cache` in Dockerfile/buildah syntax.
329#[derive(Debug, Clone, Serialize, Deserialize)]
330#[serde(deny_unknown_fields)]
331pub struct ZCacheMount {
332    /// Target path inside the container where the cache is mounted
333    pub target: String,
334
335    /// Cache identifier (shared across builds with the same id)
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub id: Option<String>,
338
339    /// Sharing mode: "locked", "shared", or "private"
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub sharing: Option<String>,
342
343    /// Whether the mount is read-only
344    #[serde(default, skip_serializing_if = "crate::zimage::types::is_false")]
345    pub readonly: bool,
346}
347
348// ---------------------------------------------------------------------------
349// Command (shell string or exec array)
350// ---------------------------------------------------------------------------
351
352/// A command that can be specified as either a shell string or an exec-form
353/// array of strings.
354///
355/// # YAML Examples
356///
357/// Shell form:
358/// ```yaml
359/// run: "apt-get update && apt-get install -y curl"
360/// ```
361///
362/// Exec form:
363/// ```yaml
364/// cmd: ["node", "server.js"]
365/// ```
366#[derive(Debug, Clone, Serialize, Deserialize)]
367#[serde(untagged)]
368pub enum ZCommand {
369    /// Shell form - passed to `/bin/sh -c`
370    Shell(String),
371    /// Exec form - executed directly
372    Exec(Vec<String>),
373}
374
375// ---------------------------------------------------------------------------
376// Copy sources
377// ---------------------------------------------------------------------------
378
379/// Source specification for copy/add steps. Can be a single path or multiple.
380///
381/// # YAML Examples
382///
383/// Single source:
384/// ```yaml
385/// copy: "package.json"
386/// ```
387///
388/// Multiple sources:
389/// ```yaml
390/// copy: ["package.json", "package-lock.json"]
391/// ```
392#[derive(Debug, Clone, Serialize, Deserialize)]
393#[serde(untagged)]
394pub enum ZCopySources {
395    /// A single source path
396    Single(String),
397    /// Multiple source paths
398    Multiple(Vec<String>),
399}
400
401impl ZCopySources {
402    /// Convert to a vector of source paths regardless of variant.
403    #[must_use]
404    pub fn to_vec(&self) -> Vec<String> {
405        match self {
406            Self::Single(s) => vec![s.clone()],
407            Self::Multiple(v) => v.clone(),
408        }
409    }
410}
411
412// ---------------------------------------------------------------------------
413// Expose
414// ---------------------------------------------------------------------------
415
416/// Port exposure specification. Can be a single port or multiple port specs.
417///
418/// # YAML Examples
419///
420/// Single port:
421/// ```yaml
422/// expose: 8080
423/// ```
424///
425/// Multiple ports with optional protocol:
426/// ```yaml
427/// expose:
428///   - 8080
429///   - "9090/udp"
430/// ```
431#[derive(Debug, Clone, Serialize, Deserialize)]
432#[serde(untagged)]
433pub enum ZExpose {
434    /// A single port number
435    Single(u16),
436    /// Multiple port specifications
437    Multiple(Vec<ZPortSpec>),
438}
439
440// ---------------------------------------------------------------------------
441// Port spec
442// ---------------------------------------------------------------------------
443
444/// A single port specification, either a bare port number or a port with
445/// protocol suffix.
446///
447/// # YAML Examples
448///
449/// ```yaml
450/// - 8080        # bare number, defaults to TCP
451/// - "8080/tcp"  # explicit TCP
452/// - "53/udp"    # explicit UDP
453/// ```
454#[derive(Debug, Clone, Serialize, Deserialize)]
455#[serde(untagged)]
456pub enum ZPortSpec {
457    /// Bare port number (defaults to TCP)
458    Number(u16),
459    /// Port with protocol, e.g. "8080/tcp" or "53/udp"
460    WithProtocol(String),
461}
462
463// ---------------------------------------------------------------------------
464// Healthcheck
465// ---------------------------------------------------------------------------
466
467/// Healthcheck configuration for the container.
468///
469/// # YAML Example
470///
471/// ```yaml
472/// healthcheck:
473///   cmd: "curl -f http://localhost:8080/health || exit 1"
474///   interval: "30s"
475///   timeout: "10s"
476///   start_period: "5s"
477///   retries: 3
478/// ```
479#[derive(Debug, Clone, Serialize, Deserialize)]
480#[serde(deny_unknown_fields)]
481pub struct ZHealthcheck {
482    /// Command to run for the health check
483    pub cmd: ZCommand,
484
485    /// Interval between health checks (e.g. "30s", "1m")
486    #[serde(default, skip_serializing_if = "Option::is_none")]
487    pub interval: Option<String>,
488
489    /// Timeout for each health check (e.g. "10s")
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub timeout: Option<String>,
492
493    /// Grace period before first check (e.g. "5s")
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub start_period: Option<String>,
496
497    /// Number of consecutive failures before unhealthy
498    #[serde(default, skip_serializing_if = "Option::is_none")]
499    pub retries: Option<u32>,
500}
501
502// ---------------------------------------------------------------------------
503// WASM config
504// ---------------------------------------------------------------------------
505
506/// WebAssembly build configuration for WASM mode.
507///
508/// # YAML Example
509///
510/// ```yaml
511/// wasm:
512///   target: "preview2"
513///   optimize: true
514///   opt_level: "Oz"
515///   language: "rust"
516///   world: "zlayer-http-handler"
517///   wit: "./wit"
518///   output: "./output.wasm"
519///   features: [json, metrics]
520///   build_args:
521///     CARGO_PROFILE_RELEASE_LTO: "true"
522///   pre_build:
523///     - "wit-bindgen tiny-go --world zlayer-http-handler --out-dir bindings/"
524///   post_build:
525///     - "wasm-tools component embed --world zlayer-http-handler wit/ output.wasm -o output.wasm"
526///   adapter: "./wasi_snapshot_preview1.reactor.wasm"
527/// ```
528#[derive(Debug, Clone, Serialize, Deserialize)]
529#[serde(deny_unknown_fields)]
530pub struct ZWasmConfig {
531    /// WASI target version: "preview1" or "preview2" (default: "preview2")
532    #[serde(default = "default_wasm_target")]
533    pub target: String,
534
535    /// Whether to run wasm-opt on the output
536    #[serde(default, skip_serializing_if = "crate::zimage::types::is_false")]
537    pub optimize: bool,
538
539    /// Optimization level for wasm-opt: "O", "Os", "Oz", "O2", "O3" (default: "Oz")
540    #[serde(
541        default = "default_wasm_opt_level",
542        skip_serializing_if = "Option::is_none"
543    )]
544    pub opt_level: Option<String>,
545
546    /// Source language (auto-detected if omitted): rust, go, python, typescript, assemblyscript, c, zig
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub language: Option<String>,
549
550    /// Path to WIT definitions (default: "./wit")
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub wit: Option<String>,
553
554    /// Target WIT world name (e.g., "zlayer-http-handler", "zlayer-plugin", "zlayer-transformer",
555    /// "zlayer-authenticator", "zlayer-rate-limiter", "zlayer-middleware", "zlayer-router")
556    #[serde(default, skip_serializing_if = "Option::is_none")]
557    pub world: Option<String>,
558
559    /// Output path for the compiled WASM file
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub output: Option<String>,
562
563    /// Language-specific features to enable during build
564    #[serde(default, skip_serializing_if = "Vec::is_empty")]
565    pub features: Vec<String>,
566
567    /// Additional build arguments (language-specific, e.g. `CARGO_PROFILE_RELEASE_LTO`)
568    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
569    pub build_args: HashMap<String, String>,
570
571    /// Pre-build commands to run before compilation (e.g., WIT binding generation for Go)
572    #[serde(default, skip_serializing_if = "Vec::is_empty")]
573    pub pre_build: Vec<ZCommand>,
574
575    /// Post-build commands to run after compilation (before optimization)
576    #[serde(default, skip_serializing_if = "Vec::is_empty")]
577    pub post_build: Vec<ZCommand>,
578
579    /// Component adapter path for WASI preview1 -> preview2 lifting
580    #[serde(default, skip_serializing_if = "Option::is_none")]
581    pub adapter: Option<String>,
582}
583
584// ---------------------------------------------------------------------------
585// Helpers
586// ---------------------------------------------------------------------------
587
588/// Default WASM target version.
589fn default_wasm_target() -> String {
590    "preview2".to_string()
591}
592
593/// Default WASM optimization level.
594#[allow(clippy::unnecessary_wraps)]
595fn default_wasm_opt_level() -> Option<String> {
596    Some("Oz".to_string())
597}
598
599/// Helper for `skip_serializing_if` on boolean fields.
600#[allow(clippy::trivially_copy_pass_by_ref)]
601fn is_false(v: &bool) -> bool {
602    !v
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    #[test]
610    fn test_runtime_mode_deserialize() {
611        let yaml = r#"
612runtime: node22
613cmd: "node server.js"
614"#;
615        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
616        assert_eq!(img.runtime.as_deref(), Some("node22"));
617        assert!(matches!(img.cmd, Some(ZCommand::Shell(ref s)) if s == "node server.js"));
618    }
619
620    #[test]
621    fn test_single_stage_deserialize() {
622        let yaml = r#"
623base: "alpine:3.19"
624steps:
625  - run: "apk add --no-cache curl"
626  - copy: "app.sh"
627    to: "/usr/local/bin/app.sh"
628    chmod: "755"
629  - workdir: "/app"
630env:
631  NODE_ENV: production
632expose: 8080
633cmd: ["./app.sh"]
634"#;
635        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
636        assert_eq!(img.base.as_deref(), Some("alpine:3.19"));
637        assert_eq!(img.steps.len(), 3);
638        assert_eq!(img.env.get("NODE_ENV").unwrap(), "production");
639        assert!(matches!(img.expose, Some(ZExpose::Single(8080))));
640        assert!(matches!(img.cmd, Some(ZCommand::Exec(ref v)) if v.len() == 1));
641    }
642
643    #[test]
644    fn test_multi_stage_deserialize() {
645        let yaml = r#"
646stages:
647  builder:
648    base: "node:22-alpine"
649    workdir: "/src"
650    steps:
651      - copy: ["package.json", "package-lock.json"]
652        to: "./"
653      - run: "npm ci"
654      - copy: "."
655        to: "."
656      - run: "npm run build"
657  runtime:
658    base: "node:22-alpine"
659    workdir: "/app"
660    steps:
661      - copy: "dist"
662        from: builder
663        to: "/app"
664    cmd: ["node", "dist/index.js"]
665expose: 3000
666"#;
667        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
668        let stages = img.stages.as_ref().unwrap();
669        assert_eq!(stages.len(), 2);
670
671        // Verify insertion order is preserved
672        let keys: Vec<&String> = stages.keys().collect();
673        assert_eq!(keys, vec!["builder", "runtime"]);
674
675        let builder = &stages["builder"];
676        assert_eq!(builder.base.as_deref(), Some("node:22-alpine"));
677        assert_eq!(builder.steps.len(), 4);
678
679        let runtime = &stages["runtime"];
680        assert_eq!(runtime.steps.len(), 1);
681        assert_eq!(runtime.steps[0].from.as_deref(), Some("builder"));
682    }
683
684    #[test]
685    fn test_wasm_mode_deserialize() {
686        let yaml = r#"
687wasm:
688  target: preview2
689  optimize: true
690  language: rust
691  wit: "./wit"
692  output: "./output.wasm"
693"#;
694        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
695        let wasm = img.wasm.as_ref().unwrap();
696        assert_eq!(wasm.target, "preview2");
697        assert!(wasm.optimize);
698        assert_eq!(wasm.language.as_deref(), Some("rust"));
699        assert_eq!(wasm.wit.as_deref(), Some("./wit"));
700        assert_eq!(wasm.output.as_deref(), Some("./output.wasm"));
701    }
702
703    #[test]
704    fn test_wasm_defaults() {
705        let yaml = r"
706wasm: {}
707";
708        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
709        let wasm = img.wasm.as_ref().unwrap();
710        assert_eq!(wasm.target, "preview2");
711        assert!(!wasm.optimize);
712        assert!(wasm.language.is_none());
713        assert_eq!(wasm.opt_level.as_deref(), Some("Oz"));
714        assert!(wasm.world.is_none());
715        assert!(wasm.features.is_empty());
716        assert!(wasm.build_args.is_empty());
717        assert!(wasm.pre_build.is_empty());
718        assert!(wasm.post_build.is_empty());
719        assert!(wasm.adapter.is_none());
720    }
721
722    #[test]
723    fn test_wasm_full_config() {
724        let yaml = r#"
725wasm:
726  target: "preview2"
727  optimize: true
728  opt_level: "O3"
729  language: "rust"
730  world: "zlayer-http-handler"
731  wit: "./wit"
732  output: "./output.wasm"
733  features:
734    - json
735    - metrics
736  build_args:
737    CARGO_PROFILE_RELEASE_LTO: "true"
738    RUSTFLAGS: "-C target-feature=+simd128"
739  pre_build:
740    - "wit-bindgen tiny-go --world zlayer-http-handler --out-dir bindings/"
741  post_build:
742    - "wasm-tools component embed --world zlayer-http-handler wit/ output.wasm -o output.wasm"
743  adapter: "./wasi_snapshot_preview1.reactor.wasm"
744"#;
745        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
746        let wasm = img.wasm.as_ref().unwrap();
747        assert_eq!(wasm.target, "preview2");
748        assert!(wasm.optimize);
749        assert_eq!(wasm.opt_level.as_deref(), Some("O3"));
750        assert_eq!(wasm.language.as_deref(), Some("rust"));
751        assert_eq!(wasm.world.as_deref(), Some("zlayer-http-handler"));
752        assert_eq!(wasm.wit.as_deref(), Some("./wit"));
753        assert_eq!(wasm.output.as_deref(), Some("./output.wasm"));
754        assert_eq!(wasm.features, vec!["json", "metrics"]);
755        assert_eq!(
756            wasm.build_args.get("CARGO_PROFILE_RELEASE_LTO").unwrap(),
757            "true"
758        );
759        assert_eq!(
760            wasm.build_args.get("RUSTFLAGS").unwrap(),
761            "-C target-feature=+simd128"
762        );
763        assert_eq!(wasm.pre_build.len(), 1);
764        assert_eq!(wasm.post_build.len(), 1);
765        assert_eq!(
766            wasm.adapter.as_deref(),
767            Some("./wasi_snapshot_preview1.reactor.wasm")
768        );
769    }
770
771    #[test]
772    fn test_zcommand_shell() {
773        let yaml = r#""echo hello""#;
774        let cmd: ZCommand = serde_yaml::from_str(yaml).unwrap();
775        assert!(matches!(cmd, ZCommand::Shell(ref s) if s == "echo hello"));
776    }
777
778    #[test]
779    fn test_zcommand_exec() {
780        let yaml = r#"["echo", "hello"]"#;
781        let cmd: ZCommand = serde_yaml::from_str(yaml).unwrap();
782        assert!(matches!(cmd, ZCommand::Exec(ref v) if v == &["echo", "hello"]));
783    }
784
785    #[test]
786    fn test_zcopy_sources_single() {
787        let yaml = r#""package.json""#;
788        let src: ZCopySources = serde_yaml::from_str(yaml).unwrap();
789        assert_eq!(src.to_vec(), vec!["package.json"]);
790    }
791
792    #[test]
793    fn test_zcopy_sources_multiple() {
794        let yaml = r#"["package.json", "tsconfig.json"]"#;
795        let src: ZCopySources = serde_yaml::from_str(yaml).unwrap();
796        assert_eq!(src.to_vec(), vec!["package.json", "tsconfig.json"]);
797    }
798
799    #[test]
800    fn test_zexpose_single() {
801        let yaml = "8080";
802        let exp: ZExpose = serde_yaml::from_str(yaml).unwrap();
803        assert!(matches!(exp, ZExpose::Single(8080)));
804    }
805
806    #[test]
807    fn test_zexpose_multiple() {
808        let yaml = r#"
809- 8080
810- "9090/udp"
811"#;
812        let exp: ZExpose = serde_yaml::from_str(yaml).unwrap();
813        if let ZExpose::Multiple(ports) = exp {
814            assert_eq!(ports.len(), 2);
815            assert!(matches!(ports[0], ZPortSpec::Number(8080)));
816            assert!(matches!(ports[1], ZPortSpec::WithProtocol(ref s) if s == "9090/udp"));
817        } else {
818            panic!("Expected ZExpose::Multiple");
819        }
820    }
821
822    #[test]
823    fn test_healthcheck_deserialize() {
824        let yaml = r#"
825cmd: "curl -f http://localhost/ || exit 1"
826interval: "30s"
827timeout: "10s"
828start_period: "5s"
829retries: 3
830"#;
831        let hc: ZHealthcheck = serde_yaml::from_str(yaml).unwrap();
832        assert!(matches!(hc.cmd, ZCommand::Shell(_)));
833        assert_eq!(hc.interval.as_deref(), Some("30s"));
834        assert_eq!(hc.timeout.as_deref(), Some("10s"));
835        assert_eq!(hc.start_period.as_deref(), Some("5s"));
836        assert_eq!(hc.retries, Some(3));
837    }
838
839    #[test]
840    fn test_cache_mount_deserialize() {
841        let yaml = r"
842target: /var/cache/apt
843id: apt-cache
844sharing: shared
845readonly: false
846";
847        let cm: ZCacheMount = serde_yaml::from_str(yaml).unwrap();
848        assert_eq!(cm.target, "/var/cache/apt");
849        assert_eq!(cm.id.as_deref(), Some("apt-cache"));
850        assert_eq!(cm.sharing.as_deref(), Some("shared"));
851        assert!(!cm.readonly);
852    }
853
854    #[test]
855    fn test_step_with_cache_mounts() {
856        let yaml = r#"
857run: "apt-get update && apt-get install -y curl"
858cache:
859  - target: /var/cache/apt
860    id: apt-cache
861    sharing: shared
862  - target: /var/lib/apt
863    readonly: true
864"#;
865        let step: ZStep = serde_yaml::from_str(yaml).unwrap();
866        assert!(step.run.is_some());
867        assert_eq!(step.cache.len(), 2);
868        assert_eq!(step.cache[0].target, "/var/cache/apt");
869        assert!(step.cache[1].readonly);
870    }
871
872    #[test]
873    fn test_deny_unknown_fields_zimage() {
874        let yaml = r#"
875base: "alpine:3.19"
876bogus_field: "should fail"
877"#;
878        let result: Result<ZImage, _> = serde_yaml::from_str(yaml);
879        assert!(result.is_err(), "Should reject unknown fields");
880    }
881
882    #[test]
883    fn test_deny_unknown_fields_zstep() {
884        let yaml = r#"
885run: "echo hello"
886bogus: "nope"
887"#;
888        let result: Result<ZStep, _> = serde_yaml::from_str(yaml);
889        assert!(result.is_err(), "Should reject unknown fields on ZStep");
890    }
891
892    #[test]
893    fn test_roundtrip_serialize() {
894        let yaml = r#"
895base: "alpine:3.19"
896steps:
897  - run: "echo hello"
898  - copy: "."
899    to: "/app"
900cmd: "echo done"
901"#;
902        let img: ZImage = serde_yaml::from_str(yaml).unwrap();
903        let serialized = serde_yaml::to_string(&img).unwrap();
904        let img2: ZImage = serde_yaml::from_str(&serialized).unwrap();
905        assert_eq!(img.base, img2.base);
906        assert_eq!(img.steps.len(), img2.steps.len());
907    }
908}