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}