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, String> + 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            with_owners = self
126                .bases
127                .iter()
128                .filter(|b| b.manifest.owners.is_some())
129                .count(),
130            with_ignore = self
131                .bases
132                .iter()
133                .filter(|b| b.manifest.ignore.is_some())
134                .count(),
135            failures = load_failures.len(),
136            "Base discovery complete"
137        );
138
139        Ok(())
140    }
141
142    /// Load a single Base configuration from its env.cue path
143    fn load_base(
144        &self,
145        env_cue_path: &Path,
146        eval_fn: &BaseEvalFn,
147    ) -> Result<DiscoveredBase, DiscoveryError> {
148        let project_root = env_cue_path
149            .parent()
150            .ok_or_else(|| DiscoveryError::InvalidPath(env_cue_path.to_path_buf()))?
151            .to_path_buf();
152
153        // Use provided eval function to evaluate the env.cue file as Base
154        let manifest = eval_fn(&project_root)
155            .map_err(|e| DiscoveryError::EvalError(env_cue_path.to_path_buf(), e))?;
156
157        // Generate synthetic name from directory path
158        let synthetic_name = derive_synthetic_name(&self.workspace_root, &project_root);
159
160        Ok(DiscoveredBase {
161            env_cue_path: env_cue_path.to_path_buf(),
162            project_root,
163            manifest,
164            synthetic_name,
165        })
166    }
167
168    /// Get all discovered Base configurations
169    pub fn bases(&self) -> &[DiscoveredBase] {
170        &self.bases
171    }
172
173    /// Get Base configurations that have owners defined
174    pub fn with_owners(&self) -> impl Iterator<Item = &DiscoveredBase> {
175        self.bases.iter().filter(|b| b.manifest.owners.is_some())
176    }
177
178    /// Get Base configurations that have ignore defined
179    pub fn with_ignore(&self) -> impl Iterator<Item = &DiscoveredBase> {
180        self.bases.iter().filter(|b| b.manifest.ignore.is_some())
181    }
182}
183
184/// Derive a synthetic name from the directory path relative to workspace root
185///
186/// Examples:
187/// - `/workspace/services/api` relative to `/workspace` → "services-api"
188/// - `/workspace` relative to `/workspace` → "root"
189fn derive_synthetic_name(workspace_root: &Path, project_root: &Path) -> String {
190    let relative = project_root
191        .strip_prefix(workspace_root)
192        .unwrap_or(project_root);
193
194    if relative.as_os_str().is_empty() {
195        return "root".to_string();
196    }
197
198    relative
199        .to_string_lossy()
200        .replace(['/', '\\'], "-")
201        .trim_matches('-')
202        .to_string()
203}
204
205/// Errors that can occur during Base discovery
206#[derive(Debug, thiserror::Error)]
207pub enum DiscoveryError {
208    #[error("Invalid path: {0}")]
209    InvalidPath(PathBuf),
210
211    #[error("Failed to evaluate {0}: {1}")]
212    EvalError(PathBuf, String),
213
214    #[error("No evaluation function provided - use with_eval_fn()")]
215    NoEvalFunction,
216
217    #[error("IO error: {0}")]
218    Io(#[from] std::io::Error),
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn test_derive_synthetic_name() {
227        // Root directory
228        let workspace = PathBuf::from("/workspace");
229        assert_eq!(derive_synthetic_name(&workspace, &workspace), "root");
230
231        // Nested directory
232        let nested = PathBuf::from("/workspace/services/api");
233        assert_eq!(derive_synthetic_name(&workspace, &nested), "services-api");
234
235        // Single level
236        let single = PathBuf::from("/workspace/frontend");
237        assert_eq!(derive_synthetic_name(&workspace, &single), "frontend");
238
239        // Deep nesting
240        let deep = PathBuf::from("/workspace/a/b/c/d");
241        assert_eq!(derive_synthetic_name(&workspace, &deep), "a-b-c-d");
242    }
243
244    #[test]
245    fn test_discovery_requires_eval_fn() {
246        let mut discovery = BaseDiscovery::new(PathBuf::from("/tmp"));
247        let result = discovery.discover();
248        assert!(matches!(result, Err(DiscoveryError::NoEvalFunction)));
249    }
250}