typst_batch/file.rs
1//! File caching with fingerprint-based invalidation.
2//!
3//! Files are cached globally to enable reuse across compilations.
4//! Fingerprint-based invalidation ensures changed files are re-read.
5//!
6//! # Caching Strategy
7//!
8//! ```text
9//! GLOBAL_FILE_CACHE (shared across all compilations)
10//! └── FxHashMap<FileId, FileSlot>
11//! └── FileSlot
12//! ├── source: SlotCell<Source> ─┐
13//! └── file: SlotCell<Bytes> ─┼── Fingerprint-based invalidation
14//!
15//! Access Flow:
16//! 1. If accessed=true && data.is_some() → return cached (fast path)
17//! 2. Load file, compute fingerprint
18//! 3. If fingerprint unchanged → return cached
19//! 4. Otherwise → recompute and cache
20//! ```
21//!
22//! # Virtual Data Extension
23//!
24//! This module provides basic file caching. The main application (tola) extends
25//! this with virtual data support for `/_data/*.json` files via the
26//! [`VirtualDataProvider`] trait.
27
28use std::cell::RefCell;
29use std::fs;
30use std::io::{self, Read};
31use std::mem;
32use std::path::Path;
33use std::sync::LazyLock;
34
35use parking_lot::RwLock;
36use rustc_hash::FxHashMap;
37use typst::diag::{FileError, FileResult};
38use typst::foundations::Bytes;
39use typst::syntax::{FileId, Source, VirtualPath};
40use typst_kit::download::{DownloadState, Progress};
41
42use crate::config::package_storage;
43
44// =============================================================================
45// Constants
46// =============================================================================
47
48/// Virtual `FileId` for stdin input.
49pub static STDIN_ID: LazyLock<FileId> =
50 LazyLock::new(|| FileId::new_fake(VirtualPath::new("<stdin>")));
51
52/// Virtual `FileId` for empty/no input.
53pub static EMPTY_ID: LazyLock<FileId> =
54 LazyLock::new(|| FileId::new_fake(VirtualPath::new("<empty>")));
55
56// =============================================================================
57// FileId Helper Functions
58// =============================================================================
59
60/// Create a `FileId` for a project file.
61///
62/// This is the standard way to create file IDs for files within your project.
63/// The path should be root-relative (e.g., `/content/post.typ`).
64///
65/// # Arguments
66///
67/// * `path` - Path relative to project root, with leading `/`
68///
69/// # Example
70///
71/// ```ignore
72/// use typst_batch::file_id;
73///
74/// let id = file_id("/content/post.typ");
75/// ```
76pub fn file_id(path: impl AsRef<Path>) -> FileId {
77 FileId::new(None, VirtualPath::new(path.as_ref()))
78}
79
80/// Create a `FileId` for a file from its absolute path within a project root.
81///
82/// Returns `None` if the file is outside the root directory.
83///
84/// # Arguments
85///
86/// * `file_path` - Absolute path to the file
87/// * `root` - Absolute path to the project root
88///
89/// # Example
90///
91/// ```ignore
92/// use typst_batch::file_id_from_path;
93/// use std::path::Path;
94///
95/// let id = file_id_from_path(
96/// Path::new("/project/content/post.typ"),
97/// Path::new("/project"),
98/// );
99/// ```
100pub fn file_id_from_path(file_path: &Path, root: &Path) -> Option<FileId> {
101 VirtualPath::within_root(file_path, root).map(|vpath| FileId::new(None, vpath))
102}
103
104/// Create a virtual/fake `FileId` for dynamically generated content.
105///
106/// Use this for content that doesn't correspond to a real file on disk.
107/// Each call creates a unique ID that won't conflict with real file IDs.
108///
109/// # Arguments
110///
111/// * `name` - A descriptive name for the virtual file
112///
113/// # Example
114///
115/// ```ignore
116/// use typst_batch::virtual_file_id;
117///
118/// let id = virtual_file_id("<generated-data>");
119/// ```
120pub fn virtual_file_id(name: &str) -> FileId {
121 FileId::new_fake(VirtualPath::new(name))
122}
123
124// =============================================================================
125// Virtual File System
126// =============================================================================
127
128/// Trait for providing virtual files that don't exist on disk.
129///
130/// This is the primary extension point for batch compilation scenarios.
131/// Implement this trait to inject dynamically generated content into
132/// Typst's file system.
133///
134/// # Use Cases
135///
136/// - **Data injection**: Provide `/_data/posts.json` with blog post metadata
137/// - **Configuration**: Inject site configuration without physical files
138/// - **Template variables**: Provide computed values accessible via `json()`
139/// - **Asset manifests**: Generate asset URLs at compile time
140///
141/// # Example
142///
143/// ```ignore
144/// use typst_batch::{VirtualFileSystem, set_virtual_fs};
145/// use std::path::Path;
146///
147/// struct MyVirtualFS {
148/// site_config: String,
149/// }
150///
151/// impl VirtualFileSystem for MyVirtualFS {
152/// fn read(&self, path: &Path) -> Option<Vec<u8>> {
153/// match path.to_str()? {
154/// "/_data/site.json" => Some(self.site_config.as_bytes().to_vec()),
155/// "/_data/build-time.txt" => {
156/// Some(chrono::Utc::now().to_rfc3339().into_bytes())
157/// }
158/// _ => None, // Fall back to real filesystem
159/// }
160/// }
161/// }
162///
163/// set_virtual_fs(MyVirtualFS { site_config: r#"{"title":"My Blog"}"#.into() });
164/// ```
165///
166/// # Thread Safety
167///
168/// Implementations must be `Send + Sync` as compilation may run in parallel.
169/// The provider is called for each file access, so implementations should be
170/// efficient (consider caching expensive computations).
171///
172/// # Note on Global State
173///
174/// Due to Typst's `World` trait design, the virtual file system must be
175/// registered globally via [`set_virtual_fs`]. This is a limitation of
176/// the underlying architecture, not a design choice.
177pub trait VirtualFileSystem: Send + Sync {
178 /// Read a virtual file by path.
179 ///
180 /// Return `Some(bytes)` to provide virtual content, or `None` to fall
181 /// back to the real filesystem.
182 ///
183 /// The path is root-relative (e.g., `/_data/config.json` or `/assets/style.css`).
184 fn read(&self, path: &Path) -> Option<Vec<u8>>;
185}
186
187/// No-op virtual file system (all files from real filesystem).
188pub struct NoVirtualFS;
189
190impl VirtualFileSystem for NoVirtualFS {
191 fn read(&self, _path: &Path) -> Option<Vec<u8>> {
192 None
193 }
194}
195
196/// A simple map-based virtual file system.
197///
198/// This provides a convenient way to inject virtual files without implementing
199/// the [`VirtualFileSystem`] trait manually.
200///
201/// # Example
202///
203/// ```ignore
204/// use typst_batch::{MapVirtualFS, set_virtual_fs};
205///
206/// let mut vfs = MapVirtualFS::new();
207/// vfs.insert("/_data/site.json", r#"{"title":"My Blog"}"#);
208/// vfs.insert("/_data/posts.json", serde_json::to_string(&posts)?);
209///
210/// set_virtual_fs(vfs);
211/// ```
212#[derive(Default, Clone)]
213pub struct MapVirtualFS {
214 files: FxHashMap<String, Vec<u8>>,
215}
216
217impl MapVirtualFS {
218 /// Create a new empty virtual file system.
219 pub fn new() -> Self {
220 Self::default()
221 }
222
223 /// Insert a virtual file with string content.
224 ///
225 /// The path should be root-relative (e.g., `/_data/config.json`).
226 pub fn insert(&mut self, path: impl Into<String>, content: impl AsRef<str>) {
227 self.files
228 .insert(path.into(), content.as_ref().as_bytes().to_vec());
229 }
230
231 /// Insert a virtual file with binary content.
232 pub fn insert_bytes(&mut self, path: impl Into<String>, content: impl Into<Vec<u8>>) {
233 self.files.insert(path.into(), content.into());
234 }
235
236 /// Check if a path exists in the virtual file system.
237 pub fn contains(&self, path: &str) -> bool {
238 self.files.contains_key(path)
239 }
240
241 /// Remove a virtual file.
242 pub fn remove(&mut self, path: &str) -> Option<Vec<u8>> {
243 self.files.remove(path)
244 }
245
246 /// Get the number of virtual files.
247 pub fn len(&self) -> usize {
248 self.files.len()
249 }
250
251 /// Check if the virtual file system is empty.
252 pub fn is_empty(&self) -> bool {
253 self.files.is_empty()
254 }
255
256 /// Iterate over all virtual file paths.
257 pub fn paths(&self) -> impl Iterator<Item = &str> {
258 self.files.keys().map(String::as_str)
259 }
260}
261
262impl VirtualFileSystem for MapVirtualFS {
263 fn read(&self, path: &Path) -> Option<Vec<u8>> {
264 let path_str = path.to_str()?;
265 self.files.get(path_str).cloned()
266 }
267}
268
269// Legacy type alias for backward compatibility
270#[doc(hidden)]
271pub type VirtualDataProvider = dyn VirtualFileSystem;
272
273// =============================================================================
274// Global Virtual File System
275// =============================================================================
276
277/// Global virtual file system instance.
278///
279/// This allows the main application to register a custom virtual file system
280/// that will be used by all file access operations during compilation.
281static GLOBAL_VIRTUAL_FS: LazyLock<RwLock<Box<dyn VirtualFileSystem>>> =
282 LazyLock::new(|| RwLock::new(Box::new(NoVirtualFS)));
283
284/// Set the global virtual file system.
285///
286/// Call this at application startup to enable virtual files.
287/// The virtual file system will be consulted for every file access.
288/// Return `Some(bytes)` from your implementation to provide virtual content,
289/// or `None` to fall back to the real filesystem.
290///
291/// # Example
292///
293/// ```ignore
294/// use typst_batch::{VirtualFileSystem, set_virtual_fs};
295/// use std::path::Path;
296///
297/// struct SiteData {
298/// posts: Vec<Post>,
299/// }
300///
301/// impl VirtualFileSystem for SiteData {
302/// fn read(&self, path: &Path) -> Option<Vec<u8>> {
303/// if path == Path::new("/_data/posts.json") {
304/// Some(serde_json::to_vec(&self.posts).unwrap())
305/// } else {
306/// None
307/// }
308/// }
309/// }
310///
311/// set_virtual_fs(SiteData { posts: vec![...] });
312/// ```
313pub fn set_virtual_fs<V: VirtualFileSystem + 'static>(fs: V) {
314 *GLOBAL_VIRTUAL_FS.write() = Box::new(fs);
315}
316
317/// Read a file, checking virtual file system first.
318///
319/// Returns virtual content if the VFS provides it, otherwise returns `None`
320/// to indicate the real filesystem should be used.
321pub fn read_virtual(path: &Path) -> Option<Vec<u8>> {
322 GLOBAL_VIRTUAL_FS.read().read(path)
323}
324
325/// Check if a path has virtual content available.
326pub fn is_virtual_path(path: &Path) -> bool {
327 GLOBAL_VIRTUAL_FS.read().read(path).is_some()
328}
329
330// Legacy function alias for backward compatibility
331#[doc(hidden)]
332pub fn set_virtual_provider<V: VirtualFileSystem + 'static>(provider: V) {
333 set_virtual_fs(provider);
334}
335
336// =============================================================================
337// Global File Cache
338// =============================================================================
339
340/// Global shared file cache - reused across all compilations.
341pub static GLOBAL_FILE_CACHE: LazyLock<RwLock<FxHashMap<FileId, FileSlot>>> =
342 LazyLock::new(|| RwLock::new(FxHashMap::default()));
343
344// =============================================================================
345// Thread-Local Access Tracking
346// =============================================================================
347
348thread_local! {
349 /// Thread-local set of accessed file IDs for the current compilation.
350 /// This avoids race conditions when compiling files in parallel.
351 static ACCESSED_FILES: RefCell<rustc_hash::FxHashSet<FileId>> =
352 RefCell::new(rustc_hash::FxHashSet::default());
353}
354
355/// Clear the thread-local accessed files set and reset global cache access flags.
356///
357/// Call at the start of each file compilation.
358pub fn reset_access_flags() {
359 // Reset thread-local tracking
360 ACCESSED_FILES.with(|files| files.borrow_mut().clear());
361
362 // Reset global cache access flags for fingerprint re-checking
363 for slot in GLOBAL_FILE_CACHE.write().values_mut() {
364 slot.reset_access();
365 }
366}
367
368/// Record a file access in the thread-local set.
369pub fn record_file_access(id: FileId) {
370 ACCESSED_FILES.with(|files| {
371 files.borrow_mut().insert(id);
372 });
373}
374
375/// Get all files accessed during the current compilation.
376///
377/// Returns a list of `FileId`s that were accessed since last `reset_access_flags()`.
378/// Thread-safe: each thread has its own tracking.
379pub fn get_accessed_files() -> Vec<FileId> {
380 ACCESSED_FILES.with(|files| files.borrow().iter().copied().collect())
381}
382
383/// Clear the global file cache.
384///
385/// Call when template/dependency files change to ensure fresh data is loaded.
386/// This also clears the comemo cache.
387pub fn clear_file_cache() {
388 GLOBAL_FILE_CACHE.write().clear();
389 typst::comemo::evict(0);
390}
391
392// =============================================================================
393// SlotCell - Fingerprint-based Caching
394// =============================================================================
395
396/// Lazily processes data for a file with fingerprint-based caching.
397pub struct SlotCell<T> {
398 data: Option<FileResult<T>>,
399 fingerprint: u128,
400 /// Whether this cell has been accessed in the current compilation.
401 pub accessed: bool,
402}
403
404impl<T: Clone> Default for SlotCell<T> {
405 fn default() -> Self {
406 Self::new()
407 }
408}
409
410impl<T: Clone> SlotCell<T> {
411 /// Create a new empty slot cell.
412 pub const fn new() -> Self {
413 Self {
414 data: None,
415 fingerprint: 0,
416 accessed: false,
417 }
418 }
419
420 /// Reset the access flag for a new compilation.
421 pub const fn reset_access(&mut self) {
422 self.accessed = false;
423 }
424
425 /// Get or initialize cached data using fingerprint-based invalidation.
426 pub fn get_or_init(
427 &mut self,
428 load: impl FnOnce() -> FileResult<Vec<u8>>,
429 process: impl FnOnce(Vec<u8>, Option<T>) -> FileResult<T>,
430 ) -> FileResult<T> {
431 // Fast path: already accessed in this compilation
432 if mem::replace(&mut self.accessed, true)
433 && let Some(data) = &self.data
434 {
435 return data.clone();
436 }
437
438 let result = load();
439 let fingerprint = typst::utils::hash128(&result);
440
441 // Fingerprint unchanged: reuse previous result
442 if mem::replace(&mut self.fingerprint, fingerprint) == fingerprint
443 && let Some(data) = &self.data
444 {
445 return data.clone();
446 }
447
448 // Process and cache new data
449 let prev = self.data.take().and_then(Result::ok);
450 let value = result.and_then(|data| process(data, prev));
451 self.data = Some(value.clone());
452 value
453 }
454}
455
456// =============================================================================
457// File Reading
458// =============================================================================
459
460/// Read file content from a `FileId`.
461///
462/// Handles special cases:
463/// - `EMPTY_ID`: Returns empty bytes
464/// - `STDIN_ID`: Reads from stdin
465/// - Package files: Downloads package if needed
466pub fn read(id: FileId, project_root: &Path) -> FileResult<Vec<u8>> {
467 read_with_virtual(id, project_root, &NoVirtualFS)
468}
469
470/// Read file content using the global virtual file system.
471///
472/// This function uses the globally registered virtual file system.
473/// Call [`set_virtual_fs`] at startup to register your VFS.
474pub fn read_with_global_virtual(id: FileId, project_root: &Path) -> FileResult<Vec<u8>> {
475 // Handle virtual file IDs first (don't need provider)
476 if id == *EMPTY_ID {
477 return Ok(Vec::new());
478 }
479 if id == *STDIN_ID {
480 return read_stdin();
481 }
482
483 // Check global virtual provider
484 let vpath = id.vpath().as_rooted_path();
485 if is_virtual_path(vpath) {
486 record_file_access(id);
487 return read_virtual(vpath).ok_or_else(|| FileError::NotFound(vpath.to_path_buf()));
488 }
489
490 // Resolve path and read from disk
491 let path = resolve_path(project_root, id)?;
492 read_disk(&path)
493}
494
495/// Read file content with virtual data support.
496///
497/// Like [`read`], but also handles virtual data files via the provider.
498pub fn read_with_virtual<V: VirtualFileSystem>(
499 id: FileId,
500 project_root: &Path,
501 virtual_fs: &V,
502) -> FileResult<Vec<u8>> {
503 // Handle virtual file IDs
504 if id == *EMPTY_ID {
505 return Ok(Vec::new());
506 }
507 if id == *STDIN_ID {
508 return read_stdin();
509 }
510
511 // Handle virtual data files (e.g., /_data/*.json)
512 let vpath = id.vpath().as_rooted_path();
513 if let Some(data) = virtual_fs.read(vpath) {
514 record_file_access(id);
515 return Ok(data);
516 }
517
518 // Resolve path and read from disk
519 let path = resolve_path(project_root, id)?;
520 read_disk(&path)
521}
522
523/// Decode bytes as UTF-8, stripping BOM if present.
524pub fn decode_utf8(buf: &[u8]) -> FileResult<&str> {
525 let buf = buf.strip_prefix(b"\xef\xbb\xbf").unwrap_or(buf);
526 std::str::from_utf8(buf).map_err(|_| FileError::InvalidUtf8)
527}
528
529/// Resolve file path, downloading package if needed.
530fn resolve_path(project_root: &Path, id: FileId) -> FileResult<std::path::PathBuf> {
531 let root = id
532 .package()
533 .map(|spec| package_storage().prepare_package(spec, &mut SilentProgress))
534 .transpose()?
535 .unwrap_or_else(|| project_root.to_path_buf());
536
537 id.vpath().resolve(&root).ok_or(FileError::AccessDenied)
538}
539
540/// Read file from disk.
541fn read_disk(path: &Path) -> FileResult<Vec<u8>> {
542 let map_err = |e| FileError::from_io(e, path);
543 fs::metadata(path).map_err(map_err).and_then(|m| {
544 if m.is_dir() {
545 Err(FileError::IsDirectory)
546 } else {
547 fs::read(path).map_err(map_err)
548 }
549 })
550}
551
552/// Read all data from stdin.
553fn read_stdin() -> FileResult<Vec<u8>> {
554 let mut buf = Vec::new();
555 io::stdin()
556 .read_to_end(&mut buf)
557 .or_else(|e| {
558 if e.kind() == io::ErrorKind::BrokenPipe {
559 Ok(0)
560 } else {
561 Err(FileError::from_io(e, Path::new("<stdin>")))
562 }
563 })?;
564 Ok(buf)
565}
566
567/// No-op progress reporter for silent package downloads.
568struct SilentProgress;
569
570impl Progress for SilentProgress {
571 fn print_start(&mut self) {}
572 fn print_progress(&mut self, _: &DownloadState) {}
573 fn print_finish(&mut self, _: &DownloadState) {}
574}
575
576// =============================================================================
577// FileSlot - Per-file Caching
578// =============================================================================
579
580/// Holds cached data for a file ID.
581pub struct FileSlot {
582 id: FileId,
583 source: SlotCell<Source>,
584 file: SlotCell<Bytes>,
585}
586
587impl FileSlot {
588 /// Create a new file slot for the given ID.
589 pub const fn new(id: FileId) -> Self {
590 Self {
591 id,
592 source: SlotCell::new(),
593 file: SlotCell::new(),
594 }
595 }
596
597 /// Reset access flags for a new compilation.
598 pub const fn reset_access(&mut self) {
599 self.source.reset_access();
600 self.file.reset_access();
601 }
602
603 /// Retrieve parsed source for this file (no virtual data).
604 pub fn source(&mut self, project_root: &Path) -> FileResult<Source> {
605 self.source_with_virtual(project_root, &NoVirtualFS)
606 }
607
608 /// Retrieve parsed source using the global virtual file system.
609 ///
610 /// This uses the VFS registered via [`set_virtual_fs`].
611 pub fn source_with_global_virtual(&mut self, project_root: &Path) -> FileResult<Source> {
612 record_file_access(self.id);
613 self.source.get_or_init(
614 || read_with_global_virtual(self.id, project_root),
615 |data, prev| {
616 let text = decode_utf8(&data)?;
617 match prev {
618 Some(mut src) => {
619 src.replace(text);
620 Ok(src)
621 }
622 None => Ok(Source::new(self.id, text.into())),
623 }
624 },
625 )
626 }
627
628 /// Retrieve parsed source with virtual file system support.
629 pub fn source_with_virtual<V: VirtualFileSystem>(
630 &mut self,
631 project_root: &Path,
632 virtual_fs: &V,
633 ) -> FileResult<Source> {
634 record_file_access(self.id);
635 self.source.get_or_init(
636 || read_with_virtual(self.id, project_root, virtual_fs),
637 |data, prev| {
638 let text = decode_utf8(&data)?;
639 match prev {
640 Some(mut src) => {
641 src.replace(text);
642 Ok(src)
643 }
644 None => Ok(Source::new(self.id, text.into())),
645 }
646 },
647 )
648 }
649
650 /// Retrieve raw bytes for this file (no virtual data).
651 pub fn file(&mut self, project_root: &Path) -> FileResult<Bytes> {
652 self.file_with_virtual(project_root, &NoVirtualFS)
653 }
654
655 /// Retrieve raw bytes using the global virtual file system.
656 ///
657 /// This uses the VFS registered via [`set_virtual_fs`].
658 pub fn file_with_global_virtual(&mut self, project_root: &Path) -> FileResult<Bytes> {
659 record_file_access(self.id);
660 self.file.get_or_init(
661 || read_with_global_virtual(self.id, project_root),
662 |data, _| Ok(Bytes::new(data)),
663 )
664 }
665
666 /// Retrieve raw bytes with virtual file system support.
667 pub fn file_with_virtual<V: VirtualFileSystem>(
668 &mut self,
669 project_root: &Path,
670 virtual_fs: &V,
671 ) -> FileResult<Bytes> {
672 record_file_access(self.id);
673 self.file.get_or_init(
674 || read_with_virtual(self.id, project_root, virtual_fs),
675 |data, _| Ok(Bytes::new(data)),
676 )
677 }
678}
679
680// =============================================================================
681// Tests
682// =============================================================================
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687 use std::fs;
688 use tempfile::TempDir;
689
690 #[test]
691 fn test_decode_utf8_valid() {
692 let text = "Hello, 世界!";
693 assert_eq!(decode_utf8(text.as_bytes()).unwrap(), text);
694 }
695
696 #[test]
697 fn test_decode_utf8_strips_bom() {
698 let mut bytes = vec![0xef, 0xbb, 0xbf];
699 bytes.extend_from_slice(b"Hello");
700 assert_eq!(decode_utf8(&bytes).unwrap(), "Hello");
701 }
702
703 #[test]
704 fn test_decode_utf8_invalid() {
705 let invalid = vec![0xff, 0xfe];
706 assert!(decode_utf8(&invalid).is_err());
707 }
708
709 #[test]
710 fn test_read_disk() {
711 let dir = TempDir::new().unwrap();
712 let path = dir.path().join("test.txt");
713 fs::write(&path, "test content").unwrap();
714
715 assert_eq!(read_disk(&path).unwrap(), b"test content");
716 }
717
718 #[test]
719 fn test_read_disk_directory() {
720 let dir = TempDir::new().unwrap();
721 assert!(read_disk(dir.path()).is_err());
722 }
723
724 #[test]
725 fn test_read_disk_nonexistent() {
726 assert!(read_disk(Path::new("/nonexistent/file.txt")).is_err());
727 }
728
729 #[test]
730 fn test_slot_cell_fingerprint() {
731 let mut slot: SlotCell<String> = SlotCell::new();
732
733 let result1 = slot.get_or_init(
734 || Ok(b"hello".to_vec()),
735 |data, _| Ok(String::from_utf8(data).unwrap()),
736 );
737 assert_eq!(result1.unwrap(), "hello");
738
739 slot.accessed = false;
740 let result2 = slot.get_or_init(
741 || Ok(b"hello".to_vec()),
742 |_, _| panic!("Should not reprocess"),
743 );
744 assert_eq!(result2.unwrap(), "hello");
745 }
746
747 #[test]
748 fn test_file_slot_caching() {
749 let dir = TempDir::new().unwrap();
750 let path = dir.path().join("test.typ");
751 fs::write(&path, "= Hello").unwrap();
752
753 let vpath = VirtualPath::new("test.typ");
754 let id = FileId::new(None, vpath);
755 let mut slot = FileSlot::new(id);
756
757 let result1 = slot.file(dir.path());
758 let result2 = slot.file(dir.path());
759
760 assert!(result1.is_ok());
761 assert_eq!(result1.unwrap(), result2.unwrap());
762 }
763
764 #[test]
765 fn test_empty_id() {
766 let dir = TempDir::new().unwrap();
767 let result = read(*EMPTY_ID, dir.path());
768 assert!(result.is_ok());
769 assert!(result.unwrap().is_empty());
770 }
771}