use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OrbDefinition {
#[serde(default)]
pub version: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub display: Option<DisplayInfo>,
#[serde(default)]
pub orbs: HashMap<String, String>,
#[serde(default)]
pub commands: HashMap<String, Command>,
#[serde(default)]
pub jobs: HashMap<String, Job>,
#[serde(default)]
pub executors: HashMap<String, Executor>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DisplayInfo {
#[serde(default)]
pub home_url: Option<String>,
#[serde(default)]
pub source_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Command {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub parameters: HashMap<String, Parameter>,
#[serde(default)]
pub steps: Vec<Step>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExecutorConfig {
#[serde(default)]
pub docker: Option<Vec<DockerImage>>,
#[serde(default)]
pub machine: Option<MachineConfig>,
#[serde(default)]
pub macos: Option<MacOsConfig>,
#[serde(default)]
pub resource_class: Option<String>,
#[serde(default)]
pub working_directory: Option<String>,
#[serde(default)]
pub environment: HashMap<String, String>,
#[serde(default)]
pub shell: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Job {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub executor: Option<ExecutorRef>,
#[serde(flatten)]
pub config: ExecutorConfig,
#[serde(default)]
pub parameters: HashMap<String, Parameter>,
#[serde(default)]
pub steps: Vec<Step>,
#[serde(default)]
pub parallelism: Option<u32>,
#[serde(default)]
pub circleci_ip_ranges: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Executor {
#[serde(default)]
pub description: Option<String>,
#[serde(flatten)]
pub config: ExecutorConfig,
#[serde(default)]
pub parameters: HashMap<String, Parameter>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ExecutorRef {
Name(String),
WithParams {
name: String,
#[serde(flatten)]
parameters: HashMap<String, serde_yaml::Value>,
},
}
impl Default for ExecutorRef {
fn default() -> Self {
Self::Name(String::new())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Parameter {
#[serde(rename = "type")]
pub param_type: ParameterType,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub default: Option<serde_yaml::Value>,
#[serde(default, rename = "enum")]
pub enum_values: Option<Vec<String>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ParameterType {
#[default]
String,
Boolean,
Integer,
Enum,
#[serde(rename = "env_var_name")]
EnvVarName,
Steps,
Executor,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Step {
Simple(String),
Structured(StructuredStep),
}
impl Default for Step {
fn default() -> Self {
Self::Simple(String::new())
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StructuredStep {
Run(RunStep),
Checkout(CheckoutStep),
#[serde(rename = "restore_cache")]
RestoreCache(CacheStep),
#[serde(rename = "save_cache")]
SaveCache(SaveCacheStep),
When(ConditionalStep),
Unless(ConditionalStep),
#[serde(rename = "persist_to_workspace")]
PersistToWorkspace(WorkspaceStep),
#[serde(rename = "attach_workspace")]
AttachWorkspace(AttachWorkspaceStep),
#[serde(rename = "store_test_results")]
StoreTestResults(StoreTestResultsStep),
#[serde(rename = "store_artifacts")]
StoreArtifacts(StoreArtifactsStep),
#[serde(rename = "add_ssh_keys")]
AddSshKeys(AddSshKeysStep),
#[serde(rename = "setup_remote_docker")]
SetupRemoteDocker(SetupRemoteDockerStep),
#[serde(untagged)]
CommandInvocation(HashMap<String, serde_yaml::Value>),
}
impl serde::Serialize for StructuredStep {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
match self {
Self::Run(v) => {
let mut m = s.serialize_map(Some(1))?;
m.serialize_entry("run", v)?;
m.end()
}
Self::Checkout(v) => {
let mut m = s.serialize_map(Some(1))?;
m.serialize_entry("checkout", v)?;
m.end()
}
Self::RestoreCache(v) => {
let mut m = s.serialize_map(Some(1))?;
m.serialize_entry("restore_cache", v)?;
m.end()
}
Self::SaveCache(v) => {
let mut m = s.serialize_map(Some(1))?;
m.serialize_entry("save_cache", v)?;
m.end()
}
Self::When(v) => {
let mut m = s.serialize_map(Some(1))?;
m.serialize_entry("when", v)?;
m.end()
}
Self::Unless(v) => {
let mut m = s.serialize_map(Some(1))?;
m.serialize_entry("unless", v)?;
m.end()
}
Self::PersistToWorkspace(v) => {
let mut m = s.serialize_map(Some(1))?;
m.serialize_entry("persist_to_workspace", v)?;
m.end()
}
Self::AttachWorkspace(v) => {
let mut m = s.serialize_map(Some(1))?;
m.serialize_entry("attach_workspace", v)?;
m.end()
}
Self::StoreTestResults(v) => {
let mut m = s.serialize_map(Some(1))?;
m.serialize_entry("store_test_results", v)?;
m.end()
}
Self::StoreArtifacts(v) => {
let mut m = s.serialize_map(Some(1))?;
m.serialize_entry("store_artifacts", v)?;
m.end()
}
Self::AddSshKeys(v) => {
let mut m = s.serialize_map(Some(1))?;
m.serialize_entry("add_ssh_keys", v)?;
m.end()
}
Self::SetupRemoteDocker(v) => {
let mut m = s.serialize_map(Some(1))?;
m.serialize_entry("setup_remote_docker", v)?;
m.end()
}
Self::CommandInvocation(v) => v.serialize(s),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum RunStep {
Simple(String),
Full {
command: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
working_directory: Option<String>,
#[serde(default)]
environment: HashMap<String, String>,
#[serde(default)]
shell: Option<String>,
#[serde(default)]
background: Option<bool>,
#[serde(default)]
no_output_timeout: Option<String>,
#[serde(default)]
when: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CheckoutStep {
#[serde(default)]
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CacheStep {
#[serde(default)]
pub key: Option<String>,
#[serde(default)]
pub keys: Option<Vec<String>>,
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SaveCacheStep {
pub key: String,
#[serde(default)]
pub paths: Vec<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub when: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConditionalStep {
pub condition: serde_yaml::Value,
#[serde(default)]
pub steps: Vec<Step>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkspaceStep {
pub root: String,
#[serde(default)]
pub paths: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AttachWorkspaceStep {
pub at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StoreTestResultsStep {
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StoreArtifactsStep {
pub path: String,
#[serde(default)]
pub destination: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AddSshKeysStep {
#[serde(default)]
pub fingerprints: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SetupRemoteDockerStep {
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub docker_layer_caching: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum DockerImage {
Simple(String),
Full(Box<DockerImageFull>),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DockerImageFull {
pub image: String,
#[serde(default)]
pub auth: Option<DockerAuth>,
#[serde(default)]
pub aws_auth: Option<AwsAuth>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub entrypoint: Option<Vec<String>>,
#[serde(default)]
pub command: Option<Vec<String>>,
#[serde(default)]
pub user: Option<String>,
#[serde(default)]
pub environment: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DockerAuth {
pub username: String,
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AwsAuth {
#[serde(default)]
pub aws_access_key_id: Option<String>,
#[serde(default)]
pub aws_secret_access_key: Option<String>,
#[serde(default)]
pub oidc_role_arn: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MachineConfig {
Enabled(bool),
Image {
image: String,
#[serde(default)]
docker_layer_caching: Option<bool>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MacOsConfig {
pub xcode: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_step_run_serde_roundtrip() {
let step = Step::Structured(StructuredStep::Run(RunStep::Full {
command: "cargo test".to_string(),
name: Some("Run tests".to_string()),
working_directory: None,
environment: Default::default(),
shell: None,
background: None,
no_output_timeout: None,
when: None,
}));
let yaml = serde_yaml::to_string(&step).unwrap();
assert!(
!yaml.contains("!run"),
"serialised step must not use YAML tags, got:\n{yaml}"
);
let back: Step = serde_yaml::from_str(&yaml).unwrap();
matches!(back, Step::Structured(StructuredStep::Run(_)));
}
#[test]
fn test_step_when_serde_roundtrip() {
let step = Step::Structured(StructuredStep::When(ConditionalStep {
condition: serde_yaml::Value::String("always".to_string()),
steps: vec![Step::Simple("checkout".to_string())],
}));
let yaml = serde_yaml::to_string(&step).unwrap();
assert!(
!yaml.contains("!when"),
"serialised step must not use YAML tags, got:\n{yaml}"
);
let back: Step = serde_yaml::from_str(&yaml).unwrap();
matches!(back, Step::Structured(StructuredStep::When(_)));
}
#[test]
fn test_step_unless_serde_roundtrip() {
let step = Step::Structured(StructuredStep::Unless(ConditionalStep {
condition: serde_yaml::Value::Bool(false),
steps: vec![],
}));
let yaml = serde_yaml::to_string(&step).unwrap();
assert!(!yaml.contains("!unless"));
let back: Step = serde_yaml::from_str(&yaml).unwrap();
matches!(back, Step::Structured(StructuredStep::Unless(_)));
}
#[test]
fn test_orb_definition_serde_roundtrip() {
let mut commands = std::collections::HashMap::new();
commands.insert(
"my_cmd".to_string(),
Command {
description: Some("test".to_string()),
parameters: Default::default(),
steps: vec![
Step::Structured(StructuredStep::Run(RunStep::Simple(
"echo hello".to_string(),
))),
Step::Structured(StructuredStep::When(ConditionalStep {
condition: serde_yaml::Value::String("on_success".to_string()),
steps: vec![Step::Simple("checkout".to_string())],
})),
],
},
);
let orb = OrbDefinition {
version: "2.1".to_string(),
commands,
..Default::default()
};
let yaml = serde_yaml::to_string(&orb).unwrap();
let back: OrbDefinition = serde_yaml::from_str(&yaml).unwrap();
assert!(back.commands.contains_key("my_cmd"));
assert_eq!(back.commands["my_cmd"].steps.len(), 2);
}
#[test]
fn test_parameter_type_deserialize() {
let yaml = r#"string"#;
let pt: ParameterType = serde_yaml::from_str(yaml).unwrap();
assert_eq!(pt, ParameterType::String);
let yaml = r#"boolean"#;
let pt: ParameterType = serde_yaml::from_str(yaml).unwrap();
assert_eq!(pt, ParameterType::Boolean);
let yaml = r#"env_var_name"#;
let pt: ParameterType = serde_yaml::from_str(yaml).unwrap();
assert_eq!(pt, ParameterType::EnvVarName);
}
#[test]
fn test_simple_command_deserialize() {
let yaml = r#"
description: "Run tests"
parameters:
coverage:
type: boolean
default: false
description: "Enable coverage"
steps:
- checkout
- run: cargo test
"#;
let cmd: Command = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cmd.description, Some("Run tests".to_string()));
assert!(cmd.parameters.contains_key("coverage"));
assert_eq!(cmd.steps.len(), 2);
}
#[test]
fn test_docker_image_simple() {
let yaml = r#""rust:1.75""#;
let img: DockerImage = serde_yaml::from_str(yaml).unwrap();
matches!(img, DockerImage::Simple(s) if s == "rust:1.75");
}
#[test]
fn test_docker_image_full() {
let yaml = r#"
image: rust:1.75
auth:
username: $DOCKER_USER
password: $DOCKER_PASS
"#;
let img: DockerImage = serde_yaml::from_str(yaml).unwrap();
match img {
DockerImage::Full(full) => {
assert_eq!(full.image, "rust:1.75");
assert!(full.auth.is_some());
}
_ => panic!("Expected Full variant"),
}
}
#[test]
fn test_executor_ref_simple() {
let yaml = r#""default""#;
let exec: ExecutorRef = serde_yaml::from_str(yaml).unwrap();
matches!(exec, ExecutorRef::Name(s) if s == "default");
}
#[test]
fn test_step_simple() {
let yaml = r#""checkout""#;
let step: Step = serde_yaml::from_str(yaml).unwrap();
matches!(step, Step::Simple(s) if s == "checkout");
}
#[test]
fn test_run_step_simple() {
let yaml = r#"
run: echo hello
"#;
let step: StructuredStep = serde_yaml::from_str(yaml).unwrap();
match step {
StructuredStep::Run(RunStep::Simple(cmd)) => {
assert_eq!(cmd, "echo hello");
}
_ => panic!("Expected Run with Simple variant"),
}
}
#[test]
fn test_run_step_full() {
let yaml = r#"
run:
name: Run tests
command: cargo test
working_directory: ~/project
"#;
let step: StructuredStep = serde_yaml::from_str(yaml).unwrap();
match step {
StructuredStep::Run(RunStep::Full { command, name, .. }) => {
assert_eq!(command, "cargo test");
assert_eq!(name, Some("Run tests".to_string()));
}
_ => panic!("Expected Run with Full variant"),
}
}
#[test]
fn test_orb_definition_empty() {
let yaml = r#"
version: "2.1"
"#;
let orb: OrbDefinition = serde_yaml::from_str(yaml).unwrap();
assert_eq!(orb.version, "2.1");
assert!(orb.commands.is_empty());
assert!(orb.jobs.is_empty());
assert!(orb.executors.is_empty());
}
}