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 task reference prefixes from a reference path to get the canonical task name.
18///
19/// The CUE bridge exports raw reference paths which may include:
20/// - Direct task references: "tasks.build", "tasks.ci.deploy"
21/// - Let binding aliases: "_t.build", "_tasks.deploy"
22fn 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
32/// Returns true when the current object is a CI matrix task in `ci.pipelines.*.tasks[*]`.
33fn 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
45/// Enrich task references in a JSON value with _name fields using reference metadata.
46///
47/// Walks the JSON structure recursively, finding task references and injecting
48/// `_name` fields based on the CUE reference metadata extracted during evaluation.
49///
50/// Handles:
51/// - `dependsOn` arrays (task dependencies)
52/// - `task` fields (pipeline MatrixTask references)
53/// - Pipeline task arrays (`ci.pipelines.*.tasks`)
54fn enrich_task_refs(value: &mut serde_json::Value, instance_path: &str, references: &ReferenceMap) {
55    enrich_task_refs_recursive(value, instance_path, "", references);
56}
57
58/// Recursively walk JSON and enrich task references
59fn 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            // Handle dependsOn arrays (task dependencies)
68            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            // Handle "task" field for CI matrix tasks only.
79            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                            // Skip if _name already set
93                            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                            // Reference targets can be non-object task shapes (e.g., TaskSequence arrays).
102                            // Synthesize a canonical TaskRef object so downstream deserialization can resolve names.
103                            *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            // Recurse into all object fields
114            for (key, child) in obj.iter_mut() {
115                if key == "dependsOn" || key == "task" {
116                    continue; // Already handled above
117                }
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            // Check if this is a pipeline tasks array (ci.pipelines.*.tasks)
128            // by seeing if any element is a task ref object
129            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
145/// Enrich task reference objects in an array with _name from reference metadata
146fn 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        // Look up the reference in metadata
154        let meta_key = format!("{}/{}[{}]", instance_path, array_path, i);
155        if let Some(reference) = references.get(&meta_key) {
156            // CUE ReferencePath already provides canonical path - just strip prefix
157            let task_name = strip_tasks_prefix(reference).to_string();
158
159            match element {
160                serde_json::Value::Object(obj) => {
161                    // Skip if _name already set
162                    if obj.contains_key("_name") {
163                        continue;
164                    }
165                    obj.insert("_name".to_string(), serde_json::Value::String(task_name));
166                }
167                _ => {
168                    // Reference targets can be non-object task shapes (e.g., TaskSequence arrays).
169                    // Convert to a canonical reference object.
170                    *element = serde_json::json!({ "_name": task_name });
171                }
172            }
173        }
174    }
175}
176
177/// Result of evaluating an entire CUE module
178///
179/// Contains all evaluated instances (directories with env.cue files)
180/// from a CUE module, enabling cross-instance analysis.
181#[derive(Debug, Clone)]
182pub struct ModuleEvaluation {
183    /// Path to the CUE module root (directory containing cue.mod/)
184    pub root: PathBuf,
185
186    /// Map of relative path to evaluated instance
187    pub instances: HashMap<PathBuf, Instance>,
188}
189
190impl ModuleEvaluation {
191    /// Create a new module evaluation from raw FFI result
192    ///
193    /// # Arguments
194    /// * `root` - Path to the CUE module root
195    /// * `raw_instances` - Map of relative paths to evaluated JSON values
196    /// * `project_paths` - Paths verified to conform to `schema.#Project` via CUE unification
197    /// * `references` - Optional reference map for dependsOn resolution (extracted from CUE metadata)
198    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        // Convert project paths to a set for O(1) lookup
205        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                // Use CUE's schema verification instead of heuristic name check
213                let kind = if project_set.contains(path.as_str()) {
214                    InstanceKind::Project
215                } else {
216                    InstanceKind::Base
217                };
218
219                // Enrich dependsOn arrays with _name using reference metadata
220                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    /// Get all Base instances (directories without a `name` field)
237    pub fn bases(&self) -> impl Iterator<Item = &Instance> {
238        self.instances
239            .values()
240            .filter(|i| matches!(i.kind, InstanceKind::Base))
241    }
242
243    /// Get all Project instances (directories with a `name` field)
244    pub fn projects(&self) -> impl Iterator<Item = &Instance> {
245        self.instances
246            .values()
247            .filter(|i| matches!(i.kind, InstanceKind::Project))
248    }
249
250    /// Get the root instance (the module root directory)
251    pub fn root_instance(&self) -> Option<&Instance> {
252        self.instances.get(Path::new("."))
253    }
254
255    /// Get an instance by its relative path
256    pub fn get(&self, path: &Path) -> Option<&Instance> {
257        self.instances.get(path)
258    }
259
260    /// Count of Base instances
261    pub fn base_count(&self) -> usize {
262        self.bases().count()
263    }
264
265    /// Count of Project instances
266    pub fn project_count(&self) -> usize {
267        self.projects().count()
268    }
269
270    /// Get all ancestor paths for a given path
271    ///
272    /// Returns paths from immediate parent up to (and including) the root ".".
273    /// Returns empty vector if path is already the root.
274    pub fn ancestors(&self, path: &Path) -> Vec<PathBuf> {
275        // Root has no ancestors
276        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                // Reached filesystem root, add "." as the module root path
286                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    /// Check if a field value in a child instance is inherited from an ancestor
297    ///
298    /// Returns true if the field exists in both the child and an ancestor,
299    /// and the values are equal (indicating inheritance via CUE unification).
300    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        // Check each ancestor
310        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/// A single evaluated CUE instance (directory with env.cue)
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct Instance {
326    /// Relative path from module root to this instance
327    pub path: PathBuf,
328
329    /// Whether this is a Base or Project instance
330    pub kind: InstanceKind,
331
332    /// The raw evaluated JSON value
333    pub value: serde_json::Value,
334}
335
336impl Instance {
337    /// Deserialize this instance's value into a typed struct
338    ///
339    /// This enables commands to extract strongly-typed configuration
340    /// from the evaluated CUE without re-evaluating.
341    ///
342    /// # Example
343    ///
344    /// ```ignore
345    /// let instance = module.get(path)?;
346    /// let project: Project = instance.deserialize()?;
347    /// ```
348    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    /// Get the project name if this is a Project instance
361    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    /// Get a field value from the evaluated config
370    pub fn get_field(&self, field: &str) -> Option<&serde_json::Value> {
371        self.value.get(field)
372    }
373
374    /// Check if a field exists in the evaluated config
375    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/// The kind of CUE instance
447#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
448pub enum InstanceKind {
449    /// A Base instance (no `name` field) - typically intermediate/root config
450    Base,
451    /// A Project instance (has `name` field) - a leaf node with full features
452    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        // Root (Base)
476        raw.insert(
477            ".".to_string(),
478            json!({
479                "env": { "SHARED": "value" },
480                "owners": { "rules": { "default": { "pattern": "**", "owners": ["@owner"] } } }
481            }),
482        );
483
484        // Project with inherited owners
485        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        // Project with local owners
495        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        // Specify which paths are projects (simulating CUE schema verification)
505        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        // api's owners should be inherited (same as root)
543        assert!(module.is_inherited(Path::new("projects/api"), "owners"));
544
545        // web's owners should NOT be inherited (different from root)
546        assert!(!module.is_inherited(Path::new("projects/web"), "owners"));
547
548        // env is the same, so should be inherited
549        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)] // Test struct for deserialization error testing
584        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!({}), // Missing required field
592        };
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    // ==========================================================================
643    // Additional ModuleEvaluation tests
644    // ==========================================================================
645
646    #[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        // Non-existent child should return false
714        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        // Child exists but doesn't have the field
725        assert!(!module.is_inherited(Path::new("child"), "missing_field"));
726    }
727
728    // ==========================================================================
729    // Instance tests
730    // ==========================================================================
731
732    #[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        // Base instances don't return project name even if they have one
770        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    // ==========================================================================
813    // InstanceKind tests
814    // ==========================================================================
815
816    #[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    // ==========================================================================
849    // strip_tasks_prefix tests
850    // ==========================================================================
851
852    #[test]
853    fn test_strip_tasks_prefix() {
854        // Standard tasks prefix
855        assert_eq!(strip_tasks_prefix("tasks.build"), "build");
856        assert_eq!(strip_tasks_prefix("tasks.ci.deploy"), "ci.deploy");
857
858        // Common _t alias (used in env.cue for scope conflict avoidance)
859        assert_eq!(strip_tasks_prefix("_t.cargo.build"), "cargo.build");
860        assert_eq!(strip_tasks_prefix("_t.release.publish"), "release.publish");
861
862        // Hidden _tasks alias
863        assert_eq!(strip_tasks_prefix("_tasks.internal"), "internal");
864
865        // No prefix (already canonical)
866        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}