use std::path::{Path, PathBuf};
use ignore::WalkBuilder;
use crate::manifest::Base;
#[derive(Debug, Clone)]
pub struct DiscoveredBase {
pub env_cue_path: PathBuf,
pub project_root: PathBuf,
pub manifest: Base,
pub synthetic_name: String,
}
pub type BaseEvalFn = Box<dyn Fn(&Path) -> Result<Base, crate::Error> + Send + Sync>;
pub struct BaseDiscovery {
workspace_root: PathBuf,
bases: Vec<DiscoveredBase>,
eval_fn: Option<BaseEvalFn>,
}
impl BaseDiscovery {
pub fn new(workspace_root: PathBuf) -> Self {
Self {
workspace_root,
bases: Vec::new(),
eval_fn: None,
}
}
pub fn with_eval_fn(mut self, eval_fn: BaseEvalFn) -> Self {
self.eval_fn = Some(eval_fn);
self
}
pub fn discover(&mut self) -> Result<(), DiscoveryError> {
self.bases.clear();
let eval_fn = self
.eval_fn
.as_ref()
.ok_or(DiscoveryError::NoEvalFunction)?;
let walker = WalkBuilder::new(&self.workspace_root)
.follow_links(true)
.standard_filters(true) .build();
let mut load_failures: Vec<(PathBuf, String)> = Vec::new();
for result in walker {
match result {
Ok(entry) => {
let path = entry.path();
if path.file_name() == Some("env.cue".as_ref()) {
match self.load_base(path, eval_fn) {
Ok(base) => {
self.bases.push(base);
}
Err(e) => {
let error_msg = e.to_string();
tracing::warn!(
path = %path.display(),
error = %error_msg,
"Failed to load Base config - this config will be skipped"
);
load_failures.push((path.to_path_buf(), error_msg));
}
}
}
}
Err(err) => {
tracing::warn!(
error = %err,
"Error during workspace scan - some configs may not be discovered"
);
}
}
}
if !load_failures.is_empty() {
tracing::warn!(
count = load_failures.len(),
"Some Base configs failed to load during discovery. \
Fix CUE errors in these configs or add them to .gitignore to exclude. \
Run with RUST_LOG=debug for details."
);
}
tracing::debug!(
discovered = self.bases.len(),
failures = load_failures.len(),
"Base discovery complete"
);
Ok(())
}
fn load_base(
&self,
env_cue_path: &Path,
eval_fn: &BaseEvalFn,
) -> Result<DiscoveredBase, DiscoveryError> {
let project_root = env_cue_path
.parent()
.ok_or_else(|| DiscoveryError::InvalidPath(env_cue_path.to_path_buf()))?
.to_path_buf();
let manifest = eval_fn(&project_root)
.map_err(|e| DiscoveryError::EvalError(env_cue_path.to_path_buf(), Box::new(e)))?;
let synthetic_name = derive_synthetic_name(&self.workspace_root, &project_root);
Ok(DiscoveredBase {
env_cue_path: env_cue_path.to_path_buf(),
project_root,
manifest,
synthetic_name,
})
}
pub fn bases(&self) -> &[DiscoveredBase] {
&self.bases
}
}
fn derive_synthetic_name(workspace_root: &Path, project_root: &Path) -> String {
let relative = project_root
.strip_prefix(workspace_root)
.unwrap_or(project_root);
if relative.as_os_str().is_empty() {
return "root".to_string();
}
relative
.to_string_lossy()
.replace(['/', '\\'], "-")
.trim_matches('-')
.to_string()
}
#[derive(Debug, thiserror::Error)]
pub enum DiscoveryError {
#[error("Invalid path: {0}")]
InvalidPath(PathBuf),
#[error("Failed to evaluate {}: {}", .0.display(), .1)]
EvalError(PathBuf, #[source] Box<crate::Error>),
#[error("No evaluation function provided - use with_eval_fn()")]
NoEvalFunction,
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_derive_synthetic_name() {
let workspace = PathBuf::from("/workspace");
assert_eq!(derive_synthetic_name(&workspace, &workspace), "root");
let nested = PathBuf::from("/workspace/services/api");
assert_eq!(derive_synthetic_name(&workspace, &nested), "services-api");
let single = PathBuf::from("/workspace/frontend");
assert_eq!(derive_synthetic_name(&workspace, &single), "frontend");
let deep = PathBuf::from("/workspace/a/b/c/d");
assert_eq!(derive_synthetic_name(&workspace, &deep), "a-b-c-d");
}
#[test]
fn test_derive_synthetic_name_trailing_slash() {
let workspace = PathBuf::from("/workspace/");
let nested = PathBuf::from("/workspace/services/");
assert_eq!(derive_synthetic_name(&workspace, &nested), "services");
}
#[test]
fn test_derive_synthetic_name_unrelated_paths() {
let workspace = PathBuf::from("/workspace");
let other = PathBuf::from("/other/project");
let name = derive_synthetic_name(&workspace, &other);
assert!(!name.is_empty());
}
#[test]
fn test_base_discovery_new() {
let discovery = BaseDiscovery::new(PathBuf::from("/workspace"));
assert!(discovery.bases().is_empty());
}
#[test]
fn test_base_discovery_with_eval_fn() {
let discovery = BaseDiscovery::new(PathBuf::from("/workspace"))
.with_eval_fn(Box::new(|_| Ok(crate::manifest::Base::default())));
assert!(discovery.bases().is_empty());
}
#[test]
fn test_discovery_requires_eval_fn() {
let mut discovery = BaseDiscovery::new(PathBuf::from("/tmp"));
let result = discovery.discover();
assert!(matches!(result, Err(DiscoveryError::NoEvalFunction)));
}
#[test]
fn test_discovery_empty_workspace() {
let temp = TempDir::new().unwrap();
let mut discovery = BaseDiscovery::new(temp.path().to_path_buf())
.with_eval_fn(Box::new(|_| Ok(crate::manifest::Base::default())));
discovery.discover().unwrap();
assert!(discovery.bases().is_empty());
}
#[test]
fn test_discovery_with_env_cue() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join("env.cue"), "{}").unwrap();
let mut discovery = BaseDiscovery::new(temp.path().to_path_buf())
.with_eval_fn(Box::new(|_| Ok(crate::manifest::Base::default())));
discovery.discover().unwrap();
assert_eq!(discovery.bases().len(), 1);
assert_eq!(discovery.bases()[0].synthetic_name, "root");
}
#[test]
fn test_discovery_nested_env_cue() {
let temp = TempDir::new().unwrap();
std::fs::create_dir_all(temp.path().join("services/api")).unwrap();
std::fs::write(temp.path().join("services/api/env.cue"), "{}").unwrap();
let mut discovery = BaseDiscovery::new(temp.path().to_path_buf())
.with_eval_fn(Box::new(|_| Ok(crate::manifest::Base::default())));
discovery.discover().unwrap();
assert_eq!(discovery.bases().len(), 1);
assert_eq!(discovery.bases()[0].synthetic_name, "services-api");
}
#[test]
fn test_discovery_multiple_env_cue() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join("env.cue"), "{}").unwrap();
std::fs::create_dir_all(temp.path().join("frontend")).unwrap();
std::fs::write(temp.path().join("frontend/env.cue"), "{}").unwrap();
std::fs::create_dir_all(temp.path().join("backend")).unwrap();
std::fs::write(temp.path().join("backend/env.cue"), "{}").unwrap();
let mut discovery = BaseDiscovery::new(temp.path().to_path_buf())
.with_eval_fn(Box::new(|_| Ok(crate::manifest::Base::default())));
discovery.discover().unwrap();
assert_eq!(discovery.bases().len(), 3);
}
#[test]
fn test_discovery_skips_failed_loads() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join("env.cue"), "{}").unwrap();
std::fs::create_dir_all(temp.path().join("bad")).unwrap();
std::fs::write(temp.path().join("bad/env.cue"), "invalid").unwrap();
let mut discovery =
BaseDiscovery::new(temp.path().to_path_buf()).with_eval_fn(Box::new(|path| {
if path.ends_with("bad") {
Err(crate::Error::configuration("Invalid CUE"))
} else {
Ok(crate::manifest::Base::default())
}
}));
discovery.discover().unwrap();
assert_eq!(discovery.bases().len(), 1);
}
#[test]
fn test_discovery_respects_gitignore() {
let temp = TempDir::new().unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(temp.path())
.output()
.ok();
std::fs::write(temp.path().join(".gitignore"), "ignored/\n").unwrap();
std::fs::create_dir_all(temp.path().join("ignored")).unwrap();
std::fs::write(temp.path().join("ignored/env.cue"), "{}").unwrap();
std::fs::create_dir_all(temp.path().join("included")).unwrap();
std::fs::write(temp.path().join("included/env.cue"), "{}").unwrap();
let mut discovery = BaseDiscovery::new(temp.path().to_path_buf())
.with_eval_fn(Box::new(|_| Ok(crate::manifest::Base::default())));
discovery.discover().unwrap();
assert!(!discovery.bases().is_empty());
assert!(
discovery
.bases()
.iter()
.any(|b| b.synthetic_name == "included")
);
}
#[test]
fn test_discovered_base_fields() {
let base = DiscoveredBase {
env_cue_path: PathBuf::from("/project/env.cue"),
project_root: PathBuf::from("/project"),
manifest: crate::manifest::Base::default(),
synthetic_name: "project".to_string(),
};
assert_eq!(base.env_cue_path, PathBuf::from("/project/env.cue"));
assert_eq!(base.project_root, PathBuf::from("/project"));
assert_eq!(base.synthetic_name, "project");
}
#[test]
fn test_discovered_base_clone() {
let base = DiscoveredBase {
env_cue_path: PathBuf::from("/project/env.cue"),
project_root: PathBuf::from("/project"),
manifest: crate::manifest::Base::default(),
synthetic_name: "project".to_string(),
};
let cloned = base.clone();
assert_eq!(cloned.synthetic_name, "project");
}
#[test]
fn test_discovered_base_debug() {
let base = DiscoveredBase {
env_cue_path: PathBuf::from("/project/env.cue"),
project_root: PathBuf::from("/project"),
manifest: crate::manifest::Base::default(),
synthetic_name: "project".to_string(),
};
let debug_str = format!("{:?}", base);
assert!(debug_str.contains("project"));
}
#[test]
fn test_discovery_error_invalid_path() {
let err = DiscoveryError::InvalidPath(PathBuf::from("/bad/path"));
let msg = err.to_string();
assert!(msg.contains("Invalid path"));
assert!(msg.contains("/bad/path"));
}
#[test]
fn test_discovery_error_no_eval_function() {
let err = DiscoveryError::NoEvalFunction;
let msg = err.to_string();
assert!(msg.contains("No evaluation function"));
}
#[test]
fn test_discovery_error_io() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let err = DiscoveryError::Io(io_err);
let msg = err.to_string();
assert!(msg.contains("IO error"));
}
#[test]
fn test_discovery_error_eval_error() {
let inner = crate::Error::configuration("bad cue syntax");
let err = DiscoveryError::EvalError(PathBuf::from("/project/env.cue"), Box::new(inner));
let msg = err.to_string();
assert!(msg.contains("Failed to evaluate"));
}
}