use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::Error;
pub type ReferenceMap = HashMap<String, String>;
fn strip_dependency_prefix(path: &str) -> &str {
const DEP_PREFIXES: &[&str] = &[
"tasks.",
"_tasks.",
"_t.",
"services.",
"_services.",
"_s.",
"images.",
"_images.",
"_i.",
];
for prefix in DEP_PREFIXES {
if let Some(stripped) = path.strip_prefix(prefix) {
return stripped;
}
}
path
}
fn is_ci_matrix_task_object(
field_path: &str,
obj: &serde_json::Map<String, serde_json::Value>,
) -> bool {
if !(field_path.starts_with("ci.pipelines.") && field_path.contains(".tasks[")) {
return false;
}
obj.contains_key("matrix")
|| obj.get("type").and_then(serde_json::Value::as_str) == Some("matrix")
}
fn enrich_task_refs(value: &mut serde_json::Value, instance_path: &str, references: &ReferenceMap) {
enrich_task_refs_recursive(value, instance_path, "", references);
}
fn enrich_task_refs_recursive(
value: &mut serde_json::Value,
instance_path: &str,
field_path: &str,
references: &ReferenceMap,
) {
match value {
serde_json::Value::Object(obj) => {
if let Some(serde_json::Value::Array(deps)) = obj.get_mut("dependsOn") {
let depends_on_path = if field_path.is_empty() {
"dependsOn".to_string()
} else {
format!("{}.dependsOn", field_path)
};
enrich_task_ref_array(deps, instance_path, &depends_on_path, references);
}
if is_ci_matrix_task_object(field_path, obj)
&& let Some(task_value) = obj.get_mut("task")
{
let task_path = if field_path.is_empty() {
"task".to_string()
} else {
format!("{}.task", field_path)
};
let meta_key = format!("{}/{}", instance_path, task_path);
if let Some(reference) = references.get(&meta_key) {
let task_name = strip_dependency_prefix(reference).to_string();
match task_value {
serde_json::Value::Object(task_obj) => {
if !task_obj.contains_key("_name") {
task_obj.insert(
"_name".to_string(),
serde_json::Value::String(task_name),
);
}
}
serde_json::Value::Array(_) => {
*task_value = serde_json::json!({ "_name": task_name });
}
serde_json::Value::Null
| serde_json::Value::Bool(_)
| serde_json::Value::Number(_)
| serde_json::Value::String(_) => {}
}
}
}
for (key, child) in obj.iter_mut() {
if key == "dependsOn" || key == "task" {
continue; }
let child_path = if field_path.is_empty() {
key.clone()
} else {
format!("{}.{}", field_path, key)
};
enrich_task_refs_recursive(child, instance_path, &child_path, references);
}
}
serde_json::Value::Array(arr) => {
let is_pipeline_tasks =
field_path.contains("pipelines.") && field_path.ends_with(".tasks");
if is_pipeline_tasks {
enrich_task_ref_array(arr, instance_path, field_path, references);
}
for (i, child) in arr.iter_mut().enumerate() {
let child_path = format!("{}[{}]", field_path, i);
enrich_task_refs_recursive(child, instance_path, &child_path, references);
}
}
_ => {}
}
}
fn enrich_task_ref_array(
arr: &mut [serde_json::Value],
instance_path: &str,
array_path: &str,
references: &ReferenceMap,
) {
for (i, element) in arr.iter_mut().enumerate() {
let meta_key = format!("{}/{}[{}]", instance_path, array_path, i);
if let Some(reference) = references.get(&meta_key) {
let task_name = strip_dependency_prefix(reference).to_string();
match element {
serde_json::Value::Object(obj) => {
if obj.contains_key("_name") {
continue;
}
obj.insert("_name".to_string(), serde_json::Value::String(task_name));
}
_ => {
*element = serde_json::json!({ "_name": task_name });
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct ModuleEvaluation {
pub root: PathBuf,
pub instances: HashMap<PathBuf, Instance>,
}
impl ModuleEvaluation {
pub fn from_raw(
root: PathBuf,
raw_instances: HashMap<String, serde_json::Value>,
project_paths: Vec<String>,
references: Option<ReferenceMap>,
) -> Self {
let project_set: std::collections::HashSet<&str> =
project_paths.iter().map(String::as_str).collect();
let instances = raw_instances
.into_iter()
.map(|(path, mut value)| {
let path_buf = PathBuf::from(&path);
let kind = if project_set.contains(path.as_str()) {
InstanceKind::Project
} else {
InstanceKind::Base
};
if let Some(ref refs) = references {
enrich_task_refs(&mut value, &path, refs);
}
let output_ref_deps = crate::tasks::output_refs::process_output_refs(&mut value);
let instance = Instance {
path: path_buf.clone(),
kind,
value,
output_ref_deps,
};
(path_buf, instance)
})
.collect();
Self { root, instances }
}
pub fn bases(&self) -> impl Iterator<Item = &Instance> {
self.instances
.values()
.filter(|i| matches!(i.kind, InstanceKind::Base))
}
pub fn projects(&self) -> impl Iterator<Item = &Instance> {
self.instances
.values()
.filter(|i| matches!(i.kind, InstanceKind::Project))
}
pub fn root_instance(&self) -> Option<&Instance> {
self.instances.get(Path::new("."))
}
pub fn get(&self, path: &Path) -> Option<&Instance> {
self.instances.get(path)
}
pub fn base_count(&self) -> usize {
self.bases().count()
}
pub fn project_count(&self) -> usize {
self.projects().count()
}
pub fn ancestors(&self, path: &Path) -> Vec<PathBuf> {
if path == Path::new(".") {
return Vec::new();
}
let mut ancestors = Vec::new();
let mut current = path.to_path_buf();
while let Some(parent) = current.parent() {
if parent.as_os_str().is_empty() {
ancestors.push(PathBuf::from("."));
break;
}
ancestors.push(parent.to_path_buf());
current = parent.to_path_buf();
}
ancestors
}
pub fn is_inherited(&self, child_path: &Path, field: &str) -> bool {
let Some(child) = self.instances.get(child_path) else {
return false;
};
let Some(child_value) = child.value.get(field) else {
return false;
};
for ancestor_path in self.ancestors(child_path) {
if let Some(ancestor) = self.instances.get(&ancestor_path)
&& let Some(ancestor_value) = ancestor.value.get(field)
&& child_value == ancestor_value
{
return true;
}
}
false
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Instance {
pub path: PathBuf,
pub kind: InstanceKind,
pub value: serde_json::Value,
#[serde(default, skip_serializing)]
pub output_ref_deps: Vec<crate::tasks::output_refs::OutputRefDep>,
}
impl Instance {
pub fn deserialize<T: DeserializeOwned>(&self) -> crate::Result<T> {
if self.value.is_null() {
return Err(Error::configuration(format!(
"CUE instance at {} evaluated to null — cannot deserialize as {}. \
This typically means the CUE evaluator returned no data for this path.",
self.path.display(),
std::any::type_name::<T>(),
)));
}
serde_json::from_value(self.value.clone()).map_err(|fallback_error| {
let error_detail = detailed_deserialize_error::<T>(&self.value, &fallback_error);
Error::configuration(format!(
"Failed to deserialize {} as {}: {}",
self.path.display(),
std::any::type_name::<T>(),
error_detail
))
})
}
pub fn project_name(&self) -> Option<&str> {
if matches!(self.kind, InstanceKind::Project) {
self.value.get("name").and_then(|v| v.as_str())
} else {
None
}
}
pub fn get_field(&self, field: &str) -> Option<&serde_json::Value> {
self.value.get(field)
}
pub fn has_field(&self, field: &str) -> bool {
self.value.get(field).is_some()
}
}
const ENV_VALUE_HINT: &str = "Hint: `env` values must be a string, int, bool, secret object (`{resolver: ...}`), interpolated array (`[\"prefix\", {resolver: ...}]`), or `{ value: <value>, policies: [...] }`.";
fn should_include_env_value_hint(message: &str) -> bool {
message.contains("untagged enum EnvValue") || message.contains("untagged enum EnvValueSimple")
}
fn detailed_deserialize_error<T: DeserializeOwned>(
value: &serde_json::Value,
fallback: &serde_json::Error,
) -> String {
let json = value.to_string();
let mut deserializer = serde_json::Deserializer::from_str(&json);
match serde_path_to_error::deserialize::<_, T>(&mut deserializer) {
Ok(_) => fallback.to_string(),
Err(error) => {
let path = error.path().to_string();
let inner_message = error.into_inner().to_string();
let mut display_path = if path.is_empty() { None } else { Some(path) };
if should_include_env_value_hint(&inner_message)
&& let Some(env_path) = find_invalid_env_value_path(value)
{
display_path = Some(env_path);
}
let mut message = match display_path {
Some(path) => format!("{inner_message} (at `{path}`)"),
None => inner_message,
};
if should_include_env_value_hint(&message) {
message.push_str(". ");
message.push_str(ENV_VALUE_HINT);
}
message
}
}
}
fn find_invalid_env_value_path(value: &serde_json::Value) -> Option<String> {
let env = value.get("env")?.as_object()?;
for (key, raw_value) in env {
if key == "environment" {
continue;
}
if serde_json::from_value::<crate::environment::EnvValue>(raw_value.clone()).is_err() {
return Some(format!("env.{key}"));
}
}
let environments = env.get("environment")?.as_object()?;
for (environment_name, overrides) in environments {
let overrides = overrides.as_object()?;
for (key, raw_value) in overrides {
if serde_json::from_value::<crate::environment::EnvValue>(raw_value.clone()).is_err() {
return Some(format!("env.environment.{environment_name}.{key}"));
}
}
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum InstanceKind {
Base,
Project,
}
impl std::fmt::Display for InstanceKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Base => write!(f, "Base"),
Self::Project => write!(f, "Project"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ci::PipelineTask;
use crate::manifest::Project;
use crate::tasks::TaskNode;
use serde_json::json;
fn create_test_module() -> ModuleEvaluation {
let mut raw = HashMap::new();
raw.insert(
".".to_string(),
json!({
"env": { "SHARED": "value" },
"owners": { "rules": { "default": { "pattern": "**", "owners": ["@owner"] } } }
}),
);
raw.insert(
"projects/api".to_string(),
json!({
"name": "api",
"env": { "SHARED": "value" },
"owners": { "rules": { "default": { "pattern": "**", "owners": ["@owner"] } } }
}),
);
raw.insert(
"projects/web".to_string(),
json!({
"name": "web",
"env": { "SHARED": "value" },
"owners": { "rules": { "local": { "pattern": "**", "owners": ["@web-team"] } } }
}),
);
let project_paths = vec!["projects/api".to_string(), "projects/web".to_string()];
ModuleEvaluation::from_raw(PathBuf::from("/test/repo"), raw, project_paths, None)
}
#[test]
fn test_instance_kind_detection() {
let module = create_test_module();
assert_eq!(module.base_count(), 1);
assert_eq!(module.project_count(), 2);
let root = module.root_instance().unwrap();
assert!(matches!(root.kind, InstanceKind::Base));
let api = module.get(Path::new("projects/api")).unwrap();
assert!(matches!(api.kind, InstanceKind::Project));
assert_eq!(api.project_name(), Some("api"));
}
#[test]
fn test_ancestors() {
let module = create_test_module();
let ancestors = module.ancestors(Path::new("projects/api"));
assert_eq!(ancestors.len(), 2);
assert_eq!(ancestors[0], PathBuf::from("projects"));
assert_eq!(ancestors[1], PathBuf::from("."));
let root_ancestors = module.ancestors(Path::new("."));
assert!(root_ancestors.is_empty());
}
#[test]
fn test_is_inherited() {
let module = create_test_module();
assert!(module.is_inherited(Path::new("projects/api"), "owners"));
assert!(!module.is_inherited(Path::new("projects/web"), "owners"));
assert!(module.is_inherited(Path::new("projects/api"), "env"));
}
#[test]
fn test_instance_kind_display() {
assert_eq!(InstanceKind::Base.to_string(), "Base");
assert_eq!(InstanceKind::Project.to_string(), "Project");
}
#[test]
fn test_instance_deserialize() {
#[derive(Debug, Deserialize, PartialEq)]
struct TestConfig {
name: String,
env: std::collections::HashMap<String, String>,
}
let instance = Instance {
path: PathBuf::from("test/path"),
kind: InstanceKind::Project,
value: json!({
"name": "my-project",
"env": { "FOO": "bar" }
}),
output_ref_deps: vec![],
};
let config: TestConfig = instance.deserialize().unwrap();
assert_eq!(config.name, "my-project");
assert_eq!(config.env.get("FOO"), Some(&"bar".to_string()));
}
#[test]
fn test_instance_deserialize_error() {
#[derive(Debug, Deserialize)]
#[allow(dead_code)] struct RequiredFields {
required_field: String,
}
let instance = Instance {
path: PathBuf::from("test/path"),
kind: InstanceKind::Base,
value: json!({}), output_ref_deps: vec![],
};
let result: crate::Result<RequiredFields> = instance.deserialize();
assert!(result.is_err());
let err = result.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("test/path"),
"Error should mention path: {}",
msg
);
assert!(
msg.contains("RequiredFields"),
"Error should mention target type: {}",
msg
);
}
#[test]
fn test_instance_deserialize_error_includes_field_path_and_env_hint() {
let instance = Instance {
path: PathBuf::from("projects/klustered.dev"),
kind: InstanceKind::Project,
value: json!({
"name": "klustered.dev",
"env": {
"BROKEN": {
"unexpected": "shape"
}
}
}),
output_ref_deps: vec![],
};
let result: crate::Result<Project> = instance.deserialize();
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("at `env") && msg.contains("BROKEN"),
"Error should include field path to invalid env key: {}",
msg
);
assert!(
msg.contains("Hint: `env` values must be"),
"Error should include env value hint: {}",
msg
);
}
#[test]
fn test_module_evaluation_empty() {
let module =
ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
assert_eq!(module.base_count(), 0);
assert_eq!(module.project_count(), 0);
assert!(module.root_instance().is_none());
}
#[test]
fn test_module_evaluation_root_only() {
let mut raw = HashMap::new();
raw.insert(".".to_string(), json!({"key": "value"}));
let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, vec![], None);
assert_eq!(module.base_count(), 1);
assert_eq!(module.project_count(), 0);
assert!(module.root_instance().is_some());
}
#[test]
fn test_module_evaluation_get_nonexistent() {
let module =
ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
assert!(module.get(Path::new("nonexistent")).is_none());
}
#[test]
fn test_module_evaluation_multiple_projects() {
let mut raw = HashMap::new();
raw.insert("proj1".to_string(), json!({"name": "proj1"}));
raw.insert("proj2".to_string(), json!({"name": "proj2"}));
raw.insert("proj3".to_string(), json!({"name": "proj3"}));
let project_paths = vec![
"proj1".to_string(),
"proj2".to_string(),
"proj3".to_string(),
];
let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, project_paths, None);
assert_eq!(module.project_count(), 3);
assert_eq!(module.base_count(), 0);
}
#[test]
fn test_module_evaluation_ancestors_deep_path() {
let module =
ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
let ancestors = module.ancestors(Path::new("a/b/c/d"));
assert_eq!(ancestors.len(), 4);
assert_eq!(ancestors[0], PathBuf::from("a/b/c"));
assert_eq!(ancestors[1], PathBuf::from("a/b"));
assert_eq!(ancestors[2], PathBuf::from("a"));
assert_eq!(ancestors[3], PathBuf::from("."));
}
#[test]
fn test_module_evaluation_is_inherited_no_child() {
let module =
ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
assert!(!module.is_inherited(Path::new("nonexistent"), "field"));
}
#[test]
fn test_module_evaluation_is_inherited_no_field() {
let mut raw = HashMap::new();
raw.insert("child".to_string(), json!({"other": "value"}));
let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, vec![], None);
assert!(!module.is_inherited(Path::new("child"), "missing_field"));
}
#[test]
fn test_instance_get_field() {
let instance = Instance {
path: PathBuf::from("test"),
kind: InstanceKind::Project,
value: json!({
"name": "my-project",
"version": "1.0.0"
}),
output_ref_deps: vec![],
};
assert_eq!(instance.get_field("name"), Some(&json!("my-project")));
assert_eq!(instance.get_field("version"), Some(&json!("1.0.0")));
assert!(instance.get_field("nonexistent").is_none());
}
#[test]
fn test_instance_has_field() {
let instance = Instance {
path: PathBuf::from("test"),
kind: InstanceKind::Project,
value: json!({"name": "test", "env": {}}),
output_ref_deps: vec![],
};
assert!(instance.has_field("name"));
assert!(instance.has_field("env"));
assert!(!instance.has_field("missing"));
}
#[test]
fn test_instance_project_name_base() {
let instance = Instance {
path: PathBuf::from("test"),
kind: InstanceKind::Base,
value: json!({"name": "should-be-ignored"}),
output_ref_deps: vec![],
};
assert!(instance.project_name().is_none());
}
#[test]
fn test_instance_project_name_missing() {
let instance = Instance {
path: PathBuf::from("test"),
kind: InstanceKind::Project,
value: json!({}),
output_ref_deps: vec![],
};
assert!(instance.project_name().is_none());
}
#[test]
fn test_instance_clone() {
let instance = Instance {
path: PathBuf::from("original"),
kind: InstanceKind::Project,
value: json!({"name": "test"}),
output_ref_deps: vec![],
};
let cloned = instance.clone();
assert_eq!(cloned.path, instance.path);
assert_eq!(cloned.kind, instance.kind);
assert_eq!(cloned.value, instance.value);
}
#[test]
fn test_instance_serialize() {
let instance = Instance {
path: PathBuf::from("test/path"),
kind: InstanceKind::Project,
value: json!({"name": "my-project"}),
output_ref_deps: vec![],
};
let json = serde_json::to_string(&instance).unwrap();
assert!(json.contains("test/path"));
assert!(json.contains("Project"));
assert!(json.contains("my-project"));
}
#[test]
fn test_instance_kind_equality() {
assert_eq!(InstanceKind::Base, InstanceKind::Base);
assert_eq!(InstanceKind::Project, InstanceKind::Project);
assert_ne!(InstanceKind::Base, InstanceKind::Project);
}
#[test]
fn test_instance_kind_copy() {
let kind = InstanceKind::Project;
let copied = kind;
assert_eq!(kind, copied);
}
#[test]
fn test_instance_kind_serialize() {
let base_json = serde_json::to_string(&InstanceKind::Base).unwrap();
let project_json = serde_json::to_string(&InstanceKind::Project).unwrap();
assert!(base_json.contains("Base"));
assert!(project_json.contains("Project"));
}
#[test]
fn test_instance_kind_deserialize() {
let base: InstanceKind = serde_json::from_str("\"Base\"").unwrap();
let project: InstanceKind = serde_json::from_str("\"Project\"").unwrap();
assert_eq!(base, InstanceKind::Base);
assert_eq!(project, InstanceKind::Project);
}
#[test]
fn test_strip_dependency_prefix() {
assert_eq!(strip_dependency_prefix("tasks.build"), "build");
assert_eq!(strip_dependency_prefix("tasks.ci.deploy"), "ci.deploy");
assert_eq!(strip_dependency_prefix("_t.cargo.build"), "cargo.build");
assert_eq!(
strip_dependency_prefix("_t.release.publish"),
"release.publish"
);
assert_eq!(strip_dependency_prefix("_tasks.internal"), "internal");
assert_eq!(strip_dependency_prefix("services.db"), "db");
assert_eq!(strip_dependency_prefix("services.api.http"), "api.http");
assert_eq!(strip_dependency_prefix("_s.db"), "db");
assert_eq!(strip_dependency_prefix("_services.cache"), "cache");
assert_eq!(strip_dependency_prefix("build"), "build");
assert_eq!(strip_dependency_prefix("ci.deploy"), "ci.deploy");
}
#[derive(Debug, Clone, Copy)]
enum TaskNodeShape {
Task,
Group,
Sequence,
}
impl TaskNodeShape {
const fn name(self) -> &'static str {
match self {
Self::Task => "task",
Self::Group => "group",
Self::Sequence => "sequence",
}
}
fn as_value(self) -> serde_json::Value {
match self {
Self::Task => json!({
"command": "echo",
"args": ["task"],
}),
Self::Group => json!({
"type": "group",
"step": {
"command": "echo",
"args": ["group"],
},
}),
Self::Sequence => json!([
{
"command": "echo",
"args": ["sequence-0"],
},
{
"command": "echo",
"args": ["sequence-1"],
},
]),
}
}
}
#[derive(Debug, Clone, Copy)]
enum DependencyOwner {
Task,
Group,
}
impl DependencyOwner {
const fn name(self) -> &'static str {
match self {
Self::Task => "task",
Self::Group => "group",
}
}
fn node_with_dependency(self, target: serde_json::Value) -> serde_json::Value {
match self {
Self::Task => json!({
"command": "echo",
"args": ["consumer"],
"dependsOn": [target],
}),
Self::Group => json!({
"type": "group",
"dependsOn": [target],
"step": {
"command": "echo",
"args": ["consumer"],
},
}),
}
}
}
fn deserialize_project_with_references(
instance: serde_json::Value,
references: ReferenceMap,
) -> Project {
let mut raw = HashMap::new();
raw.insert(".".to_string(), instance);
let module = ModuleEvaluation::from_raw(
PathBuf::from("/test"),
raw,
vec![".".to_string()],
Some(references),
);
module
.root_instance()
.expect("root instance should exist")
.deserialize::<Project>()
.expect("project deserialization should succeed")
}
#[test]
fn test_depends_on_accepts_all_task_node_shapes_for_task_and_group() {
let shapes = [
TaskNodeShape::Task,
TaskNodeShape::Group,
TaskNodeShape::Sequence,
];
let owners = [DependencyOwner::Task, DependencyOwner::Group];
for owner in owners {
for shape in shapes {
let target = shape.as_value();
let consumer = owner.node_with_dependency(target.clone());
let instance = json!({
"name": "shape-contract",
"tasks": {
"target": target,
"consumer": consumer,
},
});
let mut references = ReferenceMap::new();
references.insert(
"./tasks.consumer.dependsOn[0]".to_string(),
"tasks.target".to_string(),
);
let project = deserialize_project_with_references(instance, references);
let consumer_node = project
.tasks
.get("consumer")
.expect("consumer task should exist");
let dependency_names: Vec<&str> = consumer_node
.depends_on()
.iter()
.map(|dependency| dependency.task_name())
.collect();
assert_eq!(
dependency_names,
vec!["target"],
"dependsOn owner={} should canonicalize {} reference",
owner.name(),
shape.name()
);
}
}
}
#[test]
fn test_service_depends_on_canonicalizes_task_and_service_references() {
let instance = json!({
"name": "service-contract",
"tasks": {
"migrate": {
"command": "echo",
"args": ["migrate"]
}
},
"services": {
"db": {
"type": "service",
"command": "echo",
"args": ["db"]
},
"seed": {
"type": "service",
"command": "echo",
"args": ["seed"],
"dependsOn": ["placeholder-a", "placeholder-b"]
}
}
});
let mut references = ReferenceMap::new();
references.insert(
"./services.seed.dependsOn[0]".to_string(),
"services.db".to_string(),
);
references.insert(
"./services.seed.dependsOn[1]".to_string(),
"tasks.migrate".to_string(),
);
let project = deserialize_project_with_references(instance, references);
let seed = project
.services
.get("seed")
.expect("seed service should exist");
let dep_names: Vec<&str> = seed.depends_on.iter().map(|d| d.task_name()).collect();
assert_eq!(dep_names, vec!["db", "migrate"]);
}
#[test]
fn test_ci_pipeline_tasks_reference_accepts_all_task_node_shapes() {
for shape in [
TaskNodeShape::Task,
TaskNodeShape::Group,
TaskNodeShape::Sequence,
] {
let target = shape.as_value();
let instance = json!({
"name": "shape-contract",
"tasks": {
"target": target.clone(),
},
"ci": {
"pipelines": {
"default": {
"tasks": [target],
},
},
},
});
let mut references = ReferenceMap::new();
references.insert(
"./ci.pipelines.default.tasks[0]".to_string(),
"tasks.target".to_string(),
);
let project = deserialize_project_with_references(instance, references);
let pipeline = &project
.ci
.as_ref()
.expect("ci should exist")
.pipelines
.get("default")
.expect("default pipeline should exist");
let pipeline_task = pipeline.tasks.first().expect("pipeline task should exist");
assert!(
pipeline_task.is_simple(),
"pipeline task should remain a simple reference for {}",
shape.name()
);
assert_eq!(
pipeline_task.task_name(),
"target",
"pipeline task reference should canonicalize {} shape",
shape.name()
);
}
}
#[test]
fn test_ci_matrix_task_reference_accepts_all_task_node_shapes() {
for shape in [
TaskNodeShape::Task,
TaskNodeShape::Group,
TaskNodeShape::Sequence,
] {
let target = shape.as_value();
let instance = json!({
"name": "shape-contract",
"tasks": {
"target": target.clone(),
},
"ci": {
"pipelines": {
"default": {
"tasks": [
{
"type": "matrix",
"task": target,
"matrix": {
"arch": ["linux-x64"],
},
},
],
},
},
},
});
let mut references = ReferenceMap::new();
references.insert(
"./ci.pipelines.default.tasks[0].task".to_string(),
"tasks.target".to_string(),
);
let project = deserialize_project_with_references(instance, references);
let pipeline = &project
.ci
.as_ref()
.expect("ci should exist")
.pipelines
.get("default")
.expect("default pipeline should exist");
let pipeline_task = pipeline.tasks.first().expect("pipeline task should exist");
match pipeline_task {
PipelineTask::Matrix(matrix_task) => {
assert_eq!(
matrix_task.task.task_name(),
"target",
"matrix task reference should canonicalize {} shape",
shape.name()
);
}
PipelineTask::Simple(_) | PipelineTask::Node(_) => {
panic!("expected matrix task for {}", shape.name())
}
}
}
}
#[test]
fn test_non_ci_task_string_field_is_not_rewritten() {
let instance = json!({
"name": "shape-contract",
"tasks": {
"producer": {
"command": "echo",
"args": ["producer"],
},
"consumer": {
"command": "echo",
"args": ["consumer"],
"inputs": [
{
"task": "producer",
},
],
},
},
});
let mut references = ReferenceMap::new();
references.insert(
"./tasks.consumer.inputs[0].task".to_string(),
"tasks.producer".to_string(),
);
let project = deserialize_project_with_references(instance, references);
let consumer_node = project
.tasks
.get("consumer")
.expect("consumer task should exist");
let consumer_task = match consumer_node {
TaskNode::Task(task) => task,
TaskNode::Group(_) | TaskNode::Sequence(_) => {
panic!("expected consumer to deserialize as a task")
}
};
let task_output = consumer_task
.iter_task_outputs()
.next()
.expect("consumer should have one task output input");
assert_eq!(
task_output.task, "producer",
"non-CI task string fields must remain strings after enrichment"
);
}
}