Skip to main content

prax_schema/loader/
discovery.rs

1//! Recursive `*.prax` discovery for multi-file schema directories.
2
3use std::path::{Path, PathBuf};
4
5use walkdir::WalkDir;
6
7use crate::error::{SchemaError, SchemaResult};
8
9/// A discovered `*.prax` file with its absolute and relative paths.
10#[derive(Debug, Clone)]
11pub struct Discovered {
12    /// Absolute path on disk.
13    pub absolute: PathBuf,
14    /// Path relative to the discovery root (used for sort order + emit mirroring).
15    pub relative: PathBuf,
16}
17
18/// Recursively find all `*.prax` files under `root`, sorted lexicographically
19/// by the relative path.
20///
21/// Skipped:
22/// - Hidden entries (filename starts with `.`)
23/// - Symlinks (not followed)
24/// - Any directory named `target`
25pub fn discover(root: impl AsRef<Path>) -> SchemaResult<Vec<Discovered>> {
26    let root = root.as_ref();
27    let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
28
29    let mut out = Vec::new();
30    for entry in WalkDir::new(&canonical_root)
31        .follow_links(false)
32        .into_iter()
33        .filter_entry(|e| !is_skipped(e))
34    {
35        let entry = entry.map_err(|e| SchemaError::IoError {
36            path: e
37                .path()
38                .map(|p| p.display().to_string())
39                .unwrap_or_default(),
40            source: e
41                .into_io_error()
42                .unwrap_or_else(|| std::io::Error::other("walkdir error")),
43        })?;
44
45        if !entry.file_type().is_file() {
46            continue;
47        }
48        if entry.path().extension().and_then(|s| s.to_str()) != Some("prax") {
49            continue;
50        }
51
52        let relative = entry
53            .path()
54            .strip_prefix(&canonical_root)
55            .unwrap_or(entry.path())
56            .to_path_buf();
57
58        out.push(Discovered {
59            absolute: entry.path().to_path_buf(),
60            relative,
61        });
62    }
63
64    out.sort_by(|a, b| a.relative.cmp(&b.relative));
65    Ok(out)
66}
67
68fn is_skipped(entry: &walkdir::DirEntry) -> bool {
69    // Always allow the root itself.
70    if entry.depth() == 0 {
71        return false;
72    }
73    if let Some(name) = entry.file_name().to_str() {
74        if name.starts_with('.') {
75            return true;
76        }
77        if entry.file_type().is_dir() && name == "target" {
78            return true;
79        }
80    }
81    if entry.file_type().is_symlink() {
82        return true;
83    }
84    false
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use std::fs;
91    use tempfile::tempdir;
92
93    fn write(dir: &Path, name: &str, content: &str) {
94        let p = dir.join(name);
95        if let Some(parent) = p.parent() {
96            fs::create_dir_all(parent).unwrap();
97        }
98        fs::write(p, content).unwrap();
99    }
100
101    #[test]
102    fn flat_directory_returns_sorted_prax_files() {
103        let dir = tempdir().unwrap();
104        write(dir.path(), "b.prax", "// b");
105        write(dir.path(), "a.prax", "// a");
106        write(dir.path(), "c.prax", "// c");
107
108        let found = discover(dir.path()).unwrap();
109        let names: Vec<_> = found.iter().map(|d| d.relative.to_str().unwrap()).collect();
110        assert_eq!(names, vec!["a.prax", "b.prax", "c.prax"]);
111    }
112
113    #[test]
114    fn recursive_descent_finds_nested_files() {
115        let dir = tempdir().unwrap();
116        write(dir.path(), "schema.prax", "// root");
117        write(dir.path(), "models/user.prax", "model U {}");
118        write(dir.path(), "models/post.prax", "model P {}");
119        write(dir.path(), "enums/role.prax", "enum R {}");
120
121        let found = discover(dir.path()).unwrap();
122        assert_eq!(found.len(), 4);
123    }
124
125    #[test]
126    fn hidden_dirs_are_skipped() {
127        let dir = tempdir().unwrap();
128        write(dir.path(), "ok.prax", "// ok");
129        write(dir.path(), ".git/HEAD", "// not prax");
130        write(dir.path(), ".cache/bad.prax", "// skipped");
131
132        let found = discover(dir.path()).unwrap();
133        assert_eq!(found.len(), 1);
134        assert_eq!(found[0].relative.to_str().unwrap(), "ok.prax");
135    }
136
137    #[test]
138    fn target_directory_is_skipped() {
139        let dir = tempdir().unwrap();
140        write(dir.path(), "ok.prax", "// ok");
141        write(dir.path(), "target/build.prax", "// skipped");
142
143        let found = discover(dir.path()).unwrap();
144        assert_eq!(found.len(), 1);
145    }
146
147    #[test]
148    fn non_prax_files_ignored() {
149        let dir = tempdir().unwrap();
150        write(dir.path(), "ok.prax", "// ok");
151        write(dir.path(), "README.md", "# readme");
152        write(dir.path(), "schema.prisma", "// wrong ext");
153
154        let found = discover(dir.path()).unwrap();
155        assert_eq!(found.len(), 1);
156    }
157}