Skip to main content

cuenv_ci/
discovery.rs

1//! CI project discovery utilities.
2//!
3//! Provides functions for discovering CUE modules and projects for CI operations.
4//! Projects can be discovered from the current directory or from an already-evaluated module.
5//!
6//! Uses discovery-based evaluation: finds all env.cue files and evaluates each directory
7//! individually with `recursive: false`, avoiding CUE's `./...:package` pattern which
8//! can hang when directories contain mixed packages.
9
10use cuengine::ModuleEvalOptions;
11use cuenv_core::ModuleEvaluation;
12use cuenv_core::Result;
13use cuenv_core::cue::discovery::{
14    adjust_meta_key_path, compute_relative_path, discover_env_cue_directories, format_eval_errors,
15};
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18
19/// Find the CUE module root by walking up from `start` looking for `cue.mod/` directory.
20#[must_use]
21pub fn find_cue_module_root(start: &Path) -> Option<PathBuf> {
22    cuenv_core::cue::discovery::find_cue_module_root(start)
23}
24
25/// Evaluate the CUE module from the current working directory.
26///
27/// This is a convenience function that finds the module root from CWD,
28/// evaluates it, and returns the `ModuleEvaluation` for further processing.
29///
30/// Uses discovery-based evaluation to avoid CUE's `./...:package` pattern
31/// which can hang when directories contain mixed packages.
32///
33/// # Errors
34/// Returns an error if:
35/// - Current directory cannot be determined
36/// - Not inside a CUE module (no `cue.mod/` found)
37/// - No env.cue files with matching package found
38/// - CUE evaluation fails
39///
40/// # Example
41/// ```ignore
42/// use cuenv_ci::discovery::evaluate_module_from_cwd;
43/// use cuenv_core::manifest::Project;
44///
45/// let module = evaluate_module_from_cwd()?;
46/// for instance in module.projects() {
47///     let project = Project::try_from(instance)?;
48///     println!("Found project: {}", project.name);
49/// }
50/// ```
51pub fn evaluate_module_from_cwd() -> Result<ModuleEvaluation> {
52    const PACKAGE: &str = "cuenv";
53
54    let cwd = std::env::current_dir().map_err(|e| cuenv_core::Error::Io {
55        source: e,
56        path: None,
57        operation: "get current directory".to_string(),
58    })?;
59
60    let module_root = find_cue_module_root(&cwd).ok_or_else(|| {
61        cuenv_core::Error::configuration(
62            "Not inside a CUE module. Run 'cue mod init' or navigate to a directory with cue.mod/",
63        )
64    })?;
65
66    // Discover all directories with env.cue files matching our package
67    let env_cue_dirs = discover_env_cue_directories(&module_root, PACKAGE);
68
69    if env_cue_dirs.is_empty() {
70        return Err(cuenv_core::Error::configuration(format!(
71            "No env.cue files with package '{PACKAGE}' found in module: {}",
72            module_root.display()
73        )));
74    }
75
76    // Evaluate each directory individually (non-recursive)
77    let mut all_instances = HashMap::new();
78    let mut all_projects = Vec::new();
79    let mut all_meta = HashMap::new();
80    let mut eval_errors = Vec::new();
81
82    for dir in env_cue_dirs {
83        let dir_rel_path = compute_relative_path(&dir, &module_root);
84        let options = ModuleEvalOptions {
85            recursive: false,
86            with_references: true,
87            target_dir: Some(dir.to_string_lossy().to_string()),
88            ..Default::default()
89        };
90
91        match cuengine::evaluate_module(&module_root, PACKAGE, Some(&options)) {
92            Ok(raw) => {
93                // Merge instances (key by relative path from module_root)
94                for (path_str, value) in raw.instances {
95                    let rel_path = if path_str == "." {
96                        dir_rel_path.clone()
97                    } else {
98                        path_str
99                    };
100                    all_instances.insert(rel_path.clone(), value);
101                }
102
103                for project_path in raw.projects {
104                    let rel_project_path = if project_path == "." {
105                        dir_rel_path.clone()
106                    } else {
107                        project_path
108                    };
109                    if !all_projects.contains(&rel_project_path) {
110                        all_projects.push(rel_project_path);
111                    }
112                }
113
114                // Merge meta with adjusted paths
115                for (meta_key, meta_value) in raw.meta {
116                    let adjusted_key = adjust_meta_key_path(&meta_key, &dir_rel_path);
117                    all_meta.insert(adjusted_key, meta_value);
118                }
119            }
120            Err(e) => {
121                tracing::warn!(
122                    dir = %dir.display(),
123                    error = %e,
124                    "Failed to evaluate env.cue - skipping directory"
125                );
126                eval_errors.push((dir, e));
127            }
128        }
129    }
130
131    if all_instances.is_empty() {
132        let error_summary = format_eval_errors(&eval_errors);
133        return Err(cuenv_core::Error::configuration(format!(
134            "No instances could be evaluated. All directories failed:\n{error_summary}"
135        )));
136    }
137
138    // Convert meta to reference map for dependsOn resolution
139    let references = if all_meta.is_empty() {
140        None
141    } else {
142        Some(
143            all_meta
144                .into_iter()
145                .filter_map(|(k, v)| v.reference.map(|r| (k, r)))
146                .collect(),
147        )
148    };
149
150    Ok(ModuleEvaluation::from_raw(
151        module_root,
152        all_instances,
153        all_projects,
154        references,
155    ))
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    // ==========================================================================
163    // find_cue_module_root tests
164    // ==========================================================================
165
166    #[test]
167    fn test_find_cue_module_root_from_nested_dir() {
168        use std::fs;
169        use tempfile::TempDir;
170
171        let temp_dir = TempDir::new().expect("Failed to create temp dir");
172        let root = temp_dir.path();
173
174        // Create cue.mod at root
175        fs::create_dir_all(root.join("cue.mod")).expect("Failed to create cue.mod");
176
177        // Create nested directory
178        let nested = root.join("services").join("api");
179        fs::create_dir_all(&nested).expect("Failed to create nested dir");
180
181        // find_cue_module_root should find the root from nested
182        let found = find_cue_module_root(&nested);
183        assert!(found.is_some(), "Should find module root from nested dir");
184
185        let found_path = found.unwrap();
186        assert_eq!(
187            found_path.canonicalize().unwrap(),
188            root.canonicalize().unwrap(),
189            "Found root should match actual root"
190        );
191    }
192
193    #[test]
194    fn test_find_cue_module_root_not_found() {
195        use tempfile::TempDir;
196
197        let temp_dir = TempDir::new().expect("Failed to create temp dir");
198        // No cue.mod directory
199
200        let found = find_cue_module_root(temp_dir.path());
201        assert!(
202            found.is_none(),
203            "Should not find module root without cue.mod"
204        );
205    }
206
207    #[test]
208    fn test_find_cue_module_root_from_root() {
209        use std::fs;
210        use tempfile::TempDir;
211
212        let temp_dir = TempDir::new().expect("Failed to create temp dir");
213        let root = temp_dir.path();
214
215        // Create cue.mod at root
216        fs::create_dir_all(root.join("cue.mod")).expect("Failed to create cue.mod");
217
218        // Find from root itself
219        let found = find_cue_module_root(root);
220        assert!(found.is_some());
221    }
222
223    #[test]
224    fn test_find_cue_module_root_deeply_nested() {
225        use std::fs;
226        use tempfile::TempDir;
227
228        let temp_dir = TempDir::new().expect("Failed to create temp dir");
229        let root = temp_dir.path();
230
231        // Create cue.mod at root
232        fs::create_dir_all(root.join("cue.mod")).expect("Failed to create cue.mod");
233
234        // Create deeply nested directory
235        let nested = root.join("a").join("b").join("c").join("d").join("e");
236        fs::create_dir_all(&nested).expect("Failed to create nested dir");
237
238        // Should still find module root
239        let found = find_cue_module_root(&nested);
240        assert!(found.is_some());
241        assert_eq!(
242            found.unwrap().canonicalize().unwrap(),
243            root.canonicalize().unwrap()
244        );
245    }
246
247    #[test]
248    fn test_find_cue_module_root_cue_mod_file_not_dir() {
249        use std::fs;
250        use tempfile::TempDir;
251
252        let temp_dir = TempDir::new().expect("Failed to create temp dir");
253        let root = temp_dir.path();
254
255        // Create cue.mod as a FILE (not directory) - should not be recognized
256        fs::write(root.join("cue.mod"), "not a directory").expect("Failed to create file");
257
258        let found = find_cue_module_root(root);
259        assert!(
260            found.is_none(),
261            "File named cue.mod should not count as module root"
262        );
263    }
264
265    #[test]
266    fn test_find_cue_module_root_intermediate_cue_mod() {
267        use std::fs;
268        use tempfile::TempDir;
269
270        let temp_dir = TempDir::new().expect("Failed to create temp dir");
271        let root = temp_dir.path();
272
273        // Create cue.mod in an intermediate directory
274        let intermediate = root.join("project");
275        fs::create_dir_all(intermediate.join("cue.mod")).expect("Failed to create cue.mod");
276
277        // Create deeply nested directory
278        let nested = intermediate.join("services").join("api");
279        fs::create_dir_all(&nested).expect("Failed to create nested dir");
280
281        // Should find the intermediate cue.mod, not walk all the way up
282        let found = find_cue_module_root(&nested);
283        assert!(found.is_some());
284        assert_eq!(
285            found.unwrap().canonicalize().unwrap(),
286            intermediate.canonicalize().unwrap()
287        );
288    }
289
290    #[test]
291    fn test_find_cue_module_root_nonexistent_path() {
292        let path = PathBuf::from("/nonexistent/path/that/does/not/exist");
293        let found = find_cue_module_root(&path);
294        assert!(found.is_none());
295    }
296}