cuenv_core/base/
discovery.rs1use std::path::{Path, PathBuf};
7
8use ignore::WalkBuilder;
9
10use crate::manifest::Base;
11
12#[derive(Debug, Clone)]
14pub struct DiscoveredBase {
15 pub env_cue_path: PathBuf,
17 pub project_root: PathBuf,
19 pub manifest: Base,
21 pub synthetic_name: String,
23}
24
25pub type BaseEvalFn = Box<dyn Fn(&Path) -> Result<Base, String> + Send + Sync>;
27
28pub struct BaseDiscovery {
35 workspace_root: PathBuf,
37 bases: Vec<DiscoveredBase>,
39 eval_fn: Option<BaseEvalFn>,
41}
42
43impl BaseDiscovery {
44 pub fn new(workspace_root: PathBuf) -> Self {
46 Self {
47 workspace_root,
48 bases: Vec::new(),
49 eval_fn: None,
50 }
51 }
52
53 pub fn with_eval_fn(mut self, eval_fn: BaseEvalFn) -> Self {
55 self.eval_fn = Some(eval_fn);
56 self
57 }
58
59 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 let walker = WalkBuilder::new(&self.workspace_root)
76 .follow_links(true)
77 .standard_filters(true) .build();
79
80 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 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 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 let manifest = eval_fn(&project_root)
155 .map_err(|e| DiscoveryError::EvalError(env_cue_path.to_path_buf(), e))?;
156
157 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 pub fn bases(&self) -> &[DiscoveredBase] {
170 &self.bases
171 }
172
173 pub fn with_owners(&self) -> impl Iterator<Item = &DiscoveredBase> {
175 self.bases.iter().filter(|b| b.manifest.owners.is_some())
176 }
177
178 pub fn with_ignore(&self) -> impl Iterator<Item = &DiscoveredBase> {
180 self.bases.iter().filter(|b| b.manifest.ignore.is_some())
181 }
182}
183
184fn 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#[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 let workspace = PathBuf::from("/workspace");
229 assert_eq!(derive_synthetic_name(&workspace, &workspace), "root");
230
231 let nested = PathBuf::from("/workspace/services/api");
233 assert_eq!(derive_synthetic_name(&workspace, &nested), "services-api");
234
235 let single = PathBuf::from("/workspace/frontend");
237 assert_eq!(derive_synthetic_name(&workspace, &single), "frontend");
238
239 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}