use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use crate::tasks::TaskNode;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum AnnotationValue {
CaptureRef {
#[serde(rename = "cuenvCaptureRef")]
cuenv_capture_ref: bool,
#[serde(rename = "cuenvTask")]
cuenv_task: String,
#[serde(rename = "cuenvCapture")]
cuenv_capture: String,
},
Literal(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WorkflowDispatchInput {
pub description: String,
pub required: Option<bool>,
pub default: Option<String>,
#[serde(rename = "type")]
pub input_type: Option<String>,
pub options: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum ManualTrigger {
Enabled(bool),
WithInputs(HashMap<String, WorkflowDispatchInput>),
}
impl ManualTrigger {
pub fn is_enabled(&self) -> bool {
match self {
ManualTrigger::Enabled(enabled) => *enabled,
ManualTrigger::WithInputs(inputs) => !inputs.is_empty(),
}
}
pub fn inputs(&self) -> Option<&HashMap<String, WorkflowDispatchInput>> {
match self {
ManualTrigger::Enabled(_) => None,
ManualTrigger::WithInputs(inputs) => Some(inputs),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PipelineCondition {
pub pull_request: Option<bool>,
#[serde(default)]
pub branch: Option<StringOrVec>,
#[serde(default)]
pub tag: Option<StringOrVec>,
pub default_branch: Option<bool>,
#[serde(default)]
pub scheduled: Option<StringOrVec>,
pub manual: Option<ManualTrigger>,
pub release: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct RunnerMapping {
pub arch: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ArtifactDownload {
pub from: String,
pub to: String,
#[serde(default)]
pub filter: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct TaskRef {
#[serde(rename = "_name")]
pub name: String,
#[serde(flatten)]
_rest: serde_json::Value,
}
impl<'de> serde::Deserialize<'de> for TaskRef {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{self, Visitor};
struct TaskRefVisitor;
impl<'de> Visitor<'de> for TaskRefVisitor {
type Value = TaskRef;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("an object with _name field (task reference)")
}
fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
where
M: de::MapAccess<'de>,
{
let value: serde_json::Value =
serde::Deserialize::deserialize(de::value::MapAccessDeserializer::new(map))?;
let name = value
.get("_name")
.and_then(|v| v.as_str())
.ok_or_else(|| de::Error::missing_field("_name"))?
.to_string();
Ok(TaskRef { name, _rest: value })
}
}
deserializer.deserialize_map(TaskRefVisitor)
}
}
impl TaskRef {
#[must_use]
pub fn from_name(name: impl Into<String>) -> Self {
Self {
name: name.into(),
_rest: serde_json::Value::Null,
}
}
#[must_use]
pub fn task_name(&self) -> &str {
&self.name
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MatrixTask {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub task_type: Option<String>,
pub task: TaskRef,
pub matrix: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub artifacts: Option<Vec<ArtifactDownload>>,
#[serde(default)]
pub params: Option<BTreeMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum PipelineTask {
Matrix(MatrixTask),
Simple(TaskRef),
Node(TaskNode),
}
impl PipelineTask {
pub fn task_name(&self) -> &str {
match self {
PipelineTask::Matrix(matrix) => matrix.task.task_name(),
PipelineTask::Simple(task_ref) => task_ref.task_name(),
PipelineTask::Node(node) => Self::extract_task_name_from_node(node),
}
}
fn extract_task_name_from_node(node: &TaskNode) -> &str {
match node {
TaskNode::Task(task) => {
task.description.as_deref().unwrap_or("unnamed-task")
}
TaskNode::Group(group) => {
group
.children
.keys()
.next()
.map(String::as_str)
.unwrap_or("unnamed-group")
}
TaskNode::Sequence(sequence) => {
sequence
.first()
.map(Self::extract_task_name_from_node)
.unwrap_or("unnamed-sequence")
}
}
}
pub fn child_task_names(&self) -> Vec<&str> {
match self {
PipelineTask::Matrix(_) | PipelineTask::Simple(_) => vec![],
PipelineTask::Node(node) => Self::extract_child_names_from_node(node),
}
}
fn extract_child_names_from_node(node: &TaskNode) -> Vec<&str> {
match node {
TaskNode::Task(_) => vec![],
TaskNode::Group(group) => group.children.keys().map(String::as_str).collect(),
TaskNode::Sequence(sequence) => sequence
.iter()
.flat_map(Self::extract_child_names_from_node)
.collect(),
}
}
pub fn is_matrix(&self) -> bool {
matches!(self, PipelineTask::Matrix(_))
}
pub fn is_node(&self) -> bool {
matches!(self, PipelineTask::Node(_))
}
pub fn has_matrix_dimensions(&self) -> bool {
match self {
PipelineTask::Simple(_) | PipelineTask::Node(_) => false,
PipelineTask::Matrix(m) => !m.matrix.is_empty(),
}
}
pub fn matrix(&self) -> Option<&BTreeMap<String, Vec<String>>> {
match self {
PipelineTask::Simple(_) | PipelineTask::Node(_) => None,
PipelineTask::Matrix(m) => Some(&m.matrix),
}
}
pub fn as_node(&self) -> Option<&TaskNode> {
match self {
PipelineTask::Node(node) => Some(node),
PipelineTask::Matrix(_) | PipelineTask::Simple(_) => None,
}
}
pub fn is_simple(&self) -> bool {
matches!(self, PipelineTask::Simple(_))
}
}
pub type ProviderConfig = HashMap<String, serde_json::Value>;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct GitHubActionConfig {
pub uses: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty", rename = "with")]
pub inputs: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PipelineMode {
#[default]
Thin,
Expanded,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct Pipeline {
#[serde(default)]
pub mode: PipelineMode,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub providers: Vec<String>,
pub environment: Option<String>,
pub when: Option<PipelineCondition>,
#[serde(default)]
pub tasks: Vec<PipelineTask>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub annotations: HashMap<String, AnnotationValue>,
pub derive_paths: Option<bool>,
pub provider: Option<ProviderConfig>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskCondition {
OnSuccess,
OnFailure,
Always,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct ActivationCondition {
#[serde(skip_serializing_if = "Option::is_none")]
pub always: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workspace_member: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub runtime_type: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cuenv_source: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secrets_provider: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub provider_config: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub task_command: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub task_labels: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub environment: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub service_command: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub has_service: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum SecretRef {
Simple(String),
Detailed(SecretRefConfig),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SecretRefConfig {
pub source: String,
#[serde(default)]
pub cache_key: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct TaskProviderConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub github: Option<GitHubActionConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct AutoAssociate {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub command: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inject_dependency: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ContributorTask {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub script: Option<String>,
#[serde(default)]
pub shell: bool,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub secrets: HashMap<String, SecretRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub inputs: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub outputs: Vec<String>,
#[serde(default)]
pub hermetic: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub depends_on: Vec<String>,
#[serde(default = "default_priority")]
pub priority: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub condition: Option<TaskCondition>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<TaskProviderConfig>,
}
const fn default_priority() -> i32 {
10
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Contributor {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub when: Option<ActivationCondition>,
pub tasks: Vec<ContributorTask>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_associate: Option<AutoAssociate>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct CI {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub providers: Vec<String>,
#[serde(default)]
pub pipelines: BTreeMap<String, Pipeline>,
pub provider: Option<ProviderConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub contributors: Vec<Contributor>,
}
impl CI {
#[must_use]
pub fn providers_for_pipeline(&self, pipeline_name: &str) -> &[String] {
self.pipelines
.get(pipeline_name)
.filter(|p| !p.providers.is_empty())
.map(|p| p.providers.as_slice())
.unwrap_or(&self.providers)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum StringOrVec {
String(String),
Vec(Vec<String>),
}
impl StringOrVec {
pub fn to_vec(&self) -> Vec<String> {
match self {
StringOrVec::String(s) => vec![s.clone()],
StringOrVec::Vec(v) => v.clone(),
}
}
pub fn as_single(&self) -> Option<&str> {
match self {
StringOrVec::String(s) => Some(s),
StringOrVec::Vec(v) => v.first().map(|s| s.as_str()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_string_or_vec() {
let single = StringOrVec::String("value".to_string());
assert_eq!(single.to_vec(), vec!["value"]);
assert_eq!(single.as_single(), Some("value"));
let multi = StringOrVec::Vec(vec!["a".to_string(), "b".to_string()]);
assert_eq!(multi.to_vec(), vec!["a", "b"]);
assert_eq!(multi.as_single(), Some("a"));
}
#[test]
fn test_manual_trigger_bool() {
let json = r#"{"manual": true}"#;
let cond: PipelineCondition = serde_json::from_str(json).unwrap();
assert!(matches!(cond.manual, Some(ManualTrigger::Enabled(true))));
let json = r#"{"manual": false}"#;
let cond: PipelineCondition = serde_json::from_str(json).unwrap();
assert!(matches!(cond.manual, Some(ManualTrigger::Enabled(false))));
}
#[test]
fn test_manual_trigger_with_inputs() {
let json =
r#"{"manual": {"tag_name": {"description": "Tag to release", "required": true}}}"#;
let cond: PipelineCondition = serde_json::from_str(json).unwrap();
match &cond.manual {
Some(ManualTrigger::WithInputs(inputs)) => {
assert!(inputs.contains_key("tag_name"));
let input = inputs.get("tag_name").unwrap();
assert_eq!(input.description, "Tag to release");
assert_eq!(input.required, Some(true));
}
_ => panic!("Expected WithInputs variant"),
}
}
#[test]
fn test_manual_trigger_helpers() {
let enabled = ManualTrigger::Enabled(true);
assert!(enabled.is_enabled());
assert!(enabled.inputs().is_none());
let disabled = ManualTrigger::Enabled(false);
assert!(!disabled.is_enabled());
let mut inputs = HashMap::new();
inputs.insert(
"tag".to_string(),
WorkflowDispatchInput {
description: "Tag name".to_string(),
required: Some(true),
default: None,
input_type: None,
options: None,
},
);
let with_inputs = ManualTrigger::WithInputs(inputs);
assert!(with_inputs.is_enabled());
assert!(with_inputs.inputs().is_some());
}
#[test]
fn test_scheduled_cron_expressions() {
let json = r#"{"scheduled": "0 0 * * 0"}"#;
let cond: PipelineCondition = serde_json::from_str(json).unwrap();
match &cond.scheduled {
Some(StringOrVec::String(s)) => assert_eq!(s, "0 0 * * 0"),
_ => panic!("Expected single string"),
}
let json = r#"{"scheduled": ["0 0 * * 0", "0 12 * * *"]}"#;
let cond: PipelineCondition = serde_json::from_str(json).unwrap();
match &cond.scheduled {
Some(StringOrVec::Vec(v)) => {
assert_eq!(v.len(), 2);
assert_eq!(v[0], "0 0 * * 0");
assert_eq!(v[1], "0 12 * * *");
}
_ => panic!("Expected vec"),
}
}
#[test]
fn test_release_trigger() {
let json = r#"{"release": ["published", "created"]}"#;
let cond: PipelineCondition = serde_json::from_str(json).unwrap();
assert_eq!(
cond.release,
Some(vec!["published".to_string(), "created".to_string()])
);
}
#[test]
fn test_pipeline_derive_paths() {
let json = r#"{"tasks": [{"_name": "test"}], "derivePaths": true}"#;
let pipeline: Pipeline = serde_json::from_str(json).unwrap();
assert_eq!(pipeline.derive_paths, Some(true));
let json = r#"{"tasks": [{"_name": "sync"}], "derivePaths": false}"#;
let pipeline: Pipeline = serde_json::from_str(json).unwrap();
assert_eq!(pipeline.derive_paths, Some(false));
let json = r#"{"tasks": [{"_name": "build"}]}"#;
let pipeline: Pipeline = serde_json::from_str(json).unwrap();
assert_eq!(pipeline.derive_paths, None);
}
#[test]
fn test_pipeline_task_simple() {
let json = r#"{"_name": "build", "command": "cargo build"}"#;
let task: PipelineTask = serde_json::from_str(json).unwrap();
assert!(matches!(task, PipelineTask::Simple(_)));
assert_eq!(task.task_name(), "build");
assert!(!task.is_matrix());
assert!(task.matrix().is_none());
}
#[test]
fn test_pipeline_task_matrix() {
let json = r#"{"type": "matrix", "task": {"_name": "release.build"}, "matrix": {"arch": ["linux-x64", "darwin-arm64"]}}"#;
let task: PipelineTask = serde_json::from_str(json).unwrap();
assert!(task.is_matrix());
assert_eq!(task.task_name(), "release.build");
let matrix = task.matrix().unwrap();
assert!(matrix.contains_key("arch"));
assert_eq!(matrix["arch"], vec!["linux-x64", "darwin-arm64"]);
}
#[test]
fn test_pipeline_task_matrix_with_artifacts() {
let json = r#"{
"type": "matrix",
"task": {"_name": "release.publish"},
"matrix": {},
"artifacts": [{"from": "release.build", "to": "dist", "filter": "*stable"}],
"params": {"tag": "v1.0.0"}
}"#;
let task: PipelineTask = serde_json::from_str(json).unwrap();
if let PipelineTask::Matrix(m) = task {
assert_eq!(m.task.task_name(), "release.publish");
let artifacts = m.artifacts.unwrap();
assert_eq!(artifacts.len(), 1);
assert_eq!(artifacts[0].from, "release.build");
assert_eq!(artifacts[0].to, "dist");
assert_eq!(artifacts[0].filter, "*stable");
let params = m.params.unwrap();
assert_eq!(params.get("tag"), Some(&"v1.0.0".to_string()));
} else {
panic!("Expected Matrix variant");
}
}
#[test]
fn test_pipeline_mixed_tasks() {
let json = r#"{
"tasks": [
{"type": "matrix", "task": {"_name": "release.build"}, "matrix": {"arch": ["linux-x64", "darwin-arm64"]}},
{"_name": "release.publish:github"},
{"_name": "docs.deploy"}
]
}"#;
let pipeline: Pipeline = serde_json::from_str(json).unwrap();
assert_eq!(pipeline.tasks.len(), 3);
assert!(pipeline.tasks[0].is_matrix());
assert!(!pipeline.tasks[1].is_matrix());
assert!(!pipeline.tasks[2].is_matrix());
}
#[test]
fn test_runner_mapping() {
let json = r#"{"arch": {"linux-x64": "ubuntu-latest", "darwin-arm64": "macos-14"}}"#;
let mapping: RunnerMapping = serde_json::from_str(json).unwrap();
let arch = mapping.arch.unwrap();
assert_eq!(arch.get("linux-x64"), Some(&"ubuntu-latest".to_string()));
assert_eq!(arch.get("darwin-arm64"), Some(&"macos-14".to_string()));
}
#[test]
fn test_contributor_task_with_command_and_args() {
let json = r#"{
"id": "bun.workspace.install",
"command": "bun",
"args": ["install", "--frozen-lockfile"],
"inputs": ["package.json", "bun.lock"],
"outputs": ["node_modules"]
}"#;
let task: ContributorTask = serde_json::from_str(json).unwrap();
assert_eq!(task.id, "bun.workspace.install");
assert_eq!(task.command, Some("bun".to_string()));
assert_eq!(task.args, vec!["install", "--frozen-lockfile"]);
assert_eq!(task.inputs, vec!["package.json", "bun.lock"]);
assert_eq!(task.outputs, vec!["node_modules"]);
}
#[test]
fn test_contributor_task_with_script() {
let json = r#"{
"id": "nix.install",
"command": "sh",
"args": ["-c", "curl -sSL https://install.determinate.systems/nix | sh"]
}"#;
let task: ContributorTask = serde_json::from_str(json).unwrap();
assert_eq!(task.id, "nix.install");
assert_eq!(task.command, Some("sh".to_string()));
assert_eq!(
task.args,
vec![
"-c",
"curl -sSL https://install.determinate.systems/nix | sh"
]
);
}
#[test]
fn test_contributor_with_auto_associate() {
let json = r#"{
"id": "bun.workspace",
"when": {"workspaceMember": ["bun"]},
"tasks": [{
"id": "bun.workspace.install",
"command": "bun",
"args": ["install"]
}],
"autoAssociate": {
"command": ["bun", "bunx"],
"injectDependency": "cuenv:contributor:bun.workspace.setup"
}
}"#;
let contributor: Contributor = serde_json::from_str(json).unwrap();
assert_eq!(contributor.id, "bun.workspace");
let when = contributor.when.unwrap();
assert_eq!(when.workspace_member, vec!["bun"]);
let auto = contributor.auto_associate.unwrap();
assert_eq!(auto.command, vec!["bun", "bunx"]);
assert_eq!(
auto.inject_dependency,
Some("cuenv:contributor:bun.workspace.setup".to_string())
);
}
#[test]
fn test_activation_condition_workspace_member() {
let json = r#"{"workspaceMember": ["npm", "bun"]}"#;
let cond: ActivationCondition = serde_json::from_str(json).unwrap();
assert_eq!(cond.workspace_member, vec!["npm", "bun"]);
}
#[test]
fn test_providers_for_pipeline_global() {
let ci = CI {
providers: vec!["github".to_string()],
pipelines: BTreeMap::from([(
"ci".to_string(),
Pipeline {
providers: vec![],
mode: PipelineMode::default(),
environment: None,
when: None,
tasks: vec![],
annotations: HashMap::new(),
derive_paths: None,
provider: None,
},
)]),
..Default::default()
};
assert_eq!(ci.providers_for_pipeline("ci"), &["github"]);
}
#[test]
fn test_providers_for_pipeline_override() {
let ci = CI {
providers: vec!["github".to_string()],
pipelines: BTreeMap::from([(
"release".to_string(),
Pipeline {
providers: vec!["buildkite".to_string()],
mode: PipelineMode::default(),
environment: None,
when: None,
tasks: vec![],
annotations: HashMap::new(),
derive_paths: None,
provider: None,
},
)]),
..Default::default()
};
assert_eq!(ci.providers_for_pipeline("release"), &["buildkite"]);
}
#[test]
fn test_providers_for_pipeline_empty() {
let ci = CI::default();
assert!(ci.providers_for_pipeline("any").is_empty());
}
#[test]
fn test_providers_for_pipeline_nonexistent() {
let ci = CI {
providers: vec!["github".to_string()],
..Default::default()
};
assert_eq!(ci.providers_for_pipeline("nonexistent"), &["github"]);
}
#[test]
fn test_pipeline_task_node_task_group() {
let json = r#"{
"type": "group",
"http": {
"command": "bun",
"args": ["x", "wrangler", "deploy"]
}
}"#;
let task: PipelineTask = serde_json::from_str(json).unwrap();
assert!(task.is_node());
assert!(!task.is_matrix());
assert!(!task.is_simple());
assert_eq!(task.task_name(), "http");
let children = task.child_task_names();
assert!(children.contains(&"http"));
}
#[test]
fn test_pipeline_task_node_inline_task() {
let json = r#"{
"command": "echo",
"args": ["hello"],
"description": "Say hello"
}"#;
let task: PipelineTask = serde_json::from_str(json).unwrap();
assert!(task.is_node());
assert_eq!(task.task_name(), "Say hello");
}
#[test]
fn test_pipeline_mixed_with_node() {
let json = r#"{
"tasks": [
{"_name": "build"},
{"type": "matrix", "task": {"_name": "release"}, "matrix": {}},
{"type": "group", "deploy": {"command": "deploy"}}
]
}"#;
let pipeline: Pipeline = serde_json::from_str(json).unwrap();
assert_eq!(pipeline.tasks.len(), 3);
assert!(pipeline.tasks[0].is_simple());
assert!(pipeline.tasks[1].is_matrix());
assert!(pipeline.tasks[2].is_node());
}
#[test]
fn test_annotation_value_serde_roundtrip() {
let literal = AnnotationValue::Literal("hello".to_string());
let json = serde_json::to_string(&literal).unwrap();
let deserialized: AnnotationValue = serde_json::from_str(&json).unwrap();
assert_eq!(literal, deserialized);
let capture_ref = AnnotationValue::CaptureRef {
cuenv_capture_ref: true,
cuenv_task: "deploy.preview".to_string(),
cuenv_capture: "previewUrl".to_string(),
};
let json = serde_json::to_string(&capture_ref).unwrap();
assert!(json.contains("cuenvCaptureRef"));
assert!(json.contains("cuenvTask"));
assert!(json.contains("cuenvCapture"));
let deserialized: AnnotationValue = serde_json::from_str(&json).unwrap();
assert_eq!(capture_ref, deserialized);
}
#[test]
fn test_pipeline_with_annotations() {
let json = r#"{
"tasks": [{"_name": "deploy"}],
"annotations": {
"Preview URL": {"cuenvCaptureRef": true, "cuenvTask": "deploy.preview", "cuenvCapture": "previewUrl"},
"Version": "1.0.0"
}
}"#;
let pipeline: Pipeline = serde_json::from_str(json).unwrap();
assert_eq!(pipeline.annotations.len(), 2);
assert!(matches!(
pipeline.annotations.get("Version"),
Some(AnnotationValue::Literal(s)) if s == "1.0.0"
));
assert!(matches!(
pipeline.annotations.get("Preview URL"),
Some(AnnotationValue::CaptureRef { cuenv_task, cuenv_capture, .. })
if cuenv_task == "deploy.preview" && cuenv_capture == "previewUrl"
));
}
}