1use std::path::{Path, PathBuf};
19use std::sync::Arc;
20
21use serde::{Deserialize, Serialize};
22use smol_str::SmolStr;
23
24mod file_manifest;
25mod url_passthrough;
26
27#[cfg(not(target_arch = "wasm32"))]
28pub use file_manifest::FileAssetManifestResolver;
29pub use file_manifest::{
30 AssetManifestLoadError, FILE_ASSET_MANIFEST_KIND_V1, FileAssetManifestBundleV1,
31 FileAssetManifestEntryV1, FileAssetManifestV1,
32};
33pub use url_passthrough::UrlPassthroughAssetResolver;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
36pub enum AssetLocatorKind {
37 Memory,
38 Embedded,
39 BundleAsset,
40 File,
41 Url,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
45pub enum AssetBundleNamespace {
46 App,
47 Package,
48}
49
50impl AssetBundleNamespace {
51 pub fn as_prefix(self) -> &'static str {
52 match self {
53 Self::App => "app",
54 Self::Package => "pkg",
55 }
56 }
57
58 pub fn from_prefix(value: &str) -> Option<Self> {
59 match value {
60 "app" => Some(Self::App),
61 "pkg" => Some(Self::Package),
62 _ => None,
63 }
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
68pub struct AssetBundleId(SmolStr);
69
70impl AssetBundleId {
71 pub fn new(value: impl Into<SmolStr>) -> Self {
72 Self(value.into())
73 }
74
75 pub fn app(name: impl Into<SmolStr>) -> Self {
76 Self::scoped(AssetBundleNamespace::App, name)
77 }
78
79 pub fn package(name: impl Into<SmolStr>) -> Self {
80 Self::scoped(AssetBundleNamespace::Package, name)
81 }
82
83 pub fn as_str(&self) -> &str {
84 self.0.as_str()
85 }
86
87 pub fn namespace(&self) -> Option<AssetBundleNamespace> {
88 let (prefix, _) = self.as_str().split_once(':')?;
89 AssetBundleNamespace::from_prefix(prefix)
90 }
91
92 pub fn local_name(&self) -> &str {
93 self.as_str()
94 .split_once(':')
95 .map(|(_, name)| name)
96 .unwrap_or_else(|| self.as_str())
97 }
98
99 pub fn is_scoped(&self) -> bool {
100 self.namespace().is_some()
101 }
102
103 fn scoped(namespace: AssetBundleNamespace, name: impl Into<SmolStr>) -> Self {
104 let name = name.into();
105 Self(format!("{}:{}", namespace.as_prefix(), name).into())
106 }
107}
108
109impl From<&str> for AssetBundleId {
110 fn from(value: &str) -> Self {
111 Self::new(value)
112 }
113}
114
115impl From<String> for AssetBundleId {
116 fn from(value: String) -> Self {
117 Self::new(value)
118 }
119}
120
121impl From<SmolStr> for AssetBundleId {
122 fn from(value: SmolStr) -> Self {
123 Self::new(value)
124 }
125}
126
127#[macro_export]
128macro_rules! asset_app_bundle_id {
129 () => {
130 $crate::AssetBundleId::app(env!("CARGO_PKG_NAME"))
131 };
132 ($name:expr) => {
133 $crate::AssetBundleId::app($name)
134 };
135}
136
137#[macro_export]
138macro_rules! asset_package_bundle_id {
139 () => {
140 $crate::AssetBundleId::package(env!("CARGO_PKG_NAME"))
141 };
142 ($name:expr) => {
143 $crate::AssetBundleId::package($name)
144 };
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
148pub struct AssetKey(SmolStr);
149
150impl AssetKey {
151 pub fn new(value: impl Into<SmolStr>) -> Self {
152 Self(value.into())
153 }
154
155 pub fn as_str(&self) -> &str {
156 self.0.as_str()
157 }
158}
159
160impl From<&str> for AssetKey {
161 fn from(value: &str) -> Self {
162 Self::new(value)
163 }
164}
165
166impl From<String> for AssetKey {
167 fn from(value: String) -> Self {
168 Self::new(value)
169 }
170}
171
172impl From<SmolStr> for AssetKey {
173 fn from(value: SmolStr) -> Self {
174 Self::new(value)
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
179pub struct AssetMemoryKey(SmolStr);
180
181impl AssetMemoryKey {
182 pub fn new(value: impl Into<SmolStr>) -> Self {
183 Self(value.into())
184 }
185
186 pub fn as_str(&self) -> &str {
187 self.0.as_str()
188 }
189}
190
191impl From<&str> for AssetMemoryKey {
192 fn from(value: &str) -> Self {
193 Self::new(value)
194 }
195}
196
197impl From<String> for AssetMemoryKey {
198 fn from(value: String) -> Self {
199 Self::new(value)
200 }
201}
202
203impl From<SmolStr> for AssetMemoryKey {
204 fn from(value: SmolStr) -> Self {
205 Self::new(value)
206 }
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
210pub struct EmbeddedAssetLocator {
211 pub owner: AssetBundleId,
212 pub key: AssetKey,
213}
214
215impl EmbeddedAssetLocator {
216 pub fn new(owner: impl Into<AssetBundleId>, key: impl Into<AssetKey>) -> Self {
217 Self {
218 owner: owner.into(),
219 key: key.into(),
220 }
221 }
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
225pub struct BundleAssetLocator {
226 pub bundle: AssetBundleId,
227 pub key: AssetKey,
228}
229
230impl BundleAssetLocator {
231 pub fn new(bundle: impl Into<AssetBundleId>, key: impl Into<AssetKey>) -> Self {
232 Self {
233 bundle: bundle.into(),
234 key: key.into(),
235 }
236 }
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
240pub struct FileAssetLocator {
241 pub path: PathBuf,
242}
243
244impl FileAssetLocator {
245 pub fn new(path: impl Into<PathBuf>) -> Self {
246 Self { path: path.into() }
247 }
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
251pub struct UrlAssetLocator {
252 pub url: SmolStr,
253}
254
255impl UrlAssetLocator {
256 pub fn new(url: impl Into<SmolStr>) -> Self {
257 Self { url: url.into() }
258 }
259
260 pub fn as_str(&self) -> &str {
261 self.url.as_str()
262 }
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
266pub enum AssetLocator {
267 Memory(AssetMemoryKey),
268 Embedded(EmbeddedAssetLocator),
269 BundleAsset(BundleAssetLocator),
270 File(FileAssetLocator),
271 Url(UrlAssetLocator),
272}
273
274impl AssetLocator {
275 pub fn kind(&self) -> AssetLocatorKind {
276 match self {
277 Self::Memory(_) => AssetLocatorKind::Memory,
278 Self::Embedded(_) => AssetLocatorKind::Embedded,
279 Self::BundleAsset(_) => AssetLocatorKind::BundleAsset,
280 Self::File(_) => AssetLocatorKind::File,
281 Self::Url(_) => AssetLocatorKind::Url,
282 }
283 }
284
285 pub fn memory(key: impl Into<AssetMemoryKey>) -> Self {
286 Self::Memory(key.into())
287 }
288
289 pub fn embedded(owner: impl Into<AssetBundleId>, key: impl Into<AssetKey>) -> Self {
290 Self::Embedded(EmbeddedAssetLocator::new(owner, key))
291 }
292
293 pub fn bundle(bundle: impl Into<AssetBundleId>, key: impl Into<AssetKey>) -> Self {
294 Self::BundleAsset(BundleAssetLocator::new(bundle, key))
295 }
296
297 pub fn file(path: impl Into<PathBuf>) -> Self {
298 Self::File(FileAssetLocator::new(path))
299 }
300
301 pub fn url(url: impl Into<SmolStr>) -> Self {
302 Self::Url(UrlAssetLocator::new(url))
303 }
304}
305
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
307pub struct AssetRevision(pub u64);
308
309impl AssetRevision {
310 pub const ZERO: Self = Self(0);
311
312 pub fn next(self) -> Self {
313 Self(self.0.saturating_add(1))
314 }
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
318pub enum AssetKindHint {
319 Binary,
320 Image,
321 Svg,
322 Font,
323}
324
325#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
326pub struct AssetMediaType(SmolStr);
327
328impl AssetMediaType {
329 pub fn new(value: impl Into<SmolStr>) -> Self {
330 Self(value.into())
331 }
332
333 pub fn as_str(&self) -> &str {
334 self.0.as_str()
335 }
336}
337
338impl From<&str> for AssetMediaType {
339 fn from(value: &str) -> Self {
340 Self::new(value)
341 }
342}
343
344impl From<String> for AssetMediaType {
345 fn from(value: String) -> Self {
346 Self::new(value)
347 }
348}
349
350impl From<SmolStr> for AssetMediaType {
351 fn from(value: SmolStr) -> Self {
352 Self::new(value)
353 }
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
357pub struct AssetCapabilities {
358 pub memory: bool,
359 pub embedded: bool,
360 pub bundle_asset: bool,
361 pub file: bool,
362 pub url: bool,
363 pub file_watch: bool,
364 pub system_font_scan: bool,
365}
366
367impl AssetCapabilities {
368 pub fn supports_kind(&self, kind: AssetLocatorKind) -> bool {
369 match kind {
370 AssetLocatorKind::Memory => self.memory,
371 AssetLocatorKind::Embedded => self.embedded,
372 AssetLocatorKind::BundleAsset => self.bundle_asset,
373 AssetLocatorKind::File => self.file,
374 AssetLocatorKind::Url => self.url,
375 }
376 }
377
378 pub fn supports(&self, locator: &AssetLocator) -> bool {
379 self.supports_kind(locator.kind())
380 }
381}
382
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384pub struct AssetRequest {
385 pub locator: AssetLocator,
386 pub kind_hint: Option<AssetKindHint>,
387}
388
389impl AssetRequest {
390 pub fn new(locator: AssetLocator) -> Self {
391 Self {
392 locator,
393 kind_hint: None,
394 }
395 }
396
397 pub fn with_kind_hint(mut self, kind_hint: AssetKindHint) -> Self {
398 self.kind_hint = Some(kind_hint);
399 self
400 }
401}
402
403#[derive(Debug, Clone, PartialEq, Eq)]
404pub struct ResolvedAssetBytes {
405 pub locator: AssetLocator,
406 pub revision: AssetRevision,
407 pub media_type: Option<AssetMediaType>,
408 pub bytes: Arc<[u8]>,
409}
410
411impl ResolvedAssetBytes {
412 pub fn new(
413 locator: AssetLocator,
414 revision: AssetRevision,
415 bytes: impl Into<Arc<[u8]>>,
416 ) -> Self {
417 Self {
418 locator,
419 revision,
420 media_type: None,
421 bytes: bytes.into(),
422 }
423 }
424
425 pub fn with_media_type(mut self, media_type: impl Into<AssetMediaType>) -> Self {
426 self.media_type = Some(media_type.into());
427 self
428 }
429}
430
431#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
432pub enum AssetExternalReference {
433 FilePath(PathBuf),
434 Url(SmolStr),
435}
436
437impl AssetExternalReference {
438 pub fn file_path(path: impl Into<PathBuf>) -> Self {
439 Self::FilePath(path.into())
440 }
441
442 pub fn url(url: impl Into<SmolStr>) -> Self {
443 Self::Url(url.into())
444 }
445
446 pub fn as_file_path(&self) -> Option<&Path> {
447 match self {
448 Self::FilePath(path) => Some(path.as_path()),
449 Self::Url(_) => None,
450 }
451 }
452
453 pub fn as_url(&self) -> Option<&str> {
454 match self {
455 Self::FilePath(_) => None,
456 Self::Url(url) => Some(url.as_str()),
457 }
458 }
459}
460
461#[derive(Debug, Clone, PartialEq, Eq)]
462pub struct ResolvedAssetReference {
463 pub locator: AssetLocator,
464 pub revision: AssetRevision,
465 pub media_type: Option<AssetMediaType>,
466 pub reference: AssetExternalReference,
467}
468
469impl ResolvedAssetReference {
470 pub fn new(
471 locator: AssetLocator,
472 revision: AssetRevision,
473 reference: AssetExternalReference,
474 ) -> Self {
475 Self {
476 locator,
477 revision,
478 media_type: None,
479 reference,
480 }
481 }
482
483 pub fn with_media_type(mut self, media_type: impl Into<AssetMediaType>) -> Self {
484 self.media_type = Some(media_type.into());
485 self
486 }
487}
488
489#[derive(Debug, Clone, Copy, PartialEq, Eq)]
490pub struct StaticAssetEntry {
491 pub key: &'static str,
492 pub revision: AssetRevision,
493 pub media_type: Option<&'static str>,
494 pub bytes: &'static [u8],
495}
496
497impl StaticAssetEntry {
498 pub const fn new(key: &'static str, revision: AssetRevision, bytes: &'static [u8]) -> Self {
499 Self {
500 key,
501 revision,
502 media_type: None,
503 bytes,
504 }
505 }
506
507 pub const fn with_media_type(mut self, media_type: &'static str) -> Self {
508 self.media_type = Some(media_type);
509 self
510 }
511
512 fn into_resolved(self, locator: AssetLocator) -> ResolvedAssetBytes {
513 let resolved = ResolvedAssetBytes::new(locator, self.revision, self.bytes);
514 match self.media_type {
515 Some(media_type) => resolved.with_media_type(media_type),
516 None => resolved,
517 }
518 }
519}
520
521pub trait AssetResolver: 'static + Send + Sync {
522 fn capabilities(&self) -> AssetCapabilities;
523 fn resolve_bytes(&self, request: &AssetRequest) -> Result<ResolvedAssetBytes, AssetLoadError>;
524 fn resolve_reference(
525 &self,
526 request: &AssetRequest,
527 ) -> Result<ResolvedAssetReference, AssetLoadError> {
528 match self.resolve_bytes(request) {
529 Ok(_) => Err(AssetLoadError::ExternalReferenceUnavailable {
530 kind: request.locator.kind(),
531 }),
532 Err(err) => Err(err),
533 }
534 }
535}
536
537impl dyn AssetResolver + '_ {
538 pub fn supports(&self, locator: &AssetLocator) -> bool {
539 self.capabilities().supports(locator)
540 }
541
542 pub fn resolve_locator_bytes(
543 &self,
544 locator: AssetLocator,
545 ) -> Result<ResolvedAssetBytes, AssetLoadError> {
546 self.resolve_bytes(&AssetRequest::new(locator))
547 }
548
549 pub fn resolve_locator_reference(
550 &self,
551 locator: AssetLocator,
552 ) -> Result<ResolvedAssetReference, AssetLoadError> {
553 self.resolve_reference(&AssetRequest::new(locator))
554 }
555}
556
557#[derive(Debug, Clone, Default)]
558pub struct InMemoryAssetResolver {
559 capabilities: AssetCapabilities,
560 entries: std::collections::HashMap<AssetLocator, ResolvedAssetBytes>,
561}
562
563impl InMemoryAssetResolver {
564 pub fn new() -> Self {
565 Self {
566 capabilities: AssetCapabilities {
567 memory: true,
568 embedded: true,
569 bundle_asset: true,
570 file: false,
571 url: false,
572 file_watch: false,
573 system_font_scan: false,
574 },
575 entries: std::collections::HashMap::new(),
576 }
577 }
578
579 pub fn with_capabilities(mut self, capabilities: AssetCapabilities) -> Self {
580 self.capabilities = capabilities;
581 self
582 }
583
584 pub fn insert(&mut self, resolved: ResolvedAssetBytes) -> Option<ResolvedAssetBytes> {
585 self.entries.insert(resolved.locator.clone(), resolved)
586 }
587
588 pub fn insert_memory(
589 &mut self,
590 key: impl Into<AssetMemoryKey>,
591 revision: AssetRevision,
592 bytes: impl Into<Arc<[u8]>>,
593 ) -> Option<ResolvedAssetBytes> {
594 self.insert(ResolvedAssetBytes::new(
595 AssetLocator::memory(key),
596 revision,
597 bytes,
598 ))
599 }
600
601 pub fn insert_embedded(
602 &mut self,
603 owner: impl Into<AssetBundleId>,
604 key: impl Into<AssetKey>,
605 revision: AssetRevision,
606 bytes: impl Into<Arc<[u8]>>,
607 ) -> Option<ResolvedAssetBytes> {
608 self.insert(ResolvedAssetBytes::new(
609 AssetLocator::embedded(owner, key),
610 revision,
611 bytes,
612 ))
613 }
614
615 pub fn insert_embedded_entry(
616 &mut self,
617 owner: impl Into<AssetBundleId>,
618 entry: StaticAssetEntry,
619 ) -> Option<ResolvedAssetBytes> {
620 let owner = owner.into();
621 self.insert(entry.into_resolved(AssetLocator::embedded(owner, entry.key)))
622 }
623
624 pub fn insert_embedded_entries(
625 &mut self,
626 owner: impl Into<AssetBundleId>,
627 entries: impl IntoIterator<Item = StaticAssetEntry>,
628 ) {
629 let owner = owner.into();
630 for entry in entries {
631 let _ = self.insert_embedded_entry(owner.clone(), entry);
632 }
633 }
634
635 pub fn insert_bundle(
636 &mut self,
637 bundle: impl Into<AssetBundleId>,
638 key: impl Into<AssetKey>,
639 revision: AssetRevision,
640 bytes: impl Into<Arc<[u8]>>,
641 ) -> Option<ResolvedAssetBytes> {
642 self.insert(ResolvedAssetBytes::new(
643 AssetLocator::bundle(bundle, key),
644 revision,
645 bytes,
646 ))
647 }
648
649 pub fn insert_bundle_entry(
650 &mut self,
651 bundle: impl Into<AssetBundleId>,
652 entry: StaticAssetEntry,
653 ) -> Option<ResolvedAssetBytes> {
654 let bundle = bundle.into();
655 self.insert(entry.into_resolved(AssetLocator::bundle(bundle, entry.key)))
656 }
657
658 pub fn insert_bundle_entries(
659 &mut self,
660 bundle: impl Into<AssetBundleId>,
661 entries: impl IntoIterator<Item = StaticAssetEntry>,
662 ) {
663 let bundle = bundle.into();
664 for entry in entries {
665 let _ = self.insert_bundle_entry(bundle.clone(), entry);
666 }
667 }
668
669 pub fn resolve_locator_bytes(
670 &self,
671 locator: AssetLocator,
672 ) -> Result<ResolvedAssetBytes, AssetLoadError> {
673 self.resolve_bytes(&AssetRequest::new(locator))
674 }
675}
676
677impl AssetResolver for InMemoryAssetResolver {
678 fn capabilities(&self) -> AssetCapabilities {
679 self.capabilities
680 }
681
682 fn resolve_bytes(&self, request: &AssetRequest) -> Result<ResolvedAssetBytes, AssetLoadError> {
683 if !self.capabilities.supports(&request.locator) {
684 return Err(AssetLoadError::UnsupportedLocatorKind {
685 kind: request.locator.kind(),
686 });
687 }
688
689 self.entries
690 .get(&request.locator)
691 .cloned()
692 .ok_or(AssetLoadError::NotFound)
693 }
694}
695
696#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
697pub enum AssetIoOperation {
698 Read,
699}
700
701impl AssetIoOperation {
702 pub fn as_str(self) -> &'static str {
703 match self {
704 Self::Read => "read",
705 }
706 }
707}
708
709impl std::fmt::Display for AssetIoOperation {
710 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
711 f.write_str(self.as_str())
712 }
713}
714
715#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
716pub enum AssetLoadError {
717 #[error("asset resolver is not installed on this host")]
718 ResolverUnavailable,
719 #[error("asset locator kind {kind:?} is not supported on this host")]
720 UnsupportedLocatorKind { kind: AssetLocatorKind },
721 #[error("asset locator kind {kind:?} cannot provide an external reference handoff")]
722 ExternalReferenceUnavailable { kind: AssetLocatorKind },
723 #[error(
724 "asset locator kind {kind:?} is reference-only on this path; resolve_reference(...) instead"
725 )]
726 ReferenceOnlyLocator { kind: AssetLocatorKind },
727 #[error("asset not found")]
728 NotFound,
729 #[error("asset manifest entry points at a missing file: {path}")]
730 StaleManifestMapping { path: SmolStr },
731 #[error("asset access denied")]
732 AccessDenied,
733 #[error("asset {operation} failed for {path}: {message}")]
734 Io {
735 operation: AssetIoOperation,
736 path: SmolStr,
737 message: SmolStr,
738 },
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744
745 fn app_bundle() -> AssetBundleId {
746 AssetBundleId::app("demo-app")
747 }
748
749 fn package_bundle() -> AssetBundleId {
750 AssetBundleId::package("fret-ui-shadcn")
751 }
752
753 #[test]
754 fn bundle_id_scoped_helpers_encode_namespace() {
755 let app = AssetBundleId::app("demo-app");
756 let pkg = AssetBundleId::package("fret-ui-shadcn");
757 let opaque = AssetBundleId::new("legacy-assets");
758
759 assert_eq!(app.as_str(), "app:demo-app");
760 assert_eq!(app.namespace(), Some(AssetBundleNamespace::App));
761 assert_eq!(app.local_name(), "demo-app");
762 assert!(app.is_scoped());
763
764 assert_eq!(pkg.as_str(), "pkg:fret-ui-shadcn");
765 assert_eq!(pkg.namespace(), Some(AssetBundleNamespace::Package));
766 assert_eq!(pkg.local_name(), "fret-ui-shadcn");
767 assert!(pkg.is_scoped());
768
769 assert_eq!(opaque.namespace(), None);
770 assert_eq!(opaque.local_name(), "legacy-assets");
771 assert!(!opaque.is_scoped());
772 }
773
774 #[test]
775 fn bundle_id_macros_default_to_current_package_name() {
776 let app = asset_app_bundle_id!();
777 let pkg = asset_package_bundle_id!();
778
779 assert_eq!(app, AssetBundleId::app(env!("CARGO_PKG_NAME")));
780 assert_eq!(pkg, AssetBundleId::package(env!("CARGO_PKG_NAME")));
781 }
782
783 #[test]
784 fn locator_kind_matches_variant() {
785 assert_eq!(
786 AssetLocator::memory("framebuffer-snapshot").kind(),
787 AssetLocatorKind::Memory
788 );
789 assert_eq!(
790 AssetLocator::embedded(package_bundle(), "icons/search.svg").kind(),
791 AssetLocatorKind::Embedded
792 );
793 assert_eq!(
794 AssetLocator::bundle(app_bundle(), "images/logo.png").kind(),
795 AssetLocatorKind::BundleAsset
796 );
797 assert_eq!(
798 AssetLocator::file("assets/logo.png").kind(),
799 AssetLocatorKind::File
800 );
801 assert_eq!(
802 AssetLocator::url("https://example.com/logo.png").kind(),
803 AssetLocatorKind::Url
804 );
805 }
806
807 #[test]
808 fn capabilities_report_support_per_locator_kind() {
809 let caps = AssetCapabilities {
810 memory: true,
811 embedded: true,
812 bundle_asset: true,
813 file: false,
814 url: true,
815 file_watch: false,
816 system_font_scan: false,
817 };
818
819 assert!(caps.supports(&AssetLocator::bundle(app_bundle(), "images/logo.png")));
820 assert!(caps.supports(&AssetLocator::embedded(
821 AssetBundleId::package("ui-kit"),
822 "icons/close.svg"
823 )));
824 assert!(!caps.supports(&AssetLocator::file("assets/logo.png")));
825 }
826
827 #[test]
828 fn resolved_asset_bytes_can_attach_media_type() {
829 let resolved = ResolvedAssetBytes::new(
830 AssetLocator::bundle(app_bundle(), "images/logo.png"),
831 AssetRevision(7),
832 Arc::<[u8]>::from([1u8, 2, 3]),
833 )
834 .with_media_type("image/png");
835
836 assert_eq!(resolved.revision, AssetRevision(7));
837 assert_eq!(
838 resolved.media_type.as_ref().map(AssetMediaType::as_str),
839 Some("image/png")
840 );
841 assert_eq!(resolved.bytes.as_ref(), &[1, 2, 3]);
842 }
843
844 #[test]
845 fn resolved_asset_reference_can_attach_media_type() {
846 let resolved = ResolvedAssetReference::new(
847 AssetLocator::bundle(app_bundle(), "images/logo.png"),
848 AssetRevision(11),
849 AssetExternalReference::file_path("assets/logo.png"),
850 )
851 .with_media_type("image/png");
852
853 assert_eq!(resolved.revision, AssetRevision(11));
854 assert_eq!(
855 resolved.media_type.as_ref().map(AssetMediaType::as_str),
856 Some("image/png")
857 );
858 assert_eq!(
859 resolved
860 .reference
861 .as_file_path()
862 .map(|path| path.to_string_lossy()),
863 Some("assets/logo.png".into())
864 );
865 }
866
867 #[test]
868 fn asset_external_reference_accessors_match_variant() {
869 let path = AssetExternalReference::file_path("assets/logo.png");
870 let url = AssetExternalReference::url("https://example.com/logo.png");
871
872 assert_eq!(
873 path.as_file_path().map(|value| value.to_string_lossy()),
874 Some("assets/logo.png".into())
875 );
876 assert_eq!(path.as_url(), None);
877 assert_eq!(url.as_file_path(), None);
878 assert_eq!(url.as_url(), Some("https://example.com/logo.png"));
879 }
880
881 #[test]
882 fn asset_resolver_supports_capability_queries() {
883 struct TestResolver;
884
885 impl AssetResolver for TestResolver {
886 fn capabilities(&self) -> AssetCapabilities {
887 AssetCapabilities {
888 memory: true,
889 embedded: true,
890 bundle_asset: true,
891 file: false,
892 url: false,
893 file_watch: false,
894 system_font_scan: false,
895 }
896 }
897
898 fn resolve_bytes(
899 &self,
900 request: &AssetRequest,
901 ) -> Result<ResolvedAssetBytes, AssetLoadError> {
902 Ok(ResolvedAssetBytes::new(
903 request.locator.clone(),
904 AssetRevision(1),
905 Arc::<[u8]>::from([9u8, 8, 7]),
906 ))
907 }
908 }
909
910 let resolver = TestResolver;
911 let dyn_resolver: &dyn AssetResolver = &resolver;
912
913 assert!(dyn_resolver.supports(&AssetLocator::bundle(app_bundle(), "images/logo.png")));
914 assert!(!dyn_resolver.supports(&AssetLocator::file("assets/logo.png")));
915
916 let resolved = dyn_resolver
917 .resolve_locator_bytes(AssetLocator::bundle(app_bundle(), "images/logo.png"))
918 .expect("bundle asset should resolve");
919 assert_eq!(resolved.revision, AssetRevision(1));
920 assert_eq!(resolved.bytes.as_ref(), &[9, 8, 7]);
921 }
922
923 #[test]
924 fn in_memory_asset_resolver_resolves_bundle_and_embedded_assets() {
925 let mut resolver = InMemoryAssetResolver::new();
926 resolver.insert_bundle(
927 app_bundle(),
928 "images/logo.png",
929 AssetRevision(5),
930 [1u8, 2, 3],
931 );
932 resolver.insert_embedded(
933 package_bundle(),
934 "icons/search.svg",
935 AssetRevision(9),
936 [4u8, 5, 6],
937 );
938
939 let bundle = resolver
940 .resolve_locator_bytes(AssetLocator::bundle(app_bundle(), "images/logo.png"))
941 .expect("bundle asset should resolve");
942 let embedded = resolver
943 .resolve_locator_bytes(AssetLocator::embedded(package_bundle(), "icons/search.svg"))
944 .expect("embedded asset should resolve");
945
946 assert_eq!(bundle.revision, AssetRevision(5));
947 assert_eq!(bundle.bytes.as_ref(), &[1, 2, 3]);
948 assert_eq!(embedded.revision, AssetRevision(9));
949 assert_eq!(embedded.bytes.as_ref(), &[4, 5, 6]);
950 }
951
952 #[test]
953 fn in_memory_asset_resolver_reports_external_reference_unavailable_for_present_assets() {
954 let mut resolver = InMemoryAssetResolver::new();
955 resolver.insert_bundle(
956 app_bundle(),
957 "images/logo.png",
958 AssetRevision(5),
959 [1u8, 2, 3],
960 );
961
962 let err = resolver
963 .resolve_reference(&AssetRequest::new(AssetLocator::bundle(
964 app_bundle(),
965 "images/logo.png",
966 )))
967 .expect_err("in-memory bundle entry should not expose an external reference");
968
969 assert_eq!(
970 err,
971 AssetLoadError::ExternalReferenceUnavailable {
972 kind: AssetLocatorKind::BundleAsset,
973 }
974 );
975 }
976
977 #[test]
978 fn custom_resolver_can_publish_external_references() {
979 struct TestReferenceResolver;
980
981 impl AssetResolver for TestReferenceResolver {
982 fn capabilities(&self) -> AssetCapabilities {
983 AssetCapabilities {
984 memory: false,
985 embedded: false,
986 bundle_asset: true,
987 file: false,
988 url: false,
989 file_watch: false,
990 system_font_scan: false,
991 }
992 }
993
994 fn resolve_bytes(
995 &self,
996 _request: &AssetRequest,
997 ) -> Result<ResolvedAssetBytes, AssetLoadError> {
998 Err(AssetLoadError::ExternalReferenceUnavailable {
999 kind: AssetLocatorKind::BundleAsset,
1000 })
1001 }
1002
1003 fn resolve_reference(
1004 &self,
1005 request: &AssetRequest,
1006 ) -> Result<ResolvedAssetReference, AssetLoadError> {
1007 Ok(ResolvedAssetReference::new(
1008 request.locator.clone(),
1009 AssetRevision(13),
1010 AssetExternalReference::file_path("assets/logo.png"),
1011 )
1012 .with_media_type("image/png"))
1013 }
1014 }
1015
1016 let resolver = TestReferenceResolver;
1017 let resolved = resolver
1018 .resolve_reference(&AssetRequest::new(AssetLocator::bundle(
1019 app_bundle(),
1020 "images/logo.png",
1021 )))
1022 .expect("custom resolver should expose an external reference");
1023
1024 assert_eq!(resolved.revision, AssetRevision(13));
1025 assert_eq!(
1026 resolved
1027 .reference
1028 .as_file_path()
1029 .map(|path| path.to_string_lossy().into_owned()),
1030 Some("assets/logo.png".to_string())
1031 );
1032 assert_eq!(
1033 resolved.media_type.as_ref().map(AssetMediaType::as_str),
1034 Some("image/png")
1035 );
1036 }
1037
1038 #[test]
1039 fn static_asset_entries_support_media_type_and_bulk_registration() {
1040 let mut resolver = InMemoryAssetResolver::new();
1041 resolver.insert_bundle_entries(
1042 app_bundle(),
1043 [
1044 StaticAssetEntry::new("images/logo.png", AssetRevision(3), b"png-bytes")
1045 .with_media_type("image/png"),
1046 StaticAssetEntry::new(
1047 "icons/search.svg",
1048 AssetRevision(4),
1049 br#"<svg viewBox="0 0 1 1"></svg>"#,
1050 )
1051 .with_media_type("image/svg+xml"),
1052 ],
1053 );
1054 resolver.insert_embedded_entries(
1055 package_bundle(),
1056 [
1057 StaticAssetEntry::new("fonts/ui-sans.ttf", AssetRevision(8), b"font-bytes")
1058 .with_media_type("font/ttf"),
1059 ],
1060 );
1061
1062 let bundle = resolver
1063 .resolve_locator_bytes(AssetLocator::bundle(app_bundle(), "images/logo.png"))
1064 .expect("bundle asset should resolve");
1065 let svg = resolver
1066 .resolve_locator_bytes(AssetLocator::bundle(app_bundle(), "icons/search.svg"))
1067 .expect("svg asset should resolve");
1068 let embedded = resolver
1069 .resolve_locator_bytes(AssetLocator::embedded(
1070 package_bundle(),
1071 "fonts/ui-sans.ttf",
1072 ))
1073 .expect("embedded asset should resolve");
1074
1075 assert_eq!(
1076 bundle.media_type.as_ref().map(AssetMediaType::as_str),
1077 Some("image/png")
1078 );
1079 assert_eq!(
1080 svg.media_type.as_ref().map(AssetMediaType::as_str),
1081 Some("image/svg+xml")
1082 );
1083 assert_eq!(
1084 embedded.media_type.as_ref().map(AssetMediaType::as_str),
1085 Some("font/ttf")
1086 );
1087 assert_eq!(embedded.revision, AssetRevision(8));
1088 }
1089}