Skip to main content

fret_assets/
file_manifest.rs

1#[cfg(not(target_arch = "wasm32"))]
2use std::collections::HashMap;
3#[cfg(not(target_arch = "wasm32"))]
4use std::hash::{DefaultHasher, Hash, Hasher};
5#[cfg(not(target_arch = "wasm32"))]
6use std::path::Path;
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10use smol_str::SmolStr;
11
12use crate::{AssetBundleId, AssetKey, AssetMediaType};
13#[cfg(not(target_arch = "wasm32"))]
14use crate::{
15    AssetCapabilities, AssetExternalReference, AssetIoOperation, AssetLoadError, AssetLocator,
16    AssetRequest, AssetResolver, AssetRevision, ResolvedAssetBytes, ResolvedAssetReference,
17};
18
19pub const FILE_ASSET_MANIFEST_KIND_V1: &str = "fret_file_asset_manifest";
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct FileAssetManifestV1 {
23    pub schema_version: u32,
24    pub kind: SmolStr,
25    pub bundles: Vec<FileAssetManifestBundleV1>,
26}
27
28impl FileAssetManifestV1 {
29    pub const SCHEMA_VERSION: u32 = 1;
30
31    pub fn new(bundles: impl IntoIterator<Item = FileAssetManifestBundleV1>) -> Self {
32        Self {
33            schema_version: Self::SCHEMA_VERSION,
34            kind: FILE_ASSET_MANIFEST_KIND_V1.into(),
35            bundles: bundles.into_iter().collect(),
36        }
37    }
38
39    pub fn validate(&self) -> Result<(), AssetManifestLoadError> {
40        if self.schema_version != Self::SCHEMA_VERSION {
41            return Err(AssetManifestLoadError::InvalidManifest {
42                message: format!(
43                    "invalid schema_version {} (expected {})",
44                    self.schema_version,
45                    Self::SCHEMA_VERSION
46                )
47                .into(),
48            });
49        }
50
51        if self.kind.as_str() != FILE_ASSET_MANIFEST_KIND_V1 {
52            return Err(AssetManifestLoadError::InvalidManifest {
53                message: format!(
54                    "invalid kind {:?} (expected {FILE_ASSET_MANIFEST_KIND_V1:?})",
55                    self.kind
56                )
57                .into(),
58            });
59        }
60
61        let mut seen = std::collections::HashSet::new();
62        for bundle in &self.bundles {
63            if bundle.id.as_str().trim().is_empty() {
64                return Err(AssetManifestLoadError::InvalidManifest {
65                    message: "bundle id must not be empty".into(),
66                });
67            }
68
69            for entry in &bundle.entries {
70                if entry.key.as_str().trim().is_empty() {
71                    return Err(AssetManifestLoadError::InvalidManifest {
72                        message: format!("bundle {:?} contains an empty asset key", bundle.id)
73                            .into(),
74                    });
75                }
76
77                let duplicate_key = (bundle.id.clone(), entry.key.clone());
78                if !seen.insert(duplicate_key.clone()) {
79                    return Err(AssetManifestLoadError::DuplicateBundleKey {
80                        bundle: duplicate_key.0,
81                        key: duplicate_key.1,
82                    });
83                }
84            }
85        }
86
87        Ok(())
88    }
89
90    #[cfg(not(target_arch = "wasm32"))]
91    pub fn load_json_path(path: impl AsRef<Path>) -> Result<Self, AssetManifestLoadError> {
92        let path = path.as_ref();
93        let bytes = std::fs::read(path).map_err(|source| AssetManifestLoadError::ReadManifest {
94            path: path.to_path_buf(),
95            source,
96        })?;
97        let manifest = serde_json::from_slice::<Self>(&bytes).map_err(|source| {
98            AssetManifestLoadError::ParseManifest {
99                path: path.to_path_buf(),
100                source,
101            }
102        })?;
103        manifest.validate()?;
104        Ok(manifest)
105    }
106
107    #[cfg(not(target_arch = "wasm32"))]
108    pub fn write_json_path(&self, path: impl AsRef<Path>) -> Result<(), AssetManifestLoadError> {
109        self.validate()?;
110
111        let path = path.as_ref();
112        if let Some(parent) = path.parent() {
113            std::fs::create_dir_all(parent).map_err(|source| {
114                AssetManifestLoadError::WriteManifest {
115                    path: path.to_path_buf(),
116                    source,
117                }
118            })?;
119        }
120
121        let bytes = serde_json::to_vec_pretty(self).map_err(|source| {
122            AssetManifestLoadError::SerializeManifest {
123                path: path.to_path_buf(),
124                source,
125            }
126        })?;
127        std::fs::write(path, bytes).map_err(|source| AssetManifestLoadError::WriteManifest {
128            path: path.to_path_buf(),
129            source,
130        })?;
131        Ok(())
132    }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub struct FileAssetManifestBundleV1 {
137    pub id: AssetBundleId,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub root: Option<PathBuf>,
140    #[serde(default)]
141    pub entries: Vec<FileAssetManifestEntryV1>,
142}
143
144impl FileAssetManifestBundleV1 {
145    pub fn new(
146        id: impl Into<AssetBundleId>,
147        entries: impl IntoIterator<Item = FileAssetManifestEntryV1>,
148    ) -> Self {
149        Self {
150            id: id.into(),
151            root: None,
152            entries: entries.into_iter().collect(),
153        }
154    }
155
156    pub fn with_root(mut self, root: impl Into<PathBuf>) -> Self {
157        self.root = Some(root.into());
158        self
159    }
160
161    #[cfg(not(target_arch = "wasm32"))]
162    pub fn scan_dir(
163        id: impl Into<AssetBundleId>,
164        root: impl AsRef<Path>,
165    ) -> Result<Self, AssetManifestLoadError> {
166        let id = id.into();
167        let root = root.as_ref();
168        let metadata =
169            std::fs::metadata(root).map_err(|source| AssetManifestLoadError::ReadBundleRoot {
170                path: root.to_path_buf(),
171                source,
172            })?;
173        if !metadata.is_dir() {
174            return Err(AssetManifestLoadError::InvalidManifest {
175                message: format!("bundle root is not a directory: {}", root.display()).into(),
176            });
177        }
178
179        let mut files = Vec::new();
180        collect_bundle_files(root, &mut files)?;
181        files.sort();
182
183        let entries = files
184            .into_iter()
185            .map(|path| {
186                let rel = path.strip_prefix(root).map_err(|_| {
187                    AssetManifestLoadError::InvalidManifest {
188                        message: format!(
189                            "failed to strip bundle root {} from {}",
190                            root.display(),
191                            path.display()
192                        )
193                        .into(),
194                    }
195                })?;
196                let key = rel.to_string_lossy().replace('\\', "/");
197                Ok(FileAssetManifestEntryV1::new(key))
198            })
199            .collect::<Result<Vec<_>, AssetManifestLoadError>>()?;
200
201        Ok(Self::new(id, entries).with_root(root.to_path_buf()))
202    }
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206pub struct FileAssetManifestEntryV1 {
207    pub key: AssetKey,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub path: Option<PathBuf>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub media_type: Option<AssetMediaType>,
212}
213
214impl FileAssetManifestEntryV1 {
215    pub fn new(key: impl Into<AssetKey>) -> Self {
216        Self {
217            key: key.into(),
218            path: None,
219            media_type: None,
220        }
221    }
222
223    pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
224        self.path = Some(path.into());
225        self
226    }
227
228    pub fn with_media_type(mut self, media_type: impl Into<AssetMediaType>) -> Self {
229        self.media_type = Some(media_type.into());
230        self
231    }
232}
233
234#[derive(Debug, thiserror::Error)]
235pub enum AssetManifestLoadError {
236    #[error("failed to read asset manifest {path}: {source}")]
237    ReadManifest {
238        path: PathBuf,
239        #[source]
240        source: std::io::Error,
241    },
242    #[error("failed to parse asset manifest {path}: {source}")]
243    ParseManifest {
244        path: PathBuf,
245        #[source]
246        source: serde_json::Error,
247    },
248    #[error("failed to serialize asset manifest {path}: {source}")]
249    SerializeManifest {
250        path: PathBuf,
251        #[source]
252        source: serde_json::Error,
253    },
254    #[error("failed to write asset manifest {path}: {source}")]
255    WriteManifest {
256        path: PathBuf,
257        #[source]
258        source: std::io::Error,
259    },
260    #[error("failed to read asset bundle root {path}: {source}")]
261    ReadBundleRoot {
262        path: PathBuf,
263        #[source]
264        source: std::io::Error,
265    },
266    #[error("invalid asset manifest: {message}")]
267    InvalidManifest { message: SmolStr },
268    #[error("duplicate asset manifest entry for bundle {bundle:?} key {key:?}")]
269    DuplicateBundleKey {
270        bundle: AssetBundleId,
271        key: AssetKey,
272    },
273}
274
275#[cfg(not(target_arch = "wasm32"))]
276#[derive(Debug, Clone)]
277pub struct FileAssetManifestResolver {
278    manifest_path: PathBuf,
279    entries: HashMap<AssetLocator, FileAssetManifestResolvedEntry>,
280}
281
282#[cfg(not(target_arch = "wasm32"))]
283#[derive(Debug, Clone)]
284struct FileAssetManifestResolvedEntry {
285    path: PathBuf,
286    media_type: Option<AssetMediaType>,
287}
288
289#[cfg(not(target_arch = "wasm32"))]
290impl FileAssetManifestResolver {
291    pub fn from_manifest_path(path: impl AsRef<Path>) -> Result<Self, AssetManifestLoadError> {
292        let path = path.as_ref();
293        let manifest = FileAssetManifestV1::load_json_path(path)?;
294        let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
295        Self::from_manifest_with_base_dir(manifest, base_dir, path.to_path_buf())
296    }
297
298    pub fn from_bundle_dir(
299        bundle: impl Into<AssetBundleId>,
300        root: impl AsRef<Path>,
301    ) -> Result<Self, AssetManifestLoadError> {
302        let root = root.as_ref();
303        let manifest =
304            FileAssetManifestV1::new([FileAssetManifestBundleV1::scan_dir(bundle, root)?]);
305        Self::from_manifest_with_base_dir(manifest, PathBuf::new(), root.to_path_buf())
306    }
307
308    pub fn from_manifest_with_base_dir(
309        manifest: FileAssetManifestV1,
310        base_dir: impl Into<PathBuf>,
311        manifest_path: impl Into<PathBuf>,
312    ) -> Result<Self, AssetManifestLoadError> {
313        manifest.validate()?;
314
315        let base_dir = base_dir.into();
316        let mut entries = HashMap::new();
317        for bundle in manifest.bundles {
318            let bundle_root = bundle.root.unwrap_or_default();
319            for entry in bundle.entries {
320                let locator = AssetLocator::bundle(bundle.id.clone(), entry.key.clone());
321                let entry_path = entry
322                    .path
323                    .unwrap_or_else(|| PathBuf::from(entry.key.as_str()));
324                let path = resolve_manifest_path(&base_dir, &bundle_root, &entry_path);
325                entries.insert(
326                    locator,
327                    FileAssetManifestResolvedEntry {
328                        path,
329                        media_type: entry.media_type,
330                    },
331                );
332            }
333        }
334
335        Ok(Self {
336            manifest_path: manifest_path.into(),
337            entries,
338        })
339    }
340
341    pub fn manifest_path(&self) -> &Path {
342        &self.manifest_path
343    }
344
345    pub fn entry_count(&self) -> usize {
346        self.entries.len()
347    }
348}
349
350#[cfg(not(target_arch = "wasm32"))]
351impl AssetResolver for FileAssetManifestResolver {
352    fn capabilities(&self) -> AssetCapabilities {
353        AssetCapabilities {
354            memory: false,
355            embedded: false,
356            bundle_asset: true,
357            file: false,
358            url: false,
359            file_watch: false,
360            system_font_scan: false,
361        }
362    }
363
364    fn resolve_bytes(&self, request: &AssetRequest) -> Result<ResolvedAssetBytes, AssetLoadError> {
365        let Some(entry) = self.entries.get(&request.locator) else {
366            return Err(match request.locator {
367                AssetLocator::BundleAsset(_) => AssetLoadError::NotFound,
368                _ => AssetLoadError::UnsupportedLocatorKind {
369                    kind: request.locator.kind(),
370                },
371            });
372        };
373
374        let (bytes, revision) = read_file_bytes_with_revision(&entry.path)?;
375        let mut resolved = ResolvedAssetBytes::new(request.locator.clone(), revision, bytes);
376        if let Some(media_type) = &entry.media_type {
377            resolved = resolved.with_media_type(media_type.clone());
378        }
379        Ok(resolved)
380    }
381
382    fn resolve_reference(
383        &self,
384        request: &AssetRequest,
385    ) -> Result<ResolvedAssetReference, AssetLoadError> {
386        let Some(entry) = self.entries.get(&request.locator) else {
387            return Err(match request.locator {
388                AssetLocator::BundleAsset(_) => AssetLoadError::NotFound,
389                _ => AssetLoadError::UnsupportedLocatorKind {
390                    kind: request.locator.kind(),
391                },
392            });
393        };
394
395        let revision = read_file_revision(&entry.path)?;
396        let mut resolved = ResolvedAssetReference::new(
397            request.locator.clone(),
398            revision,
399            AssetExternalReference::file_path(entry.path.clone()),
400        );
401        if let Some(media_type) = &entry.media_type {
402            resolved = resolved.with_media_type(media_type.clone());
403        }
404        Ok(resolved)
405    }
406}
407
408#[cfg(not(target_arch = "wasm32"))]
409fn resolve_manifest_path(base_dir: &Path, bundle_root: &Path, entry_path: &Path) -> PathBuf {
410    if entry_path.is_absolute() {
411        return entry_path.to_path_buf();
412    }
413
414    let joined_root = if bundle_root.is_absolute() {
415        bundle_root.to_path_buf()
416    } else {
417        base_dir.join(bundle_root)
418    };
419    joined_root.join(entry_path)
420}
421
422#[cfg(not(target_arch = "wasm32"))]
423fn collect_bundle_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), AssetManifestLoadError> {
424    let entries =
425        std::fs::read_dir(dir).map_err(|source| AssetManifestLoadError::ReadBundleRoot {
426            path: dir.to_path_buf(),
427            source,
428        })?;
429    let mut paths = Vec::new();
430    for entry in entries {
431        let entry = entry.map_err(|source| AssetManifestLoadError::ReadBundleRoot {
432            path: dir.to_path_buf(),
433            source,
434        })?;
435        paths.push(entry.path());
436    }
437    paths.sort();
438
439    for path in paths {
440        let metadata =
441            std::fs::metadata(&path).map_err(|source| AssetManifestLoadError::ReadBundleRoot {
442                path: path.clone(),
443                source,
444            })?;
445        if metadata.is_dir() {
446            collect_bundle_files(&path, out)?;
447        } else if metadata.is_file() {
448            out.push(path);
449        }
450    }
451    Ok(())
452}
453
454#[cfg(not(target_arch = "wasm32"))]
455fn map_fs_read_error_for_manifest_entry(path: &Path, source: std::io::Error) -> AssetLoadError {
456    use std::io::ErrorKind;
457
458    match source.kind() {
459        ErrorKind::NotFound => AssetLoadError::StaleManifestMapping {
460            path: path.to_string_lossy().into_owned().into(),
461        },
462        ErrorKind::PermissionDenied => AssetLoadError::AccessDenied,
463        _ => AssetLoadError::Io {
464            operation: AssetIoOperation::Read,
465            path: path.to_string_lossy().into_owned().into(),
466            message: source.to_string().into(),
467        },
468    }
469}
470
471#[cfg(not(target_arch = "wasm32"))]
472fn read_file_bytes_with_revision(path: &Path) -> Result<(Vec<u8>, AssetRevision), AssetLoadError> {
473    let bytes =
474        std::fs::read(path).map_err(|source| map_fs_read_error_for_manifest_entry(path, source))?;
475    let revision = AssetRevision(hash_bytes(&bytes));
476    Ok((bytes, revision))
477}
478
479#[cfg(not(target_arch = "wasm32"))]
480fn read_file_revision(path: &Path) -> Result<AssetRevision, AssetLoadError> {
481    let (_, revision) = read_file_bytes_with_revision(path)?;
482    Ok(revision)
483}
484
485#[cfg(not(target_arch = "wasm32"))]
486fn hash_bytes(bytes: &[u8]) -> u64 {
487    let mut hasher = DefaultHasher::new();
488    bytes.hash(&mut hasher);
489    hasher.finish()
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    #[cfg(not(target_arch = "wasm32"))]
497    use std::time::{SystemTime, UNIX_EPOCH};
498
499    fn app_bundle() -> AssetBundleId {
500        AssetBundleId::app("demo-app")
501    }
502
503    #[cfg(not(target_arch = "wasm32"))]
504    #[test]
505    fn manifest_entry_io_failures_stay_typed() {
506        let path = Path::new("/tmp/dev-assets/icons/search.svg");
507        let err = map_fs_read_error_for_manifest_entry(path, std::io::Error::other("i/o exploded"));
508
509        assert_eq!(
510            err,
511            AssetLoadError::Io {
512                operation: AssetIoOperation::Read,
513                path: "/tmp/dev-assets/icons/search.svg".into(),
514                message: "i/o exploded".into(),
515            }
516        );
517    }
518
519    #[test]
520    fn manifest_validation_rejects_duplicate_bundle_keys() {
521        let manifest = FileAssetManifestV1::new([FileAssetManifestBundleV1::new(
522            app_bundle(),
523            [
524                FileAssetManifestEntryV1::new("images/logo.png"),
525                FileAssetManifestEntryV1::new("images/logo.png"),
526            ],
527        )]);
528
529        assert!(matches!(
530            manifest.validate(),
531            Err(AssetManifestLoadError::DuplicateBundleKey { .. })
532        ));
533    }
534
535    #[test]
536    fn manifest_entries_default_to_key_as_file_path() {
537        let manifest = FileAssetManifestV1::new([FileAssetManifestBundleV1::new(
538            app_bundle(),
539            [FileAssetManifestEntryV1::new("images/logo.png")],
540        )
541        .with_root("assets")]);
542        manifest.validate().expect("manifest should validate");
543
544        let bundle = &manifest.bundles[0];
545        let entry = &bundle.entries[0];
546        assert_eq!(entry.path, None);
547        assert_eq!(bundle.root.as_deref(), Some(Path::new("assets")));
548    }
549
550    #[cfg(not(target_arch = "wasm32"))]
551    #[test]
552    fn file_manifest_resolver_loads_manifest_and_resolves_bundle_bytes() {
553        let root = make_temp_dir("fret-assets-file-manifest");
554        let assets_dir = root.join("assets").join("images");
555        std::fs::create_dir_all(&assets_dir).expect("create assets dir");
556        let logo_path = assets_dir.join("logo.txt");
557        std::fs::write(&logo_path, b"hello-manifest").expect("write asset file");
558
559        let manifest = FileAssetManifestV1::new([FileAssetManifestBundleV1::new(
560            app_bundle(),
561            [FileAssetManifestEntryV1::new("images/logo.png")
562                .with_path("images/logo.txt")
563                .with_media_type("text/plain")],
564        )
565        .with_root("assets")]);
566        let manifest_path = root.join("assets.manifest.json");
567        std::fs::write(
568            &manifest_path,
569            serde_json::to_vec_pretty(&manifest).expect("serialize manifest"),
570        )
571        .expect("write manifest");
572
573        let resolver = FileAssetManifestResolver::from_manifest_path(&manifest_path)
574            .expect("manifest resolver should load");
575        let resolved = resolver
576            .resolve_bytes(&AssetRequest::new(AssetLocator::bundle(
577                app_bundle(),
578                "images/logo.png",
579            )))
580            .expect("bundle asset should resolve");
581
582        assert_eq!(resolver.entry_count(), 1);
583        assert_eq!(resolver.manifest_path(), manifest_path.as_path());
584        assert_eq!(resolved.bytes.as_ref(), b"hello-manifest");
585        assert_eq!(
586            resolved.media_type.as_ref().map(AssetMediaType::as_str),
587            Some("text/plain")
588        );
589    }
590
591    #[cfg(not(target_arch = "wasm32"))]
592    #[test]
593    fn file_manifest_resolver_resolves_external_file_reference_for_bundle_assets() {
594        let root = make_temp_dir("fret-assets-file-manifest-reference");
595        let assets_dir = root.join("assets").join("images");
596        std::fs::create_dir_all(&assets_dir).expect("create assets dir");
597        let logo_path = assets_dir.join("logo.txt");
598        std::fs::write(&logo_path, b"hello-manifest").expect("write asset file");
599
600        let manifest = FileAssetManifestV1::new([FileAssetManifestBundleV1::new(
601            app_bundle(),
602            [FileAssetManifestEntryV1::new("images/logo.png")
603                .with_path("images/logo.txt")
604                .with_media_type("text/plain")],
605        )
606        .with_root("assets")]);
607        let resolver = FileAssetManifestResolver::from_manifest_with_base_dir(
608            manifest,
609            &root,
610            root.join("inline.manifest.json"),
611        )
612        .expect("manifest resolver should build");
613
614        let resolved = resolver
615            .resolve_reference(&AssetRequest::new(AssetLocator::bundle(
616                app_bundle(),
617                "images/logo.png",
618            )))
619            .expect("bundle asset should expose an external reference");
620
621        assert_eq!(
622            resolved.revision,
623            AssetRevision(hash_bytes(b"hello-manifest"))
624        );
625        assert_eq!(resolved.reference.as_file_path(), Some(logo_path.as_path()));
626        assert_eq!(
627            resolved.media_type.as_ref().map(AssetMediaType::as_str),
628            Some("text/plain")
629        );
630    }
631
632    #[cfg(not(target_arch = "wasm32"))]
633    #[test]
634    fn file_manifest_resolver_uses_key_path_when_entry_path_is_omitted() {
635        let root = make_temp_dir("fret-assets-file-manifest-key-default");
636        let asset_dir = root.join("assets").join("icons");
637        std::fs::create_dir_all(&asset_dir).expect("create icons dir");
638        let icon_path = asset_dir.join("search.svg");
639        std::fs::write(&icon_path, br#"<svg></svg>"#).expect("write icon file");
640
641        let manifest = FileAssetManifestV1::new([FileAssetManifestBundleV1::new(
642            app_bundle(),
643            [FileAssetManifestEntryV1::new("icons/search.svg").with_media_type("image/svg+xml")],
644        )
645        .with_root("assets")]);
646        let resolver = FileAssetManifestResolver::from_manifest_with_base_dir(
647            manifest,
648            &root,
649            root.join("inline.manifest.json"),
650        )
651        .expect("manifest resolver should build");
652
653        let resolved = resolver
654            .resolve_bytes(&AssetRequest::new(AssetLocator::bundle(
655                app_bundle(),
656                "icons/search.svg",
657            )))
658            .expect("bundle asset should resolve");
659        assert_eq!(resolved.bytes.as_ref(), br#"<svg></svg>"#);
660    }
661
662    #[cfg(not(target_arch = "wasm32"))]
663    #[test]
664    fn scan_dir_builds_entries_from_bundle_root() {
665        let root = make_temp_dir("fret-assets-scan-dir");
666        std::fs::create_dir_all(root.join("icons")).expect("create icons dir");
667        std::fs::create_dir_all(root.join("images")).expect("create images dir");
668        std::fs::write(root.join("icons/search.svg"), br#"<svg></svg>"#).expect("write svg");
669        std::fs::write(root.join("images/logo.png"), b"png").expect("write png");
670
671        let bundle = FileAssetManifestBundleV1::scan_dir(app_bundle(), &root)
672            .expect("scan dir should build bundle");
673
674        assert_eq!(bundle.root.as_deref(), Some(root.as_path()));
675        assert_eq!(bundle.entries.len(), 2);
676        assert_eq!(bundle.entries[0].key.as_str(), "icons/search.svg");
677        assert_eq!(bundle.entries[1].key.as_str(), "images/logo.png");
678        assert!(bundle.entries.iter().all(|entry| entry.path.is_none()));
679    }
680
681    #[cfg(not(target_arch = "wasm32"))]
682    #[test]
683    fn write_json_path_round_trips_generated_manifest() {
684        let root = make_temp_dir("fret-assets-write-json");
685        std::fs::create_dir_all(root.join("images")).expect("create images dir");
686        std::fs::write(root.join("images/logo.png"), b"png").expect("write asset");
687
688        let manifest =
689            FileAssetManifestV1::new([FileAssetManifestBundleV1::scan_dir(app_bundle(), &root)
690                .expect("scan dir should succeed")]);
691        let manifest_path = root.join("out").join("assets.manifest.json");
692        manifest
693            .write_json_path(&manifest_path)
694            .expect("write json should succeed");
695
696        let loaded = FileAssetManifestV1::load_json_path(&manifest_path)
697            .expect("written manifest should parse");
698        assert_eq!(loaded, manifest);
699    }
700
701    #[cfg(not(target_arch = "wasm32"))]
702    #[test]
703    fn file_manifest_resolver_can_build_directly_from_bundle_dir() {
704        let root = make_temp_dir("fret-assets-bundle-dir-resolver");
705        std::fs::create_dir_all(root.join("images")).expect("create images dir");
706        std::fs::write(root.join("images/logo.png"), b"bundle-dir").expect("write asset");
707
708        let resolver = FileAssetManifestResolver::from_bundle_dir(app_bundle(), &root)
709            .expect("bundle dir should build resolver");
710        let resolved = resolver
711            .resolve_bytes(&AssetRequest::new(AssetLocator::bundle(
712                app_bundle(),
713                "images/logo.png",
714            )))
715            .expect("bundle dir asset should resolve");
716
717        assert_eq!(resolved.bytes.as_ref(), b"bundle-dir");
718        assert_eq!(resolver.entry_count(), 1);
719    }
720
721    #[cfg(not(target_arch = "wasm32"))]
722    #[test]
723    fn file_manifest_resolver_reports_stale_manifest_mapping_for_missing_file_bytes() {
724        let root = make_temp_dir("fret-assets-file-manifest-stale-bytes");
725        let missing_path = root.join("images/missing.png");
726
727        let manifest = FileAssetManifestV1::new([FileAssetManifestBundleV1::new(
728            app_bundle(),
729            [FileAssetManifestEntryV1::new("images/logo.png").with_path(missing_path.clone())],
730        )]);
731        let resolver = FileAssetManifestResolver::from_manifest_with_base_dir(
732            manifest,
733            &root,
734            root.join("inline.manifest.json"),
735        )
736        .expect("manifest resolver should build");
737
738        let err = resolver
739            .resolve_bytes(&AssetRequest::new(AssetLocator::bundle(
740                app_bundle(),
741                "images/logo.png",
742            )))
743            .expect_err("missing mapped file should fail");
744
745        assert_eq!(
746            err,
747            AssetLoadError::StaleManifestMapping {
748                path: missing_path.to_string_lossy().into_owned().into(),
749            }
750        );
751    }
752
753    #[cfg(not(target_arch = "wasm32"))]
754    #[test]
755    fn file_manifest_resolver_reports_stale_manifest_mapping_for_missing_file_reference() {
756        let root = make_temp_dir("fret-assets-file-manifest-stale-reference");
757        let missing_path = root.join("icons/missing.svg");
758
759        let manifest = FileAssetManifestV1::new([FileAssetManifestBundleV1::new(
760            app_bundle(),
761            [FileAssetManifestEntryV1::new("icons/search.svg").with_path(missing_path.clone())],
762        )]);
763        let resolver = FileAssetManifestResolver::from_manifest_with_base_dir(
764            manifest,
765            &root,
766            root.join("inline.manifest.json"),
767        )
768        .expect("manifest resolver should build");
769
770        let err = resolver
771            .resolve_reference(&AssetRequest::new(AssetLocator::bundle(
772                app_bundle(),
773                "icons/search.svg",
774            )))
775            .expect_err("missing mapped file should fail");
776
777        assert_eq!(
778            err,
779            AssetLoadError::StaleManifestMapping {
780                path: missing_path.to_string_lossy().into_owned().into(),
781            }
782        );
783    }
784
785    #[cfg(not(target_arch = "wasm32"))]
786    fn make_temp_dir(prefix: &str) -> PathBuf {
787        let nonce = SystemTime::now()
788            .duration_since(UNIX_EPOCH)
789            .expect("system clock should be after unix epoch")
790            .as_nanos();
791        let dir = std::env::temp_dir().join(format!("{prefix}-{nonce}"));
792        std::fs::create_dir_all(&dir).expect("create temp dir");
793        dir
794    }
795}