Skip to main content

unity_assetdb/
walk.rs

1//! Parallel walker over a Unity project's `Assets/` tree.
2//!
3//! Uses [`ignore::WalkBuilder`] with gitignore on (default) — the same
4//! mechanism `rg` / `fd` use, so anything the user already gitignores
5//! (Library/, Temp/, build artifacts) is skipped automatically.
6
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, Mutex};
9
10use ignore::WalkBuilder;
11
12/// Errors from walking the Unity project tree.
13#[derive(Debug, thiserror::Error)]
14pub enum WalkError {
15    #[error("get cwd: {0}")]
16    GetCwd(#[source] std::io::Error),
17    #[error("canonicalize project {}: {source}", path.display())]
18    Canonicalize {
19        path: PathBuf,
20        #[source]
21        source: std::io::Error,
22    },
23    #[error("not a Unity project: {} (missing Assets/ or ProjectSettings/)", path.display())]
24    NotProject { path: PathBuf },
25    #[error("no Unity project root found above {}: needs `Assets/` + `ProjectSettings/`", cwd.display())]
26    NoProjectRoot { cwd: PathBuf },
27    #[error("Assets/ not found at {}", path.display())]
28    AssetsMissing { path: PathBuf },
29    #[error("walk error: {0}")]
30    Walk(#[from] ignore::Error),
31}
32
33/// Resolve a Unity project root: arg-given or climb up from CWD until we
34/// find a directory containing both `Assets/` and `ProjectSettings/`.
35pub fn resolve_project_root(arg: Option<&Path>) -> Result<PathBuf, WalkError> {
36    if let Some(p) = arg {
37        let p = p.canonicalize().map_err(|source| WalkError::Canonicalize {
38            path: p.to_path_buf(),
39            source,
40        })?;
41        ensure_project(&p)?;
42        return Ok(p);
43    }
44    let cwd = std::env::current_dir().map_err(WalkError::GetCwd)?;
45    let mut cur: &Path = &cwd;
46    loop {
47        if is_project(cur) {
48            return Ok(cur.to_path_buf());
49        }
50        match cur.parent() {
51            Some(p) => cur = p,
52            None => return Err(WalkError::NoProjectRoot { cwd: cwd.clone() }),
53        }
54    }
55}
56
57fn is_project(p: &Path) -> bool {
58    p.join("Assets").is_dir() && p.join("ProjectSettings").is_dir()
59}
60
61fn ensure_project(p: &Path) -> Result<(), WalkError> {
62    if !is_project(p) {
63        return Err(WalkError::NotProject {
64            path: p.to_path_buf(),
65        });
66    }
67    Ok(())
68}
69
70/// Visit every `.meta` file under `<project>/Assets/` and `<project>/Packages/`,
71/// calling `factory()` once per worker thread to produce a `FnMut(&Path)`
72/// visitor. Per-thread state lives inside the visitor and avoids the contention
73/// of a single `Mutex<Vec<_>>` shared across threads.
74///
75/// Both top-level dirs are walked because Unity treats UPM packages as
76/// first-class asset sources — prefabs/materials/`.mixer` files under
77/// `Packages/` are referenced by `Assets/` content and need to round-trip
78/// through asset-db like any other asset.
79///
80/// Unity-hidden paths are skipped — Unity's importer ignores any folder
81/// or file whose name starts with `.` or ends with `~` (see
82/// <https://docs.unity3d.com/Manual/SpecialFolders.html>). Including them
83/// would surface fake assets (templates, scratch copies) that Unity itself
84/// never sees.
85///
86/// # Panics
87///
88/// Panics if the worker-side `Mutex` guarding the first-error slot is
89/// poisoned by another worker thread panic. Worker visitors aren't
90/// expected to panic in practice — the bake recovers panics into
91/// errors via `run_with_panic_safety`.
92pub fn walk_meta_files<F, V>(project_root: &Path, factory: F) -> Result<(), WalkError>
93where
94    F: Fn() -> V + Sync,
95    V: FnMut(&Path) + Send + 'static,
96{
97    let assets = project_root.join("Assets");
98    if !assets.is_dir() {
99        return Err(WalkError::AssetsMissing { path: assets });
100    }
101    let packages = project_root.join("Packages");
102
103    let mut builder = WalkBuilder::new(&assets);
104    if packages.is_dir() {
105        builder.add(&packages);
106    }
107    // standard_filters(false): gitignore parsing in a Unity project is a
108    // net loss — Library/ + Temp/ + build artifacts live outside Assets/
109    // and Packages/. Inside-Assets `.gitignore` files exist (Zenject
110    // codegen, scratch dirs, SmartLibrary `.asset` exclusions) but Unity
111    // doesn't honor them either — gitignored .meta files still carry
112    // guids that prefabs can reference, so the asset DB must include
113    // them. See [Walker ignore behavior](docs/asset-database.md#populating).
114    // `is_unity_hidden` covers `.foo` and `foo~` per Unity's special-folder rule.
115    let walker = builder
116        .standard_filters(false)
117        .follow_links(false)
118        .filter_entry(|e| !is_unity_hidden(e.file_name()))
119        .build_parallel();
120
121    let err: Arc<Mutex<Option<WalkError>>> = Arc::new(Mutex::new(None));
122
123    walker.run(|| {
124        let mut visit = factory();
125        let err = Arc::clone(&err);
126        Box::new(move |res| {
127            use ignore::WalkState;
128            let entry = match res {
129                Ok(e) => e,
130                Err(e) => {
131                    *err.lock().unwrap() = Some(WalkError::Walk(e));
132                    return WalkState::Quit;
133                }
134            };
135            if entry.file_type().is_some_and(|t| t.is_file()) {
136                let path = entry.path();
137                if path.extension().is_some_and(|e| e == "meta") {
138                    visit(path);
139                }
140            }
141            WalkState::Continue
142        })
143    });
144
145    // Take the captured error if any. `Arc::try_unwrap` would silently
146    // drop the error on a lingering clone or poisoned lock — instead lock,
147    // take, and let `unwrap()` propagate poison as a panic (a poisoned
148    // walk-error mutex is a bug we want to surface).
149    if let Some(e) = err.lock().unwrap().take() {
150        return Err(e);
151    }
152    Ok(())
153}
154
155fn is_unity_hidden(name: &std::ffi::OsStr) -> bool {
156    // Byte-level check — non-UTF-8 filenames (rare but possible on Unix)
157    // would silently slip through a `to_str()`-based check.
158    let bytes = name.as_encoded_bytes();
159    bytes.first() == Some(&b'.') || bytes.last() == Some(&b'~')
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn rejects_non_project() {
168        let tmp = std::env::temp_dir().join(format!("unity-assetdb-walk-test-{}", std::process::id()));
169        std::fs::create_dir_all(&tmp).unwrap();
170        let result = resolve_project_root(Some(&tmp));
171        assert!(result.is_err());
172        std::fs::remove_dir_all(&tmp).ok();
173    }
174}