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}