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 enrich_task_refs(value: &mut serde_json::Value, instance_path: &str, references: &ReferenceMap) {
42 enrich_task_refs_recursive(value, instance_path, "", references);
43}
44
45fn enrich_task_refs_recursive(
47 value: &mut serde_json::Value,
48 instance_path: &str,
49 field_path: &str,
50 references: &ReferenceMap,
51) {
52 match value {
53 serde_json::Value::Object(obj) => {
54 if let Some(serde_json::Value::Array(deps)) = obj.get_mut("dependsOn") {
56 let depends_on_path = if field_path.is_empty() {
57 "dependsOn".to_string()
58 } else {
59 format!("{}.dependsOn", field_path)
60 };
61
62 enrich_task_ref_array(deps, instance_path, &depends_on_path, references);
63 }
64
65 if let Some(serde_json::Value::Object(task_obj)) = obj.get_mut("task") {
67 if !task_obj.contains_key("_name") {
69 let task_path = if field_path.is_empty() {
70 "task".to_string()
71 } else {
72 format!("{}.task", field_path)
73 };
74 let meta_key = format!("{}/{}", instance_path, task_path);
75 if let Some(reference) = references.get(&meta_key) {
76 let task_name = strip_tasks_prefix(reference);
78 task_obj.insert(
79 "_name".to_string(),
80 serde_json::Value::String(task_name.to_string()),
81 );
82 }
83 }
84 }
85
86 for (key, child) in obj.iter_mut() {
88 if key == "dependsOn" || key == "task" {
89 continue; }
91 let child_path = if field_path.is_empty() {
92 key.clone()
93 } else {
94 format!("{}.{}", field_path, key)
95 };
96 enrich_task_refs_recursive(child, instance_path, &child_path, references);
97 }
98 }
99 serde_json::Value::Array(arr) => {
100 let is_pipeline_tasks =
103 field_path.contains("pipelines.") && field_path.ends_with(".tasks");
104
105 if is_pipeline_tasks {
106 enrich_task_ref_array(arr, instance_path, field_path, references);
107 }
108
109 for (i, child) in arr.iter_mut().enumerate() {
110 let child_path = format!("{}[{}]", field_path, i);
111 enrich_task_refs_recursive(child, instance_path, &child_path, references);
112 }
113 }
114 _ => {}
115 }
116}
117
118fn enrich_task_ref_array(
120 arr: &mut [serde_json::Value],
121 instance_path: &str,
122 array_path: &str,
123 references: &ReferenceMap,
124) {
125 for (i, element) in arr.iter_mut().enumerate() {
126 if let serde_json::Value::Object(obj) = element {
127 if obj.contains_key("_name") {
129 continue;
130 }
131
132 let meta_key = format!("{}/{}[{}]", instance_path, array_path, i);
134 if let Some(reference) = references.get(&meta_key) {
135 let task_name = strip_tasks_prefix(reference);
137 obj.insert(
138 "_name".to_string(),
139 serde_json::Value::String(task_name.to_string()),
140 );
141 }
142 }
143 }
144}
145
146#[derive(Debug, Clone)]
151pub struct ModuleEvaluation {
152 pub root: PathBuf,
154
155 pub instances: HashMap<PathBuf, Instance>,
157}
158
159impl ModuleEvaluation {
160 pub fn from_raw(
168 root: PathBuf,
169 raw_instances: HashMap<String, serde_json::Value>,
170 project_paths: Vec<String>,
171 references: Option<ReferenceMap>,
172 ) -> Self {
173 let project_set: std::collections::HashSet<&str> =
175 project_paths.iter().map(String::as_str).collect();
176
177 let instances = raw_instances
178 .into_iter()
179 .map(|(path, mut value)| {
180 let path_buf = PathBuf::from(&path);
181 let kind = if project_set.contains(path.as_str()) {
183 InstanceKind::Project
184 } else {
185 InstanceKind::Base
186 };
187
188 if let Some(ref refs) = references {
190 enrich_task_refs(&mut value, &path, refs);
191 }
192
193 let instance = Instance {
194 path: path_buf.clone(),
195 kind,
196 value,
197 };
198 (path_buf, instance)
199 })
200 .collect();
201
202 Self { root, instances }
203 }
204
205 pub fn bases(&self) -> impl Iterator<Item = &Instance> {
207 self.instances
208 .values()
209 .filter(|i| matches!(i.kind, InstanceKind::Base))
210 }
211
212 pub fn projects(&self) -> impl Iterator<Item = &Instance> {
214 self.instances
215 .values()
216 .filter(|i| matches!(i.kind, InstanceKind::Project))
217 }
218
219 pub fn root_instance(&self) -> Option<&Instance> {
221 self.instances.get(Path::new("."))
222 }
223
224 pub fn get(&self, path: &Path) -> Option<&Instance> {
226 self.instances.get(path)
227 }
228
229 pub fn base_count(&self) -> usize {
231 self.bases().count()
232 }
233
234 pub fn project_count(&self) -> usize {
236 self.projects().count()
237 }
238
239 pub fn ancestors(&self, path: &Path) -> Vec<PathBuf> {
244 if path == Path::new(".") {
246 return Vec::new();
247 }
248
249 let mut ancestors = Vec::new();
250 let mut current = path.to_path_buf();
251
252 while let Some(parent) = current.parent() {
253 if parent.as_os_str().is_empty() {
254 ancestors.push(PathBuf::from("."));
256 break;
257 }
258 ancestors.push(parent.to_path_buf());
259 current = parent.to_path_buf();
260 }
261
262 ancestors
263 }
264
265 pub fn is_inherited(&self, child_path: &Path, field: &str) -> bool {
270 let Some(child) = self.instances.get(child_path) else {
271 return false;
272 };
273
274 let Some(child_value) = child.value.get(field) else {
275 return false;
276 };
277
278 for ancestor_path in self.ancestors(child_path) {
280 if let Some(ancestor) = self.instances.get(&ancestor_path)
281 && let Some(ancestor_value) = ancestor.value.get(field)
282 && child_value == ancestor_value
283 {
284 return true;
285 }
286 }
287
288 false
289 }
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct Instance {
295 pub path: PathBuf,
297
298 pub kind: InstanceKind,
300
301 pub value: serde_json::Value,
303}
304
305impl Instance {
306 pub fn deserialize<T: DeserializeOwned>(&self) -> crate::Result<T> {
318 serde_json::from_value(self.value.clone()).map_err(|fallback_error| {
319 let error_detail = detailed_deserialize_error::<T>(&self.value, &fallback_error);
320 Error::configuration(format!(
321 "Failed to deserialize {} as {}: {}",
322 self.path.display(),
323 std::any::type_name::<T>(),
324 error_detail
325 ))
326 })
327 }
328
329 pub fn project_name(&self) -> Option<&str> {
331 if matches!(self.kind, InstanceKind::Project) {
332 self.value.get("name").and_then(|v| v.as_str())
333 } else {
334 None
335 }
336 }
337
338 pub fn get_field(&self, field: &str) -> Option<&serde_json::Value> {
340 self.value.get(field)
341 }
342
343 pub fn has_field(&self, field: &str) -> bool {
345 self.value.get(field).is_some()
346 }
347}
348
349const ENV_VALUE_HINT: &str = "Hint: `env` values must be a string, int, bool, secret object (`{resolver: ...}`), interpolated array (`[\"prefix\", {resolver: ...}]`), or `{ value: <value>, policies: [...] }`.";
350
351fn should_include_env_value_hint(message: &str) -> bool {
352 message.contains("untagged enum EnvValue") || message.contains("untagged enum EnvValueSimple")
353}
354
355fn detailed_deserialize_error<T: DeserializeOwned>(
356 value: &serde_json::Value,
357 fallback: &serde_json::Error,
358) -> String {
359 let json = value.to_string();
360 let mut deserializer = serde_json::Deserializer::from_str(&json);
361 match serde_path_to_error::deserialize::<_, T>(&mut deserializer) {
362 Ok(_) => fallback.to_string(),
363 Err(error) => {
364 let path = error.path().to_string();
365 let inner_message = error.into_inner().to_string();
366 let mut display_path = if path.is_empty() { None } else { Some(path) };
367
368 if should_include_env_value_hint(&inner_message)
369 && let Some(env_path) = find_invalid_env_value_path(value)
370 {
371 display_path = Some(env_path);
372 }
373
374 let mut message = match display_path {
375 Some(path) => format!("{inner_message} (at `{path}`)"),
376 None => inner_message,
377 };
378
379 if should_include_env_value_hint(&message) {
380 message.push_str(". ");
381 message.push_str(ENV_VALUE_HINT);
382 }
383
384 message
385 }
386 }
387}
388
389fn find_invalid_env_value_path(value: &serde_json::Value) -> Option<String> {
390 let env = value.get("env")?.as_object()?;
391
392 for (key, raw_value) in env {
393 if key == "environment" {
394 continue;
395 }
396
397 if serde_json::from_value::<crate::environment::EnvValue>(raw_value.clone()).is_err() {
398 return Some(format!("env.{key}"));
399 }
400 }
401
402 let environments = env.get("environment")?.as_object()?;
403 for (environment_name, overrides) in environments {
404 let overrides = overrides.as_object()?;
405 for (key, raw_value) in overrides {
406 if serde_json::from_value::<crate::environment::EnvValue>(raw_value.clone()).is_err() {
407 return Some(format!("env.environment.{environment_name}.{key}"));
408 }
409 }
410 }
411
412 None
413}
414
415#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
417pub enum InstanceKind {
418 Base,
420 Project,
422}
423
424impl std::fmt::Display for InstanceKind {
425 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
426 match self {
427 Self::Base => write!(f, "Base"),
428 Self::Project => write!(f, "Project"),
429 }
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use crate::manifest::Project;
437 use serde_json::json;
438
439 fn create_test_module() -> ModuleEvaluation {
440 let mut raw = HashMap::new();
441
442 raw.insert(
444 ".".to_string(),
445 json!({
446 "env": { "SHARED": "value" },
447 "owners": { "rules": { "default": { "pattern": "**", "owners": ["@owner"] } } }
448 }),
449 );
450
451 raw.insert(
453 "projects/api".to_string(),
454 json!({
455 "name": "api",
456 "env": { "SHARED": "value" },
457 "owners": { "rules": { "default": { "pattern": "**", "owners": ["@owner"] } } }
458 }),
459 );
460
461 raw.insert(
463 "projects/web".to_string(),
464 json!({
465 "name": "web",
466 "env": { "SHARED": "value" },
467 "owners": { "rules": { "local": { "pattern": "**", "owners": ["@web-team"] } } }
468 }),
469 );
470
471 let project_paths = vec!["projects/api".to_string(), "projects/web".to_string()];
473
474 ModuleEvaluation::from_raw(PathBuf::from("/test/repo"), raw, project_paths, None)
475 }
476
477 #[test]
478 fn test_instance_kind_detection() {
479 let module = create_test_module();
480
481 assert_eq!(module.base_count(), 1);
482 assert_eq!(module.project_count(), 2);
483
484 let root = module.root_instance().unwrap();
485 assert!(matches!(root.kind, InstanceKind::Base));
486
487 let api = module.get(Path::new("projects/api")).unwrap();
488 assert!(matches!(api.kind, InstanceKind::Project));
489 assert_eq!(api.project_name(), Some("api"));
490 }
491
492 #[test]
493 fn test_ancestors() {
494 let module = create_test_module();
495
496 let ancestors = module.ancestors(Path::new("projects/api"));
497 assert_eq!(ancestors.len(), 2);
498 assert_eq!(ancestors[0], PathBuf::from("projects"));
499 assert_eq!(ancestors[1], PathBuf::from("."));
500
501 let root_ancestors = module.ancestors(Path::new("."));
502 assert!(root_ancestors.is_empty());
503 }
504
505 #[test]
506 fn test_is_inherited() {
507 let module = create_test_module();
508
509 assert!(module.is_inherited(Path::new("projects/api"), "owners"));
511
512 assert!(!module.is_inherited(Path::new("projects/web"), "owners"));
514
515 assert!(module.is_inherited(Path::new("projects/api"), "env"));
517 }
518
519 #[test]
520 fn test_instance_kind_display() {
521 assert_eq!(InstanceKind::Base.to_string(), "Base");
522 assert_eq!(InstanceKind::Project.to_string(), "Project");
523 }
524
525 #[test]
526 fn test_instance_deserialize() {
527 #[derive(Debug, Deserialize, PartialEq)]
528 struct TestConfig {
529 name: String,
530 env: std::collections::HashMap<String, String>,
531 }
532
533 let instance = Instance {
534 path: PathBuf::from("test/path"),
535 kind: InstanceKind::Project,
536 value: json!({
537 "name": "my-project",
538 "env": { "FOO": "bar" }
539 }),
540 };
541
542 let config: TestConfig = instance.deserialize().unwrap();
543 assert_eq!(config.name, "my-project");
544 assert_eq!(config.env.get("FOO"), Some(&"bar".to_string()));
545 }
546
547 #[test]
548 fn test_instance_deserialize_error() {
549 #[derive(Debug, Deserialize)]
550 #[allow(dead_code)] struct RequiredFields {
552 required_field: String,
553 }
554
555 let instance = Instance {
556 path: PathBuf::from("test/path"),
557 kind: InstanceKind::Base,
558 value: json!({}), };
560
561 let result: crate::Result<RequiredFields> = instance.deserialize();
562 assert!(result.is_err());
563
564 let err = result.unwrap_err();
565 let msg = err.to_string();
566 assert!(
567 msg.contains("test/path"),
568 "Error should mention path: {}",
569 msg
570 );
571 assert!(
572 msg.contains("RequiredFields"),
573 "Error should mention target type: {}",
574 msg
575 );
576 }
577
578 #[test]
579 fn test_instance_deserialize_error_includes_field_path_and_env_hint() {
580 let instance = Instance {
581 path: PathBuf::from("projects/klustered.dev"),
582 kind: InstanceKind::Project,
583 value: json!({
584 "name": "klustered.dev",
585 "env": {
586 "BROKEN": {
587 "unexpected": "shape"
588 }
589 }
590 }),
591 };
592
593 let result: crate::Result<Project> = instance.deserialize();
594 assert!(result.is_err());
595
596 let msg = result.unwrap_err().to_string();
597 assert!(
598 msg.contains("at `env") && msg.contains("BROKEN"),
599 "Error should include field path to invalid env key: {}",
600 msg
601 );
602 assert!(
603 msg.contains("Hint: `env` values must be"),
604 "Error should include env value hint: {}",
605 msg
606 );
607 }
608
609 #[test]
614 fn test_module_evaluation_empty() {
615 let module =
616 ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
617
618 assert_eq!(module.base_count(), 0);
619 assert_eq!(module.project_count(), 0);
620 assert!(module.root_instance().is_none());
621 }
622
623 #[test]
624 fn test_module_evaluation_root_only() {
625 let mut raw = HashMap::new();
626 raw.insert(".".to_string(), json!({"key": "value"}));
627
628 let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, vec![], None);
629
630 assert_eq!(module.base_count(), 1);
631 assert_eq!(module.project_count(), 0);
632 assert!(module.root_instance().is_some());
633 }
634
635 #[test]
636 fn test_module_evaluation_get_nonexistent() {
637 let module =
638 ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
639
640 assert!(module.get(Path::new("nonexistent")).is_none());
641 }
642
643 #[test]
644 fn test_module_evaluation_multiple_projects() {
645 let mut raw = HashMap::new();
646 raw.insert("proj1".to_string(), json!({"name": "proj1"}));
647 raw.insert("proj2".to_string(), json!({"name": "proj2"}));
648 raw.insert("proj3".to_string(), json!({"name": "proj3"}));
649
650 let project_paths = vec![
651 "proj1".to_string(),
652 "proj2".to_string(),
653 "proj3".to_string(),
654 ];
655
656 let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, project_paths, None);
657
658 assert_eq!(module.project_count(), 3);
659 assert_eq!(module.base_count(), 0);
660 }
661
662 #[test]
663 fn test_module_evaluation_ancestors_deep_path() {
664 let module =
665 ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
666
667 let ancestors = module.ancestors(Path::new("a/b/c/d"));
668 assert_eq!(ancestors.len(), 4);
669 assert_eq!(ancestors[0], PathBuf::from("a/b/c"));
670 assert_eq!(ancestors[1], PathBuf::from("a/b"));
671 assert_eq!(ancestors[2], PathBuf::from("a"));
672 assert_eq!(ancestors[3], PathBuf::from("."));
673 }
674
675 #[test]
676 fn test_module_evaluation_is_inherited_no_child() {
677 let module =
678 ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
679
680 assert!(!module.is_inherited(Path::new("nonexistent"), "field"));
682 }
683
684 #[test]
685 fn test_module_evaluation_is_inherited_no_field() {
686 let mut raw = HashMap::new();
687 raw.insert("child".to_string(), json!({"other": "value"}));
688
689 let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, vec![], None);
690
691 assert!(!module.is_inherited(Path::new("child"), "missing_field"));
693 }
694
695 #[test]
700 fn test_instance_get_field() {
701 let instance = Instance {
702 path: PathBuf::from("test"),
703 kind: InstanceKind::Project,
704 value: json!({
705 "name": "my-project",
706 "version": "1.0.0"
707 }),
708 };
709
710 assert_eq!(instance.get_field("name"), Some(&json!("my-project")));
711 assert_eq!(instance.get_field("version"), Some(&json!("1.0.0")));
712 assert!(instance.get_field("nonexistent").is_none());
713 }
714
715 #[test]
716 fn test_instance_has_field() {
717 let instance = Instance {
718 path: PathBuf::from("test"),
719 kind: InstanceKind::Project,
720 value: json!({"name": "test", "env": {}}),
721 };
722
723 assert!(instance.has_field("name"));
724 assert!(instance.has_field("env"));
725 assert!(!instance.has_field("missing"));
726 }
727
728 #[test]
729 fn test_instance_project_name_base() {
730 let instance = Instance {
731 path: PathBuf::from("test"),
732 kind: InstanceKind::Base,
733 value: json!({"name": "should-be-ignored"}),
734 };
735
736 assert!(instance.project_name().is_none());
738 }
739
740 #[test]
741 fn test_instance_project_name_missing() {
742 let instance = Instance {
743 path: PathBuf::from("test"),
744 kind: InstanceKind::Project,
745 value: json!({}),
746 };
747
748 assert!(instance.project_name().is_none());
749 }
750
751 #[test]
752 fn test_instance_clone() {
753 let instance = Instance {
754 path: PathBuf::from("original"),
755 kind: InstanceKind::Project,
756 value: json!({"name": "test"}),
757 };
758
759 let cloned = instance.clone();
760 assert_eq!(cloned.path, instance.path);
761 assert_eq!(cloned.kind, instance.kind);
762 assert_eq!(cloned.value, instance.value);
763 }
764
765 #[test]
766 fn test_instance_serialize() {
767 let instance = Instance {
768 path: PathBuf::from("test/path"),
769 kind: InstanceKind::Project,
770 value: json!({"name": "my-project"}),
771 };
772
773 let json = serde_json::to_string(&instance).unwrap();
774 assert!(json.contains("test/path"));
775 assert!(json.contains("Project"));
776 assert!(json.contains("my-project"));
777 }
778
779 #[test]
784 fn test_instance_kind_equality() {
785 assert_eq!(InstanceKind::Base, InstanceKind::Base);
786 assert_eq!(InstanceKind::Project, InstanceKind::Project);
787 assert_ne!(InstanceKind::Base, InstanceKind::Project);
788 }
789
790 #[test]
791 fn test_instance_kind_copy() {
792 let kind = InstanceKind::Project;
793 let copied = kind;
794 assert_eq!(kind, copied);
795 }
796
797 #[test]
798 fn test_instance_kind_serialize() {
799 let base_json = serde_json::to_string(&InstanceKind::Base).unwrap();
800 let project_json = serde_json::to_string(&InstanceKind::Project).unwrap();
801
802 assert!(base_json.contains("Base"));
803 assert!(project_json.contains("Project"));
804 }
805
806 #[test]
807 fn test_instance_kind_deserialize() {
808 let base: InstanceKind = serde_json::from_str("\"Base\"").unwrap();
809 let project: InstanceKind = serde_json::from_str("\"Project\"").unwrap();
810
811 assert_eq!(base, InstanceKind::Base);
812 assert_eq!(project, InstanceKind::Project);
813 }
814
815 #[test]
820 fn test_strip_tasks_prefix() {
821 assert_eq!(strip_tasks_prefix("tasks.build"), "build");
823 assert_eq!(strip_tasks_prefix("tasks.ci.deploy"), "ci.deploy");
824
825 assert_eq!(strip_tasks_prefix("_t.cargo.build"), "cargo.build");
827 assert_eq!(strip_tasks_prefix("_t.release.publish"), "release.publish");
828
829 assert_eq!(strip_tasks_prefix("_tasks.internal"), "internal");
831
832 assert_eq!(strip_tasks_prefix("build"), "build");
834 assert_eq!(strip_tasks_prefix("ci.deploy"), "ci.deploy");
835 }
836}