Skip to main content

cuenv_core/
module.rs

1//! Module-wide CUE evaluation types
2//!
3//! This module provides types for representing the result of evaluating
4//! an entire CUE module at once, enabling analysis across all instances.
5
6use serde::de::DeserializeOwned;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use crate::Error;
12
13/// Reference metadata extracted from CUE evaluation.
14/// Maps field paths (e.g., "./tasks.docs.deploy.dependsOn[0]") to their reference paths (e.g., "tasks.build").
15pub type ReferenceMap = HashMap<String, String>;
16
17/// Strip known dependency reference prefixes from a reference path.
18///
19/// The CUE bridge exports raw reference paths which may include task or
20/// service prefixes, including common let-binding aliases.
21fn 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
41/// Returns true when the current object is a CI matrix task in `ci.pipelines.*.tasks[*]`.
42fn 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
54/// Enrich task references in a JSON value with _name fields using reference metadata.
55///
56/// Walks the JSON structure recursively, finding task references and injecting
57/// `_name` fields based on the CUE reference metadata extracted during evaluation.
58///
59/// Handles:
60/// - `dependsOn` arrays (task dependencies)
61/// - `task` fields (pipeline MatrixTask references)
62/// - Pipeline task arrays (`ci.pipelines.*.tasks`)
63fn enrich_task_refs(value: &mut serde_json::Value, instance_path: &str, references: &ReferenceMap) {
64    enrich_task_refs_recursive(value, instance_path, "", references);
65}
66
67/// Recursively walk JSON and enrich task references
68fn 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            // Handle dependsOn arrays (task dependencies)
77            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            // Handle "task" field for CI matrix tasks only.
88            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                            // Skip if _name already set
102                            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                            // Reference targets can be non-object task shapes (e.g., TaskSequence arrays).
111                            // Synthesize a canonical TaskRef object so downstream deserialization can resolve names.
112                            *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            // Recurse into all object fields
123            for (key, child) in obj.iter_mut() {
124                if key == "dependsOn" || key == "task" {
125                    continue; // Already handled above
126                }
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            // Check if this is a pipeline tasks array (ci.pipelines.*.tasks)
137            // by seeing if any element is a task ref object
138            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
154/// Enrich task reference objects in an array with _name from reference metadata
155fn 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        // Look up the reference in metadata
163        let meta_key = format!("{}/{}[{}]", instance_path, array_path, i);
164        if let Some(reference) = references.get(&meta_key) {
165            // CUE ReferencePath already provides canonical path - just strip prefix
166            let task_name = strip_dependency_prefix(reference).to_string();
167
168            match element {
169                serde_json::Value::Object(obj) => {
170                    // Skip if _name already set
171                    if obj.contains_key("_name") {
172                        continue;
173                    }
174                    obj.insert("_name".to_string(), serde_json::Value::String(task_name));
175                }
176                _ => {
177                    // Reference targets can be non-object task shapes (e.g., TaskSequence arrays).
178                    // Convert to a canonical reference object.
179                    *element = serde_json::json!({ "_name": task_name });
180                }
181            }
182        }
183    }
184}
185
186/// Result of evaluating an entire CUE module
187///
188/// Contains all evaluated instances (directories with env.cue files)
189/// from a CUE module, enabling cross-instance analysis.
190#[derive(Debug, Clone)]
191pub struct ModuleEvaluation {
192    /// Path to the CUE module root (directory containing cue.mod/)
193    pub root: PathBuf,
194
195    /// Map of relative path to evaluated instance
196    pub instances: HashMap<PathBuf, Instance>,
197}
198
199impl ModuleEvaluation {
200    /// Create a new module evaluation from raw FFI result
201    ///
202    /// # Arguments
203    /// * `root` - Path to the CUE module root
204    /// * `raw_instances` - Map of relative paths to evaluated JSON values
205    /// * `project_paths` - Paths verified to conform to `schema.#Project` via CUE unification
206    /// * `references` - Optional reference map for dependsOn resolution (extracted from CUE metadata)
207    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        // Convert project paths to a set for O(1) lookup
214        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                // Use CUE's schema verification instead of heuristic name check
222                let kind = if project_set.contains(path.as_str()) {
223                    InstanceKind::Project
224                } else {
225                    InstanceKind::Base
226                };
227
228                // Enrich dependsOn arrays with _name using reference metadata
229                if let Some(ref refs) = references {
230                    enrich_task_refs(&mut value, &path, refs);
231                }
232
233                // Process task output references: replace ref objects with
234                // placeholder strings and collect auto-dependency pairs.
235                // Must happen before Task deserialization (ref objects would
236                // fail Vec<String> deserialization in Task.args).
237                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    /// Get all Base instances (directories without a `name` field)
253    pub fn bases(&self) -> impl Iterator<Item = &Instance> {
254        self.instances
255            .values()
256            .filter(|i| matches!(i.kind, InstanceKind::Base))
257    }
258
259    /// Get all Project instances (directories with a `name` field)
260    pub fn projects(&self) -> impl Iterator<Item = &Instance> {
261        self.instances
262            .values()
263            .filter(|i| matches!(i.kind, InstanceKind::Project))
264    }
265
266    /// Get the root instance (the module root directory)
267    pub fn root_instance(&self) -> Option<&Instance> {
268        self.instances.get(Path::new("."))
269    }
270
271    /// Get an instance by its relative path
272    pub fn get(&self, path: &Path) -> Option<&Instance> {
273        self.instances.get(path)
274    }
275
276    /// Count of Base instances
277    pub fn base_count(&self) -> usize {
278        self.bases().count()
279    }
280
281    /// Count of Project instances
282    pub fn project_count(&self) -> usize {
283        self.projects().count()
284    }
285
286    /// Get all ancestor paths for a given path
287    ///
288    /// Returns paths from immediate parent up to (and including) the root ".".
289    /// Returns empty vector if path is already the root.
290    pub fn ancestors(&self, path: &Path) -> Vec<PathBuf> {
291        // Root has no ancestors
292        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                // Reached filesystem root, add "." as the module root path
302                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    /// Check if a field value in a child instance is inherited from an ancestor
313    ///
314    /// Returns true if the field exists in both the child and an ancestor,
315    /// and the values are equal (indicating inheritance via CUE unification).
316    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        // Check each ancestor
326        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/// A single evaluated CUE instance (directory with env.cue)
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct Instance {
342    /// Relative path from module root to this instance
343    pub path: PathBuf,
344
345    /// Whether this is a Base or Project instance
346    pub kind: InstanceKind,
347
348    /// The raw evaluated JSON value
349    pub value: serde_json::Value,
350
351    /// Auto-inferred dependencies from task output references.
352    /// Each pair is (task_that_references, task_being_referenced).
353    #[serde(default, skip_serializing)]
354    pub output_ref_deps: Vec<crate::tasks::output_refs::OutputRefDep>,
355}
356
357impl Instance {
358    /// Deserialize this instance's value into a typed struct
359    ///
360    /// This enables commands to extract strongly-typed configuration
361    /// from the evaluated CUE without re-evaluating.
362    ///
363    /// # Example
364    ///
365    /// ```ignore
366    /// let instance = module.get(path)?;
367    /// let project: Project = instance.deserialize()?;
368    /// ```
369    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    /// Get the project name if this is a Project instance
391    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    /// Get a field value from the evaluated config
400    pub fn get_field(&self, field: &str) -> Option<&serde_json::Value> {
401        self.value.get(field)
402    }
403
404    /// Check if a field exists in the evaluated config
405    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/// The kind of CUE instance
477#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
478pub enum InstanceKind {
479    /// A Base instance (no `name` field) - typically intermediate/root config
480    Base,
481    /// A Project instance (has `name` field) - a leaf node with full features
482    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        // Root (Base)
506        raw.insert(
507            ".".to_string(),
508            json!({
509                "env": { "SHARED": "value" },
510                "owners": { "rules": { "default": { "pattern": "**", "owners": ["@owner"] } } }
511            }),
512        );
513
514        // Project with inherited owners
515        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        // Project with local owners
525        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        // Specify which paths are projects (simulating CUE schema verification)
535        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        // api's owners should be inherited (same as root)
573        assert!(module.is_inherited(Path::new("projects/api"), "owners"));
574
575        // web's owners should NOT be inherited (different from root)
576        assert!(!module.is_inherited(Path::new("projects/web"), "owners"));
577
578        // env is the same, so should be inherited
579        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)] // Test struct for deserialization error testing
615        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!({}), // Missing required field
623            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    // ==========================================================================
676    // Additional ModuleEvaluation tests
677    // ==========================================================================
678
679    #[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        // Non-existent child should return false
747        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        // Child exists but doesn't have the field
758        assert!(!module.is_inherited(Path::new("child"), "missing_field"));
759    }
760
761    // ==========================================================================
762    // Instance tests
763    // ==========================================================================
764
765    #[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        // Base instances don't return project name even if they have one
806        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    // ==========================================================================
852    // InstanceKind tests
853    // ==========================================================================
854
855    #[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    // ==========================================================================
888    // strip_dependency_prefix tests
889    // ==========================================================================
890
891    #[test]
892    fn test_strip_dependency_prefix() {
893        // Standard tasks prefix
894        assert_eq!(strip_dependency_prefix("tasks.build"), "build");
895        assert_eq!(strip_dependency_prefix("tasks.ci.deploy"), "ci.deploy");
896
897        // Common _t alias (used in env.cue for scope conflict avoidance)
898        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        // Hidden _tasks alias
905        assert_eq!(strip_dependency_prefix("_tasks.internal"), "internal");
906
907        // Service prefixes
908        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        // No prefix (already canonical)
914        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}