use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use ignore::WalkBuilder;
#[derive(Debug, thiserror::Error)]
pub enum WalkError {
#[error("get cwd: {0}")]
GetCwd(#[source] std::io::Error),
#[error("canonicalize project {}: {source}", path.display())]
Canonicalize {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("not a Unity project: {} (missing Assets/ or ProjectSettings/)", path.display())]
NotProject { path: PathBuf },
#[error("no Unity project root found above {}: needs `Assets/` + `ProjectSettings/`", cwd.display())]
NoProjectRoot { cwd: PathBuf },
#[error("Assets/ not found at {}", path.display())]
AssetsMissing { path: PathBuf },
#[error("walk error: {0}")]
Walk(#[from] ignore::Error),
}
pub fn resolve_project_root(arg: Option<&Path>) -> Result<PathBuf, WalkError> {
if let Some(p) = arg {
let p = p.canonicalize().map_err(|source| WalkError::Canonicalize {
path: p.to_path_buf(),
source,
})?;
ensure_project(&p)?;
return Ok(p);
}
let cwd = std::env::current_dir().map_err(WalkError::GetCwd)?;
let mut cur: &Path = &cwd;
loop {
if is_project(cur) {
return Ok(cur.to_path_buf());
}
match cur.parent() {
Some(p) => cur = p,
None => return Err(WalkError::NoProjectRoot { cwd: cwd.clone() }),
}
}
}
fn is_project(p: &Path) -> bool {
p.join("Assets").is_dir() && p.join("ProjectSettings").is_dir()
}
fn ensure_project(p: &Path) -> Result<(), WalkError> {
if !is_project(p) {
return Err(WalkError::NotProject {
path: p.to_path_buf(),
});
}
Ok(())
}
pub fn walk_meta_files<F, V>(project_root: &Path, factory: F) -> Result<(), WalkError>
where
F: Fn() -> V + Sync,
V: FnMut(&Path) + Send + 'static,
{
let assets = project_root.join("Assets");
if !assets.is_dir() {
return Err(WalkError::AssetsMissing { path: assets });
}
let packages = project_root.join("Packages");
let mut builder = WalkBuilder::new(&assets);
if packages.is_dir() {
builder.add(&packages);
}
let walker = builder
.standard_filters(false)
.follow_links(false)
.filter_entry(|e| !is_unity_hidden(e.file_name()))
.build_parallel();
let err: Arc<Mutex<Option<WalkError>>> = Arc::new(Mutex::new(None));
walker.run(|| {
let mut visit = factory();
let err = Arc::clone(&err);
Box::new(move |res| {
use ignore::WalkState;
let entry = match res {
Ok(e) => e,
Err(e) => {
*err.lock().unwrap() = Some(WalkError::Walk(e));
return WalkState::Quit;
}
};
if entry.file_type().is_some_and(|t| t.is_file()) {
let path = entry.path();
if path.extension().is_some_and(|e| e == "meta") {
visit(path);
}
}
WalkState::Continue
})
});
if let Some(e) = err.lock().unwrap().take() {
return Err(e);
}
Ok(())
}
fn is_unity_hidden(name: &std::ffi::OsStr) -> bool {
let bytes = name.as_encoded_bytes();
bytes.first() == Some(&b'.') || bytes.last() == Some(&b'~')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_non_project() {
let tmp = std::env::temp_dir().join(format!("unity-assetdb-walk-test-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let result = resolve_project_root(Some(&tmp));
assert!(result.is_err());
std::fs::remove_dir_all(&tmp).ok();
}
}