1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use syn::File as SynFile;
4use walkdir::WalkDir;
5
6#[derive(Debug, Clone)]
8pub struct ParsedFile {
9 pub ast: SynFile,
11 pub path: PathBuf,
13 }
16
17impl ParsedFile {
18 pub fn new(ast: SynFile, path: PathBuf) -> Self {
19 Self { ast, path }
20 }
21}
22
23#[derive(Debug, Default)]
25pub struct AstCache {
26 cache: HashMap<PathBuf, ParsedFile>,
27}
28
29impl AstCache {
30 pub fn new() -> Self {
31 Self {
32 cache: HashMap::new(),
33 }
34 }
35
36 fn should_skip_path(path: &Path) -> bool {
37 path.components().any(|component| {
38 component
39 .as_os_str()
40 .to_str()
41 .is_some_and(|part| part == "target" || part == ".git")
42 })
43 }
44
45 pub fn parse_and_cache_all_files(
47 &mut self,
48 project_path: &str,
49 verbose: bool,
50 ) -> Result<(), Box<dyn std::error::Error>> {
51 if verbose {
52 println!("🔄 Parsing and caching all Rust files in: {}", project_path);
53 }
54
55 for entry in WalkDir::new(project_path) {
56 let entry = entry?;
57 let path = entry.path();
58
59 if Self::should_skip_path(path) {
60 continue;
61 }
62
63 if path.is_file() && path.extension().is_some_and(|ext| ext == "rs") {
64 if verbose {
65 println!("📄 Parsing file: {}", path.display());
66 }
67
68 let content = std::fs::read_to_string(path)?;
69 match syn::parse_file(&content) {
70 Ok(ast) => {
71 let parsed_file = ParsedFile::new(ast, path.to_path_buf());
72 self.cache.insert(path.to_path_buf(), parsed_file);
73 if verbose {
74 println!("✅ Successfully parsed: {}", path.display());
75 }
76 }
77 Err(e) => {
78 eprintln!("❌ Failed to parse {}: {}", path.display(), e);
79 }
81 }
82 }
83 }
84
85 if verbose {
86 println!("📊 Cached {} Rust files", self.cache.len());
87 }
88 Ok(())
89 }
90
91 pub fn get(&self, path: &PathBuf) -> Option<&ParsedFile> {
93 self.cache.get(path)
94 }
95
96 pub fn get_cloned(&self, path: &PathBuf) -> Option<ParsedFile> {
98 self.cache.get(path).cloned()
99 }
100
101 pub fn keys(&self) -> std::collections::hash_map::Keys<'_, PathBuf, ParsedFile> {
103 self.cache.keys()
104 }
105
106 pub fn iter(&self) -> std::collections::hash_map::Iter<'_, PathBuf, ParsedFile> {
108 self.cache.iter()
109 }
110
111 pub fn contains(&self, path: &PathBuf) -> bool {
113 self.cache.contains_key(path)
114 }
115
116 pub fn len(&self) -> usize {
118 self.cache.len()
119 }
120
121 pub fn is_empty(&self) -> bool {
123 self.cache.is_empty()
124 }
125
126 pub fn clear(&mut self) {
128 self.cache.clear();
129 }
130
131 pub fn insert(&mut self, path: PathBuf, parsed_file: ParsedFile) -> Option<ParsedFile> {
133 self.cache.insert(path, parsed_file)
134 }
135
136 pub fn parse_and_cache_file(
138 &mut self,
139 file_path: &std::path::Path,
140 ) -> Result<(), Box<dyn std::error::Error>> {
141 let content = std::fs::read_to_string(file_path)?;
142 let ast = syn::parse_file(&content)?;
143 let parsed_file = ParsedFile::new(ast, file_path.to_path_buf());
144 self.cache.insert(file_path.to_path_buf(), parsed_file);
145 Ok(())
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use std::fs;
153 use std::io::Write;
154 use std::sync::atomic::{AtomicU64, Ordering};
155 use std::sync::{Arc, Mutex};
156 use std::thread;
157
158 static TEMP_DIR_COUNTER: AtomicU64 = AtomicU64::new(0);
159
160 fn temp_dir() -> String {
161 use std::time::{SystemTime, UNIX_EPOCH};
162 let timestamp = SystemTime::now()
163 .duration_since(UNIX_EPOCH)
164 .unwrap()
165 .as_nanos();
166 let counter = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
167 format!(
168 "./test_ast_cache_{}_{}_{}",
169 std::process::id(),
170 timestamp,
171 counter
172 )
173 }
174
175 fn cleanup_dir(dir: impl AsRef<Path>) {
176 let _ = fs::remove_dir_all(dir.as_ref());
177 }
178
179 fn create_rust_file(dir: impl AsRef<Path>, name: &str, content: &str) -> PathBuf {
180 let path = dir.as_ref().join(name);
181 if let Some(parent) = path.parent() {
182 fs::create_dir_all(parent).unwrap();
183 }
184 let mut file = fs::File::create(&path).unwrap();
185 file.write_all(content.as_bytes()).unwrap();
186 path
187 }
188
189 #[test]
190 fn test_temp_dir_helper_is_unique_under_concurrency() {
191 let seen = Arc::new(Mutex::new(std::collections::HashSet::new()));
192 let mut handles = Vec::new();
193
194 for _ in 0..32 {
195 let seen = Arc::clone(&seen);
196 handles.push(thread::spawn(move || {
197 for _ in 0..1000 {
198 let dir = temp_dir();
199 let mut guard = seen.lock().unwrap();
200 assert!(
201 guard.insert(dir),
202 "temp_dir helper returned a duplicate path"
203 );
204 }
205 }));
206 }
207
208 for handle in handles {
209 handle.join().unwrap();
210 }
211 }
212
213 mod parsed_file {
214 use super::*;
215
216 #[test]
217 fn test_new_creates_parsed_file() {
218 let ast: SynFile = syn::parse_str("fn main() {}").unwrap();
219 let path = PathBuf::from("test.rs");
220 let parsed = ParsedFile::new(ast, path.clone());
221 assert_eq!(parsed.path, path);
222 }
223
224 #[test]
225 fn test_clone_works() {
226 let ast: SynFile = syn::parse_str("fn main() {}").unwrap();
227 let path = PathBuf::from("test.rs");
228 let parsed1 = ParsedFile::new(ast, path.clone());
229 let parsed2 = parsed1.clone();
230 assert_eq!(parsed1.path, parsed2.path);
231 }
232 }
233
234 mod initialization {
235 use super::*;
236
237 #[test]
238 fn test_new_creates_empty_cache() {
239 let cache = AstCache::new();
240 assert!(cache.is_empty());
241 assert_eq!(cache.len(), 0);
242 }
243
244 #[test]
245 fn test_default_creates_empty_cache() {
246 let cache = AstCache::default();
247 assert!(cache.is_empty());
248 }
249 }
250
251 mod single_file_operations {
252 use super::*;
253
254 #[test]
255 fn test_parse_and_cache_single_file() {
256 let dir = temp_dir();
257 let path = create_rust_file(&dir, "test.rs", "fn main() {}");
258
259 let mut cache = AstCache::new();
260 let result = cache.parse_and_cache_file(&path);
261 assert!(result.is_ok());
262 assert_eq!(cache.len(), 1);
263 assert!(cache.contains(&path));
264 cleanup_dir(&dir);
265 }
266
267 #[test]
268 fn test_parse_invalid_syntax_errors() {
269 let dir = temp_dir();
270 let path = create_rust_file(&dir, "invalid.rs", "fn main( {");
271
272 let mut cache = AstCache::new();
273 let result = cache.parse_and_cache_file(&path);
274 assert!(result.is_err());
275 assert_eq!(cache.len(), 0);
276 cleanup_dir(&dir);
277 }
278
279 #[test]
280 fn test_parse_nonexistent_file_errors() {
281 let mut cache = AstCache::new();
282 let path = PathBuf::from("nonexistent.rs");
283 let result = cache.parse_and_cache_file(&path);
284 assert!(result.is_err());
285 }
286 }
287
288 mod multi_file_operations {
289 use super::*;
290
291 #[test]
292 fn test_parse_and_cache_all_files() {
293 let dir = temp_dir();
294
295 create_rust_file(&dir, "lib.rs", "pub fn hello() {}");
296 create_rust_file(&dir, "main.rs", "fn main() {}");
297 create_rust_file(&dir, "mod/types.rs", "struct User {}");
298
299 let mut cache = AstCache::new();
300 let result = cache.parse_and_cache_all_files(&dir, false);
301 assert!(result.is_ok());
302 assert_eq!(cache.len(), 3);
303 cleanup_dir(&dir);
304 }
305
306 #[test]
307 fn test_parse_skips_target_directory() {
308 let dir = temp_dir();
309 fs::create_dir_all(Path::new(&dir).join("target")).unwrap();
310
311 create_rust_file(&dir, "lib.rs", "pub fn hello() {}");
312 create_rust_file(&dir, "target/debug.rs", "fn debug() {}");
313
314 let mut cache = AstCache::new();
315 cache.parse_and_cache_all_files(&dir, false).unwrap();
316
317 assert_eq!(cache.len(), 1);
319 cleanup_dir(&dir);
320 }
321
322 #[test]
323 fn test_parse_skips_git_directory() {
324 let dir = temp_dir();
325 fs::create_dir_all(Path::new(&dir).join(".git")).unwrap();
326
327 create_rust_file(&dir, "lib.rs", "pub fn hello() {}");
328 create_rust_file(&dir, ".git/hooks.rs", "fn hook() {}");
329
330 let mut cache = AstCache::new();
331 cache.parse_and_cache_all_files(&dir, false).unwrap();
332
333 assert_eq!(cache.len(), 1);
334 cleanup_dir(&dir);
335 }
336
337 #[test]
338 fn test_parse_continues_on_syntax_error() {
339 let dir = temp_dir();
340
341 create_rust_file(&dir, "valid.rs", "fn main() {}");
342 create_rust_file(&dir, "invalid.rs", "fn main( {");
343 create_rust_file(&dir, "valid2.rs", "struct User {}");
344
345 let mut cache = AstCache::new();
346 let result = cache.parse_and_cache_all_files(&dir, false);
347 assert!(result.is_ok());
348 assert_eq!(cache.len(), 2);
350 cleanup_dir(&dir);
351 }
352
353 #[test]
354 fn test_parse_with_verbose_output() {
355 let dir = temp_dir();
356 create_rust_file(&dir, "lib.rs", "pub fn hello() {}");
357
358 let mut cache = AstCache::new();
359 let result = cache.parse_and_cache_all_files(&dir, true);
361 assert!(result.is_ok());
362 cleanup_dir(&dir);
363 }
364 }
365
366 mod cache_operations {
367 use super::*;
368
369 #[test]
370 fn test_get_returns_reference() {
371 let mut cache = AstCache::new();
372 let ast: SynFile = syn::parse_str("fn main() {}").unwrap();
373 let path = PathBuf::from("test.rs");
374 let parsed = ParsedFile::new(ast, path.clone());
375 cache.insert(path.clone(), parsed);
376
377 let result = cache.get(&path);
378 assert!(result.is_some());
379 assert_eq!(result.unwrap().path, path);
380 }
381
382 #[test]
383 fn test_get_returns_none_for_missing() {
384 let cache = AstCache::new();
385 let path = PathBuf::from("missing.rs");
386 assert!(cache.get(&path).is_none());
387 }
388
389 #[test]
390 fn test_get_cloned_returns_owned() {
391 let mut cache = AstCache::new();
392 let ast: SynFile = syn::parse_str("fn main() {}").unwrap();
393 let path = PathBuf::from("test.rs");
394 let parsed = ParsedFile::new(ast, path.clone());
395 cache.insert(path.clone(), parsed);
396
397 let result = cache.get_cloned(&path);
398 assert!(result.is_some());
399 assert_eq!(result.unwrap().path, path);
400 }
401
402 #[test]
403 fn test_contains_checks_presence() {
404 let mut cache = AstCache::new();
405 let ast: SynFile = syn::parse_str("fn main() {}").unwrap();
406 let path = PathBuf::from("test.rs");
407 let parsed = ParsedFile::new(ast, path.clone());
408 cache.insert(path.clone(), parsed);
409
410 assert!(cache.contains(&path));
411 assert!(!cache.contains(&PathBuf::from("other.rs")));
412 }
413
414 #[test]
415 fn test_len_returns_count() {
416 let mut cache = AstCache::new();
417 assert_eq!(cache.len(), 0);
418
419 let ast: SynFile = syn::parse_str("fn main() {}").unwrap();
420 cache.insert(
421 PathBuf::from("test.rs"),
422 ParsedFile::new(ast, PathBuf::from("test.rs")),
423 );
424 assert_eq!(cache.len(), 1);
425 }
426
427 #[test]
428 fn test_is_empty_checks_emptiness() {
429 let mut cache = AstCache::new();
430 assert!(cache.is_empty());
431
432 let ast: SynFile = syn::parse_str("fn main() {}").unwrap();
433 cache.insert(
434 PathBuf::from("test.rs"),
435 ParsedFile::new(ast, PathBuf::from("test.rs")),
436 );
437 assert!(!cache.is_empty());
438 }
439
440 #[test]
441 fn test_clear_empties_cache() {
442 let mut cache = AstCache::new();
443 let ast: SynFile = syn::parse_str("fn main() {}").unwrap();
444 cache.insert(
445 PathBuf::from("test.rs"),
446 ParsedFile::new(ast, PathBuf::from("test.rs")),
447 );
448
449 assert!(!cache.is_empty());
450 cache.clear();
451 assert!(cache.is_empty());
452 }
453
454 #[test]
455 fn test_insert_returns_old_value() {
456 let mut cache = AstCache::new();
457 let ast1: SynFile = syn::parse_str("fn main() {}").unwrap();
458 let ast2: SynFile = syn::parse_str("fn test() {}").unwrap();
459 let path = PathBuf::from("test.rs");
460
461 let old = cache.insert(path.clone(), ParsedFile::new(ast1, path.clone()));
462 assert!(old.is_none());
463
464 let old = cache.insert(path.clone(), ParsedFile::new(ast2, path.clone()));
465 assert!(old.is_some());
466 }
467
468 #[test]
469 fn test_keys_returns_iterator() {
470 let mut cache = AstCache::new();
471 let ast: SynFile = syn::parse_str("fn main() {}").unwrap();
472 let path1 = PathBuf::from("test1.rs");
473 let path2 = PathBuf::from("test2.rs");
474
475 cache.insert(path1.clone(), ParsedFile::new(ast.clone(), path1.clone()));
476 cache.insert(path2.clone(), ParsedFile::new(ast.clone(), path2.clone()));
477
478 let keys: Vec<_> = cache.keys().collect();
479 assert_eq!(keys.len(), 2);
480 }
481
482 #[test]
483 fn test_iter_returns_iterator() {
484 let mut cache = AstCache::new();
485 let ast: SynFile = syn::parse_str("fn main() {}").unwrap();
486 let path = PathBuf::from("test.rs");
487 cache.insert(path.clone(), ParsedFile::new(ast, path.clone()));
488
489 let count = cache.iter().count();
490 assert_eq!(count, 1);
491 }
492 }
493
494 mod edge_cases {
495 use super::*;
496
497 #[test]
498 fn test_empty_directory() {
499 let dir = temp_dir();
500 fs::create_dir_all(&dir).unwrap();
501
502 let mut cache = AstCache::new();
503 let result = cache.parse_and_cache_all_files(&dir, false);
504 assert!(result.is_ok());
505 assert_eq!(cache.len(), 0);
506 cleanup_dir(&dir);
507 }
508
509 #[test]
510 fn test_directory_with_only_non_rust_files() {
511 let dir = temp_dir();
512 create_rust_file(&dir, "readme.txt", "Hello");
513 create_rust_file(&dir, "config.json", "{}");
514
515 let mut cache = AstCache::new();
516 cache.parse_and_cache_all_files(&dir, false).unwrap();
517 assert_eq!(cache.len(), 0);
518 cleanup_dir(&dir);
519 }
520
521 #[test]
522 fn test_parse_empty_rust_file() {
523 let dir = temp_dir();
524 let path = create_rust_file(&dir, "empty.rs", "");
525
526 let mut cache = AstCache::new();
527 let result = cache.parse_and_cache_file(&path);
528 assert!(result.is_ok());
529 assert_eq!(cache.len(), 1);
530 cleanup_dir(&dir);
531 }
532
533 #[test]
534 fn test_cache_same_file_twice() {
535 let dir = temp_dir();
536 let path = create_rust_file(&dir, "test.rs", "fn main() {}");
537
538 let mut cache = AstCache::new();
539 cache.parse_and_cache_file(&path).unwrap();
540 cache.parse_and_cache_file(&path).unwrap();
541
542 assert_eq!(cache.len(), 1);
544 cleanup_dir(&dir);
545 }
546 }
547}