Skip to main content

tauri_typegen/analysis/
ast_cache.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use syn::File as SynFile;
4use walkdir::WalkDir;
5
6/// Cache entry for a parsed Rust file
7#[derive(Debug, Clone)]
8pub struct ParsedFile {
9    /// The parsed AST
10    pub ast: SynFile,
11    /// File path for reference
12    pub path: PathBuf,
13    // Last modified time for cache invalidation (if needed later)
14    // modified: std::time::SystemTime,
15}
16
17impl ParsedFile {
18    pub fn new(ast: SynFile, path: PathBuf) -> Self {
19        Self { ast, path }
20    }
21}
22
23/// AST cache for parsed Rust files
24#[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    /// Parse and cache all Rust files in the given project path
46    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                        // Continue processing other files even if one fails
80                    }
81                }
82            }
83        }
84
85        if verbose {
86            println!("📊 Cached {} Rust files", self.cache.len());
87        }
88        Ok(())
89    }
90
91    /// Get a parsed file from the cache
92    pub fn get(&self, path: &PathBuf) -> Option<&ParsedFile> {
93        self.cache.get(path)
94    }
95
96    /// Get a cloned parsed file from the cache
97    pub fn get_cloned(&self, path: &PathBuf) -> Option<ParsedFile> {
98        self.cache.get(path).cloned()
99    }
100
101    /// Get all cached file paths
102    pub fn keys(&self) -> std::collections::hash_map::Keys<'_, PathBuf, ParsedFile> {
103        self.cache.keys()
104    }
105
106    /// Get all cached files as an iterator
107    pub fn iter(&self) -> std::collections::hash_map::Iter<'_, PathBuf, ParsedFile> {
108        self.cache.iter()
109    }
110
111    /// Check if a file is cached
112    pub fn contains(&self, path: &PathBuf) -> bool {
113        self.cache.contains_key(path)
114    }
115
116    /// Get the number of cached files
117    pub fn len(&self) -> usize {
118        self.cache.len()
119    }
120
121    /// Check if the cache is empty
122    pub fn is_empty(&self) -> bool {
123        self.cache.is_empty()
124    }
125
126    /// Clear the cache
127    pub fn clear(&mut self) {
128        self.cache.clear();
129    }
130
131    /// Insert a parsed file into the cache
132    pub fn insert(&mut self, path: PathBuf, parsed_file: ParsedFile) -> Option<ParsedFile> {
133        self.cache.insert(path, parsed_file)
134    }
135
136    /// Parse a single file and add it to the cache
137    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            // Should only have lib.rs, not target/debug.rs
318            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            // Should have 2 valid files, skip the invalid one
349            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            // Just verify it doesn't panic with verbose=true
360            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            // Should still be 1 (overwritten)
543            assert_eq!(cache.len(), 1);
544            cleanup_dir(&dir);
545        }
546    }
547}