Skip to main content

ontogen_ts/
pool.rs

1//! Type-pool walker — scans a user crate's `src/` for module-level structs,
2//! enums, and type aliases, keys them by canonical [`TypePath`], and returns
3//! the populated pool.
4//!
5//! Phase-1 rules (matching the OF-015 design pass):
6//!
7//! - Walk `src/` recursively. `examples/`, `benches/`, `tests/`, and
8//!   `build.rs` are out of scope — those don't ship wire code.
9//! - Parse each `.rs` via `syn::parse_file`. The result is raw AST without
10//!   cfg-eval; cfg-gated types live in the pool like any other.
11//! - Collect every `ItemStruct` / `ItemEnum` / `ItemType` at module level,
12//!   regardless of visibility (`pub(crate)` types reachable from a `pub`
13//!   API still flow over the wire).
14//! - Function-local and impl-block-nested types are excluded — they can't
15//!   appear as plain return-type idents in a public API signature.
16//! - Inline `mod foo { ... }` blocks are walked recursively, contributing
17//!   their module name to each contained item's canonical path.
18//!
19//! Path derivation — every key begins with the root the tree was scanned as
20//! ([`LOCAL_CRATE_ROOT`] for the consuming crate, the package name for a
21//! `pool_extra_roots` sibling):
22//!
23//! - `src/lib.rs` items → path `["crate", "ItemName"]`
24//! - `src/foo.rs` items → path `["crate", "foo", "ItemName"]`
25//! - `src/foo/mod.rs` items → path `["crate", "foo", "ItemName"]`
26//! - `src/foo/bar.rs` items → path `["crate", "foo", "bar", "ItemName"]`
27//! - Inline `mod baz { pub struct Q; }` inside `src/foo.rs` → `["crate", "foo", "baz", "Q"]`
28//!
29//! Naming the root in the key is what keeps a workspace sibling's types
30//! distinguishable from the consuming crate's own once the two pools are
31//! merged. Before this, both trees were keyed relative to their own `src/`,
32//! so a sibling's `lint::Severity` and a local `lint::Severity` produced the
33//! same key and one silently displaced the other.
34
35use std::collections::BTreeMap;
36use std::path::{Path, PathBuf};
37
38use crate::resolve::{ModuleImports, collect_module_imports};
39use crate::types::TypePath;
40
41/// Failure modes for [`scan_src_dir`].
42#[derive(Debug)]
43pub enum ScanError {
44    /// I/O error reading a file or directory.
45    Io {
46        /// The path the error happened at.
47        path: PathBuf,
48        /// The underlying OS error message.
49        message: String,
50    },
51    /// `syn::parse_file` failed on a `.rs` file.
52    Parse {
53        /// The path of the unparseable file.
54        path: PathBuf,
55        /// The syn parser's error message.
56        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
71/// First key segment for types scanned from the consuming crate's own `src/`.
72///
73/// Every pool key names the root it came from, so a key carries its crate
74/// boundary rather than losing it in a flat merge. The local root uses the
75/// literal `crate`, which is what a user writes in source and — being a
76/// keyword — is a segment no real crate name can ever occupy. Additional
77/// roots merged in via `pool_extra_roots` use their package name, so
78/// `crate::schema::Severity` and `vaultpolish_core::lint::Severity` stay
79/// distinguishable after the merge.
80pub const LOCAL_CRATE_ROOT: &str = "crate";
81
82/// Scan a `src/` directory and collect every module-level struct, enum, and
83/// type-alias into a pool keyed by canonical [`TypePath`], rooted at
84/// [`LOCAL_CRATE_ROOT`].
85///
86/// This discards the per-module `use` tables. Callers that need bare
87/// single-segment references resolved through their defining module's
88/// imports (the dep extractor in `order`) should use
89/// [`scan_src_dir_with_imports`] instead.
90pub 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
94/// Scan a `src/` directory, returning both the type pool and the per-module
95/// [`ModuleImports`] tables built from each module's `use` declarations.
96///
97/// The imports table lets the dependency extractor resolve a bare
98/// single-segment reference (`BackupManifest`) through the actual `use` that
99/// brought it into scope, instead of guessing by terminal segment — which is
100/// ambiguous when two modules define same-named types.
101///
102/// Keys are rooted at [`LOCAL_CRATE_ROOT`]; use [`scan_crate_root_with_imports`]
103/// to scan a workspace sibling under its own package name.
104pub 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
108/// Scan a `src/` directory as the crate named `crate_root`, so every pool key
109/// and every module-imports key begins with that segment.
110///
111/// This is what keeps a workspace sibling's types distinguishable from the
112/// consuming crate's own after the two pools are merged. The pool and the
113/// imports table MUST be rooted identically — a mismatch doesn't fail loudly,
114/// it just makes `ModuleImports::get` miss and silently degrades resolution
115/// to terminal-segment guessing.
116pub 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
127/// Recursive directory walker. `module_prefix` is the canonical path of the
128/// current Rust module, starting at `[crate_root]` for the scanned tree's
129/// own root. Each `.rs` file contributes its items (and items nested inside
130/// `mod` blocks) under that prefix, plus its `use` declarations into
131/// `imports` — under the *same* prefix, which is what lets the resolver look
132/// a referencing module's imports up by its pool key minus the terminal.
133fn 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    // Sort entries for deterministic walking — file system iteration order
143    // isn't guaranteed and we don't want pool key ordering to depend on it.
144    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            // Recurse into the directory, prepending its name to the module
155            // prefix. We skip the recursion if there's no `mod.rs` AND the
156            // directory contains no `.rs` files (defensive; real crates
157            // always have one or the other).
158            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        // Skip anything that's not a `.rs` file.
165        if !file_name.ends_with(".rs") {
166            continue;
167        }
168
169        // Skip the `build.rs` if it somehow lands inside `src/`.
170        if file_name == "build.rs" {
171            continue;
172        }
173
174        // Determine the module prefix this file contributes to. `mod.rs` and
175        // `lib.rs` / `main.rs` don't extend the prefix — they ARE the
176        // current module.
177        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            // `foo.rs` extends the prefix by `foo`.
181            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
199/// Walk a slice of `syn::Item`s, inserting structs / enums / type aliases
200/// into the pool and recursing into inline `mod foo { ... }` blocks.
201fn 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                // Module declarations without inline content (`mod foo;`) are
214                // resolved by the file-system walker — the corresponding
215                // `foo.rs` or `foo/mod.rs` is scanned separately.
216            }
217            _ => {} // ignore fns, impls, statics, consts, use, etc.
218        }
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    /// Build a temporary directory with `files` written into it (each entry
236    /// is `(relative_path, contents)`). Returns a guard that cleans up on
237    /// drop.
238    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    /// A pool key for the local crate — `segments` with [`LOCAL_CRATE_ROOT`]
251    /// prepended, since every key names the root it came from.
252    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    /// A pool key with an explicit root, for extra-root scans.
259    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        // Spelled out rather than going through `tp`, so the key convention
266        // itself is pinned somewhere obvious.
267        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        // The whole point: a sibling's types stay distinguishable from the
279        // consuming crate's after a merge, even when the module path and the
280        // type name both match.
281        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        // Pool and imports must be rooted identically, or `ModuleImports::get`
289        // misses and resolution silently degrades to terminal guessing.
290        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        // Before rooting, both keyed as ["lint", "Severity"] and the merge's
299        // `or_insert` silently dropped one.
300        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        // The stored item is the struct.
319        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        // Visibility doesn't matter — pub(crate) types reachable from a pub
383        // API still flow over the wire.
384        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        // The pool is keyed by BTreeMap so iteration order is the natural
444        // canonical-path order. Two scans of the same tree yield the same
445        // key vector.
446        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        // Plus: explicitly sorted by canonical path.
456        let names: Vec<&str> = keys1.iter().map(|p| p.terminal()).collect();
457        // Note: "Aye" < "Em" < "Zee" but pool key paths are ["a", "Aye"] etc.
458        // — sorted lexicographically by full path.
459        assert_eq!(names, vec!["Aye", "Em", "Zee"]);
460    }
461}