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
208#[allow(clippy::trivially_copy_pass_by_ref)]
210fn is_false(v: &bool) -> bool {
211 !v
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn test_pipeline_image_defaults() {
220 let yaml = r#"
221file: Dockerfile
222"#;
223 let img: PipelineImage = serde_yml::from_str(yaml).unwrap();
224 assert_eq!(img.file, PathBuf::from("Dockerfile"));
225 assert_eq!(img.context, PathBuf::from("."));
226 assert!(img.tags.is_empty());
227 assert!(img.build_args.is_empty());
228 assert!(img.depends_on.is_empty());
229 assert!(img.no_cache.is_none());
230 assert!(img.format.is_none());
231 assert!(img.cache_mounts.is_empty());
232 assert!(img.retries.is_none());
233 }
234
235 #[test]
236 fn test_pipeline_defaults_empty() {
237 let yaml = "{}";
238 let defaults: PipelineDefaults = serde_yml::from_str(yaml).unwrap();
239 assert!(defaults.format.is_none());
240 assert!(defaults.build_args.is_empty());
241 assert!(!defaults.no_cache);
242 assert!(defaults.cache_mounts.is_empty());
243 assert!(defaults.retries.is_none());
244 }
245
246 #[test]
247 fn test_pipeline_defaults_full() {
248 let yaml = r#"
249format: oci
250build_args:
251 RUST_VERSION: "1.90"
252no_cache: true
253"#;
254 let defaults: PipelineDefaults = serde_yml::from_str(yaml).unwrap();
255 assert_eq!(defaults.format, Some("oci".to_string()));
256 assert_eq!(
257 defaults.build_args.get("RUST_VERSION"),
258 Some(&"1.90".to_string())
259 );
260 assert!(defaults.no_cache);
261 }
262
263 #[test]
264 fn test_push_config_defaults() {
265 let yaml = "{}";
266 let push: PushConfig = serde_yml::from_str(yaml).unwrap();
267 assert!(!push.after_all);
268 }
269
270 #[test]
271 fn test_push_config_after_all() {
272 let yaml = "after_all: true";
273 let push: PushConfig = serde_yml::from_str(yaml).unwrap();
274 assert!(push.after_all);
275 }
276
277 #[test]
278 fn test_deny_unknown_fields_pipeline_image() {
279 let yaml = r#"
280file: Dockerfile
281unknown_field: "should fail"
282"#;
283 let result: Result<PipelineImage, _> = serde_yml::from_str(yaml);
284 assert!(result.is_err(), "Should reject unknown fields");
285 }
286
287 #[test]
288 fn test_deny_unknown_fields_pipeline_defaults() {
289 let yaml = r#"
290format: oci
291bogus: "nope"
292"#;
293 let result: Result<PipelineDefaults, _> = serde_yml::from_str(yaml);
294 assert!(result.is_err(), "Should reject unknown fields");
295 }
296
297 #[test]
298 fn test_cache_mounts_and_retries_deserialize_defaults() {
299 let yaml = r#"
300format: oci
301no_cache: true
302cache_mounts:
303 - target: /root/.cargo/registry
304 id: cargo-registry
305 sharing: shared
306 - target: /root/.cache/pip
307retries: 3
308"#;
309 let defaults: PipelineDefaults = serde_yml::from_str(yaml).unwrap();
310 assert_eq!(defaults.cache_mounts.len(), 2);
311 assert_eq!(defaults.cache_mounts[0].target, "/root/.cargo/registry");
312 assert_eq!(
313 defaults.cache_mounts[0].id,
314 Some("cargo-registry".to_string())
315 );
316 assert_eq!(defaults.cache_mounts[0].sharing, Some("shared".to_string()));
317 assert_eq!(defaults.cache_mounts[1].target, "/root/.cache/pip");
318 assert!(defaults.cache_mounts[1].id.is_none());
319 assert_eq!(defaults.retries, Some(3));
320 }
321
322 #[test]
323 fn test_cache_mounts_and_retries_deserialize_image() {
324 let yaml = r#"
325file: Dockerfile
326cache_mounts:
327 - target: /tmp/build-cache
328 readonly: true
329retries: 5
330"#;
331 let img: PipelineImage = serde_yml::from_str(yaml).unwrap();
332 assert_eq!(img.cache_mounts.len(), 1);
333 assert_eq!(img.cache_mounts[0].target, "/tmp/build-cache");
334 assert!(img.cache_mounts[0].readonly);
335 assert_eq!(img.retries, Some(5));
336 }
337
338 #[test]
339 fn test_deny_unknown_fields_push_config() {
340 let yaml = r#"
341after_all: true
342extra: "bad"
343"#;
344 let result: Result<PushConfig, _> = serde_yml::from_str(yaml);
345 assert!(result.is_err(), "Should reject unknown fields");
346 }
347
348 #[test]
349 fn test_serialization_skips_defaults() {
350 let img = PipelineImage {
351 file: PathBuf::from("Dockerfile"),
352 context: PathBuf::from("."),
353 tags: vec![],
354 build_args: HashMap::new(),
355 depends_on: vec![],
356 no_cache: None,
357 format: None,
358 cache_mounts: vec![],
359 retries: None,
360 };
361 let serialized = serde_yml::to_string(&img).unwrap();
362 assert!(serialized.contains("file:"));
364 assert!(!serialized.contains("context:"));
365 assert!(!serialized.contains("tags:"));
366 assert!(!serialized.contains("build_args:"));
367 assert!(!serialized.contains("depends_on:"));
368 assert!(!serialized.contains("no_cache:"));
369 assert!(!serialized.contains("format:"));
370 assert!(!serialized.contains("cache_mounts:"));
371 assert!(!serialized.contains("retries:"));
372 }
373
374 #[test]
375 fn test_serialization_includes_non_defaults() {
376 let img = PipelineImage {
377 file: PathBuf::from("Dockerfile"),
378 context: PathBuf::from("./subdir"),
379 tags: vec!["myimage:latest".to_string()],
380 build_args: HashMap::from([("KEY".to_string(), "value".to_string())]),
381 depends_on: vec!["base".to_string()],
382 no_cache: Some(true),
383 format: Some("docker".to_string()),
384 cache_mounts: vec![crate::zimage::types::ZCacheMount {
385 target: "/root/.cache".to_string(),
386 id: Some("mycache".to_string()),
387 sharing: None,
388 readonly: false,
389 }],
390 retries: Some(3),
391 };
392 let serialized = serde_yml::to_string(&img).unwrap();
393 assert!(serialized.contains("context:"));
394 assert!(serialized.contains("tags:"));
395 assert!(serialized.contains("build_args:"));
396 assert!(serialized.contains("depends_on:"));
397 assert!(serialized.contains("no_cache:"));
398 assert!(serialized.contains("format:"));
399 assert!(serialized.contains("cache_mounts:"));
400 assert!(serialized.contains("retries:"));
401 }
402}