Skip to main content

cuenv_core/base/
discovery.rs

1//! Base schema discovery across monorepo workspaces
2//!
3//! This module provides functionality to discover Base configurations (owners, ignore)
4//! across a monorepo without requiring full Project schemas with name fields.
5
6use std::path::{Path, PathBuf};
7
8use ignore::WalkBuilder;
9
10use crate::manifest::Base;
11
12/// A discovered Base configuration in the workspace
13#[derive(Debug, Clone)]
14pub struct DiscoveredBase {
15    /// Path to the env.cue file
16    pub env_cue_path: PathBuf,
17    /// Path to the project root (directory containing env.cue)
18    pub project_root: PathBuf,
19    /// The parsed Base manifest
20    pub manifest: Base,
21    /// Synthetic name derived from directory path (for CODEOWNERS sections)
22    pub synthetic_name: String,
23}
24
25/// Function type for evaluating env.cue files as Base schema
26pub type BaseEvalFn = Box<dyn Fn(&Path) -> Result<Base, crate::Error> + Send + Sync>;
27
28/// Discovers Base configurations across a monorepo workspace
29///
30/// Unlike `TaskDiscovery`, this discovers all env.cue files that can be parsed
31/// as `schema.#Base`, regardless of whether they have a `name` field. This enables
32/// discovering owners and ignore configurations in nested directories that don't
33/// define full projects.
34pub struct BaseDiscovery {
35    /// Root directory of the workspace
36    workspace_root: PathBuf,
37    /// All discovered Base configurations
38    bases: Vec<DiscoveredBase>,
39    /// Function to evaluate env.cue files
40    eval_fn: Option<BaseEvalFn>,
41}
42
43impl BaseDiscovery {
44    /// Create a new BaseDiscovery for the given workspace root
45    pub fn new(workspace_root: PathBuf) -> Self {
46        Self {
47            workspace_root,
48            bases: Vec::new(),
49            eval_fn: None,
50        }
51    }
52
53    /// Set the evaluation function for loading env.cue files
54    pub fn with_eval_fn(mut self, eval_fn: BaseEvalFn) -> Self {
55        self.eval_fn = Some(eval_fn);
56        self
57    }
58
59    /// Discover all Base configurations in the workspace
60    ///
61    /// This scans for env.cue files using the ignore crate to respect .gitignore
62    /// and evaluates each as a Base schema.
63    ///
64    /// Configurations that fail to load are logged as warnings but don't stop discovery.
65    /// A summary of failures is logged at the end if any occurred.
66    pub fn discover(&mut self) -> Result<(), DiscoveryError> {
67        self.bases.clear();
68
69        let eval_fn = self
70            .eval_fn
71            .as_ref()
72            .ok_or(DiscoveryError::NoEvalFunction)?;
73
74        // Build a walker that respects gitignore
75        let walker = WalkBuilder::new(&self.workspace_root)
76            .follow_links(true)
77            .standard_filters(true) // Enable .gitignore, .ignore, hidden file filtering
78            .build();
79
80        // Track failures for summary
81        let mut load_failures: Vec<(PathBuf, String)> = Vec::new();
82
83        for result in walker {
84            match result {
85                Ok(entry) => {
86                    let path = entry.path();
87                    if path.file_name() == Some("env.cue".as_ref()) {
88                        match self.load_base(path, eval_fn) {
89                            Ok(base) => {
90                                self.bases.push(base);
91                            }
92                            Err(e) => {
93                                let error_msg = e.to_string();
94                                tracing::warn!(
95                                    path = %path.display(),
96                                    error = %error_msg,
97                                    "Failed to load Base config - this config will be skipped"
98                                );
99                                load_failures.push((path.to_path_buf(), error_msg));
100                            }
101                        }
102                    }
103                }
104                Err(err) => {
105                    tracing::warn!(
106                        error = %err,
107                        "Error during workspace scan - some configs may not be discovered"
108                    );
109                }
110            }
111        }
112
113        // Log summary of failures
114        if !load_failures.is_empty() {
115            tracing::warn!(
116                count = load_failures.len(),
117                "Some Base configs failed to load during discovery. \
118                 Fix CUE errors in these configs or add them to .gitignore to exclude. \
119                 Run with RUST_LOG=debug for details."
120            );
121        }
122
123        tracing::debug!(
124            discovered = self.bases.len(),
125            failures = load_failures.len(),
126            "Base discovery complete"
127        );
128
129        Ok(())
130    }
131
132    /// Load a single Base configuration from its env.cue path
133    fn load_base(
134        &self,
135        env_cue_path: &Path,
136        eval_fn: &BaseEvalFn,
137    ) -> Result<DiscoveredBase, DiscoveryError> {
138        let project_root = env_cue_path
139            .parent()
140            .ok_or_else(|| DiscoveryError::InvalidPath(env_cue_path.to_path_buf()))?
141            .to_path_buf();
142
143        // Use provided eval function to evaluate the env.cue file as Base
144        let manifest = eval_fn(&project_root)
145            .map_err(|e| DiscoveryError::EvalError(env_cue_path.to_path_buf(), Box::new(e)))?;
146
147        // Generate synthetic name from directory path
148        let synthetic_name = derive_synthetic_name(&self.workspace_root, &project_root);
149
150        Ok(DiscoveredBase {
151            env_cue_path: env_cue_path.to_path_buf(),
152            project_root,
153            manifest,
154            synthetic_name,
155        })
156    }
157
158    /// Get all discovered Base configurations
159    pub fn bases(&self) -> &[DiscoveredBase] {
160        &self.bases
161    }
162}
163
164/// Derive a synthetic name from the directory path relative to workspace root
165///
166/// Examples:
167/// - `/workspace/services/api` relative to `/workspace` → "services-api"
168/// - `/workspace` relative to `/workspace` → "root"
169fn derive_synthetic_name(workspace_root: &Path, project_root: &Path) -> String {
170    let relative = project_root
171        .strip_prefix(workspace_root)
172        .unwrap_or(project_root);
173
174    if relative.as_os_str().is_empty() {
175        return "root".to_string();
176    }
177
178    relative
179        .to_string_lossy()
180        .replace(['/', '\\'], "-")
181        .trim_matches('-')
182        .to_string()
183}
184
185/// Errors that can occur during Base discovery
186#[derive(Debug, thiserror::Error)]
187pub enum DiscoveryError {
188    #[error("Invalid path: {0}")]
189    InvalidPath(PathBuf),
190
191    #[error("Failed to evaluate {}: {}", .0.display(), .1)]
192    EvalError(PathBuf, #[source] Box<crate::Error>),
193
194    #[error("No evaluation function provided - use with_eval_fn()")]
195    NoEvalFunction,
196
197    #[error("IO error: {0}")]
198    Io(#[from] std::io::Error),
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use tempfile::TempDir;
205
206    // ==========================================================================
207    // derive_synthetic_name tests
208    // ==========================================================================
209
210    #[test]
211    fn test_derive_synthetic_name() {
212        // Root directory
213        let workspace = PathBuf::from("/workspace");
214        assert_eq!(derive_synthetic_name(&workspace, &workspace), "root");
215
216        // Nested directory
217        let nested = PathBuf::from("/workspace/services/api");
218        assert_eq!(derive_synthetic_name(&workspace, &nested), "services-api");
219
220        // Single level
221        let single = PathBuf::from("/workspace/frontend");
222        assert_eq!(derive_synthetic_name(&workspace, &single), "frontend");
223
224        // Deep nesting
225        let deep = PathBuf::from("/workspace/a/b/c/d");
226        assert_eq!(derive_synthetic_name(&workspace, &deep), "a-b-c-d");
227    }
228
229    #[test]
230    fn test_derive_synthetic_name_trailing_slash() {
231        let workspace = PathBuf::from("/workspace/");
232        let nested = PathBuf::from("/workspace/services/");
233        // Should handle trailing components gracefully
234        assert_eq!(derive_synthetic_name(&workspace, &nested), "services");
235    }
236
237    #[test]
238    fn test_derive_synthetic_name_unrelated_paths() {
239        let workspace = PathBuf::from("/workspace");
240        let other = PathBuf::from("/other/project");
241        // When paths are unrelated, uses the full path as name
242        let name = derive_synthetic_name(&workspace, &other);
243        assert!(!name.is_empty());
244    }
245
246    // ==========================================================================
247    // BaseDiscovery construction tests
248    // ==========================================================================
249
250    #[test]
251    fn test_base_discovery_new() {
252        let discovery = BaseDiscovery::new(PathBuf::from("/workspace"));
253        assert!(discovery.bases().is_empty());
254    }
255
256    #[test]
257    fn test_base_discovery_with_eval_fn() {
258        let discovery = BaseDiscovery::new(PathBuf::from("/workspace"))
259            .with_eval_fn(Box::new(|_| Ok(crate::manifest::Base::default())));
260        // Just verify construction works
261        assert!(discovery.bases().is_empty());
262    }
263
264    #[test]
265    fn test_discovery_requires_eval_fn() {
266        let mut discovery = BaseDiscovery::new(PathBuf::from("/tmp"));
267        let result = discovery.discover();
268        assert!(matches!(result, Err(DiscoveryError::NoEvalFunction)));
269    }
270
271    #[test]
272    fn test_discovery_empty_workspace() {
273        let temp = TempDir::new().unwrap();
274        let mut discovery = BaseDiscovery::new(temp.path().to_path_buf())
275            .with_eval_fn(Box::new(|_| Ok(crate::manifest::Base::default())));
276
277        discovery.discover().unwrap();
278        assert!(discovery.bases().is_empty());
279    }
280
281    #[test]
282    fn test_discovery_with_env_cue() {
283        let temp = TempDir::new().unwrap();
284
285        // Create env.cue file
286        std::fs::write(temp.path().join("env.cue"), "{}").unwrap();
287
288        let mut discovery = BaseDiscovery::new(temp.path().to_path_buf())
289            .with_eval_fn(Box::new(|_| Ok(crate::manifest::Base::default())));
290
291        discovery.discover().unwrap();
292        assert_eq!(discovery.bases().len(), 1);
293        assert_eq!(discovery.bases()[0].synthetic_name, "root");
294    }
295
296    #[test]
297    fn test_discovery_nested_env_cue() {
298        let temp = TempDir::new().unwrap();
299
300        // Create nested structure
301        std::fs::create_dir_all(temp.path().join("services/api")).unwrap();
302        std::fs::write(temp.path().join("services/api/env.cue"), "{}").unwrap();
303
304        let mut discovery = BaseDiscovery::new(temp.path().to_path_buf())
305            .with_eval_fn(Box::new(|_| Ok(crate::manifest::Base::default())));
306
307        discovery.discover().unwrap();
308        assert_eq!(discovery.bases().len(), 1);
309        assert_eq!(discovery.bases()[0].synthetic_name, "services-api");
310    }
311
312    #[test]
313    fn test_discovery_multiple_env_cue() {
314        let temp = TempDir::new().unwrap();
315
316        // Create multiple env.cue files
317        std::fs::write(temp.path().join("env.cue"), "{}").unwrap();
318        std::fs::create_dir_all(temp.path().join("frontend")).unwrap();
319        std::fs::write(temp.path().join("frontend/env.cue"), "{}").unwrap();
320        std::fs::create_dir_all(temp.path().join("backend")).unwrap();
321        std::fs::write(temp.path().join("backend/env.cue"), "{}").unwrap();
322
323        let mut discovery = BaseDiscovery::new(temp.path().to_path_buf())
324            .with_eval_fn(Box::new(|_| Ok(crate::manifest::Base::default())));
325
326        discovery.discover().unwrap();
327        assert_eq!(discovery.bases().len(), 3);
328    }
329
330    #[test]
331    fn test_discovery_skips_failed_loads() {
332        let temp = TempDir::new().unwrap();
333
334        // Create env.cue files
335        std::fs::write(temp.path().join("env.cue"), "{}").unwrap();
336        std::fs::create_dir_all(temp.path().join("bad")).unwrap();
337        std::fs::write(temp.path().join("bad/env.cue"), "invalid").unwrap();
338
339        let mut discovery =
340            BaseDiscovery::new(temp.path().to_path_buf()).with_eval_fn(Box::new(|path| {
341                // Simulate failure for "bad" directory
342                if path.ends_with("bad") {
343                    Err(crate::Error::configuration("Invalid CUE"))
344                } else {
345                    Ok(crate::manifest::Base::default())
346                }
347            }));
348
349        // Should succeed even with some failures
350        discovery.discover().unwrap();
351        // Only the good one should be discovered
352        assert_eq!(discovery.bases().len(), 1);
353    }
354
355    #[test]
356    fn test_discovery_respects_gitignore() {
357        let temp = TempDir::new().unwrap();
358
359        // Initialize git repo so .gitignore is respected
360        std::process::Command::new("git")
361            .args(["init"])
362            .current_dir(temp.path())
363            .output()
364            .ok();
365
366        // Create .gitignore
367        std::fs::write(temp.path().join(".gitignore"), "ignored/\n").unwrap();
368
369        // Create ignored and non-ignored env.cue
370        std::fs::create_dir_all(temp.path().join("ignored")).unwrap();
371        std::fs::write(temp.path().join("ignored/env.cue"), "{}").unwrap();
372        std::fs::create_dir_all(temp.path().join("included")).unwrap();
373        std::fs::write(temp.path().join("included/env.cue"), "{}").unwrap();
374
375        let mut discovery = BaseDiscovery::new(temp.path().to_path_buf())
376            .with_eval_fn(Box::new(|_| Ok(crate::manifest::Base::default())));
377
378        discovery.discover().unwrap();
379        // Only included should be found (if git is available), otherwise both
380        // The ignore crate requires a git repo to respect .gitignore
381        assert!(!discovery.bases().is_empty());
382        // Verify included is always present
383        assert!(
384            discovery
385                .bases()
386                .iter()
387                .any(|b| b.synthetic_name == "included")
388        );
389    }
390
391    // ==========================================================================
392    // DiscoveredBase tests
393    // ==========================================================================
394
395    #[test]
396    fn test_discovered_base_fields() {
397        let base = DiscoveredBase {
398            env_cue_path: PathBuf::from("/project/env.cue"),
399            project_root: PathBuf::from("/project"),
400            manifest: crate::manifest::Base::default(),
401            synthetic_name: "project".to_string(),
402        };
403
404        assert_eq!(base.env_cue_path, PathBuf::from("/project/env.cue"));
405        assert_eq!(base.project_root, PathBuf::from("/project"));
406        assert_eq!(base.synthetic_name, "project");
407    }
408
409    #[test]
410    fn test_discovered_base_clone() {
411        let base = DiscoveredBase {
412            env_cue_path: PathBuf::from("/project/env.cue"),
413            project_root: PathBuf::from("/project"),
414            manifest: crate::manifest::Base::default(),
415            synthetic_name: "project".to_string(),
416        };
417
418        let cloned = base.clone();
419        assert_eq!(cloned.synthetic_name, "project");
420    }
421
422    #[test]
423    fn test_discovered_base_debug() {
424        let base = DiscoveredBase {
425            env_cue_path: PathBuf::from("/project/env.cue"),
426            project_root: PathBuf::from("/project"),
427            manifest: crate::manifest::Base::default(),
428            synthetic_name: "project".to_string(),
429        };
430
431        let debug_str = format!("{:?}", base);
432        assert!(debug_str.contains("project"));
433    }
434
435    // ==========================================================================
436    // DiscoveryError tests
437    // ==========================================================================
438
439    #[test]
440    fn test_discovery_error_invalid_path() {
441        let err = DiscoveryError::InvalidPath(PathBuf::from("/bad/path"));
442        let msg = err.to_string();
443        assert!(msg.contains("Invalid path"));
444        assert!(msg.contains("/bad/path"));
445    }
446
447    #[test]
448    fn test_discovery_error_no_eval_function() {
449        let err = DiscoveryError::NoEvalFunction;
450        let msg = err.to_string();
451        assert!(msg.contains("No evaluation function"));
452    }
453
454    #[test]
455    fn test_discovery_error_io() {
456        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
457        let err = DiscoveryError::Io(io_err);
458        let msg = err.to_string();
459        assert!(msg.contains("IO error"));
460    }
461
462    #[test]
463    fn test_discovery_error_eval_error() {
464        let inner = crate::Error::configuration("bad cue syntax");
465        let err = DiscoveryError::EvalError(PathBuf::from("/project/env.cue"), Box::new(inner));
466        let msg = err.to_string();
467        assert!(msg.contains("Failed to evaluate"));
468    }
469}