cuenv_core/rules/
discovery.rs1use ignore::WalkBuilder;
7use std::path::{Path, PathBuf};
8
9use crate::Result;
10use crate::manifest::DirectoryRules;
11
12#[derive(Debug, Clone)]
14pub struct DiscoveredRules {
15 pub file_path: PathBuf,
17
18 pub directory: PathBuf,
21
22 pub config: DirectoryRules,
24}
25
26pub type RulesEvalFn = Box<dyn Fn(&Path) -> Result<DirectoryRules> + Send + Sync>;
28
29pub struct RulesDiscovery {
34 root: PathBuf,
36
37 discovered: Vec<DiscoveredRules>,
39
40 eval_fn: Option<RulesEvalFn>,
42}
43
44impl RulesDiscovery {
45 #[must_use]
47 pub fn new(root: PathBuf) -> Self {
48 Self {
49 root,
50 discovered: Vec::new(),
51 eval_fn: None,
52 }
53 }
54
55 #[must_use]
57 pub fn with_eval_fn(mut self, eval_fn: RulesEvalFn) -> Self {
58 self.eval_fn = Some(eval_fn);
59 self
60 }
61
62 pub fn discover(&mut self) -> std::result::Result<(), RulesDiscoveryError> {
68 self.discovered.clear();
69
70 let eval_fn = self
71 .eval_fn
72 .as_ref()
73 .ok_or(RulesDiscoveryError::NoEvalFunction)?;
74
75 let walker = WalkBuilder::new(&self.root)
76 .follow_links(true)
77 .standard_filters(true)
78 .build();
79
80 let mut load_failures = Vec::new();
81
82 for result in walker {
83 match result {
84 Ok(entry) => {
85 let path = entry.path();
86 if path.file_name() == Some(".rules.cue".as_ref()) {
88 match Self::load_rules(path, eval_fn) {
89 Ok(rules) => self.discovered.push(rules),
90 Err(e) => {
91 tracing::warn!(
92 path = %path.display(),
93 error = %e,
94 "Failed to load .rules.cue - skipping"
95 );
96 load_failures.push((path.to_path_buf(), e));
97 }
98 }
99 }
100 }
101 Err(e) => {
102 tracing::warn!(error = %e, "Error during directory scan");
103 }
104 }
105 }
106
107 if !load_failures.is_empty() {
108 tracing::warn!(
109 count = load_failures.len(),
110 "Some .rules.cue files failed to load. \
111 Run with RUST_LOG=debug for details."
112 );
113 }
114
115 tracing::debug!(
116 discovered = self.discovered.len(),
117 failures = load_failures.len(),
118 "Rules discovery complete"
119 );
120
121 Ok(())
122 }
123
124 fn load_rules(
126 file_path: &Path,
127 eval_fn: &RulesEvalFn,
128 ) -> std::result::Result<DiscoveredRules, RulesDiscoveryError> {
129 let directory = file_path
130 .parent()
131 .ok_or_else(|| RulesDiscoveryError::InvalidPath(file_path.to_path_buf()))?
132 .to_path_buf();
133
134 let config = eval_fn(file_path)
136 .map_err(|e| RulesDiscoveryError::EvalError(file_path.to_path_buf(), Box::new(e)))?;
137
138 Ok(DiscoveredRules {
139 file_path: file_path.to_path_buf(),
140 directory,
141 config,
142 })
143 }
144
145 pub fn discovered(&self) -> &[DiscoveredRules] {
147 &self.discovered
148 }
149
150 pub fn root(&self) -> &Path {
152 &self.root
153 }
154}
155
156#[derive(Debug, thiserror::Error)]
158pub enum RulesDiscoveryError {
159 #[error("Invalid path: {0}")]
161 InvalidPath(PathBuf),
162
163 #[error("Failed to evaluate {}: {}", .0.display(), .1)]
165 EvalError(PathBuf, #[source] Box<crate::Error>),
166
167 #[error("No evaluation function provided")]
169 NoEvalFunction,
170
171 #[error("IO error: {0}")]
173 Io(#[from] std::io::Error),
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use crate::manifest::DirectoryRules;
180 use std::fs;
181 use tempfile::TempDir;
182
183 #[test]
188 fn test_rules_discovery_new() {
189 let discovery = RulesDiscovery::new(PathBuf::from("/some/root"));
190
191 assert_eq!(discovery.root(), Path::new("/some/root"));
192 assert!(discovery.discovered().is_empty());
193 }
194
195 #[test]
196 fn test_rules_discovery_with_eval_fn() {
197 let eval_fn: RulesEvalFn = Box::new(|_| Ok(DirectoryRules::default()));
198 let discovery = RulesDiscovery::new(PathBuf::from("/root")).with_eval_fn(eval_fn);
199
200 assert_eq!(discovery.root(), Path::new("/root"));
203 }
204
205 #[test]
210 fn test_discover_no_eval_function_error() {
211 let temp_dir = TempDir::new().unwrap();
212 let mut discovery = RulesDiscovery::new(temp_dir.path().to_path_buf());
213
214 let result = discovery.discover();
215
216 assert!(result.is_err());
217 let err = result.unwrap_err();
218 assert!(matches!(err, RulesDiscoveryError::NoEvalFunction));
219 assert_eq!(err.to_string(), "No evaluation function provided");
220 }
221
222 #[test]
223 fn test_discover_empty_directory() {
224 let temp_dir = TempDir::new().unwrap();
225 let eval_fn: RulesEvalFn = Box::new(|_| Ok(DirectoryRules::default()));
226 let mut discovery =
227 RulesDiscovery::new(temp_dir.path().to_path_buf()).with_eval_fn(eval_fn);
228
229 let result = discovery.discover();
230
231 assert!(result.is_ok());
232 assert!(discovery.discovered().is_empty());
233 }
234
235 #[test]
236 fn test_discover_processes_walker_results() {
237 let temp_dir = TempDir::new().unwrap();
239 let root = temp_dir.path();
240
241 let eval_fn: RulesEvalFn = Box::new(|_| Ok(DirectoryRules::default()));
243 let mut discovery = RulesDiscovery::new(root.to_path_buf()).with_eval_fn(eval_fn);
244
245 let result = discovery.discover();
246
247 assert!(result.is_ok());
248 assert!(discovery.discovered().is_empty());
250 }
251
252 #[test]
253 fn test_discover_ignores_non_rules_cue_files() {
254 let temp_dir = TempDir::new().unwrap();
255 let root = temp_dir.path();
256
257 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()));
263 let mut discovery = RulesDiscovery::new(root.to_path_buf()).with_eval_fn(eval_fn);
264
265 let result = discovery.discover();
266
267 assert!(result.is_ok());
268 assert!(discovery.discovered().is_empty());
269 }
270
271 #[test]
272 fn test_discover_eval_failure_continues() {
273 let temp_dir = TempDir::new().unwrap();
274 let root = temp_dir.path();
275
276 fs::write(root.join(".rules.cue"), "").unwrap();
278 fs::create_dir_all(root.join("subdir")).unwrap();
279 fs::write(root.join("subdir/.rules.cue"), "").unwrap();
280
281 let eval_fn: RulesEvalFn = Box::new(|_path| Err(crate::Error::configuration("test error")));
284 let mut discovery = RulesDiscovery::new(root.to_path_buf()).with_eval_fn(eval_fn);
285
286 let result = discovery.discover();
287
288 assert!(result.is_ok());
290 assert!(discovery.discovered().is_empty());
292 }
293
294 #[test]
295 fn test_discover_clears_previous_results() {
296 let temp_dir = TempDir::new().unwrap();
297 let root = temp_dir.path();
298
299 let eval_fn: RulesEvalFn = Box::new(|_| Ok(DirectoryRules::default()));
300 let mut discovery = RulesDiscovery::new(root.to_path_buf()).with_eval_fn(eval_fn);
301
302 discovery.discover().unwrap();
304 let first_count = discovery.discovered().len();
305
306 discovery.discover().unwrap();
308 let second_count = discovery.discovered().len();
309
310 assert_eq!(first_count, second_count);
312 assert_eq!(first_count, 0);
313 }
314
315 #[test]
320 fn test_discovered_rules_fields() {
321 let discovered = DiscoveredRules {
322 file_path: PathBuf::from("/repo/frontend/.rules.cue"),
323 directory: PathBuf::from("/repo/frontend"),
324 config: DirectoryRules::default(),
325 };
326
327 assert_eq!(
328 discovered.file_path,
329 PathBuf::from("/repo/frontend/.rules.cue")
330 );
331 assert_eq!(discovered.directory, PathBuf::from("/repo/frontend"));
332 }
333
334 #[test]
335 fn test_discovered_rules_clone() {
336 let discovered = DiscoveredRules {
337 file_path: PathBuf::from("/repo/.rules.cue"),
338 directory: PathBuf::from("/repo"),
339 config: DirectoryRules::default(),
340 };
341
342 let cloned = discovered.clone();
343
344 assert_eq!(cloned.file_path, discovered.file_path);
345 assert_eq!(cloned.directory, discovered.directory);
346 }
347
348 #[test]
353 fn test_rules_discovery_error_invalid_path_display() {
354 let err = RulesDiscoveryError::InvalidPath(PathBuf::from("/bad/path"));
355 assert_eq!(err.to_string(), "Invalid path: /bad/path");
356 }
357
358 #[test]
359 fn test_rules_discovery_error_no_eval_function_display() {
360 let err = RulesDiscoveryError::NoEvalFunction;
361 assert_eq!(err.to_string(), "No evaluation function provided");
362 }
363
364 #[test]
365 fn test_rules_discovery_error_io_display() {
366 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
367 let err = RulesDiscoveryError::Io(io_err);
368 assert!(err.to_string().contains("file not found"));
369 }
370
371 #[test]
372 fn test_rules_discovery_error_eval_error_display() {
373 let inner_err = crate::Error::configuration("CUE syntax error");
374 let err =
375 RulesDiscoveryError::EvalError(PathBuf::from("/repo/.rules.cue"), Box::new(inner_err));
376 let display = err.to_string();
377 assert!(display.contains("/repo/.rules.cue"));
378 assert!(display.contains("CUE syntax error"));
379 }
380
381 #[test]
382 fn test_rules_discovery_error_io_from() {
383 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
384 let err: RulesDiscoveryError = io_err.into();
385 assert!(matches!(err, RulesDiscoveryError::Io(_)));
386 }
387
388 #[test]
393 fn test_load_rules_sets_correct_directory() {
394 let temp_dir = TempDir::new().unwrap();
396 let root = temp_dir.path();
397 let subdir = root.join("frontend").join("components");
398 fs::create_dir_all(&subdir).unwrap();
399 let rules_file = subdir.join(".rules.cue");
400 fs::write(&rules_file, "").unwrap();
401
402 let eval_fn: RulesEvalFn = Box::new(|_| Ok(DirectoryRules::default()));
403
404 let result = RulesDiscovery::load_rules(&rules_file, &eval_fn);
406
407 assert!(result.is_ok());
408 let discovered = result.unwrap();
409 assert_eq!(discovered.directory, subdir);
410 assert_eq!(discovered.file_path, rules_file);
411 }
412
413 #[test]
414 fn test_load_rules_eval_fn_error() {
415 let temp_dir = TempDir::new().unwrap();
416 let rules_file = temp_dir.path().join(".rules.cue");
417 fs::write(&rules_file, "").unwrap();
418
419 let eval_fn: RulesEvalFn = Box::new(|_| Err(crate::Error::configuration("parse failed")));
420
421 let result = RulesDiscovery::load_rules(&rules_file, &eval_fn);
422
423 assert!(result.is_err());
424 let err = result.unwrap_err();
425 assert!(matches!(err, RulesDiscoveryError::EvalError(_, _)));
426 }
427
428 #[test]
429 fn test_root_accessor() {
430 let path = PathBuf::from("/custom/root/path");
431 let discovery = RulesDiscovery::new(path.clone());
432
433 assert_eq!(discovery.root(), path.as_path());
434 }
435
436 #[test]
437 fn test_discovered_accessor_empty() {
438 let discovery = RulesDiscovery::new(PathBuf::from("/root"));
439
440 assert!(discovery.discovered().is_empty());
441 }
442}