Skip to main content

veloren_common_assets/
fs.rs

1use std::{fs, io};
2
3use assets_manager::{
4    BoxedError,
5    hot_reloading::{EventSender, FsWatcherBuilder},
6    source::{DirEntry, FileContent, FileSystem as RawFs, Source},
7};
8use hashbrown::HashSet;
9
10/// Loads assets from the default path or `VELOREN_ASSETS_OVERRIDE` env if it is
11/// set.
12#[derive(Debug, Clone)]
13pub struct FileSystem {
14    default: RawFs,
15    override_dir: Option<RawFs>,
16}
17
18impl FileSystem {
19    pub fn new() -> io::Result<Self> {
20        let default = RawFs::new(&*super::ASSETS_PATH)?;
21        let override_dir = std::env::var_os("VELOREN_ASSETS_OVERRIDE").and_then(|path| {
22            RawFs::new(path)
23                .map_err(|err| tracing::error!("Error setting override assets directory: {}", err))
24                .ok()
25        });
26
27        let canary = fs::read_to_string(super::ASSETS_PATH.join("common").join("canary.canary"))
28            .map_err(|e| io::Error::other(format!("failed to load canary asset: {}", e)))?;
29
30        if !canary.starts_with("VELOREN_CANARY_MAGIC") {
31            panic!("Canary asset `canary.canary` was present but did not contain the expected data. This *heavily* implies that you've not correctly set up Git LFS (Large File Storage). Visit `https://book.veloren.net/contributors/development-tools.html#git-lfs` for more information about setting up Git LFS.");
32        }
33
34        Ok(Self {
35            default,
36            override_dir,
37        })
38    }
39}
40
41impl Source for FileSystem {
42    fn read(&self, id: &str, ext: &str) -> io::Result<FileContent<'_>> {
43        if let Some(dir) = &self.override_dir {
44            match dir.read(id, ext) {
45                Ok(content) => return Ok(content),
46                Err(err) => {
47                    if err.kind() != io::ErrorKind::NotFound {
48                        let path = dir.path_of(DirEntry::File(id, ext));
49                        tracing::warn!(
50                            "Error reading \"{}\": {}. Falling back to default",
51                            path.display(),
52                            err
53                        );
54                    }
55                },
56            }
57        }
58
59        // If not found in override path, try load from main asset path
60        self.default.read(id, ext)
61    }
62
63    fn read_dir(&self, id: &str, f: &mut dyn FnMut(DirEntry)) -> io::Result<()> {
64        // It's easy to get wrong, so here's the algorithm:
65        //
66        // 1) Read default assets directory first, gather directories it has.
67        // 2) Read override assets directory second, gather directories *it* has.
68        // 3) Call callback on each new directory (or file).
69        //
70        // This should route to src.read() above, which does read override
71        // first, so even if we search for default directories first, we're
72        // still overriding files proper.
73        //
74        // The rest is just properly routing errors.
75        let mut collected = HashSet::new();
76
77        let mut f = |dir_entry: DirEntry| {
78            let cache_id = match dir_entry {
79                DirEntry::File(path, ext) => (path.to_owned(), Some(ext.to_owned())),
80                DirEntry::Directory(path) => (path.to_owned(), None),
81            };
82
83            // on first hit, call the callback
84            if collected.insert(cache_id) {
85                f(dir_entry)
86            }
87        };
88
89        let default_res = self.default.read_dir(id, &mut f);
90        let Some(dir) = &self.override_dir else {
91            // If no override, return right there.
92            return default_res;
93        };
94
95        let override_res = match dir.read_dir(id, &mut f) {
96            Ok(()) => Ok(()),
97            Err(err) => {
98                if err.kind() != io::ErrorKind::NotFound {
99                    let path = dir.path_of(DirEntry::Directory(id));
100                    tracing::warn!(
101                        "Error reading \"{}\": {}. Falling back to default",
102                        path.display(),
103                        err
104                    );
105                }
106                Err(err)
107            },
108        };
109
110        // Error juggling
111        match (default_res, override_res) {
112            // If failed from the start, error.
113            //
114            // Technically not necessary, but better be safe then sorry?
115            (Err(err1), _) if err1.kind() != io::ErrorKind::NotFound => Err(err1),
116            // If override succed, cool, celebrate.
117            (_, Ok(())) => Ok(()),
118            // If override failed, but default succeded, who cares.
119            //
120            // We could be strict here, but overrides are brittle by design,
121            // and may fail with new version, so ...
122            //
123            // We log the warning there, that's it.
124            (Ok(()), Err(_)) => Ok(()),
125            // If If both failed, return last error.
126            (Err(_), Err(err2)) => Err(err2),
127        }
128    }
129
130    fn exists(&self, entry: DirEntry) -> bool {
131        self.override_dir
132            .as_ref()
133            .is_some_and(|dir| dir.exists(entry))
134            || self.default.exists(entry)
135    }
136
137    fn configure_hot_reloading(&self, events: EventSender) -> Result<(), BoxedError> {
138        let mut builder = FsWatcherBuilder::new()?;
139
140        if let Some(dir) = &self.override_dir {
141            builder.watch(dir.root().to_owned())?;
142        }
143        builder.watch(self.default.root().to_owned())?;
144
145        builder.build(events);
146        Ok(())
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use std::{fs, path::Path};
154
155    pub(super) enum FsNode<'a> {
156        File(&'a str, &'a str),
157        Dir(&'a str, Vec<FsNode<'a>>),
158    }
159
160    impl FileSystem {
161        pub(super) fn scope<R>(f: &dyn Fn(FileSystem, &Path, &Path) -> R) -> R {
162            let tempdir = tempfile::tempdir().expect("failed to get tempdir");
163            let default = RawFs::new(tempdir.path())
164                .expect("failed to create temporary filesystem for assets");
165
166            let tempdir_override =
167                tempfile::tempdir().expect("failed to get tempdir for overrides");
168            let override_dir = RawFs::new(tempdir_override.path())
169                .expect("failed to create temprorary override filesystem");
170
171            // NOTE: we're using closure pattern here, because otherwise
172            // tempdirs would get dropped about here, and run their
173            // destructors, which would remove directories.
174            // Instead they will get called at the end of this function,
175            // after the test closure gets called.
176            let this = Self {
177                default,
178                override_dir: Some(override_dir),
179            };
180
181            f(this, tempdir.path(), tempdir_override.path())
182        }
183
184        pub(super) fn read_to_str(&self, id: &str, ext: &str) -> String {
185            std::str::from_utf8(self.read(id, ext).unwrap().as_ref())
186                .unwrap()
187                .to_owned()
188        }
189
190        pub(super) fn mock_file(dir: &Path, filename: &str, content: &str) {
191            fs::write(dir.join(filename), content).unwrap();
192        }
193
194        pub(super) fn mock_tree(dir: &Path, tree: Vec<FsNode<'_>>) {
195            fn create_mock_node(path: &Path, node: FsNode<'_>) {
196                match node {
197                    FsNode::File(name, content) => FileSystem::mock_file(path, name, content),
198                    FsNode::Dir(name, entries) => {
199                        for entry in entries {
200                            fs::create_dir_all(path.join(name)).unwrap();
201                            create_mock_node(&path.join(name), entry);
202                        }
203                    },
204                }
205            }
206
207            for entry in tree {
208                create_mock_node(dir, entry);
209            }
210        }
211    }
212
213    // -- Some basic tests for the DSL above
214
215    #[test]
216    fn test_mock_tree() {
217        FileSystem::scope(&|fs, main_path, _override_path| {
218            FileSystem::mock_tree(main_path, vec![FsNode::File("template.ron", "(5)")]);
219
220            assert_eq!(fs.read_to_str("template", "ron"), "(5)");
221        })
222    }
223
224    #[test]
225    #[should_panic(expected = "assertion `left == right` failed")]
226    fn test_mock_file_properly_fails() {
227        FileSystem::scope(&|fs, main_path, _override_path| {
228            FileSystem::mock_file(main_path, "template.ron", "(5)");
229
230            assert_eq!(fs.read_to_str("template", "ron"), "(6)");
231        })
232    }
233
234    // -- Now finally testing our FileSystem
235
236    #[test]
237    fn test_read_main() {
238        FileSystem::scope(&|fs, main_path, _override_path| {
239            FileSystem::mock_file(main_path, "template.ron", "(5)");
240
241            assert_eq!(fs.read_to_str("template", "ron"), "(5)");
242        })
243    }
244
245    #[test]
246    fn test_read_override() {
247        FileSystem::scope(&|fs, _main_path, override_path| {
248            FileSystem::mock_file(override_path, "template.ron", "(5)");
249
250            assert_eq!(fs.read_to_str("template", "ron"), "(5)");
251        })
252    }
253
254    #[test]
255    fn test_read_dir() {
256        FileSystem::scope(&|fs, main_path, _override_path| {
257            #[rustfmt::skip]
258            FileSystem::mock_tree(main_path, vec![
259                FsNode::Dir("entity", vec![
260                    FsNode::File("template.ron", "(5)")
261                ]),
262            ]);
263
264            assert_eq!(fs.read_to_str("entity.template", "ron"), "(5)");
265        })
266    }
267
268    #[test]
269    fn test_read_dirfile_override() {
270        FileSystem::scope(&|fs, main_path, override_path| {
271            #[rustfmt::skip]
272            FileSystem::mock_tree(main_path, vec![
273                FsNode::Dir("entity", vec![
274                    FsNode::File("template.ron", "(5)")
275                ]),
276            ]);
277
278            #[rustfmt::skip]
279            FileSystem::mock_tree(override_path, vec![
280                FsNode::Dir("entity", vec![
281                    FsNode::File("template.ron", "(6)")
282                ]),
283            ]);
284
285            assert_eq!(fs.read_to_str("entity.template", "ron"), "(6)");
286        })
287    }
288
289    #[test]
290    fn test_read_dirfile_override_only() {
291        FileSystem::scope(&|fs, _main_path, override_path| {
292            #[rustfmt::skip]
293            FileSystem::mock_tree(override_path, vec![
294                FsNode::Dir("entity", vec![
295                    FsNode::File("template.ron", "(5)")
296                ]),
297            ]);
298
299            assert_eq!(fs.read_to_str("entity.template", "ron"), "(5)");
300        })
301    }
302
303    #[test]
304    fn test_read_dirfile_partial_override() {
305        FileSystem::scope(&|fs, main_path, override_path| {
306            // creating dir with two files
307            #[rustfmt::skip]
308            FileSystem::mock_tree(main_path, vec![
309                FsNode::Dir("entity", vec![
310                    FsNode::File("template.ron", "(5)"),
311                    FsNode::File("main.ron", "(7)")
312                ]),
313            ]);
314
315            // overriding only template here, main is still same
316            #[rustfmt::skip]
317            FileSystem::mock_tree(override_path, vec![
318                FsNode::Dir("entity", vec![
319                    FsNode::File("template.ron", "(5)")
320                ]),
321            ]);
322
323            assert_eq!(fs.read_to_str("entity.template", "ron"), "(5)");
324            assert_eq!(fs.read_to_str("entity.main", "ron"), "(7)");
325        })
326    }
327
328    #[test]
329    // I still dont understand how this one can fails while
330    // previous one doesn't, but that's why Source has two methods, I suppose.
331    //
332    // At the time of writing, broken implementation passed previous, but not
333    // that one.
334    //
335    // P.s. the difference is that before we were asserting fs.read(), and this
336    // time we're asserting fs.read_dir(), and apparently fs.read() works
337    // independently of fs.read_dir().
338    fn test_read_dir_actually() {
339        FileSystem::scope(&|fs, main_path, override_path| {
340            // creating dir with two files
341            #[rustfmt::skip]
342            FileSystem::mock_tree(main_path, vec![
343                FsNode::Dir("entity", vec![
344                    FsNode::File("template.ron", "(5)"),
345                    FsNode::File("main.ron", "(7)")
346                ]),
347            ]);
348
349            // overriding only template here, main is still same
350            #[rustfmt::skip]
351            FileSystem::mock_tree(override_path, vec![
352                FsNode::Dir("entity", vec![
353                    FsNode::File("template.ron", "(6)"),
354                    FsNode::File("fun.ron", "(5)")
355                ]),
356            ]);
357
358            let mut files: Vec<String> = vec![];
359            let _ = fs.read_dir("entity", &mut |e: DirEntry| match e {
360                DirEntry::File(path, ext) => files.push(format!("{path}.{ext}")),
361                DirEntry::Directory(path) => files.push(format!("{path}/")),
362            });
363            files.sort();
364            assert_eq!(files, vec![
365                // override only
366                "entity.fun.ron".to_owned(),
367                // main only
368                "entity.main.ron".to_owned(),
369                // shared and overriden
370                "entity.template.ron".to_owned(),
371            ]);
372        })
373    }
374
375    #[test]
376    fn test_read_dir_notfound() {
377        FileSystem::scope(&|fs, main_path, override_path| {
378            // creating dir with two files
379            #[rustfmt::skip]
380            FileSystem::mock_tree(main_path, vec![
381                FsNode::Dir("entity", vec![FsNode::File(
382                    "template.ron",
383                    "(5)",
384                )])
385            ]);
386
387            // creating dir with two files
388            #[rustfmt::skip]
389            FileSystem::mock_tree(override_path, vec![
390                FsNode::Dir("entity", vec![FsNode::File(
391                    "template.ron",
392                    "(5)",
393                )])
394            ]);
395
396            // Reading non-existent file should report the error and a path
397            //
398            // NOTE: basically a guard for potential assets_manager regressions
399            // since uh, things accidentally happened in the past.
400            let res = fs.read("loadout.template", "ron");
401            assert_eq!(res.as_ref().unwrap_err().kind(), io::ErrorKind::NotFound);
402            let msg = format!("{:#?}", res.unwrap_err());
403            if msg.find("loadout/template.ron").is_none() {
404                panic!("error message doesn't contain path:\n{msg}");
405            }
406        })
407    }
408}
409
410#[cfg(test)]
411mod integration {
412    use super::{tests::*, *};
413    use assets_manager::{Asset, AssetCache, FileAsset, SharedString};
414    use hashbrown::HashSet;
415    use serde::Deserialize;
416    use std::borrow::Cow;
417
418    #[derive(Deserialize, Clone, Debug, PartialEq)]
419    struct WowManifest {
420        prefix: usize,
421    }
422
423    #[derive(Deserialize, Clone, Debug, PartialEq)]
424    struct WowFragment {
425        pieces: Vec<usize>,
426    }
427
428    #[derive(Deserialize, Clone, Debug, PartialEq)]
429    struct WowAsset {
430        prefix: usize,
431        pieces: HashSet<usize>,
432    }
433
434    impl FileAsset for WowManifest {
435        const EXTENSION: &'static str = "ron";
436
437        fn from_bytes(bytes: Cow<[u8]>) -> Result<Self, BoxedError> {
438            assets_manager::asset::load_ron(&bytes)
439        }
440    }
441
442    impl FileAsset for WowFragment {
443        const EXTENSION: &'static str = "json";
444
445        fn from_bytes(bytes: Cow<[u8]>) -> Result<Self, BoxedError> {
446            assets_manager::asset::load_json(&bytes)
447        }
448    }
449
450    // Pattern trying to simulate our i18n bundle
451    impl crate::Asset for WowAsset {
452        fn load(cache: &AssetCache, path: &SharedString) -> Result<Self, BoxedError> {
453            let manifest = cache
454                .load::<WowManifest>(&[path, ".", "_manifest"].concat())?
455                .cloned();
456
457            let mut total_pieces = HashSet::new();
458
459            for id in cache.load_rec_dir::<WowFragment>(path)?.read().ids() {
460                match cache.load::<WowFragment>(id) {
461                    Ok(handle) => {
462                        let WowFragment { pieces } = &handle.read().clone();
463                        for piece in pieces {
464                            if !total_pieces.insert(*piece) {
465                                panic!("duplicate piece ({piece}) in: {id}");
466                            }
467                        }
468                    },
469                    // In i18n we warn here, but panics are more visible for
470                    // tests, and errors shouldn't really be happening here.
471                    //
472                    // Probably.
473                    Err(err) => panic!("error during loading: {id}\n{err:#?}"),
474                }
475            }
476
477            Ok(Self {
478                prefix: manifest.prefix,
479                pieces: total_pieces,
480            })
481        }
482    }
483
484    #[test]
485    fn test_read_dir() {
486        FileSystem::scope(&|fs, main_path, _override_path| {
487            #[rustfmt::skip]
488            FileSystem::mock_tree(main_path, vec![
489                FsNode::Dir("entity", vec![
490                    FsNode::File("_manifest.ron", "(prefix: 5)"),
491                    FsNode::File("first.json", r#"{"pieces": [1, 2]}"#),
492                    FsNode::File("second.json", r#"{"pieces": [3, 4]}"#),
493                ]),
494            ]);
495
496            let cache = AssetCache::with_source(fs);
497            let asset = WowAsset::load(&cache, &"entity".into()).unwrap();
498            assert_eq!(asset, WowAsset {
499                prefix: 5,
500                pieces: [1, 2, 3, 4].into(),
501            });
502        })
503    }
504
505    #[test]
506    fn test_read_dir_override() {
507        FileSystem::scope(&|fs, main_path, override_path| {
508            #[rustfmt::skip]
509            FileSystem::mock_tree(main_path, vec![
510                FsNode::Dir("entity", vec![
511                    FsNode::File("_manifest.ron", "(prefix: 5)"),
512                    FsNode::File("first.json", r#"{"pieces": [1, 2]}"#),
513                    FsNode::File("second.json", r#"{"pieces": [3, 4]}"#),
514                ]),
515            ]);
516
517            #[rustfmt::skip]
518            FileSystem::mock_tree(override_path, vec![
519                FsNode::Dir("entity", vec![
520                    FsNode::File("_manifest.ron", "(prefix: 5)"),
521                    FsNode::File("first.json", r#"{"pieces": [5, 6]}"#),
522                    FsNode::File("second.json", r#"{"pieces": [3, 4]}"#),
523                ]),
524            ]);
525
526            let cache = AssetCache::with_source(fs);
527            let asset = WowAsset::load(&cache, &"entity".into()).unwrap();
528            assert_eq!(asset, WowAsset {
529                prefix: 5,
530                pieces: [5, 6, 3, 4].into(),
531            });
532        })
533    }
534
535    #[test]
536    fn test_read_dir_partial_override() {
537        FileSystem::scope(&|fs, main_path, override_path| {
538            #[rustfmt::skip]
539            FileSystem::mock_tree(main_path, vec![
540                FsNode::Dir("entity", vec![
541                    FsNode::File("_manifest.ron", "(prefix: 5)"),
542                    FsNode::File("first.json", r#"{"pieces": [1, 2]}"#),
543                    FsNode::File("second.json", r#"{"pieces": [3, 4]}"#),
544                ]),
545            ]);
546
547            #[rustfmt::skip]
548            FileSystem::mock_tree(override_path, vec![
549                FsNode::Dir("entity", vec![
550                    FsNode::File("_manifest.ron", "(prefix: 5)"),
551                    // overriding only one of the files
552                    FsNode::File("first.json", r#"{"pieces": [5, 6]}"#),
553                ]),
554            ]);
555
556            let cache = AssetCache::with_source(fs);
557            let asset = WowAsset::load(&cache, &"entity".into()).unwrap();
558            assert_eq!(asset, WowAsset {
559                prefix: 5,
560                pieces: [5, 6, 3, 4].into(),
561            });
562        })
563    }
564
565    #[test]
566    fn test_read_dir_partial_nested_override() {
567        FileSystem::scope(&|fs, main_path, override_path| {
568            #[rustfmt::skip]
569            FileSystem::mock_tree(main_path, vec![
570                FsNode::Dir("entity", vec![
571                    FsNode::File("_manifest.ron", "(prefix: 5)"),
572                    FsNode::Dir("nest", vec![
573                        FsNode::File("first.json", r#"{"pieces": [1, 2]}"#),
574                        FsNode::File("second.json", r#"{"pieces": [3, 4]}"#),
575                    ]),
576                ]),
577            ]);
578
579            #[rustfmt::skip]
580            FileSystem::mock_tree(override_path, vec![
581                FsNode::Dir("entity", vec![
582                    FsNode::File("_manifest.ron", "(prefix: 7)"),
583                    FsNode::Dir("nest", vec![
584                        // overriding only one file, nested into directory
585                        FsNode::File("first.json", r#"{"pieces": [5, 6]}"#),
586                    ]),
587                ]),
588            ]);
589
590            let cache = AssetCache::with_source(fs);
591            let asset = WowAsset::load(&cache, &"entity".into()).unwrap();
592            assert_eq!(asset, WowAsset {
593                prefix: 7,
594                pieces: [5, 6, 3, 4].into(),
595            });
596        })
597    }
598}