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 names (e.g., "build").
15pub type ReferenceMap = HashMap<String, String>;
16
17/// Enrich dependsOn arrays in a JSON value with _name fields using reference metadata.
18///
19/// Walks the JSON structure recursively, finding `dependsOn` arrays and injecting
20/// `_name` fields based on the CUE reference metadata extracted during evaluation.
21fn enrich_depends_on(
22    value: &mut serde_json::Value,
23    instance_path: &str,
24    references: &ReferenceMap,
25) {
26    enrich_depends_on_recursive(value, instance_path, "", references);
27}
28
29/// Recursively walk JSON and enrich dependsOn arrays
30fn enrich_depends_on_recursive(
31    value: &mut serde_json::Value,
32    instance_path: &str,
33    field_path: &str,
34    references: &ReferenceMap,
35) {
36    match value {
37        serde_json::Value::Object(obj) => {
38            // Check if this object has a dependsOn array
39            if let Some(serde_json::Value::Array(deps)) = obj.get_mut("dependsOn") {
40                let depends_on_path = if field_path.is_empty() {
41                    "dependsOn".to_string()
42                } else {
43                    format!("{}.dependsOn", field_path)
44                };
45
46                // Enrich each dependency with _name from reference metadata
47                for (i, dep) in deps.iter_mut().enumerate() {
48                    if let serde_json::Value::Object(dep_obj) = dep {
49                        // Skip if _name already set
50                        if dep_obj.contains_key("_name") {
51                            continue;
52                        }
53
54                        // Look up the reference in metadata
55                        let meta_key = format!("{}/{}[{}]", instance_path, depends_on_path, i);
56                        if let Some(reference) = references.get(&meta_key) {
57                            // The reference is the task name (e.g., "build" or "docs.build")
58                            dep_obj.insert(
59                                "_name".to_string(),
60                                serde_json::Value::String(reference.clone()),
61                            );
62                        }
63                    }
64                }
65            }
66
67            // Recurse into all object fields
68            for (key, child) in obj.iter_mut() {
69                if key == "dependsOn" {
70                    continue; // Already handled above
71                }
72                let child_path = if field_path.is_empty() {
73                    key.clone()
74                } else {
75                    format!("{}.{}", field_path, key)
76                };
77                enrich_depends_on_recursive(child, instance_path, &child_path, references);
78            }
79        }
80        serde_json::Value::Array(arr) => {
81            for (i, child) in arr.iter_mut().enumerate() {
82                let child_path = format!("{}[{}]", field_path, i);
83                enrich_depends_on_recursive(child, instance_path, &child_path, references);
84            }
85        }
86        _ => {}
87    }
88}
89
90/// Result of evaluating an entire CUE module
91///
92/// Contains all evaluated instances (directories with env.cue files)
93/// from a CUE module, enabling cross-instance analysis.
94#[derive(Debug, Clone)]
95pub struct ModuleEvaluation {
96    /// Path to the CUE module root (directory containing cue.mod/)
97    pub root: PathBuf,
98
99    /// Map of relative path to evaluated instance
100    pub instances: HashMap<PathBuf, Instance>,
101}
102
103impl ModuleEvaluation {
104    /// Create a new module evaluation from raw FFI result
105    ///
106    /// # Arguments
107    /// * `root` - Path to the CUE module root
108    /// * `raw_instances` - Map of relative paths to evaluated JSON values
109    /// * `project_paths` - Paths verified to conform to `schema.#Project` via CUE unification
110    /// * `references` - Optional reference map for dependsOn resolution (extracted from CUE metadata)
111    pub fn from_raw(
112        root: PathBuf,
113        raw_instances: HashMap<String, serde_json::Value>,
114        project_paths: Vec<String>,
115        references: Option<ReferenceMap>,
116    ) -> Self {
117        // Convert project paths to a set for O(1) lookup
118        let project_set: std::collections::HashSet<&str> =
119            project_paths.iter().map(String::as_str).collect();
120
121        let instances = raw_instances
122            .into_iter()
123            .map(|(path, mut value)| {
124                let path_buf = PathBuf::from(&path);
125                // Use CUE's schema verification instead of heuristic name check
126                let kind = if project_set.contains(path.as_str()) {
127                    InstanceKind::Project
128                } else {
129                    InstanceKind::Base
130                };
131
132                // Enrich dependsOn arrays with _name using reference metadata
133                if let Some(ref refs) = references {
134                    enrich_depends_on(&mut value, &path, refs);
135                }
136
137                let instance = Instance {
138                    path: path_buf.clone(),
139                    kind,
140                    value,
141                };
142                (path_buf, instance)
143            })
144            .collect();
145
146        Self { root, instances }
147    }
148
149    /// Get all Base instances (directories without a `name` field)
150    pub fn bases(&self) -> impl Iterator<Item = &Instance> {
151        self.instances
152            .values()
153            .filter(|i| matches!(i.kind, InstanceKind::Base))
154    }
155
156    /// Get all Project instances (directories with a `name` field)
157    pub fn projects(&self) -> impl Iterator<Item = &Instance> {
158        self.instances
159            .values()
160            .filter(|i| matches!(i.kind, InstanceKind::Project))
161    }
162
163    /// Get the root instance (the module root directory)
164    pub fn root_instance(&self) -> Option<&Instance> {
165        self.instances.get(Path::new("."))
166    }
167
168    /// Get an instance by its relative path
169    pub fn get(&self, path: &Path) -> Option<&Instance> {
170        self.instances.get(path)
171    }
172
173    /// Count of Base instances
174    pub fn base_count(&self) -> usize {
175        self.bases().count()
176    }
177
178    /// Count of Project instances
179    pub fn project_count(&self) -> usize {
180        self.projects().count()
181    }
182
183    /// Get all ancestor paths for a given path
184    ///
185    /// Returns paths from immediate parent up to (and including) the root ".".
186    /// Returns empty vector if path is already the root.
187    pub fn ancestors(&self, path: &Path) -> Vec<PathBuf> {
188        // Root has no ancestors
189        if path == Path::new(".") {
190            return Vec::new();
191        }
192
193        let mut ancestors = Vec::new();
194        let mut current = path.to_path_buf();
195
196        while let Some(parent) = current.parent() {
197            if parent.as_os_str().is_empty() {
198                // Reached filesystem root, add "." as the module root path
199                ancestors.push(PathBuf::from("."));
200                break;
201            }
202            ancestors.push(parent.to_path_buf());
203            current = parent.to_path_buf();
204        }
205
206        ancestors
207    }
208
209    /// Check if a field value in a child instance is inherited from an ancestor
210    ///
211    /// Returns true if the field exists in both the child and an ancestor,
212    /// and the values are equal (indicating inheritance via CUE unification).
213    pub fn is_inherited(&self, child_path: &Path, field: &str) -> bool {
214        let Some(child) = self.instances.get(child_path) else {
215            return false;
216        };
217
218        let Some(child_value) = child.value.get(field) else {
219            return false;
220        };
221
222        // Check each ancestor
223        for ancestor_path in self.ancestors(child_path) {
224            if let Some(ancestor) = self.instances.get(&ancestor_path)
225                && let Some(ancestor_value) = ancestor.value.get(field)
226                && child_value == ancestor_value
227            {
228                return true;
229            }
230        }
231
232        false
233    }
234}
235
236/// A single evaluated CUE instance (directory with env.cue)
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct Instance {
239    /// Relative path from module root to this instance
240    pub path: PathBuf,
241
242    /// Whether this is a Base or Project instance
243    pub kind: InstanceKind,
244
245    /// The raw evaluated JSON value
246    pub value: serde_json::Value,
247}
248
249impl Instance {
250    /// Deserialize this instance's value into a typed struct
251    ///
252    /// This enables commands to extract strongly-typed configuration
253    /// from the evaluated CUE without re-evaluating.
254    ///
255    /// # Example
256    ///
257    /// ```ignore
258    /// let instance = module.get(path)?;
259    /// let project: Project = instance.deserialize()?;
260    /// ```
261    pub fn deserialize<T: DeserializeOwned>(&self) -> crate::Result<T> {
262        serde_json::from_value(self.value.clone()).map_err(|e| {
263            Error::configuration(format!(
264                "Failed to deserialize {} as {}: {}",
265                self.path.display(),
266                std::any::type_name::<T>(),
267                e
268            ))
269        })
270    }
271
272    /// Get the project name if this is a Project instance
273    pub fn project_name(&self) -> Option<&str> {
274        if matches!(self.kind, InstanceKind::Project) {
275            self.value.get("name").and_then(|v| v.as_str())
276        } else {
277            None
278        }
279    }
280
281    /// Get a field value from the evaluated config
282    pub fn get_field(&self, field: &str) -> Option<&serde_json::Value> {
283        self.value.get(field)
284    }
285
286    /// Check if a field exists in the evaluated config
287    pub fn has_field(&self, field: &str) -> bool {
288        self.value.get(field).is_some()
289    }
290}
291
292/// The kind of CUE instance
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
294pub enum InstanceKind {
295    /// A Base instance (no `name` field) - typically intermediate/root config
296    Base,
297    /// A Project instance (has `name` field) - a leaf node with full features
298    Project,
299}
300
301impl std::fmt::Display for InstanceKind {
302    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
303        match self {
304            Self::Base => write!(f, "Base"),
305            Self::Project => write!(f, "Project"),
306        }
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use serde_json::json;
314
315    fn create_test_module() -> ModuleEvaluation {
316        let mut raw = HashMap::new();
317
318        // Root (Base)
319        raw.insert(
320            ".".to_string(),
321            json!({
322                "env": { "SHARED": "value" },
323                "owners": { "rules": { "default": { "pattern": "**", "owners": ["@owner"] } } }
324            }),
325        );
326
327        // Project with inherited owners
328        raw.insert(
329            "projects/api".to_string(),
330            json!({
331                "name": "api",
332                "env": { "SHARED": "value" },
333                "owners": { "rules": { "default": { "pattern": "**", "owners": ["@owner"] } } }
334            }),
335        );
336
337        // Project with local owners
338        raw.insert(
339            "projects/web".to_string(),
340            json!({
341                "name": "web",
342                "env": { "SHARED": "value" },
343                "owners": { "rules": { "local": { "pattern": "**", "owners": ["@web-team"] } } }
344            }),
345        );
346
347        // Specify which paths are projects (simulating CUE schema verification)
348        let project_paths = vec!["projects/api".to_string(), "projects/web".to_string()];
349
350        ModuleEvaluation::from_raw(PathBuf::from("/test/repo"), raw, project_paths, None)
351    }
352
353    #[test]
354    fn test_instance_kind_detection() {
355        let module = create_test_module();
356
357        assert_eq!(module.base_count(), 1);
358        assert_eq!(module.project_count(), 2);
359
360        let root = module.root_instance().unwrap();
361        assert!(matches!(root.kind, InstanceKind::Base));
362
363        let api = module.get(Path::new("projects/api")).unwrap();
364        assert!(matches!(api.kind, InstanceKind::Project));
365        assert_eq!(api.project_name(), Some("api"));
366    }
367
368    #[test]
369    fn test_ancestors() {
370        let module = create_test_module();
371
372        let ancestors = module.ancestors(Path::new("projects/api"));
373        assert_eq!(ancestors.len(), 2);
374        assert_eq!(ancestors[0], PathBuf::from("projects"));
375        assert_eq!(ancestors[1], PathBuf::from("."));
376
377        let root_ancestors = module.ancestors(Path::new("."));
378        assert!(root_ancestors.is_empty());
379    }
380
381    #[test]
382    fn test_is_inherited() {
383        let module = create_test_module();
384
385        // api's owners should be inherited (same as root)
386        assert!(module.is_inherited(Path::new("projects/api"), "owners"));
387
388        // web's owners should NOT be inherited (different from root)
389        assert!(!module.is_inherited(Path::new("projects/web"), "owners"));
390
391        // env is the same, so should be inherited
392        assert!(module.is_inherited(Path::new("projects/api"), "env"));
393    }
394
395    #[test]
396    fn test_instance_kind_display() {
397        assert_eq!(InstanceKind::Base.to_string(), "Base");
398        assert_eq!(InstanceKind::Project.to_string(), "Project");
399    }
400
401    #[test]
402    fn test_instance_deserialize() {
403        #[derive(Debug, Deserialize, PartialEq)]
404        struct TestConfig {
405            name: String,
406            env: std::collections::HashMap<String, String>,
407        }
408
409        let instance = Instance {
410            path: PathBuf::from("test/path"),
411            kind: InstanceKind::Project,
412            value: json!({
413                "name": "my-project",
414                "env": { "FOO": "bar" }
415            }),
416        };
417
418        let config: TestConfig = instance.deserialize().unwrap();
419        assert_eq!(config.name, "my-project");
420        assert_eq!(config.env.get("FOO"), Some(&"bar".to_string()));
421    }
422
423    #[test]
424    fn test_instance_deserialize_error() {
425        #[derive(Debug, Deserialize)]
426        #[allow(dead_code)]
427        struct RequiredFields {
428            required_field: String,
429        }
430
431        let instance = Instance {
432            path: PathBuf::from("test/path"),
433            kind: InstanceKind::Base,
434            value: json!({}), // Missing required field
435        };
436
437        let result: crate::Result<RequiredFields> = instance.deserialize();
438        assert!(result.is_err());
439
440        let err = result.unwrap_err();
441        let msg = err.to_string();
442        assert!(
443            msg.contains("test/path"),
444            "Error should mention path: {}",
445            msg
446        );
447        assert!(
448            msg.contains("RequiredFields"),
449            "Error should mention target type: {}",
450            msg
451        );
452    }
453
454    // ==========================================================================
455    // Additional ModuleEvaluation tests
456    // ==========================================================================
457
458    #[test]
459    fn test_module_evaluation_empty() {
460        let module =
461            ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
462
463        assert_eq!(module.base_count(), 0);
464        assert_eq!(module.project_count(), 0);
465        assert!(module.root_instance().is_none());
466    }
467
468    #[test]
469    fn test_module_evaluation_root_only() {
470        let mut raw = HashMap::new();
471        raw.insert(".".to_string(), json!({"key": "value"}));
472
473        let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, vec![], None);
474
475        assert_eq!(module.base_count(), 1);
476        assert_eq!(module.project_count(), 0);
477        assert!(module.root_instance().is_some());
478    }
479
480    #[test]
481    fn test_module_evaluation_get_nonexistent() {
482        let module =
483            ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
484
485        assert!(module.get(Path::new("nonexistent")).is_none());
486    }
487
488    #[test]
489    fn test_module_evaluation_multiple_projects() {
490        let mut raw = HashMap::new();
491        raw.insert("proj1".to_string(), json!({"name": "proj1"}));
492        raw.insert("proj2".to_string(), json!({"name": "proj2"}));
493        raw.insert("proj3".to_string(), json!({"name": "proj3"}));
494
495        let project_paths = vec![
496            "proj1".to_string(),
497            "proj2".to_string(),
498            "proj3".to_string(),
499        ];
500
501        let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, project_paths, None);
502
503        assert_eq!(module.project_count(), 3);
504        assert_eq!(module.base_count(), 0);
505    }
506
507    #[test]
508    fn test_module_evaluation_ancestors_deep_path() {
509        let module =
510            ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
511
512        let ancestors = module.ancestors(Path::new("a/b/c/d"));
513        assert_eq!(ancestors.len(), 4);
514        assert_eq!(ancestors[0], PathBuf::from("a/b/c"));
515        assert_eq!(ancestors[1], PathBuf::from("a/b"));
516        assert_eq!(ancestors[2], PathBuf::from("a"));
517        assert_eq!(ancestors[3], PathBuf::from("."));
518    }
519
520    #[test]
521    fn test_module_evaluation_is_inherited_no_child() {
522        let module =
523            ModuleEvaluation::from_raw(PathBuf::from("/test"), HashMap::new(), vec![], None);
524
525        // Non-existent child should return false
526        assert!(!module.is_inherited(Path::new("nonexistent"), "field"));
527    }
528
529    #[test]
530    fn test_module_evaluation_is_inherited_no_field() {
531        let mut raw = HashMap::new();
532        raw.insert("child".to_string(), json!({"other": "value"}));
533
534        let module = ModuleEvaluation::from_raw(PathBuf::from("/test"), raw, vec![], None);
535
536        // Child exists but doesn't have the field
537        assert!(!module.is_inherited(Path::new("child"), "missing_field"));
538    }
539
540    // ==========================================================================
541    // Instance tests
542    // ==========================================================================
543
544    #[test]
545    fn test_instance_get_field() {
546        let instance = Instance {
547            path: PathBuf::from("test"),
548            kind: InstanceKind::Project,
549            value: json!({
550                "name": "my-project",
551                "version": "1.0.0"
552            }),
553        };
554
555        assert_eq!(instance.get_field("name"), Some(&json!("my-project")));
556        assert_eq!(instance.get_field("version"), Some(&json!("1.0.0")));
557        assert!(instance.get_field("nonexistent").is_none());
558    }
559
560    #[test]
561    fn test_instance_has_field() {
562        let instance = Instance {
563            path: PathBuf::from("test"),
564            kind: InstanceKind::Project,
565            value: json!({"name": "test", "env": {}}),
566        };
567
568        assert!(instance.has_field("name"));
569        assert!(instance.has_field("env"));
570        assert!(!instance.has_field("missing"));
571    }
572
573    #[test]
574    fn test_instance_project_name_base() {
575        let instance = Instance {
576            path: PathBuf::from("test"),
577            kind: InstanceKind::Base,
578            value: json!({"name": "should-be-ignored"}),
579        };
580
581        // Base instances don't return project name even if they have one
582        assert!(instance.project_name().is_none());
583    }
584
585    #[test]
586    fn test_instance_project_name_missing() {
587        let instance = Instance {
588            path: PathBuf::from("test"),
589            kind: InstanceKind::Project,
590            value: json!({}),
591        };
592
593        assert!(instance.project_name().is_none());
594    }
595
596    #[test]
597    fn test_instance_clone() {
598        let instance = Instance {
599            path: PathBuf::from("original"),
600            kind: InstanceKind::Project,
601            value: json!({"name": "test"}),
602        };
603
604        let cloned = instance.clone();
605        assert_eq!(cloned.path, instance.path);
606        assert_eq!(cloned.kind, instance.kind);
607        assert_eq!(cloned.value, instance.value);
608    }
609
610    #[test]
611    fn test_instance_serialize() {
612        let instance = Instance {
613            path: PathBuf::from("test/path"),
614            kind: InstanceKind::Project,
615            value: json!({"name": "my-project"}),
616        };
617
618        let json = serde_json::to_string(&instance).unwrap();
619        assert!(json.contains("test/path"));
620        assert!(json.contains("Project"));
621        assert!(json.contains("my-project"));
622    }
623
624    // ==========================================================================
625    // InstanceKind tests
626    // ==========================================================================
627
628    #[test]
629    fn test_instance_kind_equality() {
630        assert_eq!(InstanceKind::Base, InstanceKind::Base);
631        assert_eq!(InstanceKind::Project, InstanceKind::Project);
632        assert_ne!(InstanceKind::Base, InstanceKind::Project);
633    }
634
635    #[test]
636    fn test_instance_kind_copy() {
637        let kind = InstanceKind::Project;
638        let copied = kind;
639        assert_eq!(kind, copied);
640    }
641
642    #[test]
643    fn test_instance_kind_serialize() {
644        let base_json = serde_json::to_string(&InstanceKind::Base).unwrap();
645        let project_json = serde_json::to_string(&InstanceKind::Project).unwrap();
646
647        assert!(base_json.contains("Base"));
648        assert!(project_json.contains("Project"));
649    }
650
651    #[test]
652    fn test_instance_kind_deserialize() {
653        let base: InstanceKind = serde_json::from_str("\"Base\"").unwrap();
654        let project: InstanceKind = serde_json::from_str("\"Project\"").unwrap();
655
656        assert_eq!(base, InstanceKind::Base);
657        assert_eq!(project, InstanceKind::Project);
658    }
659}