Skip to main content

concinnity_host/store/
source.rs

1//! Finding an authored source asset on disk.
2//!
3//! A `source` string in world.jsonl may be a bare filename (a texture, HDRI,
4//! `.cube` LUT, or shader named without a directory), which resolves against
5//! an `assets/` directory by recursive search. Anything carrying a directory
6//! component is a path already and is used verbatim, so absolute and relative
7//! paths still work.
8//!
9//! The directory to search is the caller's: a build threads the root it was
10//! given, and the hot-reload watcher passes the running project's. Nothing
11//! here reads a process-global anchor, so two builds may resolve against
12//! different trees in one process. The shipped runtime plays compiled blobs
13//! and never resolves a source file.
14
15use std::path::{Path, PathBuf};
16
17/// Recursively search `assets_dir` for a file matching the given bare
18/// filename, returning the first match.
19pub fn find_in(assets_dir: &Path, filename: &str) -> Option<String> {
20    walk(assets_dir, filename).map(|p| p.to_string_lossy().into_owned())
21}
22
23/// Resolve a source string into a filesystem path: bare filenames are searched
24/// for under `assets_dir` and fall back to sitting directly in it, so a miss
25/// still names the file the read error will report. With no assets dir the
26/// source is returned verbatim, which is again the path the read error names.
27pub fn resolve_source_path(source: &str, assets_dir: Option<&Path>) -> String {
28    let Some(assets_dir) = assets_dir else {
29        return source.to_string();
30    };
31    if !is_bare(source) {
32        return source.to_string();
33    }
34    walk(assets_dir, source)
35        .unwrap_or_else(|| assets_dir.join(source))
36        .to_string_lossy()
37        .into_owned()
38}
39
40// True when `source` names a file with no directory component.
41fn is_bare(source: &str) -> bool {
42    Path::new(source)
43        .parent()
44        .map(|d| d.as_os_str().is_empty())
45        .unwrap_or(true)
46}
47
48fn walk(dir: &Path, filename: &str) -> Option<PathBuf> {
49    let Ok(entries) = std::fs::read_dir(dir) else {
50        return None;
51    };
52    for entry in entries.flatten() {
53        let path = entry.path();
54        if path.is_file() && path.file_name().and_then(|n| n.to_str()) == Some(filename) {
55            return Some(path);
56        }
57        if path.is_dir()
58            && let Some(found) = walk(&path, filename)
59        {
60            return Some(found);
61        }
62    }
63    None
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn bare_filenames_are_distinguished_from_paths() {
72        assert!(is_bare("sky.hdr"));
73        assert!(!is_bare("hdri/sky.hdr"));
74        assert!(!is_bare("/abs/sky.hdr"));
75        assert!(!is_bare("../sky.hdr"));
76    }
77
78    #[test]
79    fn a_path_with_a_directory_component_is_used_verbatim() {
80        let dir = tempfile::tempdir().unwrap();
81        // Even a path that does not exist is returned unchanged: it is already
82        // a path, so no search applies.
83        assert_eq!(
84            resolve_source_path("hdri/sky.hdr", Some(dir.path())),
85            "hdri/sky.hdr"
86        );
87        assert_eq!(
88            resolve_source_path("/abs/sky.hdr", Some(dir.path())),
89            "/abs/sky.hdr"
90        );
91    }
92
93    #[test]
94    fn a_bare_filename_is_found_in_a_nested_subdirectory() {
95        let dir = tempfile::tempdir().unwrap();
96        let nested = dir.path().join("hdri").join("outdoor");
97        std::fs::create_dir_all(&nested).unwrap();
98        let target = nested.join("sky.hdr");
99        std::fs::write(&target, b"x").unwrap();
100
101        assert_eq!(
102            resolve_source_path("sky.hdr", Some(dir.path())),
103            target.to_string_lossy().into_owned()
104        );
105    }
106
107    #[test]
108    fn a_missing_bare_filename_falls_back_to_the_assets_dir() {
109        let dir = tempfile::tempdir().unwrap();
110        assert_eq!(
111            resolve_source_path("cn_no_such_asset.hdr", Some(dir.path())),
112            dir.path()
113                .join("cn_no_such_asset.hdr")
114                .to_string_lossy()
115                .into_owned()
116        );
117    }
118
119    // With no assets dir there is nowhere to search, so a bare filename comes
120    // back untouched rather than anchored to a guessed root.
121    #[test]
122    fn resolving_without_an_assets_dir_returns_the_source_verbatim() {
123        assert_eq!(resolve_source_path("sky.hdr", None), "sky.hdr");
124        assert_eq!(resolve_source_path("hdri/sky.hdr", None), "hdri/sky.hdr");
125    }
126
127    #[test]
128    fn resolving_against_a_missing_assets_dir_still_names_the_file() {
129        // An unreadable assets dir makes the search miss rather than fail, so
130        // the caller gets a path whose read error names the file.
131        let missing = Path::new("/nonexistent/cn/assets");
132        assert_eq!(
133            resolve_source_path("sky.hdr", Some(missing)),
134            missing.join("sky.hdr").to_string_lossy().into_owned()
135        );
136    }
137
138    #[test]
139    fn walk_misses_cleanly_on_an_unreadable_directory() {
140        assert_eq!(walk(Path::new("/nonexistent/cn/assets"), "x.png"), None);
141    }
142
143    #[test]
144    fn find_in_returns_the_first_match_and_misses_cleanly() {
145        let dir = tempfile::tempdir().unwrap();
146        let nested = dir.path().join("hdri");
147        std::fs::create_dir_all(&nested).unwrap();
148        std::fs::write(nested.join("sky.hdr"), b"x").unwrap();
149
150        assert_eq!(
151            find_in(dir.path(), "sky.hdr").as_deref(),
152            nested.join("sky.hdr").to_str()
153        );
154        assert_eq!(find_in(dir.path(), "cn_test_no_such_asset.json"), None);
155    }
156}