Skip to main content

day_spec/
resource.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! Bundled resources: efficient random read-only access to app-declared data, backed by each
5//! toolkit's native resource mechanism (DESIGN.md §18.3).
6//!
7//! `day build` stages the files declared under a project's `assets/` (and `images/`) into each
8//! platform's native resource store, **uncompressed where possible** so the runtime can hand back a
9//! zero-copy view. At runtime `resource("name")` returns a [`Resource`] whose bytes are borrowed
10//! directly from that store: an mmap of a bundle file on Apple, the NDK `AAssetManager` buffer on
11//! Android, `g_resources_lookup_data` on GTK, `QResource::data` on Qt, the rawfile fd on ArkUI.
12//!
13//! The active backend registers its opener once via [`set_resource_opener`]; if none is registered
14//! (desktop dev, host tests, and the Apple backends, whose native mechanism *is* a bundle file) the
15//! default opener mmaps the file resolved from `DAY_ASSET_ROOT` or the app bundle.
16
17use std::any::Any;
18use std::borrow::Cow;
19use std::path::PathBuf;
20use std::sync::OnceLock;
21
22// ---------------------------------------------------------------------------
23// Typed resource identifiers (§18.5)
24//
25// `image(...)`, `resource(...)`, and `Font::custom(...)` take these newtypes rather than a bare
26// `&str`. Every value is meant to come from a generated `res::images/assets/fonts::…` constant
27// (`day-build`), so referencing a resource that isn't bundled is a compile error and the available
28// names autocomplete. A string *literal* does not coerce, which is what turns "present" from a
29// convention into a guarantee; a name only known at runtime uses the explicit `::dynamic`
30// escape hatch (or, for images/assets, `From<String>`).
31// ---------------------------------------------------------------------------
32
33macro_rules! resource_name {
34    ($(#[$m:meta])* $name:ident, $what:literal) => {
35        $(#[$m])*
36        #[derive(Clone, Debug, PartialEq, Eq)]
37        pub struct $name(Cow<'static, str>);
38
39        impl $name {
40            #[doc = concat!("A ", $what, " known at compile time — the form the generated `res::…` constants use.")]
41            pub const fn from_static(name: &'static str) -> Self {
42                $name(Cow::Borrowed(name))
43            }
44            #[doc = concat!("Escape hatch for a ", $what, " computed at runtime (no build-time presence guarantee).")]
45            pub fn dynamic(name: impl Into<String>) -> Self {
46                $name(Cow::Owned(name.into()))
47            }
48            /// The underlying wire name the backend resolves by.
49            pub fn as_str(&self) -> &str {
50                &self.0
51            }
52        }
53
54        // Owned strings convert (dynamic names); a `&str` literal does not, so
55        // `image("typo")` / `resource("typo")` fail to compile.
56        impl From<String> for $name {
57            fn from(s: String) -> Self {
58                $name(Cow::Owned(s))
59            }
60        }
61
62        impl std::fmt::Display for $name {
63            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64                f.write_str(&self.0)
65            }
66        }
67    };
68}
69
70resource_name!(
71    /// The bundled-image name that [`resource()`](crate::resource()) callers resolve by (the file stem).
72    ImageName,
73    "bundled-image name"
74);
75resource_name!(
76    /// The bundled-data name that [`resource`] resolves by: the file's path relative to
77    /// `resource/assets/`, `/`-separated (a top-level file is just its file name, extension
78    /// included).
79    AssetName,
80    "bundled-asset name"
81);
82resource_name!(
83    /// A bundled-asset directory (§18.5): a folder's path relative to `resource/assets/`,
84    /// `/`-separated, no trailing slash. The generated `res::assets::…` tree carries one
85    /// constant per folder, sharing its module's name: `res::assets::web::minisite` is the
86    /// `AssetDir` and `res::assets::web::minisite::index_html` a file within it.
87    AssetDir,
88    "bundled-asset directory"
89);
90
91impl AssetDir {
92    /// Name a file under this directory, for [`resource`]: the escape hatch for paths
93    /// composed at runtime (`dir.join("posts/1.html")`); prefer the generated file constants
94    /// where the path is known.
95    pub fn join(&self, rel: &str) -> AssetName {
96        AssetName::dynamic(format!("{}/{}", self.as_str(), rel.trim_start_matches('/')))
97    }
98}
99resource_name!(
100    /// The bundled-vector name that [`resource()`](crate::resource()) callers resolve by (docs/vectors.md):
101    /// the stem of a `resource/vectors/` SVG (or `.symbolset` bundle). Vectors and images share one
102    /// per-backend resolution namespace; staging guarantees every vector name resolves as whatever
103    /// form that backend loads natively (a VectorDrawable, a catalog entry, a rasterized PNG).
104    VectorName,
105    "bundled-vector name"
106);
107
108/// Vector and image names resolve through the same per-backend channel (nav-item icons, the
109/// toolbar's `.image(…)`, `bar_action`, all name-based); this conversion is that shared
110/// namespace made explicit.
111impl From<VectorName> for ImageName {
112    fn from(v: VectorName) -> ImageName {
113        ImageName::dynamic(v.as_str().to_owned())
114    }
115}
116
117/// The family name of a bundled custom font, feeding `Font::custom`. Holds a `&'static str` (so
118/// `Font` stays `Copy`); generated `res::fonts::…` constants supply it. The untyped
119/// `Font::Custom(&'static str, f64)` variant remains the escape hatch for a family known another way.
120#[derive(Clone, Copy, Debug, PartialEq, Eq)]
121pub struct FontFamily(&'static str);
122
123impl FontFamily {
124    /// A font family known at compile time: the form the generated `res::fonts::…` constants use.
125    pub const fn from_static(family: &'static str) -> Self {
126        FontFamily(family)
127    }
128    /// The underlying family name (what `Font::Custom` carries).
129    pub const fn as_str(&self) -> &'static str {
130        self.0
131    }
132}
133
134impl std::fmt::Display for FontFamily {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        f.write_str(self.0)
137    }
138}
139
140/// A handle to a bundled resource's bytes with efficient random read-only access.
141///
142/// The bytes stay valid for the lifetime of the `Resource`: it owns a guard (an mmap, a native
143/// `GBytes`/`QResource`/`AAsset` handle, or an owned buffer) that keeps the backing store alive.
144/// `Resource` is neither `Send` nor `Sync`; like the rest of the day runtime it is used on the
145/// main/UI thread.
146pub struct Resource {
147    ptr: *const u8,
148    len: usize,
149    /// Keeps the backing store alive while `ptr`/`len` are borrowed from it.
150    _guard: Box<dyn Any>,
151}
152
153impl Resource {
154    /// Build a `Resource` from a raw byte view plus the guard that owns it. Backends call this from
155    /// their opener after obtaining a zero-copy pointer from the native resource API.
156    ///
157    /// # Safety
158    /// `ptr` must point to `len` valid, immutable bytes that remain valid for as long as `guard` is
159    /// alive, and `guard` must own (and keep alive) that backing store.
160    pub unsafe fn from_raw(ptr: *const u8, len: usize, guard: Box<dyn Any>) -> Resource {
161        Resource {
162            ptr,
163            len,
164            _guard: guard,
165        }
166    }
167
168    /// Build a `Resource` from owned bytes: the copy fallback for backends that cannot expose a
169    /// stable pointer into their store (or when a native API only offers a read-into-buffer call).
170    pub fn from_vec(bytes: Vec<u8>) -> Resource {
171        let boxed: Box<[u8]> = bytes.into_boxed_slice();
172        let ptr = boxed.as_ptr();
173        let len = boxed.len();
174        // Moving the box moves the (fat) pointer, not the heap allocation, so `ptr` stays valid.
175        Resource {
176            ptr,
177            len,
178            _guard: Box::new(boxed),
179        }
180    }
181
182    /// Total byte length of the resource.
183    pub fn len(&self) -> usize {
184        self.len
185    }
186
187    /// Whether the resource is empty.
188    pub fn is_empty(&self) -> bool {
189        self.len == 0
190    }
191
192    /// A zero-copy view of the full contents. Backed directly by the native store (no allocation,
193    /// no copy). Wrap in [`std::io::Cursor`] for `Read`/`Seek`-style access.
194    pub fn as_slice(&self) -> &[u8] {
195        // Safety: `ptr`/`len` describe a valid immutable region kept alive by `_guard`.
196        unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
197    }
198
199    /// Random-access read: copy up to `buf.len()` bytes starting at `offset` into `buf`, returning
200    /// the number of bytes copied (0 if `offset >= len`). A direct `memcpy` from the backing store,
201    /// no allocation: the efficient primitive for seeking around a large embedded blob.
202    pub fn read_at(&self, offset: usize, buf: &mut [u8]) -> usize {
203        let data = self.as_slice();
204        if offset >= data.len() {
205            return 0;
206        }
207        let n = buf.len().min(data.len() - offset);
208        buf[..n].copy_from_slice(&data[offset..offset + n]);
209        n
210    }
211
212    /// Copy the full contents into a freshly allocated `Vec`.
213    pub fn to_vec(&self) -> Vec<u8> {
214        self.as_slice().to_vec()
215    }
216}
217
218/// A backend's resource opener: maps a declared name (e.g. `"stations.json"`) to its bytes, or
219/// `None` if there is no such resource.
220pub type ResourceOpener = fn(&str) -> Option<Resource>;
221
222static OPENER: OnceLock<ResourceOpener> = OnceLock::new();
223
224/// Register the active backend's resource opener (Android `AAssetManager`, GTK `GResource`, Qt
225/// `QResource`, ArkUI rawfile, …). Called once during backend init. A second call is ignored, so a
226/// backend that shares the default file opener need not call this at all.
227pub fn set_resource_opener(opener: ResourceOpener) {
228    let _ = OPENER.set(opener);
229}
230
231/// Open a bundled resource by name for efficient random read-only access.
232///
233/// Returns `None` if the backend has no resource with that name. Takes an [`AssetName`]: pass a
234/// generated `res::assets::…` constant (e.g. `resource(res::assets::stations_json)`) for a checked,
235/// guaranteed-present reference, or [`AssetName::dynamic`] for a name known only at runtime.
236pub fn resource(name: impl Into<AssetName>) -> Option<Resource> {
237    let name = name.into();
238    let name = name.as_str();
239    match OPENER.get() {
240        Some(open) => open(name),
241        None => default_open(name),
242    }
243}
244
245/// The default opener: mmap the file resolved from `DAY_ASSET_ROOT` (dev runs / `day launch`) or the
246/// app bundle's `Resources/assets`. This is the Apple backends' native path (a plain bundle file)
247/// and the desktop/host-test path.
248fn default_open(name: &str) -> Option<Resource> {
249    let path = resolve_resource_path(name)?;
250    let file = std::fs::File::open(&path).ok()?;
251    // Safety: we open read-only and never mutate the mapping; the `Mmap` guard keeps it alive.
252    let mmap = unsafe { memmap2::Mmap::map(&file).ok()? };
253    let ptr = mmap.as_ptr();
254    let len = mmap.len();
255    Some(unsafe { Resource::from_raw(ptr, len, Box::new(mmap)) })
256}
257
258/// Resolve a data-resource name to an on-disk path: `DAY_ASSET_ROOT` first (dev / CLI launch), then
259/// bundle-relative locations next to the executable (macOS `.app`, Linux `share`).
260fn resolve_resource_path(name: &str) -> Option<PathBuf> {
261    for var in ASSET_ROOT_VARS {
262        if let Ok(root) = std::env::var(var) {
263            let p = PathBuf::from(root).join(name);
264            if p.exists() {
265                return Some(p);
266            }
267        }
268    }
269    if let Ok(exe) = std::env::current_exe()
270        && let Some(dir) = exe.parent()
271    {
272        for rel in ["../Resources/assets", "Resources/assets", "assets"] {
273            let p = dir.join(rel).join(name);
274            if p.exists() {
275                return Some(p);
276            }
277        }
278    }
279    None
280}
281
282/// Resolve a data-asset file (a `/`-relative path under `resource/assets/`) to its on-disk
283/// location, for consumers that must hand a native API a path rather than bytes: lottie-ios
284/// takes a filepath, and its by-name initializer would otherwise look only at the bundle root,
285/// where Day stages nothing. Same probe order as [`resource`]: `DAY_ASSET_ROOT` (dev /
286/// `day launch`), then the bundle-relative roots.
287/// The roots a dev run probes for data assets, in order: the project's own `resource/assets/`,
288/// then the tree `day build` stages the assets its pieces ship into (docs/extending.md). A
289/// packaged app has one merged `assets/` directory beside the binary and needs neither.
290const ASSET_ROOT_VARS: [&str; 2] = ["DAY_ASSET_ROOT", "DAY_PIECE_ASSET_ROOT"];
291
292pub fn resolve_asset_file(name: &str) -> Option<PathBuf> {
293    resolve_resource_path(name)
294}
295
296/// Resolve a data-asset directory (an [`AssetDir`]'s `/`-relative path) to its on-disk
297/// location, for consumers whose local-content channel is a file URL, like the inline web
298/// view on the Apple backends (day-piece-webview's docs/webview.md). Same probe order as file
299/// resolution: `DAY_ASSET_ROOT` (dev / `day launch`), then the bundle-relative roots.
300pub fn resolve_asset_dir(rel: &str) -> Option<PathBuf> {
301    // Canonicalized: consumers derive URLs from this path, and a literal `..` segment (the
302    // bundle probe below goes through `MacOS/../Resources`) breaks prefix comparison against
303    // the standardized URLs engines report back.
304    let canon = |p: PathBuf| p.canonicalize().ok().filter(|c| c.is_dir());
305    for var in ASSET_ROOT_VARS {
306        if let Ok(root) = std::env::var(var)
307            && let Some(p) = canon(PathBuf::from(root).join(rel))
308        {
309            return Some(p);
310        }
311    }
312    if let Ok(exe) = std::env::current_exe()
313        && let Some(dir) = exe.parent()
314    {
315        for base in ["../Resources/assets", "Resources/assets", "assets"] {
316            if let Some(p) = canon(dir.join(base).join(rel)) {
317                return Some(p);
318            }
319        }
320    }
321    None
322}
323
324/// Image file extensions probed when an `image("name")` reference omits an extension.
325const IMAGE_EXTS: [&str; 8] = ["png", "jpg", "jpeg", "gif", "bmp", "webp", "pdf", "svg"];
326
327/// The suffixes `Vector::weight` appends to a glyph name (docs/vectors.md).
328pub const WEIGHT_SUFFIXES: [&str; 2] = ["__light", "__bold"];
329
330/// The base glyph a weight-suffixed name aliases, or `None` if the name carries no suffix.
331///
332/// Only SF-template sources have true per-weight art, so only they stage `__light`/`__bold`
333/// assets. A plain SVG stages once and every weight resolves back to it here, which is what
334/// keeps `.weight(…)` from 404-ing on art that has no weight axis, without staging a byte-identical
335/// copy of every glyph per weight. (Before this, 38 of Day-Showcase's 39 sources were staged three
336/// times over as identical copies.)
337pub fn weight_alias(name: &str) -> Option<&str> {
338    WEIGHT_SUFFIXES
339        .iter()
340        .find_map(|s| name.strip_suffix(s))
341        .filter(|base| !base.is_empty())
342}
343
344/// Resolve an image name to an on-disk file, for the file-loading backends (AppKit/GTK/Qt, and the
345/// desktop/dev path). Probes `DAY_IMAGE_ROOT` (the project's `images/` under `day launch`) first,
346/// then `DAY_ASSET_ROOT` and the bundle, trying the bare name and, if it has no extension, each
347/// known image extension. Native-pipeline backends (iOS `Assets.car`, Android `R`, …) resolve by
348/// name through their own store and do not use this.
349pub fn resolve_image_file(name: &str) -> Option<PathBuf> {
350    let mut roots: Vec<PathBuf> = Vec::new();
351    // DAY_VECTOR_RASTER_ROOT: the build-time raster cache for `resource/vectors/`
352    // (docs/vectors.md), which is how the file-loading desktop backends resolve a vector name
353    // with no native vector pipeline.
354    for var in ["DAY_IMAGE_ROOT", "DAY_VECTOR_RASTER_ROOT", "DAY_ASSET_ROOT"] {
355        if let Ok(v) = std::env::var(var) {
356            roots.push(PathBuf::from(v));
357        }
358    }
359    if let Ok(exe) = std::env::current_exe()
360        && let Some(dir) = exe.parent()
361    {
362        // Packed layouts (docs/vectors.md): `day pack` stages the vector raster cache under
363        // `vectors/raster` (inside `Contents/Resources` for a .app, beside the exe for the
364        // flat Windows payload), ordered after `images` to mirror the env-root precedence.
365        for rel in [
366            "../Resources/images",
367            "../Resources/vectors/raster",
368            "../Resources/assets",
369            "images",
370            "vectors/raster",
371            "assets",
372        ] {
373            roots.push(dir.join(rel));
374        }
375    }
376    // A weight-suffixed name probes its own art first, then the base glyph it aliases
377    // (`weight_alias`): the raster half of the same fallback the SVG resolver does.
378    let names: Vec<&str> = match weight_alias(name) {
379        Some(base) => vec![name, base],
380        None => vec![name],
381    };
382    let has_ext = std::path::Path::new(name).extension().is_some();
383    // Name-major, not root-major: a staged variant found in any root beats the base glyph found in
384    // a higher-precedence one, so a template's own `__bold` art is never shadowed by its Regular.
385    for (name, root) in names
386        .iter()
387        .flat_map(|n| roots.iter().map(move |r| (*n, r.clone())))
388    {
389        let exact = root.join(name);
390        if exact.is_file() {
391            return Some(exact);
392        }
393        if !has_ext {
394            for ext in IMAGE_EXTS {
395                let p = root.join(format!("{name}.{ext}"));
396                if p.is_file() {
397                    return Some(p);
398                }
399            }
400        }
401    }
402    None
403}
404
405/// Resolve a vector name to its staged glyph SVG, for backends that render SVG natively
406/// (docs/vectors.md): `DAY_VECTOR_SVG_ROOT` under `day launch`, else the packed layouts:
407/// `Contents/Resources/vectors/svg` in a .app, `vectors/svg` beside a flat-layout exe.
408/// `None` (name isn't a vector, or the layout carries no SVGs) means fall back to
409/// [`resolve_image_file`]'s raster resolution.
410pub fn resolve_vector_svg(name: &str) -> Option<PathBuf> {
411    let mut roots: Vec<PathBuf> = Vec::new();
412    if let Ok(v) = std::env::var("DAY_VECTOR_SVG_ROOT") {
413        roots.push(PathBuf::from(v));
414    }
415    if let Ok(exe) = std::env::current_exe()
416        && let Some(dir) = exe.parent()
417    {
418        roots.push(dir.join("../Resources/vectors/svg"));
419        roots.push(dir.join("vectors/svg"));
420    }
421    if let Some(found) = roots
422        .iter()
423        .map(|r| r.join(format!("{name}.svg")))
424        .find(|p| p.is_file())
425    {
426        return Some(found);
427    }
428    // A weight that stages no art of its own resolves to the base glyph (see `weight_alias`).
429    let base = weight_alias(name)?;
430    roots
431        .into_iter()
432        .map(|r| r.join(format!("{base}.svg")))
433        .find(|p| p.is_file())
434}
435
436/// Resolve a vector name to its staged XAML geometry (docs/vectors.md), the form day-toolkit-winui draws
437/// as real `Path` geometry: vector at any size, and tintable by brush at runtime.
438/// `DAY_VECTOR_XAML_ROOT` under `day launch`, else `vectors/xaml` beside a flat-layout exe.
439/// `None` (name isn't a vector, the art was outside the convertible subset, or the layout
440/// carries no geometry) means fall back to [`resolve_image_file`]'s raster resolution.
441pub fn resolve_vector_xaml(name: &str) -> Option<PathBuf> {
442    let mut roots: Vec<PathBuf> = Vec::new();
443    if let Ok(v) = std::env::var("DAY_VECTOR_XAML_ROOT") {
444        roots.push(PathBuf::from(v));
445    }
446    if let Ok(exe) = std::env::current_exe()
447        && let Some(dir) = exe.parent()
448    {
449        roots.push(dir.join("vectors/xaml"));
450    }
451    if let Some(found) = roots
452        .iter()
453        .map(|r| r.join(format!("{name}.xamlgeom")))
454        .find(|p| p.is_file())
455    {
456        return Some(found);
457    }
458    let base = weight_alias(name)?;
459    roots
460        .into_iter()
461        .map(|r| r.join(format!("{base}.xamlgeom")))
462        .find(|p| p.is_file())
463}
464
465/// Day's own drawing of `sym`, written to a cache file and answered as a path.
466///
467/// Backends that load icons from files (Qt's SVG icon engine, gdk-pixbuf) can then use the shared
468/// fallback through the loader they already have, instead of each growing a way to rasterize a
469/// path string. Written once per symbol per process; a failure to write answers `None` and the
470/// caller falls back to a label.
471pub fn stage_symbol_svg(sym: crate::Symbol) -> Option<PathBuf> {
472    let svg = sym.outline_svg()?;
473    let dir = std::env::temp_dir().join("day-symbols");
474    let file = dir.join(format!("{sym:?}.svg"));
475    if file.is_file() {
476        return Some(file);
477    }
478    std::fs::create_dir_all(&dir).ok()?;
479    std::fs::write(&file, svg).ok()?;
480    Some(file)
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    // One test owns the process env (set_var races across threads), covering both the default
488    // data opener and the image-file resolver sequentially.
489    #[test]
490    fn env_backed_resource_access() {
491        let dir = std::env::temp_dir().join(format!("day-res-test-{}", std::process::id()));
492        std::fs::create_dir_all(&dir).unwrap();
493        let payload: Vec<u8> = (0u8..=255).cycle().take(1000).collect();
494        std::fs::write(dir.join("blob.bin"), &payload).unwrap();
495        std::fs::write(dir.join("logo.png"), b"x").unwrap();
496        // SAFETY: this is the only test that touches the env.
497        unsafe {
498            std::env::set_var("DAY_ASSET_ROOT", &dir);
499            std::env::set_var("DAY_IMAGE_ROOT", &dir);
500        }
501
502        // default data opener: whole view + random access + clamping + miss.
503        let res = resource(AssetName::from_static("blob.bin")).expect("resource present");
504        assert_eq!(res.len(), 1000);
505        assert!(!res.is_empty());
506        assert_eq!(res.as_slice(), &payload[..]);
507        let mut buf = [0u8; 16];
508        assert_eq!(res.read_at(500, &mut buf), 16);
509        assert_eq!(&buf, &payload[500..516]);
510        assert_eq!(res.read_at(995, &mut buf), 5);
511        assert_eq!(&buf[..5], &payload[995..1000]);
512        assert_eq!(res.read_at(2000, &mut buf), 0);
513        assert!(resource(AssetName::from_static("does-not-exist.bin")).is_none());
514        // Dynamic names go through the explicit escape hatch.
515        assert!(resource(AssetName::dynamic(String::from("nope.bin"))).is_none());
516
517        // image-file resolver: extension inference + exact + miss.
518        assert_eq!(resolve_image_file("logo"), Some(dir.join("logo.png")));
519        assert_eq!(resolve_image_file("logo.png"), Some(dir.join("logo.png")));
520        assert_eq!(resolve_image_file("missing"), None);
521
522        // Weight aliasing (docs/vectors.md): a plain SVG stages one asset, so its `__light` /
523        // `__bold` names must resolve back to it rather than miss. `logo` has no weight art of
524        // its own, which is exactly the case every non-template glyph is in.
525        assert_eq!(weight_alias("logo__bold"), Some("logo"));
526        assert_eq!(weight_alias("logo"), None);
527        assert_eq!(weight_alias("__bold"), None); // suffix alone aliases nothing
528        assert_eq!(resolve_image_file("logo__bold"), Some(dir.join("logo.png")));
529        assert_eq!(
530            resolve_image_file("logo__light"),
531            Some(dir.join("logo.png"))
532        );
533        // A miss stays a miss: the alias must not invent art for a name with no base either.
534        assert_eq!(resolve_image_file("missing__bold"), None);
535
536        // The SVG resolver aliases the same way, and a staged variant still wins over its base.
537        let svgs = dir.join("svg");
538        std::fs::create_dir_all(&svgs).unwrap();
539        std::fs::write(svgs.join("plain.svg"), b"<svg/>").unwrap();
540        std::fs::write(svgs.join("tmpl.svg"), b"<svg/>").unwrap();
541        std::fs::write(svgs.join("tmpl__bold.svg"), b"<svg/>").unwrap();
542        // SAFETY: single test owning the env.
543        unsafe { std::env::set_var("DAY_VECTOR_SVG_ROOT", &svgs) };
544        assert_eq!(
545            resolve_vector_svg("plain__bold"),
546            Some(svgs.join("plain.svg"))
547        );
548        assert_eq!(
549            resolve_vector_svg("tmpl__bold"),
550            Some(svgs.join("tmpl__bold.svg")),
551            "a staged variant must not be shadowed by its base"
552        );
553        assert_eq!(resolve_vector_svg("absent__bold"), None);
554        // SAFETY: single test owning the env.
555        unsafe { std::env::remove_var("DAY_VECTOR_SVG_ROOT") };
556
557        // SAFETY: single test owning the env.
558        unsafe {
559            std::env::remove_var("DAY_ASSET_ROOT");
560            std::env::remove_var("DAY_IMAGE_ROOT");
561        }
562        std::fs::remove_dir_all(&dir).ok();
563    }
564
565    #[test]
566    fn from_vec_view_is_stable() {
567        let r = Resource::from_vec(vec![1, 2, 3, 4]);
568        assert_eq!(r.as_slice(), &[1, 2, 3, 4]);
569        assert_eq!(r.to_vec(), vec![1, 2, 3, 4]);
570        let mut b = [0u8; 2];
571        assert_eq!(r.read_at(2, &mut b), 2);
572        assert_eq!(b, [3, 4]);
573    }
574}