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_tasks_prefix(path: &str) -> &str {
23 const TASK_PREFIXES: &[&str] = &["tasks.", "_tasks.", "_t."];
24 for prefix in TASK_PREFIXES {
25 if let Some(stripped) = path.strip_prefix(prefix) {
26 return stripped;
27 }
28 }
29 path
30}
31
32fn is_ci_matrix_task_object(
34 field_path: &str,
35 obj: &serde_json::Map<String, serde_json::Value>,
36) -> bool {
37 if !(field_path.starts_with("ci.pipelines.") && field_path.contains(".tasks[")) {
38 return false;
39 }
40
41 obj.contains_key("matrix")
42 || obj.get("type").and_then(serde_json::Value::as_str) == Some("matrix")
43}
44
45fn enrich_task_refs(value: &mut serde_json::Value, instance_path: &str, references: &ReferenceMap) {
55 enrich_task_refs_recursive(value, instance_path, "", references);
56}
57
58fn enrich_task_refs_recursive(
60 value: &mut serde_json::Value,
61 instance_path: &str,
62 field_path: &str,
63 references: &ReferenceMap,
64) {
65 match value {
66 serde_json::Value::Object(obj) => {
67 if let Some(serde_json::Value::Array(deps)) = obj.get_mut("dependsOn") {
69 let depends_on_path = if field_path.is_empty() {
70 "dependsOn".to_string()
71 } else {
72 format!("{}.dependsOn", field_path)
73 };
74
75 enrich_task_ref_array(deps, instance_path, &depends_on_path, references);
76 }
77
78 if is_ci_matrix_task_object(field_path, obj)
80 && let Some(task_value) = obj.get_mut("task")
81 {
82 let task_path = if field_path.is_empty() {
83 "task".to_string()
84 } else {
85 format!("{}.task", field_path)
86 };
87 let meta_key = format!("{}/{}", instance_path, task_path);
88 if let Some(reference) = references.get(&meta_key) {
89 let task_name = strip_tasks_prefix(reference).to_string();
90 match task_value {
91 serde_json::Value::Object(task_obj) => {
92 if !task_obj.contains_key("_name") {
94 task_obj.insert(
95 "_name".to_string(),
96 serde_json::Value::String(task_name),
97 );
98 }
99 }
100 serde_json::Value::Array(_) => {
101 *task_value = serde_json::json!({ "_name": task_name });
104 }
105 serde_json::Value::Null
106 | serde_json::Value::Bool(_)
107 | serde_json::Value::Number(_)
108 | serde_json::Value::String(_) => {}
109 }
110 }
111 }
112
113 for (key, child) in obj.iter_mut() {
115 if key == "dependsOn" || key == "task" {
116 continue; }
118 let child_path = if field_path.is_empty() {
119 key.clone()
120 } else {
121 format!("{}.{}", field_path, key)
122 };
123 enrich_task_refs_recursive(child, instance_path, &child_path, references);
124 }
125 }
126 serde_json::Value::Array(arr) => {
127 let is_pipeline_tasks =
130 field_path.contains("pipelines.") && field_path.ends_with(".tasks");
131
132 if is_pipeline_tasks {
133 enrich_task_ref_array(arr, instance_path, field_path, references);
134 }
135
136 for (i, child) in arr.iter_mut().enumerate() {
137 let child_path = format!("{}[{}]", field_path, i);
138 enrich_task_refs_recursive(child, instance_path, &child_path, references);
139 }
140 }
141 _ => {}
142 }
143}
144
145fn enrich_task_ref_array(
147 arr: &mut [serde_json::Value],
148 instance_path: &str,
149 array_path: &str,
150 references: &ReferenceMap,
151) {
152 for (i, element) in arr.iter_mut().enumerate() {
153 let meta_key = format!("{}/{}[{}]", instance_path, array_path, i);
155 if let Some(reference) = references.get(&meta_key) {
156 let task_name = strip_tasks_prefix(reference).to_string();
158
159 match element {
160 serde_json::Value::Object(obj) => {
161 if obj.contains_key("_name") {
163 continue;
164 }
165 obj.insert("_name".to_string(), serde_json::Value::String(task_name));
166 }
167 _ => {
168 *element = serde_json::json!({ "_name": task_name });
171 }
172 }
173 }
174 }
175}
176
177#[derive(Debug, Clone)]
182pub struct ModuleEvaluation {
183 pub root: PathBuf,
185
186 pub instances: HashMap<PathBuf, Instance>,
188}
189
190impl ModuleEvaluation {
191 pub fn from_raw(
199 root: PathBuf,
200 raw_instances: HashMap<String, serde_json::Value>,
201 project_paths: Vec<String>,
202 references: Option<ReferenceMap>,
203 ) -> Self {
204 let project_set: std::collections::HashSet<&str> =
206 project_paths.iter().map(String::as_str).collect();
207
208 let instances = raw_instances
209 .into_iter()
210 .map(|(path, mut value)| {
211 let path_buf = PathBuf::from(&path);
212 let kind = if project_set.contains(path.as_str()) {
214 InstanceKind::Project
215 } else {
216 InstanceKind::Base
217 };
218
219 if let Some(ref refs) = references {
221 enrich_task_refs(&mut value, &path, refs);
222 }
223
224 let instance = Instance {
225 path: path_buf.clone(),
226 kind,
227 value,
228 };
229 (path_buf, instance)
230 })
231 .collect();
232
233 Self { root, instances }
234 }
235
236 pub fn bases(&self) -> impl Iterator<Item = &Instance> {
238 self.instances
239 .values()
240 .filter(|i| matches!(i.kind, InstanceKind::Base))
241 }
242
243 pub fn projects(&self) -> impl Iterator<Item = &Instance> {
245 self.instances
246 .values()
247 .filter(|i| matches!(i.kind, InstanceKind::Project))
248 }
249
250 pub fn root_instance(&self) -> Option<&Instance> {
252 self.instances.get(Path::new("."))
253 }
254
255 pub fn get(&self, path: &Path) -> Option<&Instance> {
257 self.instances.get(path)
258 }
259
260 pub fn base_count(&self) -> usize {
262 self.bases().count()
263 }
264
265 pub fn project_count(&self) -> usize {
267 self.projects().count()
268 }
269
270 pub fn ancestors(&self, path: &Path) -> Vec<PathBuf> {
275 if path == Path::new(".") {
277 return Vec::new();
278 }
279
280 let mut ancestors = Vec::new();
281 let mut current = path.to_path_buf();
282
283 while let Some(parent) = current.parent() {
284 if parent.as_os_str().is_empty() {
285 ancestors.push(PathBuf::from("."));
287 break;
288 }
289 ancestors.push(parent.to_path_buf());
290 current = parent.to_path_buf();
291 }
292
293 ancestors
294 }
295
296 pub fn is_inherited(&self, child_path: &Path, field: &str) -> bool {
301 let Some(child) = self.instances.get(child_path) else {
302 return false;
303 };
304
305 let Some(child_value) = child.value.get(field) else {
306 return false;
307 };
308
309 for ancestor_path in self.ancestors(child_path) {
311 if let Some(ancestor) = self.instances.get(&ancestor_path)
312 && let Some(ancestor_value) = ancestor.value.get(field)
313 && child_value == ancestor_value
314 {
315 return true;
316 }
317 }
318
319 false
320 }
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct Instance {
326 pub path: PathBuf,
328
329 pub kind: InstanceKind,
331
332 pub value: serde_json::Value,
334}
335
336impl Instance {
337 pub fn deserialize<T: DeserializeOwned>(&self) -> crate::Result<T> {
349 serde_json::from_value(self.value.clone()).map_err(|fallback_error| {
350 let error_detail = detailed_deserialize_error::<T>(&self.value, &fallback_error);
351 Error::configuration(format!(
352 "Failed to deserialize {} as {}: {}",
353 self.path.display(),
354 std::any::type_name::<T>(),
355 error_detail
356 ))
357 })
358 }
359
360 pub fn project_name(&self) -> Option<&str> {
362 if matches!(self.kind, InstanceKind::Project) {
363 self.value.get("name").and_then(|v| v.as_str())
364 } else {
365 None
366 }
367 }
368
369 pub fn get_field(&self, field: &str) -> Option<&serde_json::Value> {
371 self.value.get(field)
372 }
373
374 pub fn has_field(&self, field: &str) -> bool {
376 self.value.get(field).is_some()
377 }
378}
379
380const ENV_VALUE_HINT: &str = "Hint: `env` values must be a string, int, bool, secret object (`{resolver: ...}`), interpolated array (`[\"prefix\", {resolver: ...}]`), or `{ value: <value>, policies: [...] }`.";
381
382fn should_include_env_value_hint(message: &str) -> bool {
383 message.contains("untagged enum EnvValue") || message.contains("untagged enum EnvValueSimple")
384}
385
386fn detailed_deserialize_error<T: DeserializeOwned>(
387 value: &serde_json::Value,
388 fallback: &serde_json::Error,
389) -> String {
390 let json = value.to_string();
391 let mut deserializer = serde_json::Deserializer::from_str(&json);
392 match serde_path_to_error::deserialize::<_, T>(&mut deserializer) {
393 Ok(_) => fallback.to_string(),
394 Err(error) => {
395 let path = error.path().to_string();
396 let inner_message = error.into_inner().to_string();
397 let mut display_path = if path.is_empty() { None } else { Some(path) };
398
399 if should_include_env_value_hint(&inner_message)
400 && let Some(env_path) = find_invalid_env_value_path(value)
401 {
402 display_path = Some(env_path);
403 }
404
405 let mut message = match display_path {
406 Some(path) => format!("{inner_message} (at `{path}`)"),
407 None => inner_message,
408 };
409
410 if should_include_env_value_hint(&message) {
411 message.push_str(". ");
412 message.push_str(ENV_VALUE_HINT);
413 }
414
415 message
416 }
417 }
418}
419
420fn find_invalid_env_value_path(value: &serde_json::Value) -> Option<String> {
421 let env = value.get("env")?.as_object()?;
422
423 for (key, raw_value) in env {
424 if key == "environment" {
425 continue;
426 }
427
428 if serde_json::from_value::<crate::environment::EnvValue>(raw_value.clone()).is_err() {
429 return Some(format!("env.{key}"));
430 }
431 }
432
433 let environments = env.get("environment")?.as_object()?;
434 for (environment_name, overrides) in environments {
435 let overrides = overrides.as_object()?;
436 for (key, raw_value) in overrides {
437 if serde_json::from_value::<crate::environment::EnvValue>(raw_value.clone()).is_err() {
438 return Some(format!("env.environment.{environment_name}.{key}"));
439 }
440 }
441 }
442
443 None
444}
445
446#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
448pub enum InstanceKind {
449 Base,
451 Project,
453}
454
455impl std::fmt::Display for InstanceKind {
456 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457 match self {
458 Self::Base => write!(f, "Base"),
459 Self::Project => write!(f, "Project"),
460 }
461 }
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467 use crate::ci::PipelineTask;
468 use crate::manifest::Project;
469 use crate::tasks::TaskNode;
470 use serde_json::json;
471
472 fn create_test_module() -> ModuleEvaluation {
473 let mut raw = HashMap::new();
474
475 raw.insert(
477 ".".to_string(),
478 json!({
479 "env": { "SHARED": "value" },
480 "owners": { "rules": { "default": { "pattern": "**", "owners": ["@owner"] } } }
481 }),
482 );
483
484 raw.insert(
486 "projects/api".to_string(),
487 json!({
488 "name": "api",
489 "env": { "SHARED": "value" },
490 "owners": { "rules": { "default": { "pattern": "**", "owners": ["@owner"] } } }
491 }),
492 );
493
494 raw.insert(
496 "projects/web".to_string(),
497 json!({
498 "name": "web",
499 "env": { "SHARED": "value" },
500 "owners": { "rules": { "local": { "pattern": "**", "owners": ["@web-team"] } } }
501 }),
502 );
503
504 let project_paths = vec!["projects/api".to_string(), "projects/web".to_string()];
506
507 ModuleEvaluation::from_raw(PathBuf::from("/test/repo"), raw, project_paths, None)
508 }
509
510 #[test]
511 fn test_instance_kind_detection() {
512 let module = create_test_module();
513
514 assert_eq!(module.base_count(), 1);
515 assert_eq!(module.project_count(), 2);
516
517 let root = module.root_instance().unwrap();
518 assert!(matches!(root.kind, InstanceKind::Base));
519
520 let api = module.get(Path::new("projects/api")).unwrap();
521 assert!(matches!(api.kind, InstanceKind::Project));
522 assert_eq!(api.project_name(), Some("api"));
523 }
524
525 #[test]
526 fn test_ancestors() {
527 let module = create_test_module();
528
529 let ancestors = module.ancestors(Path::new("projects/api"));
530 assert_eq!(ancestors.len(), 2);
531 assert_eq!(ancestors[0], PathBuf::from("projects"));
532 assert_eq!(ancestors[1], PathBuf::from("."));
533
534 let root_ancestors = module.ancestors(Path::new("."));
535 assert!(root_ancestors.is_empty());
536 }
537
538 #[test]
539 fn test_is_inherited() {
540 let module = create_test_module();
541
542 assert!(module.is_inherited(Path::new("projects/api"), "owners"));
544
545 assert!(!module.is_inherited(Path::new("projects/web"), "owners"));
547
548 assert!(module.is_inherited(Path::new("projects/api"), "env"));
550 }
551
552 #[test]
553 fn test_instance_kind_display() {
554 assert_eq!(InstanceKind::Base.to_string(), "Base");
555 assert_eq!(InstanceKind::Project.to_string(), "Project");
556 }
557
558 #[test]
559 fn test_instance_deserialize() {
560 #[derive(Debug, Deserialize, PartialEq)]
561 struct TestConfig {
562 name: String,
563 env: std::collections::HashMap<String, String>,
564 }
565
566 let instance = Instance {
567 path: PathBuf::from("test/path"),
568 kind: InstanceKind::Project,
569 value: json!({
570 "name": "my-project",
571 "env": { "FOO": "bar" }
572 }),
573 };
574
575 let config: TestConfig = instance.deserialize().unwrap();
576 assert_eq!(config.name, "my-project");
577 assert_eq!(config.env.get("FOO"), Some(&"bar".to_string()));
578 }
579
580 #[test]
581 fn test_instance_deserialize_error() {
582 #[derive(Debug, Deserialize)]
583 #[allow(dead_code)] struct RequiredFields {
585 required_field: String,
586 }
587
588 let instance = Instance {
589 path: PathBuf::from("test/path"),
590 kind: InstanceKind::Base,
591 value: json!({}), };
593
594 let result: crate::Result<RequiredFields> = instance.deserialize();
595 assert!(result.is_err());
596
597 let err = result.unwrap_err();
598 let msg = err.to_string();
599 assert!(
600 msg.contains("test/path"),
601 "Error should mention path: {}",
602 msg
603 );
604 assert!(
605 msg.contains("RequiredFields"),
606 "Error should mention target type: {}",
607 msg
608 );
609 }
610
611 #[test]
612 fn test_instance_deserialize_error_includes_field_path_and_env_hint() {
613 let instance = Instance {
614 path: PathBuf::from("projects/klustered.dev"),
615 kind: InstanceKind::Project,
616 value: json!({
617 "name": "klustered.dev",
618 "env": {
619 "BROKEN": {
620 "unexpected": "shape"
621 }
622 }
623 }),
624 };
625
626 let result: crate::Result<Project> = instance.deserialize();
627 assert!(result.is_err());
628
629 let msg = result.unwrap_err().to_string();
630 assert!(
631 msg.contains("at `env") && msg.contains("BROKEN"),
632 "Error should include field path to invalid env key: {}",
633 msg
634 );
635 assert!(
636 msg.contains("Hint: `env` values must be"),
637 "Error should include env value hint: {}",
638 msg
639 );
640 }
641
642 #[test]
647 fn test_module_evaluation_empty() {
648 let module =
649 ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
650
651 assert_eq!(module.base_count(), 0);
652 assert_eq!(module.project_count(), 0);
653 assert!(module.root_instance().is_none());
654 }
655
656 #[test]
657 fn test_module_evaluation_root_only() {
658 let mut raw = HashMap::new();
659 raw.insert(".".to_string(), json!({"key": "value"}));
660
661 let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, vec![], None);
662
663 assert_eq!(module.base_count(), 1);
664 assert_eq!(module.project_count(), 0);
665 assert!(module.root_instance().is_some());
666 }
667
668 #[test]
669 fn test_module_evaluation_get_nonexistent() {
670 let module =
671 ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
672
673 assert!(module.get(Path::new("nonexistent")).is_none());
674 }
675
676 #[test]
677 fn test_module_evaluation_multiple_projects() {
678 let mut raw = HashMap::new();
679 raw.insert("proj1".to_string(), json!({"name": "proj1"}));
680 raw.insert("proj2".to_string(), json!({"name": "proj2"}));
681 raw.insert("proj3".to_string(), json!({"name": "proj3"}));
682
683 let project_paths = vec![
684 "proj1".to_string(),
685 "proj2".to_string(),
686 "proj3".to_string(),
687 ];
688
689 let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, project_paths, None);
690
691 assert_eq!(module.project_count(), 3);
692 assert_eq!(module.base_count(), 0);
693 }
694
695 #[test]
696 fn test_module_evaluation_ancestors_deep_path() {
697 let module =
698 ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
699
700 let ancestors = module.ancestors(Path::new("a/b/c/d"));
701 assert_eq!(ancestors.len(), 4);
702 assert_eq!(ancestors[0], PathBuf::from("a/b/c"));
703 assert_eq!(ancestors[1], PathBuf::from("a/b"));
704 assert_eq!(ancestors[2], PathBuf::from("a"));
705 assert_eq!(ancestors[3], PathBuf::from("."));
706 }
707
708 #[test]
709 fn test_module_evaluation_is_inherited_no_child() {
710 let module =
711 ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
712
713 assert!(!module.is_inherited(Path::new("nonexistent"), "field"));
715 }
716
717 #[test]
718 fn test_module_evaluation_is_inherited_no_field() {
719 let mut raw = HashMap::new();
720 raw.insert("child".to_string(), json!({"other": "value"}));
721
722 let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, vec![], None);
723
724 assert!(!module.is_inherited(Path::new("child"), "missing_field"));
726 }
727
728 #[test]
733 fn test_instance_get_field() {
734 let instance = Instance {
735 path: PathBuf::from("test"),
736 kind: InstanceKind::Project,
737 value: json!({
738 "name": "my-project",
739 "version": "1.0.0"
740 }),
741 };
742
743 assert_eq!(instance.get_field("name"), Some(&json!("my-project")));
744 assert_eq!(instance.get_field("version"), Some(&json!("1.0.0")));
745 assert!(instance.get_field("nonexistent").is_none());
746 }
747
748 #[test]
749 fn test_instance_has_field() {
750 let instance = Instance {
751 path: PathBuf::from("test"),
752 kind: InstanceKind::Project,
753 value: json!({"name": "test", "env": {}}),
754 };
755
756 assert!(instance.has_field("name"));
757 assert!(instance.has_field("env"));
758 assert!(!instance.has_field("missing"));
759 }
760
761 #[test]
762 fn test_instance_project_name_base() {
763 let instance = Instance {
764 path: PathBuf::from("test"),
765 kind: InstanceKind::Base,
766 value: json!({"name": "should-be-ignored"}),
767 };
768
769 assert!(instance.project_name().is_none());
771 }
772
773 #[test]
774 fn test_instance_project_name_missing() {
775 let instance = Instance {
776 path: PathBuf::from("test"),
777 kind: InstanceKind::Project,
778 value: json!({}),
779 };
780
781 assert!(instance.project_name().is_none());
782 }
783
784 #[test]
785 fn test_instance_clone() {
786 let instance = Instance {
787 path: PathBuf::from("original"),
788 kind: InstanceKind::Project,
789 value: json!({"name": "test"}),
790 };
791
792 let cloned = instance.clone();
793 assert_eq!(cloned.path, instance.path);
794 assert_eq!(cloned.kind, instance.kind);
795 assert_eq!(cloned.value, instance.value);
796 }
797
798 #[test]
799 fn test_instance_serialize() {
800 let instance = Instance {
801 path: PathBuf::from("test/path"),
802 kind: InstanceKind::Project,
803 value: json!({"name": "my-project"}),
804 };
805
806 let json = serde_json::to_string(&instance).unwrap();
807 assert!(json.contains("test/path"));
808 assert!(json.contains("Project"));
809 assert!(json.contains("my-project"));
810 }
811
812 #[test]
817 fn test_instance_kind_equality() {
818 assert_eq!(InstanceKind::Base, InstanceKind::Base);
819 assert_eq!(InstanceKind::Project, InstanceKind::Project);
820 assert_ne!(InstanceKind::Base, InstanceKind::Project);
821 }
822
823 #[test]
824 fn test_instance_kind_copy() {
825 let kind = InstanceKind::Project;
826 let copied = kind;
827 assert_eq!(kind, copied);
828 }
829
830 #[test]
831 fn test_instance_kind_serialize() {
832 let base_json = serde_json::to_string(&InstanceKind::Base).unwrap();
833 let project_json = serde_json::to_string(&InstanceKind::Project).unwrap();
834
835 assert!(base_json.contains("Base"));
836 assert!(project_json.contains("Project"));
837 }
838
839 #[test]
840 fn test_instance_kind_deserialize() {
841 let base: InstanceKind = serde_json::from_str("\"Base\"").unwrap();
842 let project: InstanceKind = serde_json::from_str("\"Project\"").unwrap();
843
844 assert_eq!(base, InstanceKind::Base);
845 assert_eq!(project, InstanceKind::Project);
846 }
847
848 #[test]
853 fn test_strip_tasks_prefix() {
854 assert_eq!(strip_tasks_prefix("tasks.build"), "build");
856 assert_eq!(strip_tasks_prefix("tasks.ci.deploy"), "ci.deploy");
857
858 assert_eq!(strip_tasks_prefix("_t.cargo.build"), "cargo.build");
860 assert_eq!(strip_tasks_prefix("_t.release.publish"), "release.publish");
861
862 assert_eq!(strip_tasks_prefix("_tasks.internal"), "internal");
864
865 assert_eq!(strip_tasks_prefix("build"), "build");
867 assert_eq!(strip_tasks_prefix("ci.deploy"), "ci.deploy");
868 }
869
870 #[derive(Debug, Clone, Copy)]
871 enum TaskNodeShape {
872 Task,
873 Group,
874 Sequence,
875 }
876
877 impl TaskNodeShape {
878 const fn name(self) -> &'static str {
879 match self {
880 Self::Task => "task",
881 Self::Group => "group",
882 Self::Sequence => "sequence",
883 }
884 }
885
886 fn as_value(self) -> serde_json::Value {
887 match self {
888 Self::Task => json!({
889 "command": "echo",
890 "args": ["task"],
891 }),
892 Self::Group => json!({
893 "type": "group",
894 "step": {
895 "command": "echo",
896 "args": ["group"],
897 },
898 }),
899 Self::Sequence => json!([
900 {
901 "command": "echo",
902 "args": ["sequence-0"],
903 },
904 {
905 "command": "echo",
906 "args": ["sequence-1"],
907 },
908 ]),
909 }
910 }
911 }
912
913 #[derive(Debug, Clone, Copy)]
914 enum DependencyOwner {
915 Task,
916 Group,
917 }
918
919 impl DependencyOwner {
920 const fn name(self) -> &'static str {
921 match self {
922 Self::Task => "task",
923 Self::Group => "group",
924 }
925 }
926
927 fn node_with_dependency(self, target: serde_json::Value) -> serde_json::Value {
928 match self {
929 Self::Task => json!({
930 "command": "echo",
931 "args": ["consumer"],
932 "dependsOn": [target],
933 }),
934 Self::Group => json!({
935 "type": "group",
936 "dependsOn": [target],
937 "step": {
938 "command": "echo",
939 "args": ["consumer"],
940 },
941 }),
942 }
943 }
944 }
945
946 fn deserialize_project_with_references(
947 instance: serde_json::Value,
948 references: ReferenceMap,
949 ) -> Project {
950 let mut raw = HashMap::new();
951 raw.insert(".".to_string(), instance);
952 let module = ModuleEvaluation::from_raw(
953 PathBuf::from("/test"),
954 raw,
955 vec![".".to_string()],
956 Some(references),
957 );
958 module
959 .root_instance()
960 .expect("root instance should exist")
961 .deserialize::<Project>()
962 .expect("project deserialization should succeed")
963 }
964
965 #[test]
966 fn test_depends_on_accepts_all_task_node_shapes_for_task_and_group() {
967 let shapes = [
968 TaskNodeShape::Task,
969 TaskNodeShape::Group,
970 TaskNodeShape::Sequence,
971 ];
972 let owners = [DependencyOwner::Task, DependencyOwner::Group];
973
974 for owner in owners {
975 for shape in shapes {
976 let target = shape.as_value();
977 let consumer = owner.node_with_dependency(target.clone());
978
979 let instance = json!({
980 "name": "shape-contract",
981 "tasks": {
982 "target": target,
983 "consumer": consumer,
984 },
985 });
986
987 let mut references = ReferenceMap::new();
988 references.insert(
989 "./tasks.consumer.dependsOn[0]".to_string(),
990 "tasks.target".to_string(),
991 );
992
993 let project = deserialize_project_with_references(instance, references);
994 let consumer_node = project
995 .tasks
996 .get("consumer")
997 .expect("consumer task should exist");
998 let dependency_names: Vec<&str> = consumer_node
999 .depends_on()
1000 .iter()
1001 .map(|dependency| dependency.task_name())
1002 .collect();
1003
1004 assert_eq!(
1005 dependency_names,
1006 vec!["target"],
1007 "dependsOn owner={} should canonicalize {} reference",
1008 owner.name(),
1009 shape.name()
1010 );
1011 }
1012 }
1013 }
1014
1015 #[test]
1016 fn test_ci_pipeline_tasks_reference_accepts_all_task_node_shapes() {
1017 for shape in [
1018 TaskNodeShape::Task,
1019 TaskNodeShape::Group,
1020 TaskNodeShape::Sequence,
1021 ] {
1022 let target = shape.as_value();
1023 let instance = json!({
1024 "name": "shape-contract",
1025 "tasks": {
1026 "target": target.clone(),
1027 },
1028 "ci": {
1029 "pipelines": {
1030 "default": {
1031 "tasks": [target],
1032 },
1033 },
1034 },
1035 });
1036
1037 let mut references = ReferenceMap::new();
1038 references.insert(
1039 "./ci.pipelines.default.tasks[0]".to_string(),
1040 "tasks.target".to_string(),
1041 );
1042
1043 let project = deserialize_project_with_references(instance, references);
1044 let pipeline = &project
1045 .ci
1046 .as_ref()
1047 .expect("ci should exist")
1048 .pipelines
1049 .get("default")
1050 .expect("default pipeline should exist");
1051 let pipeline_task = pipeline.tasks.first().expect("pipeline task should exist");
1052
1053 assert!(
1054 pipeline_task.is_simple(),
1055 "pipeline task should remain a simple reference for {}",
1056 shape.name()
1057 );
1058 assert_eq!(
1059 pipeline_task.task_name(),
1060 "target",
1061 "pipeline task reference should canonicalize {} shape",
1062 shape.name()
1063 );
1064 }
1065 }
1066
1067 #[test]
1068 fn test_ci_matrix_task_reference_accepts_all_task_node_shapes() {
1069 for shape in [
1070 TaskNodeShape::Task,
1071 TaskNodeShape::Group,
1072 TaskNodeShape::Sequence,
1073 ] {
1074 let target = shape.as_value();
1075 let instance = json!({
1076 "name": "shape-contract",
1077 "tasks": {
1078 "target": target.clone(),
1079 },
1080 "ci": {
1081 "pipelines": {
1082 "default": {
1083 "tasks": [
1084 {
1085 "type": "matrix",
1086 "task": target,
1087 "matrix": {
1088 "arch": ["linux-x64"],
1089 },
1090 },
1091 ],
1092 },
1093 },
1094 },
1095 });
1096
1097 let mut references = ReferenceMap::new();
1098 references.insert(
1099 "./ci.pipelines.default.tasks[0].task".to_string(),
1100 "tasks.target".to_string(),
1101 );
1102
1103 let project = deserialize_project_with_references(instance, references);
1104 let pipeline = &project
1105 .ci
1106 .as_ref()
1107 .expect("ci should exist")
1108 .pipelines
1109 .get("default")
1110 .expect("default pipeline should exist");
1111 let pipeline_task = pipeline.tasks.first().expect("pipeline task should exist");
1112
1113 match pipeline_task {
1114 PipelineTask::Matrix(matrix_task) => {
1115 assert_eq!(
1116 matrix_task.task.task_name(),
1117 "target",
1118 "matrix task reference should canonicalize {} shape",
1119 shape.name()
1120 );
1121 }
1122 PipelineTask::Simple(_) | PipelineTask::Node(_) => {
1123 panic!("expected matrix task for {}", shape.name())
1124 }
1125 }
1126 }
1127 }
1128
1129 #[test]
1130 fn test_non_ci_task_string_field_is_not_rewritten() {
1131 let instance = json!({
1132 "name": "shape-contract",
1133 "tasks": {
1134 "producer": {
1135 "command": "echo",
1136 "args": ["producer"],
1137 },
1138 "consumer": {
1139 "command": "echo",
1140 "args": ["consumer"],
1141 "inputs": [
1142 {
1143 "task": "producer",
1144 },
1145 ],
1146 },
1147 },
1148 });
1149
1150 let mut references = ReferenceMap::new();
1151 references.insert(
1152 "./tasks.consumer.inputs[0].task".to_string(),
1153 "tasks.producer".to_string(),
1154 );
1155
1156 let project = deserialize_project_with_references(instance, references);
1157 let consumer_node = project
1158 .tasks
1159 .get("consumer")
1160 .expect("consumer task should exist");
1161 let consumer_task = match consumer_node {
1162 TaskNode::Task(task) => task,
1163 TaskNode::Group(_) | TaskNode::Sequence(_) => {
1164 panic!("expected consumer to deserialize as a task")
1165 }
1166 };
1167
1168 let task_output = consumer_task
1169 .iter_task_outputs()
1170 .next()
1171 .expect("consumer should have one task output input");
1172 assert_eq!(
1173 task_output.task, "producer",
1174 "non-CI task string fields must remain strings after enrichment"
1175 );
1176 }
1177}