Skip to main content

cuenv_core/rules/
discovery.rs

1//! Independent .rules.cue file discovery.
2//!
3//! Discovers `.rules.cue` files throughout the repository and evaluates
4//! each one independently (NOT as part of module unification).
5
6use ignore::WalkBuilder;
7use std::path::{Path, PathBuf};
8
9use crate::Result;
10use crate::manifest::DirectoryRules;
11
12/// A discovered .rules.cue configuration.
13#[derive(Debug, Clone)]
14pub struct DiscoveredRules {
15    /// Path to the .rules.cue file.
16    pub file_path: PathBuf,
17
18    /// Directory containing the .rules.cue file.
19    /// This is where ignore files and .editorconfig will be generated.
20    pub directory: PathBuf,
21
22    /// The parsed rules configuration.
23    pub config: DirectoryRules,
24}
25
26/// Function type for evaluating a single .rules.cue file independently.
27pub type RulesEvalFn = Box<dyn Fn(&Path) -> Result<DirectoryRules> + Send + Sync>;
28
29/// Discovers .rules.cue files across the repository.
30///
31/// Each .rules.cue file is evaluated independently (not unified with
32/// other CUE files in the module).
33pub struct RulesDiscovery {
34    /// Root directory to search from.
35    root: PathBuf,
36
37    /// Discovered rules configurations.
38    discovered: Vec<DiscoveredRules>,
39
40    /// Function to evaluate .rules.cue files.
41    eval_fn: Option<RulesEvalFn>,
42}
43
44impl RulesDiscovery {
45    /// Create a new RulesDiscovery for the given root directory.
46    #[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    /// Set the evaluation function for .rules.cue files.
56    #[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    /// Discover all .rules.cue files in the repository.
63    ///
64    /// # Errors
65    ///
66    /// Returns an error if no evaluation function is provided.
67    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                    // Look for .rules.cue files
87                    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    /// Load a single .rules.cue configuration.
125    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        // Evaluate the .rules.cue file independently
135        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    /// Get all discovered .rules.cue configurations.
146    pub fn discovered(&self) -> &[DiscoveredRules] {
147        &self.discovered
148    }
149
150    /// Get the root directory being searched.
151    pub fn root(&self) -> &Path {
152        &self.root
153    }
154}
155
156/// Errors that can occur during rules discovery.
157#[derive(Debug, thiserror::Error)]
158pub enum RulesDiscoveryError {
159    /// Invalid path encountered.
160    #[error("Invalid path: {0}")]
161    InvalidPath(PathBuf),
162
163    /// Failed to evaluate a .rules.cue file.
164    #[error("Failed to evaluate {}: {}", .0.display(), .1)]
165    EvalError(PathBuf, #[source] Box<crate::Error>),
166
167    /// No evaluation function was provided.
168    #[error("No evaluation function provided")]
169    NoEvalFunction,
170
171    /// IO error during discovery.
172    #[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    // ==========================================================================
184    // RulesDiscovery construction tests
185    // ==========================================================================
186
187    #[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        // Can't directly check if eval_fn is set, but we can verify discover doesn't fail
201        // with NoEvalFunction error (we'll test that separately)
202        assert_eq!(discovery.root(), Path::new("/root"));
203    }
204
205    // ==========================================================================
206    // RulesDiscovery::discover tests
207    // ==========================================================================
208
209    #[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        // Test the basic discovery flow without relying on hidden file behavior
238        let temp_dir = TempDir::new().unwrap();
239        let root = temp_dir.path();
240
241        // Discovery with no .rules.cue files should succeed with empty results
242        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        // Empty because no .rules.cue files exist
249        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        // Create various files that should NOT be picked up
258        fs::write(root.join("rules.cue"), "").unwrap(); // Missing leading dot
259        fs::write(root.join(".rules.txt"), "").unwrap(); // Wrong extension
260        fs::write(root.join("config.cue"), "").unwrap(); // Different name
261
262        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        // Create two .rules.cue files
277        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        // Eval function that always fails - discovery should still succeed
282        // (failures are logged but not fatal)
283        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        // Should succeed overall, even with eval failures
289        assert!(result.is_ok());
290        // No discoveries because all evaluations failed
291        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        // First discovery
303        discovery.discover().unwrap();
304        let first_count = discovery.discovered().len();
305
306        // Second discovery - should start fresh (both empty)
307        discovery.discover().unwrap();
308        let second_count = discovery.discovered().len();
309
310        // Both should be empty (no .rules.cue files)
311        assert_eq!(first_count, second_count);
312        assert_eq!(first_count, 0);
313    }
314
315    // ==========================================================================
316    // DiscoveredRules tests
317    // ==========================================================================
318
319    #[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    // ==========================================================================
349    // RulesDiscoveryError tests
350    // ==========================================================================
351
352    #[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    // ==========================================================================
389    // load_rules tests (unit testing the internal function)
390    // ==========================================================================
391
392    #[test]
393    fn test_load_rules_sets_correct_directory() {
394        // Test load_rules directly by creating a temporary file and calling it
395        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        // Call load_rules directly
405        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}