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(|e| {
319 Error::configuration(format!(
320 "Failed to deserialize {} as {}: {}",
321 self.path.display(),
322 std::any::type_name::<T>(),
323 e
324 ))
325 })
326 }
327
328 pub fn project_name(&self) -> Option<&str> {
330 if matches!(self.kind, InstanceKind::Project) {
331 self.value.get("name").and_then(|v| v.as_str())
332 } else {
333 None
334 }
335 }
336
337 pub fn get_field(&self, field: &str) -> Option<&serde_json::Value> {
339 self.value.get(field)
340 }
341
342 pub fn has_field(&self, field: &str) -> bool {
344 self.value.get(field).is_some()
345 }
346}
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
350pub enum InstanceKind {
351 Base,
353 Project,
355}
356
357impl std::fmt::Display for InstanceKind {
358 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359 match self {
360 Self::Base => write!(f, "Base"),
361 Self::Project => write!(f, "Project"),
362 }
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use serde_json::json;
370
371 fn create_test_module() -> ModuleEvaluation {
372 let mut raw = HashMap::new();
373
374 raw.insert(
376 ".".to_string(),
377 json!({
378 "env": { "SHARED": "value" },
379 "owners": { "rules": { "default": { "pattern": "**", "owners": ["@owner"] } } }
380 }),
381 );
382
383 raw.insert(
385 "projects/api".to_string(),
386 json!({
387 "name": "api",
388 "env": { "SHARED": "value" },
389 "owners": { "rules": { "default": { "pattern": "**", "owners": ["@owner"] } } }
390 }),
391 );
392
393 raw.insert(
395 "projects/web".to_string(),
396 json!({
397 "name": "web",
398 "env": { "SHARED": "value" },
399 "owners": { "rules": { "local": { "pattern": "**", "owners": ["@web-team"] } } }
400 }),
401 );
402
403 let project_paths = vec!["projects/api".to_string(), "projects/web".to_string()];
405
406 ModuleEvaluation::from_raw(PathBuf::from("/test/repo"), raw, project_paths, None)
407 }
408
409 #[test]
410 fn test_instance_kind_detection() {
411 let module = create_test_module();
412
413 assert_eq!(module.base_count(), 1);
414 assert_eq!(module.project_count(), 2);
415
416 let root = module.root_instance().unwrap();
417 assert!(matches!(root.kind, InstanceKind::Base));
418
419 let api = module.get(Path::new("projects/api")).unwrap();
420 assert!(matches!(api.kind, InstanceKind::Project));
421 assert_eq!(api.project_name(), Some("api"));
422 }
423
424 #[test]
425 fn test_ancestors() {
426 let module = create_test_module();
427
428 let ancestors = module.ancestors(Path::new("projects/api"));
429 assert_eq!(ancestors.len(), 2);
430 assert_eq!(ancestors[0], PathBuf::from("projects"));
431 assert_eq!(ancestors[1], PathBuf::from("."));
432
433 let root_ancestors = module.ancestors(Path::new("."));
434 assert!(root_ancestors.is_empty());
435 }
436
437 #[test]
438 fn test_is_inherited() {
439 let module = create_test_module();
440
441 assert!(module.is_inherited(Path::new("projects/api"), "owners"));
443
444 assert!(!module.is_inherited(Path::new("projects/web"), "owners"));
446
447 assert!(module.is_inherited(Path::new("projects/api"), "env"));
449 }
450
451 #[test]
452 fn test_instance_kind_display() {
453 assert_eq!(InstanceKind::Base.to_string(), "Base");
454 assert_eq!(InstanceKind::Project.to_string(), "Project");
455 }
456
457 #[test]
458 fn test_instance_deserialize() {
459 #[derive(Debug, Deserialize, PartialEq)]
460 struct TestConfig {
461 name: String,
462 env: std::collections::HashMap<String, String>,
463 }
464
465 let instance = Instance {
466 path: PathBuf::from("test/path"),
467 kind: InstanceKind::Project,
468 value: json!({
469 "name": "my-project",
470 "env": { "FOO": "bar" }
471 }),
472 };
473
474 let config: TestConfig = instance.deserialize().unwrap();
475 assert_eq!(config.name, "my-project");
476 assert_eq!(config.env.get("FOO"), Some(&"bar".to_string()));
477 }
478
479 #[test]
480 fn test_instance_deserialize_error() {
481 #[derive(Debug, Deserialize)]
482 #[allow(dead_code)] struct RequiredFields {
484 required_field: String,
485 }
486
487 let instance = Instance {
488 path: PathBuf::from("test/path"),
489 kind: InstanceKind::Base,
490 value: json!({}), };
492
493 let result: crate::Result<RequiredFields> = instance.deserialize();
494 assert!(result.is_err());
495
496 let err = result.unwrap_err();
497 let msg = err.to_string();
498 assert!(
499 msg.contains("test/path"),
500 "Error should mention path: {}",
501 msg
502 );
503 assert!(
504 msg.contains("RequiredFields"),
505 "Error should mention target type: {}",
506 msg
507 );
508 }
509
510 #[test]
515 fn test_module_evaluation_empty() {
516 let module =
517 ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
518
519 assert_eq!(module.base_count(), 0);
520 assert_eq!(module.project_count(), 0);
521 assert!(module.root_instance().is_none());
522 }
523
524 #[test]
525 fn test_module_evaluation_root_only() {
526 let mut raw = HashMap::new();
527 raw.insert(".".to_string(), json!({"key": "value"}));
528
529 let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, vec![], None);
530
531 assert_eq!(module.base_count(), 1);
532 assert_eq!(module.project_count(), 0);
533 assert!(module.root_instance().is_some());
534 }
535
536 #[test]
537 fn test_module_evaluation_get_nonexistent() {
538 let module =
539 ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
540
541 assert!(module.get(Path::new("nonexistent")).is_none());
542 }
543
544 #[test]
545 fn test_module_evaluation_multiple_projects() {
546 let mut raw = HashMap::new();
547 raw.insert("proj1".to_string(), json!({"name": "proj1"}));
548 raw.insert("proj2".to_string(), json!({"name": "proj2"}));
549 raw.insert("proj3".to_string(), json!({"name": "proj3"}));
550
551 let project_paths = vec![
552 "proj1".to_string(),
553 "proj2".to_string(),
554 "proj3".to_string(),
555 ];
556
557 let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, project_paths, None);
558
559 assert_eq!(module.project_count(), 3);
560 assert_eq!(module.base_count(), 0);
561 }
562
563 #[test]
564 fn test_module_evaluation_ancestors_deep_path() {
565 let module =
566 ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
567
568 let ancestors = module.ancestors(Path::new("a/b/c/d"));
569 assert_eq!(ancestors.len(), 4);
570 assert_eq!(ancestors[0], PathBuf::from("a/b/c"));
571 assert_eq!(ancestors[1], PathBuf::from("a/b"));
572 assert_eq!(ancestors[2], PathBuf::from("a"));
573 assert_eq!(ancestors[3], PathBuf::from("."));
574 }
575
576 #[test]
577 fn test_module_evaluation_is_inherited_no_child() {
578 let module =
579 ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
580
581 assert!(!module.is_inherited(Path::new("nonexistent"), "field"));
583 }
584
585 #[test]
586 fn test_module_evaluation_is_inherited_no_field() {
587 let mut raw = HashMap::new();
588 raw.insert("child".to_string(), json!({"other": "value"}));
589
590 let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, vec![], None);
591
592 assert!(!module.is_inherited(Path::new("child"), "missing_field"));
594 }
595
596 #[test]
601 fn test_instance_get_field() {
602 let instance = Instance {
603 path: PathBuf::from("test"),
604 kind: InstanceKind::Project,
605 value: json!({
606 "name": "my-project",
607 "version": "1.0.0"
608 }),
609 };
610
611 assert_eq!(instance.get_field("name"), Some(&json!("my-project")));
612 assert_eq!(instance.get_field("version"), Some(&json!("1.0.0")));
613 assert!(instance.get_field("nonexistent").is_none());
614 }
615
616 #[test]
617 fn test_instance_has_field() {
618 let instance = Instance {
619 path: PathBuf::from("test"),
620 kind: InstanceKind::Project,
621 value: json!({"name": "test", "env": {}}),
622 };
623
624 assert!(instance.has_field("name"));
625 assert!(instance.has_field("env"));
626 assert!(!instance.has_field("missing"));
627 }
628
629 #[test]
630 fn test_instance_project_name_base() {
631 let instance = Instance {
632 path: PathBuf::from("test"),
633 kind: InstanceKind::Base,
634 value: json!({"name": "should-be-ignored"}),
635 };
636
637 assert!(instance.project_name().is_none());
639 }
640
641 #[test]
642 fn test_instance_project_name_missing() {
643 let instance = Instance {
644 path: PathBuf::from("test"),
645 kind: InstanceKind::Project,
646 value: json!({}),
647 };
648
649 assert!(instance.project_name().is_none());
650 }
651
652 #[test]
653 fn test_instance_clone() {
654 let instance = Instance {
655 path: PathBuf::from("original"),
656 kind: InstanceKind::Project,
657 value: json!({"name": "test"}),
658 };
659
660 let cloned = instance.clone();
661 assert_eq!(cloned.path, instance.path);
662 assert_eq!(cloned.kind, instance.kind);
663 assert_eq!(cloned.value, instance.value);
664 }
665
666 #[test]
667 fn test_instance_serialize() {
668 let instance = Instance {
669 path: PathBuf::from("test/path"),
670 kind: InstanceKind::Project,
671 value: json!({"name": "my-project"}),
672 };
673
674 let json = serde_json::to_string(&instance).unwrap();
675 assert!(json.contains("test/path"));
676 assert!(json.contains("Project"));
677 assert!(json.contains("my-project"));
678 }
679
680 #[test]
685 fn test_instance_kind_equality() {
686 assert_eq!(InstanceKind::Base, InstanceKind::Base);
687 assert_eq!(InstanceKind::Project, InstanceKind::Project);
688 assert_ne!(InstanceKind::Base, InstanceKind::Project);
689 }
690
691 #[test]
692 fn test_instance_kind_copy() {
693 let kind = InstanceKind::Project;
694 let copied = kind;
695 assert_eq!(kind, copied);
696 }
697
698 #[test]
699 fn test_instance_kind_serialize() {
700 let base_json = serde_json::to_string(&InstanceKind::Base).unwrap();
701 let project_json = serde_json::to_string(&InstanceKind::Project).unwrap();
702
703 assert!(base_json.contains("Base"));
704 assert!(project_json.contains("Project"));
705 }
706
707 #[test]
708 fn test_instance_kind_deserialize() {
709 let base: InstanceKind = serde_json::from_str("\"Base\"").unwrap();
710 let project: InstanceKind = serde_json::from_str("\"Project\"").unwrap();
711
712 assert_eq!(base, InstanceKind::Base);
713 assert_eq!(project, InstanceKind::Project);
714 }
715
716 #[test]
721 fn test_strip_tasks_prefix() {
722 assert_eq!(strip_tasks_prefix("tasks.build"), "build");
724 assert_eq!(strip_tasks_prefix("tasks.ci.deploy"), "ci.deploy");
725
726 assert_eq!(strip_tasks_prefix("_t.cargo.build"), "cargo.build");
728 assert_eq!(strip_tasks_prefix("_t.release.publish"), "release.publish");
729
730 assert_eq!(strip_tasks_prefix("_tasks.internal"), "internal");
732
733 assert_eq!(strip_tasks_prefix("build"), "build");
735 assert_eq!(strip_tasks_prefix("ci.deploy"), "ci.deploy");
736 }
737}