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