herolib_code/parser/
walker.rs

1//! Directory walker for discovering Rust source files.
2//!
3//! This module provides functionality to walk through a directory tree
4//! and discover all Rust source files (`.rs` files).
5
6use std::path::{Path, PathBuf};
7use walkdir::WalkDir;
8
9use super::error::{ParseError, ParseResult};
10
11/// Configuration options for the directory walker.
12#[derive(Debug, Clone)]
13pub struct WalkerConfig {
14    /// Whether to follow symbolic links.
15    pub follow_symlinks: bool,
16    /// Maximum depth to recurse (None for unlimited).
17    pub max_depth: Option<usize>,
18    /// Directories to skip (e.g., "target", ".git").
19    pub skip_dirs: Vec<String>,
20    /// File patterns to include (default: ["*.rs"]).
21    pub include_patterns: Vec<String>,
22}
23
24impl Default for WalkerConfig {
25    fn default() -> Self {
26        Self {
27            follow_symlinks: false,
28            max_depth: None,
29            skip_dirs: vec![
30                "target".to_string(),
31                ".git".to_string(),
32                "node_modules".to_string(),
33                ".cargo".to_string(),
34            ],
35            include_patterns: vec!["*.rs".to_string()],
36        }
37    }
38}
39
40impl WalkerConfig {
41    /// Creates a new WalkerConfig with default settings.
42    pub fn new() -> Self {
43        Self::default()
44    }
45
46    /// Sets whether to follow symbolic links.
47    pub fn follow_symlinks(mut self, follow: bool) -> Self {
48        self.follow_symlinks = follow;
49        self
50    }
51
52    /// Sets the maximum depth to recurse.
53    pub fn max_depth(mut self, depth: Option<usize>) -> Self {
54        self.max_depth = depth;
55        self
56    }
57
58    /// Adds a directory to skip.
59    pub fn skip_dir(mut self, dir: impl Into<String>) -> Self {
60        self.skip_dirs.push(dir.into());
61        self
62    }
63
64    /// Sets the directories to skip.
65    pub fn skip_dirs(mut self, dirs: Vec<String>) -> Self {
66        self.skip_dirs = dirs;
67        self
68    }
69}
70
71/// Walks a directory tree and discovers Rust source files.
72pub struct DirectoryWalker {
73    /// Configuration for the walker.
74    config: WalkerConfig,
75}
76
77impl DirectoryWalker {
78    /// Creates a new DirectoryWalker with the given configuration.
79    pub fn new(config: WalkerConfig) -> Self {
80        Self { config }
81    }
82
83    /// Creates a new DirectoryWalker with default configuration.
84    pub fn with_defaults() -> Self {
85        Self::new(WalkerConfig::default())
86    }
87
88    /// Discovers all Rust source files in the given directory.
89    ///
90    /// # Arguments
91    ///
92    /// * `root` - The root directory to start walking from.
93    ///
94    /// # Returns
95    ///
96    /// A vector of paths to all discovered Rust source files.
97    pub fn discover_rust_files<P: AsRef<Path>>(&self, root: P) -> ParseResult<Vec<PathBuf>> {
98        let root = root.as_ref();
99
100        if !root.exists() {
101            return Err(ParseError::DirectoryNotFound(root.to_path_buf()));
102        }
103
104        let mut walker = WalkDir::new(root).follow_links(self.config.follow_symlinks);
105
106        if let Some(depth) = self.config.max_depth {
107            walker = walker.max_depth(depth);
108        }
109
110        let mut rust_files = Vec::new();
111        let skip_dirs = &self.config.skip_dirs;
112
113        let walker_iter = walker.into_iter().filter_entry(move |entry| {
114            // Don't filter out the root directory
115            if entry.depth() == 0 {
116                return true;
117            }
118            if let Some(name) = entry.file_name().to_str() {
119                // Skip files and directories starting with . or _
120                if name.starts_with('.') || name.starts_with('_') {
121                    return false;
122                }
123                // Filter out directories that should be skipped
124                if entry.file_type().is_dir() && skip_dirs.contains(&name.to_string()) {
125                    return false;
126                }
127            }
128            true
129        });
130
131        for entry in walker_iter {
132            let entry = entry.map_err(|e| ParseError::WalkError {
133                path: root.to_path_buf(),
134                source: e,
135            })?;
136
137            let path = entry.path();
138
139            // Check if this is a Rust file
140            if entry.file_type().is_file() {
141                if let Some(extension) = path.extension() {
142                    if extension == "rs" {
143                        rust_files.push(path.to_path_buf());
144                    }
145                }
146            }
147        }
148
149        // Sort files for consistent ordering
150        rust_files.sort();
151
152        Ok(rust_files)
153    }
154
155    /// Returns a reference to the walker configuration.
156    pub fn config(&self) -> &WalkerConfig {
157        &self.config
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use std::fs;
165    use tempfile::tempdir;
166
167    #[test]
168    fn test_walker_config_default() {
169        let config = WalkerConfig::default();
170        assert!(!config.follow_symlinks);
171        assert!(config.max_depth.is_none());
172        assert!(config.skip_dirs.contains(&"target".to_string()));
173    }
174
175    #[test]
176    fn test_walker_config_builder() {
177        let config = WalkerConfig::new()
178            .follow_symlinks(true)
179            .max_depth(Some(5))
180            .skip_dir("custom_dir");
181
182        assert!(config.follow_symlinks);
183        assert_eq!(config.max_depth, Some(5));
184        assert!(config.skip_dirs.contains(&"custom_dir".to_string()));
185    }
186
187    #[test]
188    fn test_discover_rust_files() {
189        let dir = tempdir().unwrap();
190        let dir_path = dir.path();
191
192        // Create some Rust files
193        fs::write(dir_path.join("main.rs"), "fn main() {}").unwrap();
194        fs::write(dir_path.join("lib.rs"), "pub fn lib() {}").unwrap();
195
196        // Create a subdirectory with more Rust files
197        let subdir = dir_path.join("src");
198        fs::create_dir(&subdir).unwrap();
199        fs::write(subdir.join("module.rs"), "pub mod module;").unwrap();
200
201        // Create a non-Rust file
202        fs::write(dir_path.join("readme.md"), "# Readme").unwrap();
203
204        let walker = DirectoryWalker::with_defaults();
205        let files = walker.discover_rust_files(dir_path).unwrap();
206
207        assert_eq!(files.len(), 3);
208        assert!(files.iter().all(|f| f.extension().unwrap() == "rs"));
209    }
210
211    #[test]
212    fn test_skip_directories() {
213        let dir = tempdir().unwrap();
214        let dir_path = dir.path();
215
216        // Create a target directory (should be skipped)
217        let target_dir = dir_path.join("target");
218        fs::create_dir(&target_dir).unwrap();
219        fs::write(target_dir.join("generated.rs"), "// generated").unwrap();
220
221        // Create a regular file
222        fs::write(dir_path.join("main.rs"), "fn main() {}").unwrap();
223
224        let walker = DirectoryWalker::with_defaults();
225        let files = walker.discover_rust_files(dir_path).unwrap();
226
227        // Only main.rs should be found, not target/generated.rs
228        assert_eq!(files.len(), 1);
229        assert!(files[0].ends_with("main.rs"));
230    }
231
232    #[test]
233    fn test_directory_not_found() {
234        let walker = DirectoryWalker::with_defaults();
235        let result = walker.discover_rust_files("/nonexistent/path");
236
237        assert!(matches!(result, Err(ParseError::DirectoryNotFound(_))));
238    }
239}