use std::collections::HashMap;
use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ZPipeline {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub vars: HashMap<String, String>,
#[serde(default)]
pub defaults: PipelineDefaults,
pub images: IndexMap<String, PipelineImage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache: Option<PipelineCacheConfig>,
#[serde(default)]
pub push: PushConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PipelineDefaults {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub build_args: HashMap<String, String>,
#[serde(default, skip_serializing_if = "is_false")]
pub no_cache: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cache_mounts: Vec<crate::zimage::types::ZCacheMount>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retries: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub platforms: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PipelineImage {
pub file: PathBuf,
#[serde(
default = "default_context",
skip_serializing_if = "is_default_context"
)]
pub context: PathBuf,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub build_args: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub depends_on: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub no_cache: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cache_mounts: Vec<crate::zimage::types::ZCacheMount>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retries: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub platforms: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PushConfig {
#[serde(default, skip_serializing_if = "is_false")]
pub after_all: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PipelineCacheConfig {
#[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
pub cache_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bucket: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefix: Option<String>,
}
fn default_context() -> PathBuf {
PathBuf::from(".")
}
fn is_default_context(path: &Path) -> bool {
path.as_os_str() == "."
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(v: &bool) -> bool {
!v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pipeline_image_defaults() {
let yaml = r"
file: Dockerfile
";
let img: PipelineImage = serde_yaml::from_str(yaml).unwrap();
assert_eq!(img.file, PathBuf::from("Dockerfile"));
assert_eq!(img.context, PathBuf::from("."));
assert!(img.tags.is_empty());
assert!(img.build_args.is_empty());
assert!(img.depends_on.is_empty());
assert!(img.no_cache.is_none());
assert!(img.format.is_none());
assert!(img.cache_mounts.is_empty());
assert!(img.retries.is_none());
assert!(img.platforms.is_empty());
}
#[test]
fn test_pipeline_defaults_empty() {
let yaml = "{}";
let defaults: PipelineDefaults = serde_yaml::from_str(yaml).unwrap();
assert!(defaults.format.is_none());
assert!(defaults.build_args.is_empty());
assert!(!defaults.no_cache);
assert!(defaults.cache_mounts.is_empty());
assert!(defaults.retries.is_none());
assert!(defaults.platforms.is_empty());
}
#[test]
fn test_pipeline_defaults_full() {
let yaml = r#"
format: oci
build_args:
RUST_VERSION: "1.90"
no_cache: true
"#;
let defaults: PipelineDefaults = serde_yaml::from_str(yaml).unwrap();
assert_eq!(defaults.format, Some("oci".to_string()));
assert_eq!(
defaults.build_args.get("RUST_VERSION"),
Some(&"1.90".to_string())
);
assert!(defaults.no_cache);
}
#[test]
fn test_push_config_defaults() {
let yaml = "{}";
let push: PushConfig = serde_yaml::from_str(yaml).unwrap();
assert!(!push.after_all);
}
#[test]
fn test_push_config_after_all() {
let yaml = "after_all: true";
let push: PushConfig = serde_yaml::from_str(yaml).unwrap();
assert!(push.after_all);
}
#[test]
fn test_pipeline_cache_config() {
let yaml = r"
version: '1'
cache:
type: persistent
path: /tmp/test-cache
images:
test:
file: Dockerfile
";
let pipeline: ZPipeline = serde_yaml::from_str(yaml).unwrap();
let cache = pipeline.cache.unwrap();
assert_eq!(cache.cache_type.as_deref(), Some("persistent"));
assert_eq!(cache.path, Some(PathBuf::from("/tmp/test-cache")));
}
#[test]
fn test_deny_unknown_fields_pipeline_image() {
let yaml = r#"
file: Dockerfile
unknown_field: "should fail"
"#;
let result: Result<PipelineImage, _> = serde_yaml::from_str(yaml);
assert!(result.is_err(), "Should reject unknown fields");
}
#[test]
fn test_deny_unknown_fields_pipeline_defaults() {
let yaml = r#"
format: oci
bogus: "nope"
"#;
let result: Result<PipelineDefaults, _> = serde_yaml::from_str(yaml);
assert!(result.is_err(), "Should reject unknown fields");
}
#[test]
fn test_cache_mounts_and_retries_deserialize_defaults() {
let yaml = r"
format: oci
no_cache: true
cache_mounts:
- target: /root/.cargo/registry
id: cargo-registry
sharing: shared
- target: /root/.cache/pip
retries: 3
";
let defaults: PipelineDefaults = serde_yaml::from_str(yaml).unwrap();
assert_eq!(defaults.cache_mounts.len(), 2);
assert_eq!(defaults.cache_mounts[0].target, "/root/.cargo/registry");
assert_eq!(
defaults.cache_mounts[0].id,
Some("cargo-registry".to_string())
);
assert_eq!(defaults.cache_mounts[0].sharing, Some("shared".to_string()));
assert_eq!(defaults.cache_mounts[1].target, "/root/.cache/pip");
assert!(defaults.cache_mounts[1].id.is_none());
assert_eq!(defaults.retries, Some(3));
}
#[test]
fn test_cache_mounts_and_retries_deserialize_image() {
let yaml = r"
file: Dockerfile
cache_mounts:
- target: /tmp/build-cache
readonly: true
retries: 5
";
let img: PipelineImage = serde_yaml::from_str(yaml).unwrap();
assert_eq!(img.cache_mounts.len(), 1);
assert_eq!(img.cache_mounts[0].target, "/tmp/build-cache");
assert!(img.cache_mounts[0].readonly);
assert_eq!(img.retries, Some(5));
}
#[test]
fn test_deny_unknown_fields_push_config() {
let yaml = r#"
after_all: true
extra: "bad"
"#;
let result: Result<PushConfig, _> = serde_yaml::from_str(yaml);
assert!(result.is_err(), "Should reject unknown fields");
}
#[test]
fn test_serialization_skips_defaults() {
let img = PipelineImage {
file: PathBuf::from("Dockerfile"),
context: PathBuf::from("."),
tags: vec![],
build_args: HashMap::new(),
depends_on: vec![],
no_cache: None,
format: None,
cache_mounts: vec![],
retries: None,
platforms: vec![],
};
let serialized = serde_yaml::to_string(&img).unwrap();
assert!(serialized.contains("file:"));
assert!(!serialized.contains("context:"));
assert!(!serialized.contains("tags:"));
assert!(!serialized.contains("build_args:"));
assert!(!serialized.contains("depends_on:"));
assert!(!serialized.contains("no_cache:"));
assert!(!serialized.contains("format:"));
assert!(!serialized.contains("cache_mounts:"));
assert!(!serialized.contains("retries:"));
assert!(!serialized.contains("platforms:"));
}
#[test]
fn test_serialization_includes_non_defaults() {
let img = PipelineImage {
file: PathBuf::from("Dockerfile"),
context: PathBuf::from("./subdir"),
tags: vec!["myimage:latest".to_string()],
build_args: HashMap::from([("KEY".to_string(), "value".to_string())]),
depends_on: vec!["base".to_string()],
no_cache: Some(true),
format: Some("docker".to_string()),
cache_mounts: vec![crate::zimage::types::ZCacheMount {
target: "/root/.cache".to_string(),
id: Some("mycache".to_string()),
sharing: None,
readonly: false,
}],
retries: Some(3),
platforms: vec!["linux/amd64".to_string(), "linux/arm64".to_string()],
};
let serialized = serde_yaml::to_string(&img).unwrap();
assert!(serialized.contains("context:"));
assert!(serialized.contains("tags:"));
assert!(serialized.contains("build_args:"));
assert!(serialized.contains("depends_on:"));
assert!(serialized.contains("no_cache:"));
assert!(serialized.contains("format:"));
assert!(serialized.contains("cache_mounts:"));
assert!(serialized.contains("retries:"));
assert!(serialized.contains("platforms:"));
}
}