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/// Result of evaluating an entire CUE module
14///
15/// Contains all evaluated instances (directories with env.cue files)
16/// from a CUE module, enabling cross-instance analysis.
17#[derive(Debug, Clone)]
18pub struct ModuleEvaluation {
19    /// Path to the CUE module root (directory containing cue.mod/)
20    pub root: PathBuf,
21
22    /// Map of relative path to evaluated instance
23    pub instances: HashMap<PathBuf, Instance>,
24}
25
26impl ModuleEvaluation {
27    /// Create a new module evaluation from raw FFI result
28    ///
29    /// # Arguments
30    /// * `root` - Path to the CUE module root
31    /// * `raw_instances` - Map of relative paths to evaluated JSON values
32    /// * `project_paths` - Paths verified to conform to `schema.#Project` via CUE unification
33    pub fn from_raw(
34        root: PathBuf,
35        raw_instances: HashMap<String, serde_json::Value>,
36        project_paths: Vec<String>,
37    ) -> Self {
38        // Convert project paths to a set for O(1) lookup
39        let project_set: std::collections::HashSet<&str> =
40            project_paths.iter().map(String::as_str).collect();
41
42        let instances = raw_instances
43            .into_iter()
44            .map(|(path, value)| {
45                let path_buf = PathBuf::from(&path);
46                // Use CUE's schema verification instead of heuristic name check
47                let kind = if project_set.contains(path.as_str()) {
48                    InstanceKind::Project
49                } else {
50                    InstanceKind::Base
51                };
52                let instance = Instance {
53                    path: path_buf.clone(),
54                    kind,
55                    value,
56                };
57                (path_buf, instance)
58            })
59            .collect();
60
61        Self { root, instances }
62    }
63
64    /// Get all Base instances (directories without a `name` field)
65    pub fn bases(&self) -> impl Iterator<Item = &Instance> {
66        self.instances
67            .values()
68            .filter(|i| matches!(i.kind, InstanceKind::Base))
69    }
70
71    /// Get all Project instances (directories with a `name` field)
72    pub fn projects(&self) -> impl Iterator<Item = &Instance> {
73        self.instances
74            .values()
75            .filter(|i| matches!(i.kind, InstanceKind::Project))
76    }
77
78    /// Get the root instance (the module root directory)
79    pub fn root_instance(&self) -> Option<&Instance> {
80        self.instances.get(Path::new("."))
81    }
82
83    /// Get an instance by its relative path
84    pub fn get(&self, path: &Path) -> Option<&Instance> {
85        self.instances.get(path)
86    }
87
88    /// Count of Base instances
89    pub fn base_count(&self) -> usize {
90        self.bases().count()
91    }
92
93    /// Count of Project instances
94    pub fn project_count(&self) -> usize {
95        self.projects().count()
96    }
97
98    /// Get all ancestor paths for a given path
99    ///
100    /// Returns paths from immediate parent up to (and including) the root ".".
101    /// Returns empty vector if path is already the root.
102    pub fn ancestors(&self, path: &Path) -> Vec<PathBuf> {
103        // Root has no ancestors
104        if path == Path::new(".") {
105            return Vec::new();
106        }
107
108        let mut ancestors = Vec::new();
109        let mut current = path.to_path_buf();
110
111        while let Some(parent) = current.parent() {
112            if parent.as_os_str().is_empty() {
113                // Reached filesystem root, add "." as the module root path
114                ancestors.push(PathBuf::from("."));
115                break;
116            }
117            ancestors.push(parent.to_path_buf());
118            current = parent.to_path_buf();
119        }
120
121        ancestors
122    }
123
124    /// Check if a field value in a child instance is inherited from an ancestor
125    ///
126    /// Returns true if the field exists in both the child and an ancestor,
127    /// and the values are equal (indicating inheritance via CUE unification).
128    pub fn is_inherited(&self, child_path: &Path, field: &str) -> bool {
129        let Some(child) = self.instances.get(child_path) else {
130            return false;
131        };
132
133        let Some(child_value) = child.value.get(field) else {
134            return false;
135        };
136
137        // Check each ancestor
138        for ancestor_path in self.ancestors(child_path) {
139            if let Some(ancestor) = self.instances.get(&ancestor_path)
140                && let Some(ancestor_value) = ancestor.value.get(field)
141                && child_value == ancestor_value
142            {
143                return true;
144            }
145        }
146
147        false
148    }
149}
150
151/// A single evaluated CUE instance (directory with env.cue)
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct Instance {
154    /// Relative path from module root to this instance
155    pub path: PathBuf,
156
157    /// Whether this is a Base or Project instance
158    pub kind: InstanceKind,
159
160    /// The raw evaluated JSON value
161    pub value: serde_json::Value,
162}
163
164impl Instance {
165    /// Deserialize this instance's value into a typed struct
166    ///
167    /// This enables commands to extract strongly-typed configuration
168    /// from the evaluated CUE without re-evaluating.
169    ///
170    /// # Example
171    ///
172    /// ```ignore
173    /// let instance = module.get(path)?;
174    /// let project: Project = instance.deserialize()?;
175    /// ```
176    pub fn deserialize<T: DeserializeOwned>(&self) -> crate::Result<T> {
177        serde_json::from_value(self.value.clone()).map_err(|e| {
178            Error::configuration(format!(
179                "Failed to deserialize {} as {}: {}",
180                self.path.display(),
181                std::any::type_name::<T>(),
182                e
183            ))
184        })
185    }
186
187    /// Get the project name if this is a Project instance
188    pub fn project_name(&self) -> Option<&str> {
189        if matches!(self.kind, InstanceKind::Project) {
190            self.value.get("name").and_then(|v| v.as_str())
191        } else {
192            None
193        }
194    }
195
196    /// Get a field value from the evaluated config
197    pub fn get_field(&self, field: &str) -> Option<&serde_json::Value> {
198        self.value.get(field)
199    }
200
201    /// Check if a field exists in the evaluated config
202    pub fn has_field(&self, field: &str) -> bool {
203        self.value.get(field).is_some()
204    }
205}
206
207/// The kind of CUE instance
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
209pub enum InstanceKind {
210    /// A Base instance (no `name` field) - typically intermediate/root config
211    Base,
212    /// A Project instance (has `name` field) - a leaf node with full features
213    Project,
214}
215
216impl std::fmt::Display for InstanceKind {
217    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        match self {
219            Self::Base => write!(f, "Base"),
220            Self::Project => write!(f, "Project"),
221        }
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use serde_json::json;
229
230    fn create_test_module() -> ModuleEvaluation {
231        let mut raw = HashMap::new();
232
233        // Root (Base)
234        raw.insert(
235            ".".to_string(),
236            json!({
237                "env": { "SHARED": "value" },
238                "owners": { "rules": { "default": { "pattern": "**", "owners": ["@owner"] } } }
239            }),
240        );
241
242        // Project with inherited owners
243        raw.insert(
244            "projects/api".to_string(),
245            json!({
246                "name": "api",
247                "env": { "SHARED": "value" },
248                "owners": { "rules": { "default": { "pattern": "**", "owners": ["@owner"] } } }
249            }),
250        );
251
252        // Project with local owners
253        raw.insert(
254            "projects/web".to_string(),
255            json!({
256                "name": "web",
257                "env": { "SHARED": "value" },
258                "owners": { "rules": { "local": { "pattern": "**", "owners": ["@web-team"] } } }
259            }),
260        );
261
262        // Specify which paths are projects (simulating CUE schema verification)
263        let project_paths = vec!["projects/api".to_string(), "projects/web".to_string()];
264
265        ModuleEvaluation::from_raw(PathBuf::from("/test/repo"), raw, project_paths)
266    }
267
268    #[test]
269    fn test_instance_kind_detection() {
270        let module = create_test_module();
271
272        assert_eq!(module.base_count(), 1);
273        assert_eq!(module.project_count(), 2);
274
275        let root = module.root_instance().unwrap();
276        assert!(matches!(root.kind, InstanceKind::Base));
277
278        let api = module.get(Path::new("projects/api")).unwrap();
279        assert!(matches!(api.kind, InstanceKind::Project));
280        assert_eq!(api.project_name(), Some("api"));
281    }
282
283    #[test]
284    fn test_ancestors() {
285        let module = create_test_module();
286
287        let ancestors = module.ancestors(Path::new("projects/api"));
288        assert_eq!(ancestors.len(), 2);
289        assert_eq!(ancestors[0], PathBuf::from("projects"));
290        assert_eq!(ancestors[1], PathBuf::from("."));
291
292        let root_ancestors = module.ancestors(Path::new("."));
293        assert!(root_ancestors.is_empty());
294    }
295
296    #[test]
297    fn test_is_inherited() {
298        let module = create_test_module();
299
300        // api's owners should be inherited (same as root)
301        assert!(module.is_inherited(Path::new("projects/api"), "owners"));
302
303        // web's owners should NOT be inherited (different from root)
304        assert!(!module.is_inherited(Path::new("projects/web"), "owners"));
305
306        // env is the same, so should be inherited
307        assert!(module.is_inherited(Path::new("projects/api"), "env"));
308    }
309
310    #[test]
311    fn test_instance_kind_display() {
312        assert_eq!(InstanceKind::Base.to_string(), "Base");
313        assert_eq!(InstanceKind::Project.to_string(), "Project");
314    }
315
316    #[test]
317    fn test_instance_deserialize() {
318        #[derive(Debug, Deserialize, PartialEq)]
319        struct TestConfig {
320            name: String,
321            env: std::collections::HashMap<String, String>,
322        }
323
324        let instance = Instance {
325            path: PathBuf::from("test/path"),
326            kind: InstanceKind::Project,
327            value: json!({
328                "name": "my-project",
329                "env": { "FOO": "bar" }
330            }),
331        };
332
333        let config: TestConfig = instance.deserialize().unwrap();
334        assert_eq!(config.name, "my-project");
335        assert_eq!(config.env.get("FOO"), Some(&"bar".to_string()));
336    }
337
338    #[test]
339    fn test_instance_deserialize_error() {
340        #[derive(Debug, Deserialize)]
341        #[allow(dead_code)]
342        struct RequiredFields {
343            required_field: String,
344        }
345
346        let instance = Instance {
347            path: PathBuf::from("test/path"),
348            kind: InstanceKind::Base,
349            value: json!({}), // Missing required field
350        };
351
352        let result: crate::Result<RequiredFields> = instance.deserialize();
353        assert!(result.is_err());
354
355        let err = result.unwrap_err();
356        let msg = err.to_string();
357        assert!(
358            msg.contains("test/path"),
359            "Error should mention path: {}",
360            msg
361        );
362        assert!(
363            msg.contains("RequiredFields"),
364            "Error should mention target type: {}",
365            msg
366        );
367    }
368}