use ignore::WalkBuilder;
use std::path::{Path, PathBuf};
use crate::Result;
use crate::manifest::DirectoryRules;
#[derive(Debug, Clone)]
pub struct DiscoveredRules {
pub file_path: PathBuf,
pub directory: PathBuf,
pub config: DirectoryRules,
}
pub type RulesEvalFn = Box<dyn Fn(&Path) -> Result<DirectoryRules> + Send + Sync>;
pub struct RulesDiscovery {
root: PathBuf,
discovered: Vec<DiscoveredRules>,
eval_fn: Option<RulesEvalFn>,
}
impl RulesDiscovery {
#[must_use]
pub fn new(root: PathBuf) -> Self {
Self {
root,
discovered: Vec::new(),
eval_fn: None,
}
}
#[must_use]
pub fn with_eval_fn(mut self, eval_fn: RulesEvalFn) -> Self {
self.eval_fn = Some(eval_fn);
self
}
pub fn discover(&mut self) -> std::result::Result<(), RulesDiscoveryError> {
self.discovered.clear();
let eval_fn = self
.eval_fn
.as_ref()
.ok_or(RulesDiscoveryError::NoEvalFunction)?;
let walker = WalkBuilder::new(&self.root)
.follow_links(true)
.standard_filters(true)
.build();
let mut load_failures = Vec::new();
for result in walker {
match result {
Ok(entry) => {
let path = entry.path();
if path.file_name() == Some(".rules.cue".as_ref()) {
match Self::load_rules(path, eval_fn) {
Ok(rules) => self.discovered.push(rules),
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"Failed to load .rules.cue - skipping"
);
load_failures.push((path.to_path_buf(), e));
}
}
}
}
Err(e) => {
tracing::warn!(error = %e, "Error during directory scan");
}
}
}
if !load_failures.is_empty() {
tracing::warn!(
count = load_failures.len(),
"Some .rules.cue files failed to load. \
Run with RUST_LOG=debug for details."
);
}
tracing::debug!(
discovered = self.discovered.len(),
failures = load_failures.len(),
"Rules discovery complete"
);
Ok(())
}
fn load_rules(
file_path: &Path,
eval_fn: &RulesEvalFn,
) -> std::result::Result<DiscoveredRules, RulesDiscoveryError> {
let directory = file_path
.parent()
.ok_or_else(|| RulesDiscoveryError::InvalidPath(file_path.to_path_buf()))?
.to_path_buf();
let config = eval_fn(file_path)
.map_err(|e| RulesDiscoveryError::EvalError(file_path.to_path_buf(), Box::new(e)))?;
Ok(DiscoveredRules {
file_path: file_path.to_path_buf(),
directory,
config,
})
}
pub fn discovered(&self) -> &[DiscoveredRules] {
&self.discovered
}
pub fn root(&self) -> &Path {
&self.root
}
}
#[derive(Debug, thiserror::Error)]
pub enum RulesDiscoveryError {
#[error("Invalid path: {0}")]
InvalidPath(PathBuf),
#[error("Failed to evaluate {}: {}", .0.display(), .1)]
EvalError(PathBuf, #[source] Box<crate::Error>),
#[error("No evaluation function provided")]
NoEvalFunction,
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::DirectoryRules;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_rules_discovery_new() {
let discovery = RulesDiscovery::new(PathBuf::from("/some/root"));
assert_eq!(discovery.root(), Path::new("/some/root"));
assert!(discovery.discovered().is_empty());
}
#[test]
fn test_rules_discovery_with_eval_fn() {
let eval_fn: RulesEvalFn = Box::new(|_| Ok(DirectoryRules::default()));
let discovery = RulesDiscovery::new(PathBuf::from("/root")).with_eval_fn(eval_fn);
assert_eq!(discovery.root(), Path::new("/root"));
}
#[test]
fn test_discover_no_eval_function_error() {
let temp_dir = TempDir::new().unwrap();
let mut discovery = RulesDiscovery::new(temp_dir.path().to_path_buf());
let result = discovery.discover();
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, RulesDiscoveryError::NoEvalFunction));
assert_eq!(err.to_string(), "No evaluation function provided");
}
#[test]
fn test_discover_empty_directory() {
let temp_dir = TempDir::new().unwrap();
let eval_fn: RulesEvalFn = Box::new(|_| Ok(DirectoryRules::default()));
let mut discovery =
RulesDiscovery::new(temp_dir.path().to_path_buf()).with_eval_fn(eval_fn);
let result = discovery.discover();
assert!(result.is_ok());
assert!(discovery.discovered().is_empty());
}
#[test]
fn test_discover_processes_walker_results() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let eval_fn: RulesEvalFn = Box::new(|_| Ok(DirectoryRules::default()));
let mut discovery = RulesDiscovery::new(root.to_path_buf()).with_eval_fn(eval_fn);
let result = discovery.discover();
assert!(result.is_ok());
assert!(discovery.discovered().is_empty());
}
#[test]
fn test_discover_ignores_non_rules_cue_files() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
fs::write(root.join("rules.cue"), "").unwrap(); fs::write(root.join(".rules.txt"), "").unwrap(); fs::write(root.join("config.cue"), "").unwrap();
let eval_fn: RulesEvalFn = Box::new(|_| Ok(DirectoryRules::default()));
let mut discovery = RulesDiscovery::new(root.to_path_buf()).with_eval_fn(eval_fn);
let result = discovery.discover();
assert!(result.is_ok());
assert!(discovery.discovered().is_empty());
}
#[test]
fn test_discover_eval_failure_continues() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
fs::write(root.join(".rules.cue"), "").unwrap();
fs::create_dir_all(root.join("subdir")).unwrap();
fs::write(root.join("subdir/.rules.cue"), "").unwrap();
let eval_fn: RulesEvalFn = Box::new(|_path| Err(crate::Error::configuration("test error")));
let mut discovery = RulesDiscovery::new(root.to_path_buf()).with_eval_fn(eval_fn);
let result = discovery.discover();
assert!(result.is_ok());
assert!(discovery.discovered().is_empty());
}
#[test]
fn test_discover_clears_previous_results() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let eval_fn: RulesEvalFn = Box::new(|_| Ok(DirectoryRules::default()));
let mut discovery = RulesDiscovery::new(root.to_path_buf()).with_eval_fn(eval_fn);
discovery.discover().unwrap();
let first_count = discovery.discovered().len();
discovery.discover().unwrap();
let second_count = discovery.discovered().len();
assert_eq!(first_count, second_count);
assert_eq!(first_count, 0);
}
#[test]
fn test_discovered_rules_fields() {
let discovered = DiscoveredRules {
file_path: PathBuf::from("/repo/frontend/.rules.cue"),
directory: PathBuf::from("/repo/frontend"),
config: DirectoryRules::default(),
};
assert_eq!(
discovered.file_path,
PathBuf::from("/repo/frontend/.rules.cue")
);
assert_eq!(discovered.directory, PathBuf::from("/repo/frontend"));
}
#[test]
fn test_discovered_rules_clone() {
let discovered = DiscoveredRules {
file_path: PathBuf::from("/repo/.rules.cue"),
directory: PathBuf::from("/repo"),
config: DirectoryRules::default(),
};
let cloned = discovered.clone();
assert_eq!(cloned.file_path, discovered.file_path);
assert_eq!(cloned.directory, discovered.directory);
}
#[test]
fn test_rules_discovery_error_invalid_path_display() {
let err = RulesDiscoveryError::InvalidPath(PathBuf::from("/bad/path"));
assert_eq!(err.to_string(), "Invalid path: /bad/path");
}
#[test]
fn test_rules_discovery_error_no_eval_function_display() {
let err = RulesDiscoveryError::NoEvalFunction;
assert_eq!(err.to_string(), "No evaluation function provided");
}
#[test]
fn test_rules_discovery_error_io_display() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let err = RulesDiscoveryError::Io(io_err);
assert!(err.to_string().contains("file not found"));
}
#[test]
fn test_rules_discovery_error_eval_error_display() {
let inner_err = crate::Error::configuration("CUE syntax error");
let err =
RulesDiscoveryError::EvalError(PathBuf::from("/repo/.rules.cue"), Box::new(inner_err));
let display = err.to_string();
assert!(display.contains("/repo/.rules.cue"));
assert!(display.contains("CUE syntax error"));
}
#[test]
fn test_rules_discovery_error_io_from() {
let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
let err: RulesDiscoveryError = io_err.into();
assert!(matches!(err, RulesDiscoveryError::Io(_)));
}
#[test]
fn test_load_rules_sets_correct_directory() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let subdir = root.join("frontend").join("components");
fs::create_dir_all(&subdir).unwrap();
let rules_file = subdir.join(".rules.cue");
fs::write(&rules_file, "").unwrap();
let eval_fn: RulesEvalFn = Box::new(|_| Ok(DirectoryRules::default()));
let result = RulesDiscovery::load_rules(&rules_file, &eval_fn);
assert!(result.is_ok());
let discovered = result.unwrap();
assert_eq!(discovered.directory, subdir);
assert_eq!(discovered.file_path, rules_file);
}
#[test]
fn test_load_rules_eval_fn_error() {
let temp_dir = TempDir::new().unwrap();
let rules_file = temp_dir.path().join(".rules.cue");
fs::write(&rules_file, "").unwrap();
let eval_fn: RulesEvalFn = Box::new(|_| Err(crate::Error::configuration("parse failed")));
let result = RulesDiscovery::load_rules(&rules_file, &eval_fn);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, RulesDiscoveryError::EvalError(_, _)));
}
#[test]
fn test_root_accessor() {
let path = PathBuf::from("/custom/root/path");
let discovery = RulesDiscovery::new(path.clone());
assert_eq!(discovery.root(), path.as_path());
}
#[test]
fn test_discovered_accessor_empty() {
let discovery = RulesDiscovery::new(PathBuf::from("/root"));
assert!(discovery.discovered().is_empty());
}
}