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/// Enrich task references in a JSON value with _name fields using reference metadata.
33///
34/// Walks the JSON structure recursively, finding task references and injecting
35/// `_name` fields based on the CUE reference metadata extracted during evaluation.
36///
37/// Handles:
38/// - `dependsOn` arrays (task dependencies)
39/// - `task` fields (pipeline MatrixTask references)
40/// - Pipeline task arrays (`ci.pipelines.*.tasks`)
41fn enrich_task_refs(value: &mut serde_json::Value, instance_path: &str, references: &ReferenceMap) {
42    enrich_task_refs_recursive(value, instance_path, "", references);
43}
44
45/// Recursively walk JSON and enrich task references
46fn 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            // Handle dependsOn arrays (task dependencies)
55            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            // Handle "task" field (pipeline MatrixTask references)
66            if let Some(serde_json::Value::Object(task_obj)) = obj.get_mut("task") {
67                // Skip if _name already set
68                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                        // Strip "tasks." prefix from the raw CUE reference path
77                        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            // Recurse into all object fields
87            for (key, child) in obj.iter_mut() {
88                if key == "dependsOn" || key == "task" {
89                    continue; // Already handled above
90                }
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            // Check if this is a pipeline tasks array (ci.pipelines.*.tasks)
101            // by seeing if any element is a task ref object
102            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
118/// Enrich task reference objects in an array with _name from reference metadata
119fn 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            // Skip if _name already set
128            if obj.contains_key("_name") {
129                continue;
130            }
131
132            // Look up the reference in metadata
133            let meta_key = format!("{}/{}[{}]", instance_path, array_path, i);
134            if let Some(reference) = references.get(&meta_key) {
135                // CUE ReferencePath already provides canonical path - just strip prefix
136                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/// Result of evaluating an entire CUE module
147///
148/// Contains all evaluated instances (directories with env.cue files)
149/// from a CUE module, enabling cross-instance analysis.
150#[derive(Debug, Clone)]
151pub struct ModuleEvaluation {
152    /// Path to the CUE module root (directory containing cue.mod/)
153    pub root: PathBuf,
154
155    /// Map of relative path to evaluated instance
156    pub instances: HashMap<PathBuf, Instance>,
157}
158
159impl ModuleEvaluation {
160    /// Create a new module evaluation from raw FFI result
161    ///
162    /// # Arguments
163    /// * `root` - Path to the CUE module root
164    /// * `raw_instances` - Map of relative paths to evaluated JSON values
165    /// * `project_paths` - Paths verified to conform to `schema.#Project` via CUE unification
166    /// * `references` - Optional reference map for dependsOn resolution (extracted from CUE metadata)
167    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        // Convert project paths to a set for O(1) lookup
174        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                // Use CUE's schema verification instead of heuristic name check
182                let kind = if project_set.contains(path.as_str()) {
183                    InstanceKind::Project
184                } else {
185                    InstanceKind::Base
186                };
187
188                // Enrich dependsOn arrays with _name using reference metadata
189                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    /// Get all Base instances (directories without a `name` field)
206    pub fn bases(&self) -> impl Iterator<Item = &Instance> {
207        self.instances
208            .values()
209            .filter(|i| matches!(i.kind, InstanceKind::Base))
210    }
211
212    /// Get all Project instances (directories with a `name` field)
213    pub fn projects(&self) -> impl Iterator<Item = &Instance> {
214        self.instances
215            .values()
216            .filter(|i| matches!(i.kind, InstanceKind::Project))
217    }
218
219    /// Get the root instance (the module root directory)
220    pub fn root_instance(&self) -> Option<&Instance> {
221        self.instances.get(Path::new("."))
222    }
223
224    /// Get an instance by its relative path
225    pub fn get(&self, path: &Path) -> Option<&Instance> {
226        self.instances.get(path)
227    }
228
229    /// Count of Base instances
230    pub fn base_count(&self) -> usize {
231        self.bases().count()
232    }
233
234    /// Count of Project instances
235    pub fn project_count(&self) -> usize {
236        self.projects().count()
237    }
238
239    /// Get all ancestor paths for a given path
240    ///
241    /// Returns paths from immediate parent up to (and including) the root ".".
242    /// Returns empty vector if path is already the root.
243    pub fn ancestors(&self, path: &Path) -> Vec<PathBuf> {
244        // Root has no ancestors
245        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                // Reached filesystem root, add "." as the module root path
255                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    /// Check if a field value in a child instance is inherited from an ancestor
266    ///
267    /// Returns true if the field exists in both the child and an ancestor,
268    /// and the values are equal (indicating inheritance via CUE unification).
269    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        // Check each ancestor
279        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/// A single evaluated CUE instance (directory with env.cue)
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct Instance {
295    /// Relative path from module root to this instance
296    pub path: PathBuf,
297
298    /// Whether this is a Base or Project instance
299    pub kind: InstanceKind,
300
301    /// The raw evaluated JSON value
302    pub value: serde_json::Value,
303}
304
305impl Instance {
306    /// Deserialize this instance's value into a typed struct
307    ///
308    /// This enables commands to extract strongly-typed configuration
309    /// from the evaluated CUE without re-evaluating.
310    ///
311    /// # Example
312    ///
313    /// ```ignore
314    /// let instance = module.get(path)?;
315    /// let project: Project = instance.deserialize()?;
316    /// ```
317    pub fn deserialize<T: DeserializeOwned>(&self) -> crate::Result<T> {
318        serde_json::from_value(self.value.clone()).map_err(|fallback_error| {
319            let error_detail = detailed_deserialize_error::<T>(&self.value, &fallback_error);
320            Error::configuration(format!(
321                "Failed to deserialize {} as {}: {}",
322                self.path.display(),
323                std::any::type_name::<T>(),
324                error_detail
325            ))
326        })
327    }
328
329    /// Get the project name if this is a Project instance
330    pub fn project_name(&self) -> Option<&str> {
331        if matches!(self.kind, InstanceKind::Project) {
332            self.value.get("name").and_then(|v| v.as_str())
333        } else {
334            None
335        }
336    }
337
338    /// Get a field value from the evaluated config
339    pub fn get_field(&self, field: &str) -> Option<&serde_json::Value> {
340        self.value.get(field)
341    }
342
343    /// Check if a field exists in the evaluated config
344    pub fn has_field(&self, field: &str) -> bool {
345        self.value.get(field).is_some()
346    }
347}
348
349const ENV_VALUE_HINT: &str = "Hint: `env` values must be a string, int, bool, secret object (`{resolver: ...}`), interpolated array (`[\"prefix\", {resolver: ...}]`), or `{ value: <value>, policies: [...] }`.";
350
351fn should_include_env_value_hint(message: &str) -> bool {
352    message.contains("untagged enum EnvValue") || message.contains("untagged enum EnvValueSimple")
353}
354
355fn detailed_deserialize_error<T: DeserializeOwned>(
356    value: &serde_json::Value,
357    fallback: &serde_json::Error,
358) -> String {
359    let json = value.to_string();
360    let mut deserializer = serde_json::Deserializer::from_str(&json);
361    match serde_path_to_error::deserialize::<_, T>(&mut deserializer) {
362        Ok(_) => fallback.to_string(),
363        Err(error) => {
364            let path = error.path().to_string();
365            let inner_message = error.into_inner().to_string();
366            let mut display_path = if path.is_empty() { None } else { Some(path) };
367
368            if should_include_env_value_hint(&inner_message)
369                && let Some(env_path) = find_invalid_env_value_path(value)
370            {
371                display_path = Some(env_path);
372            }
373
374            let mut message = match display_path {
375                Some(path) => format!("{inner_message} (at `{path}`)"),
376                None => inner_message,
377            };
378
379            if should_include_env_value_hint(&message) {
380                message.push_str(". ");
381                message.push_str(ENV_VALUE_HINT);
382            }
383
384            message
385        }
386    }
387}
388
389fn find_invalid_env_value_path(value: &serde_json::Value) -> Option<String> {
390    let env = value.get("env")?.as_object()?;
391
392    for (key, raw_value) in env {
393        if key == "environment" {
394            continue;
395        }
396
397        if serde_json::from_value::<crate::environment::EnvValue>(raw_value.clone()).is_err() {
398            return Some(format!("env.{key}"));
399        }
400    }
401
402    let environments = env.get("environment")?.as_object()?;
403    for (environment_name, overrides) in environments {
404        let overrides = overrides.as_object()?;
405        for (key, raw_value) in overrides {
406            if serde_json::from_value::<crate::environment::EnvValue>(raw_value.clone()).is_err() {
407                return Some(format!("env.environment.{environment_name}.{key}"));
408            }
409        }
410    }
411
412    None
413}
414
415/// The kind of CUE instance
416#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
417pub enum InstanceKind {
418    /// A Base instance (no `name` field) - typically intermediate/root config
419    Base,
420    /// A Project instance (has `name` field) - a leaf node with full features
421    Project,
422}
423
424impl std::fmt::Display for InstanceKind {
425    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
426        match self {
427            Self::Base => write!(f, "Base"),
428            Self::Project => write!(f, "Project"),
429        }
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use crate::manifest::Project;
437    use serde_json::json;
438
439    fn create_test_module() -> ModuleEvaluation {
440        let mut raw = HashMap::new();
441
442        // Root (Base)
443        raw.insert(
444            ".".to_string(),
445            json!({
446                "env": { "SHARED": "value" },
447                "owners": { "rules": { "default": { "pattern": "**", "owners": ["@owner"] } } }
448            }),
449        );
450
451        // Project with inherited owners
452        raw.insert(
453            "projects/api".to_string(),
454            json!({
455                "name": "api",
456                "env": { "SHARED": "value" },
457                "owners": { "rules": { "default": { "pattern": "**", "owners": ["@owner"] } } }
458            }),
459        );
460
461        // Project with local owners
462        raw.insert(
463            "projects/web".to_string(),
464            json!({
465                "name": "web",
466                "env": { "SHARED": "value" },
467                "owners": { "rules": { "local": { "pattern": "**", "owners": ["@web-team"] } } }
468            }),
469        );
470
471        // Specify which paths are projects (simulating CUE schema verification)
472        let project_paths = vec!["projects/api".to_string(), "projects/web".to_string()];
473
474        ModuleEvaluation::from_raw(PathBuf::from("/test/repo"), raw, project_paths, None)
475    }
476
477    #[test]
478    fn test_instance_kind_detection() {
479        let module = create_test_module();
480
481        assert_eq!(module.base_count(), 1);
482        assert_eq!(module.project_count(), 2);
483
484        let root = module.root_instance().unwrap();
485        assert!(matches!(root.kind, InstanceKind::Base));
486
487        let api = module.get(Path::new("projects/api")).unwrap();
488        assert!(matches!(api.kind, InstanceKind::Project));
489        assert_eq!(api.project_name(), Some("api"));
490    }
491
492    #[test]
493    fn test_ancestors() {
494        let module = create_test_module();
495
496        let ancestors = module.ancestors(Path::new("projects/api"));
497        assert_eq!(ancestors.len(), 2);
498        assert_eq!(ancestors[0], PathBuf::from("projects"));
499        assert_eq!(ancestors[1], PathBuf::from("."));
500
501        let root_ancestors = module.ancestors(Path::new("."));
502        assert!(root_ancestors.is_empty());
503    }
504
505    #[test]
506    fn test_is_inherited() {
507        let module = create_test_module();
508
509        // api's owners should be inherited (same as root)
510        assert!(module.is_inherited(Path::new("projects/api"), "owners"));
511
512        // web's owners should NOT be inherited (different from root)
513        assert!(!module.is_inherited(Path::new("projects/web"), "owners"));
514
515        // env is the same, so should be inherited
516        assert!(module.is_inherited(Path::new("projects/api"), "env"));
517    }
518
519    #[test]
520    fn test_instance_kind_display() {
521        assert_eq!(InstanceKind::Base.to_string(), "Base");
522        assert_eq!(InstanceKind::Project.to_string(), "Project");
523    }
524
525    #[test]
526    fn test_instance_deserialize() {
527        #[derive(Debug, Deserialize, PartialEq)]
528        struct TestConfig {
529            name: String,
530            env: std::collections::HashMap<String, String>,
531        }
532
533        let instance = Instance {
534            path: PathBuf::from("test/path"),
535            kind: InstanceKind::Project,
536            value: json!({
537                "name": "my-project",
538                "env": { "FOO": "bar" }
539            }),
540        };
541
542        let config: TestConfig = instance.deserialize().unwrap();
543        assert_eq!(config.name, "my-project");
544        assert_eq!(config.env.get("FOO"), Some(&"bar".to_string()));
545    }
546
547    #[test]
548    fn test_instance_deserialize_error() {
549        #[derive(Debug, Deserialize)]
550        #[allow(dead_code)] // Test struct for deserialization error testing
551        struct RequiredFields {
552            required_field: String,
553        }
554
555        let instance = Instance {
556            path: PathBuf::from("test/path"),
557            kind: InstanceKind::Base,
558            value: json!({}), // Missing required field
559        };
560
561        let result: crate::Result<RequiredFields> = instance.deserialize();
562        assert!(result.is_err());
563
564        let err = result.unwrap_err();
565        let msg = err.to_string();
566        assert!(
567            msg.contains("test/path"),
568            "Error should mention path: {}",
569            msg
570        );
571        assert!(
572            msg.contains("RequiredFields"),
573            "Error should mention target type: {}",
574            msg
575        );
576    }
577
578    #[test]
579    fn test_instance_deserialize_error_includes_field_path_and_env_hint() {
580        let instance = Instance {
581            path: PathBuf::from("projects/klustered.dev"),
582            kind: InstanceKind::Project,
583            value: json!({
584                "name": "klustered.dev",
585                "env": {
586                    "BROKEN": {
587                        "unexpected": "shape"
588                    }
589                }
590            }),
591        };
592
593        let result: crate::Result<Project> = instance.deserialize();
594        assert!(result.is_err());
595
596        let msg = result.unwrap_err().to_string();
597        assert!(
598            msg.contains("at `env") && msg.contains("BROKEN"),
599            "Error should include field path to invalid env key: {}",
600            msg
601        );
602        assert!(
603            msg.contains("Hint: `env` values must be"),
604            "Error should include env value hint: {}",
605            msg
606        );
607    }
608
609    // ==========================================================================
610    // Additional ModuleEvaluation tests
611    // ==========================================================================
612
613    #[test]
614    fn test_module_evaluation_empty() {
615        let module =
616            ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
617
618        assert_eq!(module.base_count(), 0);
619        assert_eq!(module.project_count(), 0);
620        assert!(module.root_instance().is_none());
621    }
622
623    #[test]
624    fn test_module_evaluation_root_only() {
625        let mut raw = HashMap::new();
626        raw.insert(".".to_string(), json!({"key": "value"}));
627
628        let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, vec![], None);
629
630        assert_eq!(module.base_count(), 1);
631        assert_eq!(module.project_count(), 0);
632        assert!(module.root_instance().is_some());
633    }
634
635    #[test]
636    fn test_module_evaluation_get_nonexistent() {
637        let module =
638            ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
639
640        assert!(module.get(Path::new("nonexistent")).is_none());
641    }
642
643    #[test]
644    fn test_module_evaluation_multiple_projects() {
645        let mut raw = HashMap::new();
646        raw.insert("proj1".to_string(), json!({"name": "proj1"}));
647        raw.insert("proj2".to_string(), json!({"name": "proj2"}));
648        raw.insert("proj3".to_string(), json!({"name": "proj3"}));
649
650        let project_paths = vec![
651            "proj1".to_string(),
652            "proj2".to_string(),
653            "proj3".to_string(),
654        ];
655
656        let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, project_paths, None);
657
658        assert_eq!(module.project_count(), 3);
659        assert_eq!(module.base_count(), 0);
660    }
661
662    #[test]
663    fn test_module_evaluation_ancestors_deep_path() {
664        let module =
665            ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
666
667        let ancestors = module.ancestors(Path::new("a/b/c/d"));
668        assert_eq!(ancestors.len(), 4);
669        assert_eq!(ancestors[0], PathBuf::from("a/b/c"));
670        assert_eq!(ancestors[1], PathBuf::from("a/b"));
671        assert_eq!(ancestors[2], PathBuf::from("a"));
672        assert_eq!(ancestors[3], PathBuf::from("."));
673    }
674
675    #[test]
676    fn test_module_evaluation_is_inherited_no_child() {
677        let module =
678            ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
679
680        // Non-existent child should return false
681        assert!(!module.is_inherited(Path::new("nonexistent"), "field"));
682    }
683
684    #[test]
685    fn test_module_evaluation_is_inherited_no_field() {
686        let mut raw = HashMap::new();
687        raw.insert("child".to_string(), json!({"other": "value"}));
688
689        let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, vec![], None);
690
691        // Child exists but doesn't have the field
692        assert!(!module.is_inherited(Path::new("child"), "missing_field"));
693    }
694
695    // ==========================================================================
696    // Instance tests
697    // ==========================================================================
698
699    #[test]
700    fn test_instance_get_field() {
701        let instance = Instance {
702            path: PathBuf::from("test"),
703            kind: InstanceKind::Project,
704            value: json!({
705                "name": "my-project",
706                "version": "1.0.0"
707            }),
708        };
709
710        assert_eq!(instance.get_field("name"), Some(&json!("my-project")));
711        assert_eq!(instance.get_field("version"), Some(&json!("1.0.0")));
712        assert!(instance.get_field("nonexistent").is_none());
713    }
714
715    #[test]
716    fn test_instance_has_field() {
717        let instance = Instance {
718            path: PathBuf::from("test"),
719            kind: InstanceKind::Project,
720            value: json!({"name": "test", "env": {}}),
721        };
722
723        assert!(instance.has_field("name"));
724        assert!(instance.has_field("env"));
725        assert!(!instance.has_field("missing"));
726    }
727
728    #[test]
729    fn test_instance_project_name_base() {
730        let instance = Instance {
731            path: PathBuf::from("test"),
732            kind: InstanceKind::Base,
733            value: json!({"name": "should-be-ignored"}),
734        };
735
736        // Base instances don't return project name even if they have one
737        assert!(instance.project_name().is_none());
738    }
739
740    #[test]
741    fn test_instance_project_name_missing() {
742        let instance = Instance {
743            path: PathBuf::from("test"),
744            kind: InstanceKind::Project,
745            value: json!({}),
746        };
747
748        assert!(instance.project_name().is_none());
749    }
750
751    #[test]
752    fn test_instance_clone() {
753        let instance = Instance {
754            path: PathBuf::from("original"),
755            kind: InstanceKind::Project,
756            value: json!({"name": "test"}),
757        };
758
759        let cloned = instance.clone();
760        assert_eq!(cloned.path, instance.path);
761        assert_eq!(cloned.kind, instance.kind);
762        assert_eq!(cloned.value, instance.value);
763    }
764
765    #[test]
766    fn test_instance_serialize() {
767        let instance = Instance {
768            path: PathBuf::from("test/path"),
769            kind: InstanceKind::Project,
770            value: json!({"name": "my-project"}),
771        };
772
773        let json = serde_json::to_string(&instance).unwrap();
774        assert!(json.contains("test/path"));
775        assert!(json.contains("Project"));
776        assert!(json.contains("my-project"));
777    }
778
779    // ==========================================================================
780    // InstanceKind tests
781    // ==========================================================================
782
783    #[test]
784    fn test_instance_kind_equality() {
785        assert_eq!(InstanceKind::Base, InstanceKind::Base);
786        assert_eq!(InstanceKind::Project, InstanceKind::Project);
787        assert_ne!(InstanceKind::Base, InstanceKind::Project);
788    }
789
790    #[test]
791    fn test_instance_kind_copy() {
792        let kind = InstanceKind::Project;
793        let copied = kind;
794        assert_eq!(kind, copied);
795    }
796
797    #[test]
798    fn test_instance_kind_serialize() {
799        let base_json = serde_json::to_string(&InstanceKind::Base).unwrap();
800        let project_json = serde_json::to_string(&InstanceKind::Project).unwrap();
801
802        assert!(base_json.contains("Base"));
803        assert!(project_json.contains("Project"));
804    }
805
806    #[test]
807    fn test_instance_kind_deserialize() {
808        let base: InstanceKind = serde_json::from_str("\"Base\"").unwrap();
809        let project: InstanceKind = serde_json::from_str("\"Project\"").unwrap();
810
811        assert_eq!(base, InstanceKind::Base);
812        assert_eq!(project, InstanceKind::Project);
813    }
814
815    // ==========================================================================
816    // strip_tasks_prefix tests
817    // ==========================================================================
818
819    #[test]
820    fn test_strip_tasks_prefix() {
821        // Standard tasks prefix
822        assert_eq!(strip_tasks_prefix("tasks.build"), "build");
823        assert_eq!(strip_tasks_prefix("tasks.ci.deploy"), "ci.deploy");
824
825        // Common _t alias (used in env.cue for scope conflict avoidance)
826        assert_eq!(strip_tasks_prefix("_t.cargo.build"), "cargo.build");
827        assert_eq!(strip_tasks_prefix("_t.release.publish"), "release.publish");
828
829        // Hidden _tasks alias
830        assert_eq!(strip_tasks_prefix("_tasks.internal"), "internal");
831
832        // No prefix (already canonical)
833        assert_eq!(strip_tasks_prefix("build"), "build");
834        assert_eq!(strip_tasks_prefix("ci.deploy"), "ci.deploy");
835    }
836}