Skip to main content

fret_assets/
lib.rs

1//! Portable asset contract vocabulary for the Fret workspace.
2//!
3//! This crate intentionally defines only stable, dependency-light asset contract types:
4//!
5//! - logical asset identity (`AssetBundleId`, `AssetKey`, `AssetLocator`),
6//! - capability reporting (`AssetCapabilities`),
7//! - revisioning (`AssetRevision`),
8//! - and small request/result/error types for higher layers to build on.
9//!
10//! It does not own:
11//!
12//! - packaging policy,
13//! - async loading orchestration,
14//! - cache lifetimes,
15//! - UI invalidation,
16//! - or platform-specific resolver implementations.
17
18use 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}