1use 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, crate::Error> + 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 failures = load_failures.len(),
126 "Base discovery complete"
127 );
128
129 Ok(())
130 }
131
132 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 let manifest = eval_fn(&project_root)
145 .map_err(|e| DiscoveryError::EvalError(env_cue_path.to_path_buf(), Box::new(e)))?;
146
147 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 pub fn bases(&self) -> &[DiscoveredBase] {
160 &self.bases
161 }
162}
163
164fn 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#[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 #[test]
211 fn test_derive_synthetic_name() {
212 let workspace = PathBuf::from("/workspace");
214 assert_eq!(derive_synthetic_name(&workspace, &workspace), "root");
215
216 let nested = PathBuf::from("/workspace/services/api");
218 assert_eq!(derive_synthetic_name(&workspace, &nested), "services-api");
219
220 let single = PathBuf::from("/workspace/frontend");
222 assert_eq!(derive_synthetic_name(&workspace, &single), "frontend");
223
224 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 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 let name = derive_synthetic_name(&workspace, &other);
243 assert!(!name.is_empty());
244 }
245
246 #[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 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 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 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 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 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 if path.ends_with("bad") {
343 Err(crate::Error::configuration("Invalid CUE"))
344 } else {
345 Ok(crate::manifest::Base::default())
346 }
347 }));
348
349 discovery.discover().unwrap();
351 assert_eq!(discovery.bases().len(), 1);
353 }
354
355 #[test]
356 fn test_discovery_respects_gitignore() {
357 let temp = TempDir::new().unwrap();
358
359 std::process::Command::new("git")
361 .args(["init"])
362 .current_dir(temp.path())
363 .output()
364 .ok();
365
366 std::fs::write(temp.path().join(".gitignore"), "ignored/\n").unwrap();
368
369 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 assert!(!discovery.bases().is_empty());
382 assert!(
384 discovery
385 .bases()
386 .iter()
387 .any(|b| b.synthetic_name == "included")
388 );
389 }
390
391 #[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 #[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}