1use serde::de::DeserializeOwned;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use crate::Error;
12
13pub type ReferenceMap = HashMap<String, String>;
16
17fn strip_dependency_prefix(path: &str) -> &str {
22 const DEP_PREFIXES: &[&str] = &[
23 "tasks.",
24 "_tasks.",
25 "_t.",
26 "services.",
27 "_services.",
28 "_s.",
29 "images.",
30 "_images.",
31 "_i.",
32 ];
33 for prefix in DEP_PREFIXES {
34 if let Some(stripped) = path.strip_prefix(prefix) {
35 return stripped;
36 }
37 }
38 path
39}
40
41fn is_ci_matrix_task_object(
43 field_path: &str,
44 obj: &serde_json::Map<String, serde_json::Value>,
45) -> bool {
46 if !(field_path.starts_with("ci.pipelines.") && field_path.contains(".tasks[")) {
47 return false;
48 }
49
50 obj.contains_key("matrix")
51 || obj.get("type").and_then(serde_json::Value::as_str) == Some("matrix")
52}
53
54fn enrich_task_refs(value: &mut serde_json::Value, instance_path: &str, references: &ReferenceMap) {
64 enrich_task_refs_recursive(value, instance_path, "", references);
65}
66
67fn enrich_task_refs_recursive(
69 value: &mut serde_json::Value,
70 instance_path: &str,
71 field_path: &str,
72 references: &ReferenceMap,
73) {
74 match value {
75 serde_json::Value::Object(obj) => {
76 if let Some(serde_json::Value::Array(deps)) = obj.get_mut("dependsOn") {
78 let depends_on_path = if field_path.is_empty() {
79 "dependsOn".to_string()
80 } else {
81 format!("{}.dependsOn", field_path)
82 };
83
84 enrich_task_ref_array(deps, instance_path, &depends_on_path, references);
85 }
86
87 if is_ci_matrix_task_object(field_path, obj)
89 && let Some(task_value) = obj.get_mut("task")
90 {
91 let task_path = if field_path.is_empty() {
92 "task".to_string()
93 } else {
94 format!("{}.task", field_path)
95 };
96 let meta_key = format!("{}/{}", instance_path, task_path);
97 if let Some(reference) = references.get(&meta_key) {
98 let task_name = strip_dependency_prefix(reference).to_string();
99 match task_value {
100 serde_json::Value::Object(task_obj) => {
101 if !task_obj.contains_key("_name") {
103 task_obj.insert(
104 "_name".to_string(),
105 serde_json::Value::String(task_name),
106 );
107 }
108 }
109 serde_json::Value::Array(_) => {
110 *task_value = serde_json::json!({ "_name": task_name });
113 }
114 serde_json::Value::Null
115 | serde_json::Value::Bool(_)
116 | serde_json::Value::Number(_)
117 | serde_json::Value::String(_) => {}
118 }
119 }
120 }
121
122 for (key, child) in obj.iter_mut() {
124 if key == "dependsOn" || key == "task" {
125 continue; }
127 let child_path = if field_path.is_empty() {
128 key.clone()
129 } else {
130 format!("{}.{}", field_path, key)
131 };
132 enrich_task_refs_recursive(child, instance_path, &child_path, references);
133 }
134 }
135 serde_json::Value::Array(arr) => {
136 let is_pipeline_tasks =
139 field_path.contains("pipelines.") && field_path.ends_with(".tasks");
140
141 if is_pipeline_tasks {
142 enrich_task_ref_array(arr, instance_path, field_path, references);
143 }
144
145 for (i, child) in arr.iter_mut().enumerate() {
146 let child_path = format!("{}[{}]", field_path, i);
147 enrich_task_refs_recursive(child, instance_path, &child_path, references);
148 }
149 }
150 _ => {}
151 }
152}
153
154fn enrich_task_ref_array(
156 arr: &mut [serde_json::Value],
157 instance_path: &str,
158 array_path: &str,
159 references: &ReferenceMap,
160) {
161 for (i, element) in arr.iter_mut().enumerate() {
162 let meta_key = format!("{}/{}[{}]", instance_path, array_path, i);
164 if let Some(reference) = references.get(&meta_key) {
165 let task_name = strip_dependency_prefix(reference).to_string();
167
168 match element {
169 serde_json::Value::Object(obj) => {
170 if obj.contains_key("_name") {
172 continue;
173 }
174 obj.insert("_name".to_string(), serde_json::Value::String(task_name));
175 }
176 _ => {
177 *element = serde_json::json!({ "_name": task_name });
180 }
181 }
182 }
183 }
184}
185
186#[derive(Debug, Clone)]
191pub struct ModuleEvaluation {
192 pub root: PathBuf,
194
195 pub instances: HashMap<PathBuf, Instance>,
197}
198
199impl ModuleEvaluation {
200 pub fn from_raw(
208 root: PathBuf,
209 raw_instances: HashMap<String, serde_json::Value>,
210 project_paths: Vec<String>,
211 references: Option<ReferenceMap>,
212 ) -> Self {
213 let project_set: std::collections::HashSet<&str> =
215 project_paths.iter().map(String::as_str).collect();
216
217 let instances = raw_instances
218 .into_iter()
219 .map(|(path, mut value)| {
220 let path_buf = PathBuf::from(&path);
221 let kind = if project_set.contains(path.as_str()) {
223 InstanceKind::Project
224 } else {
225 InstanceKind::Base
226 };
227
228 if let Some(ref refs) = references {
230 enrich_task_refs(&mut value, &path, refs);
231 }
232
233 let output_ref_deps = crate::tasks::output_refs::process_output_refs(&mut value);
238
239 let instance = Instance {
240 path: path_buf.clone(),
241 kind,
242 value,
243 output_ref_deps,
244 };
245 (path_buf, instance)
246 })
247 .collect();
248
249 Self { root, instances }
250 }
251
252 pub fn bases(&self) -> impl Iterator<Item = &Instance> {
254 self.instances
255 .values()
256 .filter(|i| matches!(i.kind, InstanceKind::Base))
257 }
258
259 pub fn projects(&self) -> impl Iterator<Item = &Instance> {
261 self.instances
262 .values()
263 .filter(|i| matches!(i.kind, InstanceKind::Project))
264 }
265
266 pub fn root_instance(&self) -> Option<&Instance> {
268 self.instances.get(Path::new("."))
269 }
270
271 pub fn get(&self, path: &Path) -> Option<&Instance> {
273 self.instances.get(path)
274 }
275
276 pub fn base_count(&self) -> usize {
278 self.bases().count()
279 }
280
281 pub fn project_count(&self) -> usize {
283 self.projects().count()
284 }
285
286 pub fn ancestors(&self, path: &Path) -> Vec<PathBuf> {
291 if path == Path::new(".") {
293 return Vec::new();
294 }
295
296 let mut ancestors = Vec::new();
297 let mut current = path.to_path_buf();
298
299 while let Some(parent) = current.parent() {
300 if parent.as_os_str().is_empty() {
301 ancestors.push(PathBuf::from("."));
303 break;
304 }
305 ancestors.push(parent.to_path_buf());
306 current = parent.to_path_buf();
307 }
308
309 ancestors
310 }
311
312 pub fn is_inherited(&self, child_path: &Path, field: &str) -> bool {
317 let Some(child) = self.instances.get(child_path) else {
318 return false;
319 };
320
321 let Some(child_value) = child.value.get(field) else {
322 return false;
323 };
324
325 for ancestor_path in self.ancestors(child_path) {
327 if let Some(ancestor) = self.instances.get(&ancestor_path)
328 && let Some(ancestor_value) = ancestor.value.get(field)
329 && child_value == ancestor_value
330 {
331 return true;
332 }
333 }
334
335 false
336 }
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct Instance {
342 pub path: PathBuf,
344
345 pub kind: InstanceKind,
347
348 pub value: serde_json::Value,
350
351 #[serde(default, skip_serializing)]
354 pub output_ref_deps: Vec<crate::tasks::output_refs::OutputRefDep>,
355}
356
357impl Instance {
358 pub fn deserialize<T: DeserializeOwned>(&self) -> crate::Result<T> {
370 if self.value.is_null() {
371 return Err(Error::configuration(format!(
372 "CUE instance at {} evaluated to null — cannot deserialize as {}. \
373 This typically means the CUE evaluator returned no data for this path.",
374 self.path.display(),
375 std::any::type_name::<T>(),
376 )));
377 }
378
379 serde_json::from_value(self.value.clone()).map_err(|fallback_error| {
380 let error_detail = detailed_deserialize_error::<T>(&self.value, &fallback_error);
381 Error::configuration(format!(
382 "Failed to deserialize {} as {}: {}",
383 self.path.display(),
384 std::any::type_name::<T>(),
385 error_detail
386 ))
387 })
388 }
389
390 pub fn project_name(&self) -> Option<&str> {
392 if matches!(self.kind, InstanceKind::Project) {
393 self.value.get("name").and_then(|v| v.as_str())
394 } else {
395 None
396 }
397 }
398
399 pub fn get_field(&self, field: &str) -> Option<&serde_json::Value> {
401 self.value.get(field)
402 }
403
404 pub fn has_field(&self, field: &str) -> bool {
406 self.value.get(field).is_some()
407 }
408}
409
410const ENV_VALUE_HINT: &str = "Hint: `env` values must be a string, int, bool, secret object (`{resolver: ...}`), interpolated array (`[\"prefix\", {resolver: ...}]`), or `{ value: <value>, policies: [...] }`.";
411
412fn should_include_env_value_hint(message: &str) -> bool {
413 message.contains("untagged enum EnvValue") || message.contains("untagged enum EnvValueSimple")
414}
415
416fn detailed_deserialize_error<T: DeserializeOwned>(
417 value: &serde_json::Value,
418 fallback: &serde_json::Error,
419) -> String {
420 let json = value.to_string();
421 let mut deserializer = serde_json::Deserializer::from_str(&json);
422 match serde_path_to_error::deserialize::<_, T>(&mut deserializer) {
423 Ok(_) => fallback.to_string(),
424 Err(error) => {
425 let path = error.path().to_string();
426 let inner_message = error.into_inner().to_string();
427 let mut display_path = if path.is_empty() { None } else { Some(path) };
428
429 if should_include_env_value_hint(&inner_message)
430 && let Some(env_path) = find_invalid_env_value_path(value)
431 {
432 display_path = Some(env_path);
433 }
434
435 let mut message = match display_path {
436 Some(path) => format!("{inner_message} (at `{path}`)"),
437 None => inner_message,
438 };
439
440 if should_include_env_value_hint(&message) {
441 message.push_str(". ");
442 message.push_str(ENV_VALUE_HINT);
443 }
444
445 message
446 }
447 }
448}
449
450fn find_invalid_env_value_path(value: &serde_json::Value) -> Option<String> {
451 let env = value.get("env")?.as_object()?;
452
453 for (key, raw_value) in env {
454 if key == "environment" {
455 continue;
456 }
457
458 if serde_json::from_value::<crate::environment::EnvValue>(raw_value.clone()).is_err() {
459 return Some(format!("env.{key}"));
460 }
461 }
462
463 let environments = env.get("environment")?.as_object()?;
464 for (environment_name, overrides) in environments {
465 let overrides = overrides.as_object()?;
466 for (key, raw_value) in overrides {
467 if serde_json::from_value::<crate::environment::EnvValue>(raw_value.clone()).is_err() {
468 return Some(format!("env.environment.{environment_name}.{key}"));
469 }
470 }
471 }
472
473 None
474}
475
476#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
478pub enum InstanceKind {
479 Base,
481 Project,
483}
484
485impl std::fmt::Display for InstanceKind {
486 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
487 match self {
488 Self::Base => write!(f, "Base"),
489 Self::Project => write!(f, "Project"),
490 }
491 }
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use crate::ci::PipelineTask;
498 use crate::manifest::Project;
499 use crate::tasks::TaskNode;
500 use serde_json::json;
501
502 fn create_test_module() -> ModuleEvaluation {
503 let mut raw = HashMap::new();
504
505 raw.insert(
507 ".".to_string(),
508 json!({
509 "env": { "SHARED": "value" },
510 "owners": { "rules": { "default": { "pattern": "**", "owners": ["@owner"] } } }
511 }),
512 );
513
514 raw.insert(
516 "projects/api".to_string(),
517 json!({
518 "name": "api",
519 "env": { "SHARED": "value" },
520 "owners": { "rules": { "default": { "pattern": "**", "owners": ["@owner"] } } }
521 }),
522 );
523
524 raw.insert(
526 "projects/web".to_string(),
527 json!({
528 "name": "web",
529 "env": { "SHARED": "value" },
530 "owners": { "rules": { "local": { "pattern": "**", "owners": ["@web-team"] } } }
531 }),
532 );
533
534 let project_paths = vec!["projects/api".to_string(), "projects/web".to_string()];
536
537 ModuleEvaluation::from_raw(PathBuf::from("/test/repo"), raw, project_paths, None)
538 }
539
540 #[test]
541 fn test_instance_kind_detection() {
542 let module = create_test_module();
543
544 assert_eq!(module.base_count(), 1);
545 assert_eq!(module.project_count(), 2);
546
547 let root = module.root_instance().unwrap();
548 assert!(matches!(root.kind, InstanceKind::Base));
549
550 let api = module.get(Path::new("projects/api")).unwrap();
551 assert!(matches!(api.kind, InstanceKind::Project));
552 assert_eq!(api.project_name(), Some("api"));
553 }
554
555 #[test]
556 fn test_ancestors() {
557 let module = create_test_module();
558
559 let ancestors = module.ancestors(Path::new("projects/api"));
560 assert_eq!(ancestors.len(), 2);
561 assert_eq!(ancestors[0], PathBuf::from("projects"));
562 assert_eq!(ancestors[1], PathBuf::from("."));
563
564 let root_ancestors = module.ancestors(Path::new("."));
565 assert!(root_ancestors.is_empty());
566 }
567
568 #[test]
569 fn test_is_inherited() {
570 let module = create_test_module();
571
572 assert!(module.is_inherited(Path::new("projects/api"), "owners"));
574
575 assert!(!module.is_inherited(Path::new("projects/web"), "owners"));
577
578 assert!(module.is_inherited(Path::new("projects/api"), "env"));
580 }
581
582 #[test]
583 fn test_instance_kind_display() {
584 assert_eq!(InstanceKind::Base.to_string(), "Base");
585 assert_eq!(InstanceKind::Project.to_string(), "Project");
586 }
587
588 #[test]
589 fn test_instance_deserialize() {
590 #[derive(Debug, Deserialize, PartialEq)]
591 struct TestConfig {
592 name: String,
593 env: std::collections::HashMap<String, String>,
594 }
595
596 let instance = Instance {
597 path: PathBuf::from("test/path"),
598 kind: InstanceKind::Project,
599 value: json!({
600 "name": "my-project",
601 "env": { "FOO": "bar" }
602 }),
603 output_ref_deps: vec![],
604 };
605
606 let config: TestConfig = instance.deserialize().unwrap();
607 assert_eq!(config.name, "my-project");
608 assert_eq!(config.env.get("FOO"), Some(&"bar".to_string()));
609 }
610
611 #[test]
612 fn test_instance_deserialize_error() {
613 #[derive(Debug, Deserialize)]
614 #[allow(dead_code)] struct RequiredFields {
616 required_field: String,
617 }
618
619 let instance = Instance {
620 path: PathBuf::from("test/path"),
621 kind: InstanceKind::Base,
622 value: json!({}), output_ref_deps: vec![],
624 };
625
626 let result: crate::Result<RequiredFields> = instance.deserialize();
627 assert!(result.is_err());
628
629 let err = result.unwrap_err();
630 let msg = err.to_string();
631 assert!(
632 msg.contains("test/path"),
633 "Error should mention path: {}",
634 msg
635 );
636 assert!(
637 msg.contains("RequiredFields"),
638 "Error should mention target type: {}",
639 msg
640 );
641 }
642
643 #[test]
644 fn test_instance_deserialize_error_includes_field_path_and_env_hint() {
645 let instance = Instance {
646 path: PathBuf::from("projects/klustered.dev"),
647 kind: InstanceKind::Project,
648 value: json!({
649 "name": "klustered.dev",
650 "env": {
651 "BROKEN": {
652 "unexpected": "shape"
653 }
654 }
655 }),
656 output_ref_deps: vec![],
657 };
658
659 let result: crate::Result<Project> = instance.deserialize();
660 assert!(result.is_err());
661
662 let msg = result.unwrap_err().to_string();
663 assert!(
664 msg.contains("at `env") && msg.contains("BROKEN"),
665 "Error should include field path to invalid env key: {}",
666 msg
667 );
668 assert!(
669 msg.contains("Hint: `env` values must be"),
670 "Error should include env value hint: {}",
671 msg
672 );
673 }
674
675 #[test]
680 fn test_module_evaluation_empty() {
681 let module =
682 ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
683
684 assert_eq!(module.base_count(), 0);
685 assert_eq!(module.project_count(), 0);
686 assert!(module.root_instance().is_none());
687 }
688
689 #[test]
690 fn test_module_evaluation_root_only() {
691 let mut raw = HashMap::new();
692 raw.insert(".".to_string(), json!({"key": "value"}));
693
694 let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, vec![], None);
695
696 assert_eq!(module.base_count(), 1);
697 assert_eq!(module.project_count(), 0);
698 assert!(module.root_instance().is_some());
699 }
700
701 #[test]
702 fn test_module_evaluation_get_nonexistent() {
703 let module =
704 ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
705
706 assert!(module.get(Path::new("nonexistent")).is_none());
707 }
708
709 #[test]
710 fn test_module_evaluation_multiple_projects() {
711 let mut raw = HashMap::new();
712 raw.insert("proj1".to_string(), json!({"name": "proj1"}));
713 raw.insert("proj2".to_string(), json!({"name": "proj2"}));
714 raw.insert("proj3".to_string(), json!({"name": "proj3"}));
715
716 let project_paths = vec![
717 "proj1".to_string(),
718 "proj2".to_string(),
719 "proj3".to_string(),
720 ];
721
722 let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, project_paths, None);
723
724 assert_eq!(module.project_count(), 3);
725 assert_eq!(module.base_count(), 0);
726 }
727
728 #[test]
729 fn test_module_evaluation_ancestors_deep_path() {
730 let module =
731 ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
732
733 let ancestors = module.ancestors(Path::new("a/b/c/d"));
734 assert_eq!(ancestors.len(), 4);
735 assert_eq!(ancestors[0], PathBuf::from("a/b/c"));
736 assert_eq!(ancestors[1], PathBuf::from("a/b"));
737 assert_eq!(ancestors[2], PathBuf::from("a"));
738 assert_eq!(ancestors[3], PathBuf::from("."));
739 }
740
741 #[test]
742 fn test_module_evaluation_is_inherited_no_child() {
743 let module =
744 ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
745
746 assert!(!module.is_inherited(Path::new("nonexistent"), "field"));
748 }
749
750 #[test]
751 fn test_module_evaluation_is_inherited_no_field() {
752 let mut raw = HashMap::new();
753 raw.insert("child".to_string(), json!({"other": "value"}));
754
755 let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, vec![], None);
756
757 assert!(!module.is_inherited(Path::new("child"), "missing_field"));
759 }
760
761 #[test]
766 fn test_instance_get_field() {
767 let instance = Instance {
768 path: PathBuf::from("test"),
769 kind: InstanceKind::Project,
770 value: json!({
771 "name": "my-project",
772 "version": "1.0.0"
773 }),
774 output_ref_deps: vec![],
775 };
776
777 assert_eq!(instance.get_field("name"), Some(&json!("my-project")));
778 assert_eq!(instance.get_field("version"), Some(&json!("1.0.0")));
779 assert!(instance.get_field("nonexistent").is_none());
780 }
781
782 #[test]
783 fn test_instance_has_field() {
784 let instance = Instance {
785 path: PathBuf::from("test"),
786 kind: InstanceKind::Project,
787 value: json!({"name": "test", "env": {}}),
788 output_ref_deps: vec![],
789 };
790
791 assert!(instance.has_field("name"));
792 assert!(instance.has_field("env"));
793 assert!(!instance.has_field("missing"));
794 }
795
796 #[test]
797 fn test_instance_project_name_base() {
798 let instance = Instance {
799 path: PathBuf::from("test"),
800 kind: InstanceKind::Base,
801 value: json!({"name": "should-be-ignored"}),
802 output_ref_deps: vec![],
803 };
804
805 assert!(instance.project_name().is_none());
807 }
808
809 #[test]
810 fn test_instance_project_name_missing() {
811 let instance = Instance {
812 path: PathBuf::from("test"),
813 kind: InstanceKind::Project,
814 value: json!({}),
815 output_ref_deps: vec![],
816 };
817
818 assert!(instance.project_name().is_none());
819 }
820
821 #[test]
822 fn test_instance_clone() {
823 let instance = Instance {
824 path: PathBuf::from("original"),
825 kind: InstanceKind::Project,
826 value: json!({"name": "test"}),
827 output_ref_deps: vec![],
828 };
829
830 let cloned = instance.clone();
831 assert_eq!(cloned.path, instance.path);
832 assert_eq!(cloned.kind, instance.kind);
833 assert_eq!(cloned.value, instance.value);
834 }
835
836 #[test]
837 fn test_instance_serialize() {
838 let instance = Instance {
839 path: PathBuf::from("test/path"),
840 kind: InstanceKind::Project,
841 value: json!({"name": "my-project"}),
842 output_ref_deps: vec![],
843 };
844
845 let json = serde_json::to_string(&instance).unwrap();
846 assert!(json.contains("test/path"));
847 assert!(json.contains("Project"));
848 assert!(json.contains("my-project"));
849 }
850
851 #[test]
856 fn test_instance_kind_equality() {
857 assert_eq!(InstanceKind::Base, InstanceKind::Base);
858 assert_eq!(InstanceKind::Project, InstanceKind::Project);
859 assert_ne!(InstanceKind::Base, InstanceKind::Project);
860 }
861
862 #[test]
863 fn test_instance_kind_copy() {
864 let kind = InstanceKind::Project;
865 let copied = kind;
866 assert_eq!(kind, copied);
867 }
868
869 #[test]
870 fn test_instance_kind_serialize() {
871 let base_json = serde_json::to_string(&InstanceKind::Base).unwrap();
872 let project_json = serde_json::to_string(&InstanceKind::Project).unwrap();
873
874 assert!(base_json.contains("Base"));
875 assert!(project_json.contains("Project"));
876 }
877
878 #[test]
879 fn test_instance_kind_deserialize() {
880 let base: InstanceKind = serde_json::from_str("\"Base\"").unwrap();
881 let project: InstanceKind = serde_json::from_str("\"Project\"").unwrap();
882
883 assert_eq!(base, InstanceKind::Base);
884 assert_eq!(project, InstanceKind::Project);
885 }
886
887 #[test]
892 fn test_strip_dependency_prefix() {
893 assert_eq!(strip_dependency_prefix("tasks.build"), "build");
895 assert_eq!(strip_dependency_prefix("tasks.ci.deploy"), "ci.deploy");
896
897 assert_eq!(strip_dependency_prefix("_t.cargo.build"), "cargo.build");
899 assert_eq!(
900 strip_dependency_prefix("_t.release.publish"),
901 "release.publish"
902 );
903
904 assert_eq!(strip_dependency_prefix("_tasks.internal"), "internal");
906
907 assert_eq!(strip_dependency_prefix("services.db"), "db");
909 assert_eq!(strip_dependency_prefix("services.api.http"), "api.http");
910 assert_eq!(strip_dependency_prefix("_s.db"), "db");
911 assert_eq!(strip_dependency_prefix("_services.cache"), "cache");
912
913 assert_eq!(strip_dependency_prefix("build"), "build");
915 assert_eq!(strip_dependency_prefix("ci.deploy"), "ci.deploy");
916 }
917
918 #[derive(Debug, Clone, Copy)]
919 enum TaskNodeShape {
920 Task,
921 Group,
922 Sequence,
923 }
924
925 impl TaskNodeShape {
926 const fn name(self) -> &'static str {
927 match self {
928 Self::Task => "task",
929 Self::Group => "group",
930 Self::Sequence => "sequence",
931 }
932 }
933
934 fn as_value(self) -> serde_json::Value {
935 match self {
936 Self::Task => json!({
937 "command": "echo",
938 "args": ["task"],
939 }),
940 Self::Group => json!({
941 "type": "group",
942 "step": {
943 "command": "echo",
944 "args": ["group"],
945 },
946 }),
947 Self::Sequence => json!([
948 {
949 "command": "echo",
950 "args": ["sequence-0"],
951 },
952 {
953 "command": "echo",
954 "args": ["sequence-1"],
955 },
956 ]),
957 }
958 }
959 }
960
961 #[derive(Debug, Clone, Copy)]
962 enum DependencyOwner {
963 Task,
964 Group,
965 }
966
967 impl DependencyOwner {
968 const fn name(self) -> &'static str {
969 match self {
970 Self::Task => "task",
971 Self::Group => "group",
972 }
973 }
974
975 fn node_with_dependency(self, target: serde_json::Value) -> serde_json::Value {
976 match self {
977 Self::Task => json!({
978 "command": "echo",
979 "args": ["consumer"],
980 "dependsOn": [target],
981 }),
982 Self::Group => json!({
983 "type": "group",
984 "dependsOn": [target],
985 "step": {
986 "command": "echo",
987 "args": ["consumer"],
988 },
989 }),
990 }
991 }
992 }
993
994 fn deserialize_project_with_references(
995 instance: serde_json::Value,
996 references: ReferenceMap,
997 ) -> Project {
998 let mut raw = HashMap::new();
999 raw.insert(".".to_string(), instance);
1000 let module = ModuleEvaluation::from_raw(
1001 PathBuf::from("/test"),
1002 raw,
1003 vec![".".to_string()],
1004 Some(references),
1005 );
1006 module
1007 .root_instance()
1008 .expect("root instance should exist")
1009 .deserialize::<Project>()
1010 .expect("project deserialization should succeed")
1011 }
1012
1013 #[test]
1014 fn test_depends_on_accepts_all_task_node_shapes_for_task_and_group() {
1015 let shapes = [
1016 TaskNodeShape::Task,
1017 TaskNodeShape::Group,
1018 TaskNodeShape::Sequence,
1019 ];
1020 let owners = [DependencyOwner::Task, DependencyOwner::Group];
1021
1022 for owner in owners {
1023 for shape in shapes {
1024 let target = shape.as_value();
1025 let consumer = owner.node_with_dependency(target.clone());
1026
1027 let instance = json!({
1028 "name": "shape-contract",
1029 "tasks": {
1030 "target": target,
1031 "consumer": consumer,
1032 },
1033 });
1034
1035 let mut references = ReferenceMap::new();
1036 references.insert(
1037 "./tasks.consumer.dependsOn[0]".to_string(),
1038 "tasks.target".to_string(),
1039 );
1040
1041 let project = deserialize_project_with_references(instance, references);
1042 let consumer_node = project
1043 .tasks
1044 .get("consumer")
1045 .expect("consumer task should exist");
1046 let dependency_names: Vec<&str> = consumer_node
1047 .depends_on()
1048 .iter()
1049 .map(|dependency| dependency.task_name())
1050 .collect();
1051
1052 assert_eq!(
1053 dependency_names,
1054 vec!["target"],
1055 "dependsOn owner={} should canonicalize {} reference",
1056 owner.name(),
1057 shape.name()
1058 );
1059 }
1060 }
1061 }
1062
1063 #[test]
1064 fn test_service_depends_on_canonicalizes_task_and_service_references() {
1065 let instance = json!({
1066 "name": "service-contract",
1067 "tasks": {
1068 "migrate": {
1069 "command": "echo",
1070 "args": ["migrate"]
1071 }
1072 },
1073 "services": {
1074 "db": {
1075 "type": "service",
1076 "command": "echo",
1077 "args": ["db"]
1078 },
1079 "seed": {
1080 "type": "service",
1081 "command": "echo",
1082 "args": ["seed"],
1083 "dependsOn": ["placeholder-a", "placeholder-b"]
1084 }
1085 }
1086 });
1087
1088 let mut references = ReferenceMap::new();
1089 references.insert(
1090 "./services.seed.dependsOn[0]".to_string(),
1091 "services.db".to_string(),
1092 );
1093 references.insert(
1094 "./services.seed.dependsOn[1]".to_string(),
1095 "tasks.migrate".to_string(),
1096 );
1097
1098 let project = deserialize_project_with_references(instance, references);
1099 let seed = project
1100 .services
1101 .get("seed")
1102 .expect("seed service should exist");
1103
1104 let dep_names: Vec<&str> = seed.depends_on.iter().map(|d| d.task_name()).collect();
1105 assert_eq!(dep_names, vec!["db", "migrate"]);
1106 }
1107
1108 #[test]
1109 fn test_ci_pipeline_tasks_reference_accepts_all_task_node_shapes() {
1110 for shape in [
1111 TaskNodeShape::Task,
1112 TaskNodeShape::Group,
1113 TaskNodeShape::Sequence,
1114 ] {
1115 let target = shape.as_value();
1116 let instance = json!({
1117 "name": "shape-contract",
1118 "tasks": {
1119 "target": target.clone(),
1120 },
1121 "ci": {
1122 "pipelines": {
1123 "default": {
1124 "tasks": [target],
1125 },
1126 },
1127 },
1128 });
1129
1130 let mut references = ReferenceMap::new();
1131 references.insert(
1132 "./ci.pipelines.default.tasks[0]".to_string(),
1133 "tasks.target".to_string(),
1134 );
1135
1136 let project = deserialize_project_with_references(instance, references);
1137 let pipeline = &project
1138 .ci
1139 .as_ref()
1140 .expect("ci should exist")
1141 .pipelines
1142 .get("default")
1143 .expect("default pipeline should exist");
1144 let pipeline_task = pipeline.tasks.first().expect("pipeline task should exist");
1145
1146 assert!(
1147 pipeline_task.is_simple(),
1148 "pipeline task should remain a simple reference for {}",
1149 shape.name()
1150 );
1151 assert_eq!(
1152 pipeline_task.task_name(),
1153 "target",
1154 "pipeline task reference should canonicalize {} shape",
1155 shape.name()
1156 );
1157 }
1158 }
1159
1160 #[test]
1161 fn test_ci_matrix_task_reference_accepts_all_task_node_shapes() {
1162 for shape in [
1163 TaskNodeShape::Task,
1164 TaskNodeShape::Group,
1165 TaskNodeShape::Sequence,
1166 ] {
1167 let target = shape.as_value();
1168 let instance = json!({
1169 "name": "shape-contract",
1170 "tasks": {
1171 "target": target.clone(),
1172 },
1173 "ci": {
1174 "pipelines": {
1175 "default": {
1176 "tasks": [
1177 {
1178 "type": "matrix",
1179 "task": target,
1180 "matrix": {
1181 "arch": ["linux-x64"],
1182 },
1183 },
1184 ],
1185 },
1186 },
1187 },
1188 });
1189
1190 let mut references = ReferenceMap::new();
1191 references.insert(
1192 "./ci.pipelines.default.tasks[0].task".to_string(),
1193 "tasks.target".to_string(),
1194 );
1195
1196 let project = deserialize_project_with_references(instance, references);
1197 let pipeline = &project
1198 .ci
1199 .as_ref()
1200 .expect("ci should exist")
1201 .pipelines
1202 .get("default")
1203 .expect("default pipeline should exist");
1204 let pipeline_task = pipeline.tasks.first().expect("pipeline task should exist");
1205
1206 match pipeline_task {
1207 PipelineTask::Matrix(matrix_task) => {
1208 assert_eq!(
1209 matrix_task.task.task_name(),
1210 "target",
1211 "matrix task reference should canonicalize {} shape",
1212 shape.name()
1213 );
1214 }
1215 PipelineTask::Simple(_) | PipelineTask::Node(_) => {
1216 panic!("expected matrix task for {}", shape.name())
1217 }
1218 }
1219 }
1220 }
1221
1222 #[test]
1223 fn test_non_ci_task_string_field_is_not_rewritten() {
1224 let instance = json!({
1225 "name": "shape-contract",
1226 "tasks": {
1227 "producer": {
1228 "command": "echo",
1229 "args": ["producer"],
1230 },
1231 "consumer": {
1232 "command": "echo",
1233 "args": ["consumer"],
1234 "inputs": [
1235 {
1236 "task": "producer",
1237 },
1238 ],
1239 },
1240 },
1241 });
1242
1243 let mut references = ReferenceMap::new();
1244 references.insert(
1245 "./tasks.consumer.inputs[0].task".to_string(),
1246 "tasks.producer".to_string(),
1247 );
1248
1249 let project = deserialize_project_with_references(instance, references);
1250 let consumer_node = project
1251 .tasks
1252 .get("consumer")
1253 .expect("consumer task should exist");
1254 let consumer_task = match consumer_node {
1255 TaskNode::Task(task) => task,
1256 TaskNode::Group(_) | TaskNode::Sequence(_) => {
1257 panic!("expected consumer to deserialize as a task")
1258 }
1259 };
1260
1261 let task_output = consumer_task
1262 .iter_task_outputs()
1263 .next()
1264 .expect("consumer should have one task output input");
1265 assert_eq!(
1266 task_output.task, "producer",
1267 "non-CI task string fields must remain strings after enrichment"
1268 );
1269 }
1270}