1use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use indexmap::IndexMap;
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct ZPipeline {
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub version: Option<String>,
61
62 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
65 pub vars: HashMap<String, String>,
66
67 #[serde(default)]
70 pub defaults: PipelineDefaults,
71
72 pub images: IndexMap<String, PipelineImage>,
75
76 #[serde(default)]
78 pub push: PushConfig,
79}
80
81#[derive(Debug, Clone, Default, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct PipelineDefaults {
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub format: Option<String>,
94
95 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
97 pub build_args: HashMap<String, String>,
98
99 #[serde(default, skip_serializing_if = "is_false")]
101 pub no_cache: bool,
102
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
106 pub cache_mounts: Vec<crate::zimage::types::ZCacheMount>,
107
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub retries: Option<u32>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(deny_unknown_fields)]
136pub struct PipelineImage {
137 pub file: PathBuf,
139
140 #[serde(
142 default = "default_context",
143 skip_serializing_if = "is_default_context"
144 )]
145 pub context: PathBuf,
146
147 #[serde(default, skip_serializing_if = "Vec::is_empty")]
149 pub tags: Vec<String>,
150
151 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
154 pub build_args: HashMap<String, String>,
155
156 #[serde(default, skip_serializing_if = "Vec::is_empty")]
159 pub depends_on: Vec<String>,
160
161 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub no_cache: Option<bool>,
165
166 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub format: Option<String>,
170
171 #[serde(default, skip_serializing_if = "Vec::is_empty")]
173 pub cache_mounts: Vec<crate::zimage::types::ZCacheMount>,
174
175 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub retries: Option<u32>,
178}
179
180#[derive(Debug, Clone, Default, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct PushConfig {
188 #[serde(default, skip_serializing_if = "is_false")]
191 pub after_all: bool,
192}
193
194fn default_context() -> PathBuf {
200 PathBuf::from(".")
201}
202
203fn is_default_context(path: &Path) -> bool {
205 path.as_os_str() == "."
206}
207
208fn is_false(v: &bool) -> bool {
210 !v
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn test_pipeline_image_defaults() {
219 let yaml = r#"
220file: Dockerfile
221"#;
222 let img: PipelineImage = serde_yaml::from_str(yaml).unwrap();
223 assert_eq!(img.file, PathBuf::from("Dockerfile"));
224 assert_eq!(img.context, PathBuf::from("."));
225 assert!(img.tags.is_empty());
226 assert!(img.build_args.is_empty());
227 assert!(img.depends_on.is_empty());
228 assert!(img.no_cache.is_none());
229 assert!(img.format.is_none());
230 assert!(img.cache_mounts.is_empty());
231 assert!(img.retries.is_none());
232 }
233
234 #[test]
235 fn test_pipeline_defaults_empty() {
236 let yaml = "{}";
237 let defaults: PipelineDefaults = serde_yaml::from_str(yaml).unwrap();
238 assert!(defaults.format.is_none());
239 assert!(defaults.build_args.is_empty());
240 assert!(!defaults.no_cache);
241 assert!(defaults.cache_mounts.is_empty());
242 assert!(defaults.retries.is_none());
243 }
244
245 #[test]
246 fn test_pipeline_defaults_full() {
247 let yaml = r#"
248format: oci
249build_args:
250 RUST_VERSION: "1.90"
251no_cache: true
252"#;
253 let defaults: PipelineDefaults = serde_yaml::from_str(yaml).unwrap();
254 assert_eq!(defaults.format, Some("oci".to_string()));
255 assert_eq!(
256 defaults.build_args.get("RUST_VERSION"),
257 Some(&"1.90".to_string())
258 );
259 assert!(defaults.no_cache);
260 }
261
262 #[test]
263 fn test_push_config_defaults() {
264 let yaml = "{}";
265 let push: PushConfig = serde_yaml::from_str(yaml).unwrap();
266 assert!(!push.after_all);
267 }
268
269 #[test]
270 fn test_push_config_after_all() {
271 let yaml = "after_all: true";
272 let push: PushConfig = serde_yaml::from_str(yaml).unwrap();
273 assert!(push.after_all);
274 }
275
276 #[test]
277 fn test_deny_unknown_fields_pipeline_image() {
278 let yaml = r#"
279file: Dockerfile
280unknown_field: "should fail"
281"#;
282 let result: Result<PipelineImage, _> = serde_yaml::from_str(yaml);
283 assert!(result.is_err(), "Should reject unknown fields");
284 }
285
286 #[test]
287 fn test_deny_unknown_fields_pipeline_defaults() {
288 let yaml = r#"
289format: oci
290bogus: "nope"
291"#;
292 let result: Result<PipelineDefaults, _> = serde_yaml::from_str(yaml);
293 assert!(result.is_err(), "Should reject unknown fields");
294 }
295
296 #[test]
297 fn test_cache_mounts_and_retries_deserialize_defaults() {
298 let yaml = r#"
299format: oci
300no_cache: true
301cache_mounts:
302 - target: /root/.cargo/registry
303 id: cargo-registry
304 sharing: shared
305 - target: /root/.cache/pip
306retries: 3
307"#;
308 let defaults: PipelineDefaults = serde_yaml::from_str(yaml).unwrap();
309 assert_eq!(defaults.cache_mounts.len(), 2);
310 assert_eq!(defaults.cache_mounts[0].target, "/root/.cargo/registry");
311 assert_eq!(
312 defaults.cache_mounts[0].id,
313 Some("cargo-registry".to_string())
314 );
315 assert_eq!(defaults.cache_mounts[0].sharing, Some("shared".to_string()));
316 assert_eq!(defaults.cache_mounts[1].target, "/root/.cache/pip");
317 assert!(defaults.cache_mounts[1].id.is_none());
318 assert_eq!(defaults.retries, Some(3));
319 }
320
321 #[test]
322 fn test_cache_mounts_and_retries_deserialize_image() {
323 let yaml = r#"
324file: Dockerfile
325cache_mounts:
326 - target: /tmp/build-cache
327 readonly: true
328retries: 5
329"#;
330 let img: PipelineImage = serde_yaml::from_str(yaml).unwrap();
331 assert_eq!(img.cache_mounts.len(), 1);
332 assert_eq!(img.cache_mounts[0].target, "/tmp/build-cache");
333 assert!(img.cache_mounts[0].readonly);
334 assert_eq!(img.retries, Some(5));
335 }
336
337 #[test]
338 fn test_deny_unknown_fields_push_config() {
339 let yaml = r#"
340after_all: true
341extra: "bad"
342"#;
343 let result: Result<PushConfig, _> = serde_yaml::from_str(yaml);
344 assert!(result.is_err(), "Should reject unknown fields");
345 }
346
347 #[test]
348 fn test_serialization_skips_defaults() {
349 let img = PipelineImage {
350 file: PathBuf::from("Dockerfile"),
351 context: PathBuf::from("."),
352 tags: vec![],
353 build_args: HashMap::new(),
354 depends_on: vec![],
355 no_cache: None,
356 format: None,
357 cache_mounts: vec![],
358 retries: None,
359 };
360 let serialized = serde_yaml::to_string(&img).unwrap();
361 assert!(serialized.contains("file:"));
363 assert!(!serialized.contains("context:"));
364 assert!(!serialized.contains("tags:"));
365 assert!(!serialized.contains("build_args:"));
366 assert!(!serialized.contains("depends_on:"));
367 assert!(!serialized.contains("no_cache:"));
368 assert!(!serialized.contains("format:"));
369 assert!(!serialized.contains("cache_mounts:"));
370 assert!(!serialized.contains("retries:"));
371 }
372
373 #[test]
374 fn test_serialization_includes_non_defaults() {
375 let img = PipelineImage {
376 file: PathBuf::from("Dockerfile"),
377 context: PathBuf::from("./subdir"),
378 tags: vec!["myimage:latest".to_string()],
379 build_args: HashMap::from([("KEY".to_string(), "value".to_string())]),
380 depends_on: vec!["base".to_string()],
381 no_cache: Some(true),
382 format: Some("docker".to_string()),
383 cache_mounts: vec![crate::zimage::types::ZCacheMount {
384 target: "/root/.cache".to_string(),
385 id: Some("mycache".to_string()),
386 sharing: None,
387 readonly: false,
388 }],
389 retries: Some(3),
390 };
391 let serialized = serde_yaml::to_string(&img).unwrap();
392 assert!(serialized.contains("context:"));
393 assert!(serialized.contains("tags:"));
394 assert!(serialized.contains("build_args:"));
395 assert!(serialized.contains("depends_on:"));
396 assert!(serialized.contains("no_cache:"));
397 assert!(serialized.contains("format:"));
398 assert!(serialized.contains("cache_mounts:"));
399 assert!(serialized.contains("retries:"));
400 }
401}