1use std::collections::BTreeMap;
36use std::path::{Path, PathBuf};
37
38use crate::resolve::{ModuleImports, collect_module_imports};
39use crate::types::TypePath;
40
41#[derive(Debug)]
43pub enum ScanError {
44 Io {
46 path: PathBuf,
48 message: String,
50 },
51 Parse {
53 path: PathBuf,
55 message: String,
57 },
58}
59
60impl std::fmt::Display for ScanError {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 match self {
63 Self::Io { path, message } => write!(f, "I/O error reading `{}`: {message}", path.display()),
64 Self::Parse { path, message } => write!(f, "syn parse error in `{}`: {message}", path.display()),
65 }
66 }
67}
68
69impl std::error::Error for ScanError {}
70
71pub const LOCAL_CRATE_ROOT: &str = "crate";
81
82pub fn scan_src_dir(src_dir: &Path) -> Result<BTreeMap<TypePath, syn::Item>, ScanError> {
91 scan_src_dir_with_imports(src_dir).map(|(pool, _imports)| pool)
92}
93
94pub fn scan_src_dir_with_imports(src_dir: &Path) -> Result<(BTreeMap<TypePath, syn::Item>, ModuleImports), ScanError> {
105 scan_crate_root_with_imports(src_dir, LOCAL_CRATE_ROOT)
106}
107
108pub fn scan_crate_root_with_imports(
117 src_dir: &Path,
118 crate_root: &str,
119) -> Result<(BTreeMap<TypePath, syn::Item>, ModuleImports), ScanError> {
120 let mut pool = BTreeMap::new();
121 let mut imports = ModuleImports::default();
122 let root = [crate_root.to_string()];
123 scan_dir_recursive(src_dir, &root, &mut pool, &mut imports)?;
124 Ok((pool, imports))
125}
126
127fn scan_dir_recursive(
134 dir: &Path,
135 module_prefix: &[String],
136 pool: &mut BTreeMap<TypePath, syn::Item>,
137 imports: &mut ModuleImports,
138) -> Result<(), ScanError> {
139 let entries =
140 std::fs::read_dir(dir).map_err(|e| ScanError::Io { path: dir.to_path_buf(), message: e.to_string() })?;
141
142 let mut sorted: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
145 sorted.sort();
146
147 for path in sorted {
148 let file_name = match path.file_name().and_then(|s| s.to_str()) {
149 Some(name) => name.to_string(),
150 None => continue,
151 };
152
153 if path.is_dir() {
154 let mut next_prefix = module_prefix.to_vec();
159 next_prefix.push(file_name);
160 scan_dir_recursive(&path, &next_prefix, pool, imports)?;
161 continue;
162 }
163
164 if !file_name.ends_with(".rs") {
166 continue;
167 }
168
169 if file_name == "build.rs" {
171 continue;
172 }
173
174 let file_prefix: Vec<String> = if matches!(file_name.as_str(), "lib.rs" | "main.rs" | "mod.rs") {
178 module_prefix.to_vec()
179 } else {
180 let stem = file_name.trim_end_matches(".rs");
182 let mut p = module_prefix.to_vec();
183 p.push(stem.to_string());
184 p
185 };
186
187 let src =
188 std::fs::read_to_string(&path).map_err(|e| ScanError::Io { path: path.clone(), message: e.to_string() })?;
189 let parsed: syn::File =
190 syn::parse_file(&src).map_err(|e| ScanError::Parse { path: path.clone(), message: e.to_string() })?;
191
192 collect_items(&parsed.items, &file_prefix, pool);
193 collect_module_imports(&parsed, &file_prefix, imports);
194 }
195
196 Ok(())
197}
198
199fn collect_items(items: &[syn::Item], module_prefix: &[String], pool: &mut BTreeMap<TypePath, syn::Item>) {
202 for item in items {
203 match item {
204 syn::Item::Struct(s) => insert(pool, module_prefix, &s.ident, item.clone()),
205 syn::Item::Enum(e) => insert(pool, module_prefix, &e.ident, item.clone()),
206 syn::Item::Type(t) => insert(pool, module_prefix, &t.ident, item.clone()),
207 syn::Item::Mod(m) => {
208 if let Some((_, inner_items)) = &m.content {
209 let mut sub_prefix = module_prefix.to_vec();
210 sub_prefix.push(m.ident.to_string());
211 collect_items(inner_items, &sub_prefix, pool);
212 }
213 }
217 _ => {} }
219 }
220}
221
222fn insert(pool: &mut BTreeMap<TypePath, syn::Item>, prefix: &[String], ident: &syn::Ident, item: syn::Item) {
223 let mut segments = prefix.to_vec();
224 segments.push(ident.to_string());
225 if let Ok(path) = TypePath::new(segments) {
226 pool.insert(path, item);
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use std::fs;
234
235 fn make_tempdir(files: &[(&str, &str)]) -> tempfile::TempDir {
239 let dir = tempfile::tempdir().expect("tempdir");
240 for (rel, content) in files {
241 let abs = dir.path().join(rel);
242 if let Some(parent) = abs.parent() {
243 fs::create_dir_all(parent).expect("create parent");
244 }
245 fs::write(&abs, content).expect("write file");
246 }
247 dir
248 }
249
250 fn tp(segments: &[&str]) -> TypePath {
253 let mut all = vec![LOCAL_CRATE_ROOT.to_string()];
254 all.extend(segments.iter().map(|s| (*s).to_string()));
255 TypePath::new(all).expect("non-empty")
256 }
257
258 fn rooted(segments: &[&str]) -> TypePath {
260 TypePath::new(segments.iter().map(|s| (*s).to_string()).collect()).expect("non-empty")
261 }
262
263 #[test]
264 fn keys_are_rooted_at_the_local_crate() {
265 let dir = make_tempdir(&[("lib.rs", ""), ("models.rs", "pub struct Workout { pub id: u64 }")]);
268 let pool = scan_src_dir(dir.path()).unwrap();
269 assert!(
270 pool.contains_key(&rooted(&["crate", "models", "Workout"])),
271 "pool keys: {:?}",
272 pool.keys().collect::<Vec<_>>()
273 );
274 }
275
276 #[test]
277 fn extra_root_keys_are_rooted_at_their_package_name() {
278 let dir = make_tempdir(&[("lint/mod.rs", "pub enum Severity { Error, Warning }")]);
282 let (pool, imports) = scan_crate_root_with_imports(dir.path(), "vaultpolish_core").unwrap();
283 assert!(
284 pool.contains_key(&rooted(&["vaultpolish_core", "lint", "Severity"])),
285 "pool keys: {:?}",
286 pool.keys().collect::<Vec<_>>()
287 );
288 assert!(
291 imports.get(&["vaultpolish_core".to_string(), "lint".to_string()]).is_some(),
292 "imports table must be rooted the same way as the pool"
293 );
294 }
295
296 #[test]
297 fn a_local_and_a_sibling_type_no_longer_share_a_key() {
298 let local = make_tempdir(&[("lint/mod.rs", "pub enum Severity { Error }")]);
301 let sibling = make_tempdir(&[("lint/mod.rs", "pub enum Severity { Error, Warning, Info }")]);
302 let local_pool = scan_src_dir(local.path()).unwrap();
303 let (sibling_pool, _) = scan_crate_root_with_imports(sibling.path(), "vaultpolish_core").unwrap();
304
305 let mut merged = local_pool;
306 for (key, item) in sibling_pool {
307 merged.entry(key).or_insert(item);
308 }
309 assert_eq!(merged.len(), 2, "both definitions survive the merge: {:?}", merged.keys().collect::<Vec<_>>());
310 }
311
312 #[test]
313 fn scans_lib_rs_top_level_struct() {
314 let dir = make_tempdir(&[("lib.rs", "pub struct Foo { pub bar: u32 }")]);
315 let pool = scan_src_dir(dir.path()).unwrap();
316 assert_eq!(pool.len(), 1);
317 assert!(pool.contains_key(&tp(&["Foo"])));
318 match pool.get(&tp(&["Foo"])).unwrap() {
320 syn::Item::Struct(s) => assert_eq!(s.ident.to_string(), "Foo"),
321 other => panic!("expected ItemStruct, got {other:?}"),
322 }
323 }
324
325 #[test]
326 fn scans_module_file_paths() {
327 let dir = make_tempdir(&[("lib.rs", ""), ("models.rs", "pub struct Workout { pub id: u64 }")]);
328 let pool = scan_src_dir(dir.path()).unwrap();
329 assert!(pool.contains_key(&tp(&["models", "Workout"])));
330 }
331
332 #[test]
333 fn scans_nested_directory_paths() {
334 let dir = make_tempdir(&[
335 ("lib.rs", "pub mod outer;"),
336 ("outer/mod.rs", "pub mod inner;"),
337 ("outer/inner.rs", "pub enum Status { Live, Dead }"),
338 ]);
339 let pool = scan_src_dir(dir.path()).unwrap();
340 assert!(
341 pool.contains_key(&tp(&["outer", "inner", "Status"])),
342 "pool keys: {:?}",
343 pool.keys().collect::<Vec<_>>()
344 );
345 }
346
347 #[test]
348 fn collects_all_three_item_kinds() {
349 let dir = make_tempdir(&[(
350 "lib.rs",
351 r#"
352 pub struct S { pub x: u32 }
353 pub enum E { A, B }
354 pub type T = u32;
355 "#,
356 )]);
357 let pool = scan_src_dir(dir.path()).unwrap();
358 assert!(pool.contains_key(&tp(&["S"])));
359 assert!(pool.contains_key(&tp(&["E"])));
360 assert!(pool.contains_key(&tp(&["T"])));
361 }
362
363 #[test]
364 fn ignores_functions_and_impls() {
365 let dir = make_tempdir(&[(
366 "lib.rs",
367 r#"
368 pub struct S { pub x: u32 }
369 pub fn unrelated() {}
370 impl S {
371 pub fn method(&self) {}
372 }
373 "#,
374 )]);
375 let pool = scan_src_dir(dir.path()).unwrap();
376 assert_eq!(pool.len(), 1);
377 assert!(pool.contains_key(&tp(&["S"])));
378 }
379
380 #[test]
381 fn collects_pub_crate_types() {
382 let dir = make_tempdir(&[(
385 "lib.rs",
386 r#"
387 pub(crate) struct Internal { pub x: u32 }
388 "#,
389 )]);
390 let pool = scan_src_dir(dir.path()).unwrap();
391 assert!(pool.contains_key(&tp(&["Internal"])));
392 }
393
394 #[test]
395 fn collects_inline_module_blocks() {
396 let dir = make_tempdir(&[(
397 "lib.rs",
398 r#"
399 pub mod nested {
400 pub struct Inner { pub x: u32 }
401 pub enum Sub { A }
402 }
403 "#,
404 )]);
405 let pool = scan_src_dir(dir.path()).unwrap();
406 assert!(pool.contains_key(&tp(&["nested", "Inner"])));
407 assert!(pool.contains_key(&tp(&["nested", "Sub"])));
408 }
409
410 #[test]
411 fn parse_error_surfaces_with_path() {
412 let dir = make_tempdir(&[("lib.rs", "pub struct Broken { this is not valid rust")]);
413 let err = scan_src_dir(dir.path()).unwrap_err();
414 match err {
415 ScanError::Parse { path, .. } => {
416 assert!(path.to_string_lossy().ends_with("lib.rs"));
417 }
418 other => panic!("expected Parse error, got {other:?}"),
419 }
420 }
421
422 #[test]
423 fn missing_directory_yields_io_error() {
424 let dir = make_tempdir(&[]);
425 let phantom = dir.path().join("does_not_exist");
426 let err = scan_src_dir(&phantom).unwrap_err();
427 assert!(matches!(err, ScanError::Io { .. }));
428 }
429
430 #[test]
431 fn skips_non_rust_files() {
432 let dir = make_tempdir(&[
433 ("lib.rs", "pub struct S { pub x: u32 }"),
434 ("README.md", "# unrelated"),
435 ("data.json", "{}"),
436 ]);
437 let pool = scan_src_dir(dir.path()).unwrap();
438 assert_eq!(pool.len(), 1);
439 }
440
441 #[test]
442 fn deterministic_ordering_via_btreemap() {
443 let files: &[(&str, &str)] =
447 &[("lib.rs", ""), ("z.rs", "pub struct Zee;"), ("a.rs", "pub struct Aye;"), ("m.rs", "pub struct Em;")];
448 let dir1 = make_tempdir(files);
449 let dir2 = make_tempdir(files);
450 let pool1 = scan_src_dir(dir1.path()).unwrap();
451 let pool2 = scan_src_dir(dir2.path()).unwrap();
452 let keys1: Vec<_> = pool1.keys().collect();
453 let keys2: Vec<_> = pool2.keys().collect();
454 assert_eq!(keys1, keys2);
455 let names: Vec<&str> = keys1.iter().map(|p| p.terminal()).collect();
457 assert_eq!(names, vec!["Aye", "Em", "Zee"]);
460 }
461}