Skip to main content

limnifs_write/
lib.rs

1//! `LimniFS` writer pipeline — directory tree to `.lim` image.
2//!
3//! The writer takes a real directory tree and produces a valid `.lim`
4//! manifest artifact with inlined metadata. Files at or below the
5//! inline threshold (4 KiB) are stored as inline data in their inodes;
6//! larger files are stored as drops packed into a single slab.
7//!
8//! ## Usage
9//!
10//! ```no_run
11//! use std::path::Path;
12//! use limnifs_write::write_directory;
13//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
14//! let artifact = write_directory(Path::new("/path/to/dir"))?;
15//! std::fs::write("output.lim", &artifact.bytes)?;
16//! # Ok(())
17//! # }
18//! ```
19
20#![forbid(unsafe_code)]
21#![warn(clippy::pedantic)]
22
23pub mod chunker;
24pub mod classifier;
25pub mod compaction;
26pub mod delta_builder;
27pub mod flatten;
28pub mod turnover;
29
30use std::collections::HashMap;
31use std::path::{Path, PathBuf};
32
33use crate::chunker::FastCDC;
34use limnifs_core::{
35    compute_merkle_root, hash_empty_section, hash_section, ManifestHeader, SectionHashes,
36    FEATURE_FLAGS_SECTION_VERSION, HISTORY_SECTION_VERSION, METADATA_REFERENCE_SECTION_VERSION,
37    SLAB_INDEX_SECTION_VERSION,
38};
39use limnifs_format::{ManifestRoot, SlabId};
40
41/// Inline-data threshold: files at or below this size get inline data
42/// in their inode. Larger files are stored as drops in a slab.
43pub const INLINE_THRESHOLD: usize = 4096;
44
45/// Result of writing a directory tree.
46#[derive(Clone, Debug)]
47pub struct WriteArtifact {
48    pub bytes: Vec<u8>,
49    pub merkle_root: ManifestRoot,
50    pub slab_bytes: Option<Vec<u8>>,
51    pub slab_locator: Option<String>,
52    pub inode_count: usize,
53    pub file_count: usize,
54    pub dir_count: usize,
55    pub drop_count: usize,
56    /// Inode number of the root directory (i.e. the inode that
57    /// represents the source directory itself, not a child of it).
58    /// Always a directory and always referenced by the inlined
59    /// metadata blob's directory inode table.
60    pub root_inode_number: u64,
61}
62
63/// Error during writing.
64#[derive(Debug)]
65pub enum WriteError {
66    Io(std::io::Error),
67}
68
69impl std::fmt::Display for WriteError {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        match self {
72            Self::Io(e) => write!(f, "I/O error: {e}"),
73        }
74    }
75}
76
77impl std::error::Error for WriteError {}
78
79impl From<std::io::Error> for WriteError {
80    fn from(e: std::io::Error) -> Self {
81        Self::Io(e)
82    }
83}
84
85/// Walk a directory tree and produce a valid `.lim` manifest artifact
86/// with inlined metadata. Files at or below [`INLINE_THRESHOLD`] bytes
87/// are stored inline; larger files are packed into a single slab as
88/// content-addressed drops.
89///
90/// File contents (read, `FastCDC` chunk, `BLAKE3` hash, `LZ4` compress) are
91/// processed in parallel across `CPU` cores via `rayon`. The directory
92/// tree walk and slab assembly remain sequential so the output is
93/// deterministic.
94///
95/// # Errors
96///
97/// Returns [`WriteError::Io`] for filesystem errors.
98pub fn write_directory(root: &Path) -> Result<WriteArtifact, WriteError> {
99    use rayon::prelude::*;
100
101    let mut ctx = WriteContext::new();
102
103    // Phase 1: walk the tree SEQUENTIALLY (deterministic inode
104    // allocation). Collect files that need chunking (> INLINE_THRESHOLD)
105    // for parallel processing.
106    let root_inode_number = ctx.walk(root)?;
107    ctx.root_inode_number = root_inode_number;
108
109    // Phase 2: process each pending chunked file in PARALLEL via rayon.
110    // This is the CPU-heavy path: file read + FastCDC + BLAKE3 + LZ4.
111    // Each file is independent; results are collected in original order
112    // so the output stays deterministic.
113    let pending = std::mem::take(&mut ctx.pending_files);
114    if !pending.is_empty() {
115        let chunker = ctx.chunker.clone();
116        let classifier = ctx.classifier;
117        let results: Vec<ChunkedFileResult> = pending
118            .par_iter()
119            .map(|pf| process_file(pf, &chunker, classifier))
120            .collect::<Result<Vec<_>, _>>()?;
121
122        // Phase 3: merge results SEQUENTIALLY into drops + inodes.
123        // Dedup happens here so the slab layout is deterministic.
124        for (pf, result) in pending.iter().zip(results) {
125            ctx.merge_chunked_file(pf, result);
126        }
127    }
128
129    let artifact = ctx.assemble();
130    Ok(artifact)
131}
132
133/// One chunk of a file before dedup: (`drop_id`, `plaintext`, `compressed`, `codec`).
134type RawDrop = ([u8; 32], Vec<u8>, Vec<u8>, u8);
135/// Result of parallel file processing: the drop data (uncompressed,
136struct ChunkedFileResult {
137    drops: Vec<RawDrop>, // (id, plaintext, compressed, codec)
138    slices: Vec<PendingSlice>,
139}
140
141/// Process a single file's contents (CPU-heavy work that runs in a
142/// rayon worker thread). Returns the unique chunks and slice map.
143fn process_file(
144    pf: &PendingFile,
145    chunker: &FastCDC,
146    classifier: classifier::Classifier,
147) -> Result<ChunkedFileResult, WriteError> {
148    let data = std::fs::read(&pf.path)?;
149    let file_len = data.len();
150    let chunks = chunker.chunk_slice(&data);
151    let mut drops = Vec::with_capacity(chunks.len());
152    let mut slices = Vec::with_capacity(chunks.len());
153    let mut file_offset: u64 = 0;
154
155    for chunk in chunks {
156        let chunk_len = u64::try_from(chunk.len()).expect("chunk len fits u64");
157        let drop_id = hash_section(chunk);
158
159        let class = classifier.classify(chunk);
160        let codec_id = match class {
161            classifier::Class::Binary => limnifs_core::codec::best_binary_codec(),
162            classifier::Class::Text | classifier::Class::Code => {
163                limnifs_core::codec::best_compressible_codec()
164            }
165            _ => limnifs_core::codec::CODEC_STORE,
166        };
167        let compressed = if codec_id == limnifs_core::codec::CODEC_STORE {
168            chunk.to_vec()
169        } else {
170            limnifs_core::codec::compress(codec_id, chunk).unwrap_or_else(|_| chunk.to_vec())
171        };
172
173        drops.push((drop_id, chunk.to_vec(), compressed, codec_id));
174        slices.push(PendingSlice {
175            drop_id,
176            file_byte_start: file_offset,
177            file_byte_end: file_offset + chunk_len,
178        });
179        file_offset += chunk_len;
180    }
181    let _ = file_len;
182    Ok(ChunkedFileResult { drops, slices })
183}
184
185struct PendingDrop {
186    id: [u8; 32],
187    plaintext: Vec<u8>,
188    compressed: Vec<u8>,
189    codec: u8,
190    offset_in_window: u32,
191}
192
193impl PendingDrop {
194    /// The byte length stored in the slab's solid window. Equals
195    /// `plaintext.len()` for store codec, or the compressed size for
196    /// LZ4.
197    fn len_in_window(&self) -> u32 {
198        u32::try_from(self.compressed.len()).expect("compressed fits u32")
199    }
200
201    /// The original (decompressed) byte length.
202    fn plaintext_len(&self) -> u32 {
203        u32::try_from(self.plaintext.len()).expect("plaintext fits u32")
204    }
205}
206
207/// One slice of a file backed by drops. Records which drop holds
208/// this slice's bytes and which byte range of the original file
209/// the slice covers. The slice always spans the entire drop (the
210/// chunker never splits a drop across multiple slices).
211struct PendingSlice {
212    drop_id: [u8; 32],
213    file_byte_start: u64,
214    file_byte_end: u64,
215}
216
217/// A file that needs chunking (> `INLINE_THRESHOLD`). Collected during
218/// the sequential tree walk and processed in parallel by `rayon`.
219struct PendingFile {
220    inode_number: u64,
221    path: PathBuf,
222    mtime_ns: u64,
223    file_len: u64,
224}
225
226struct PendingInode {
227    number: u64,
228    mode: u32,
229    mtime_ns: u64,
230    content: PendingContent,
231}
232
233enum PendingContent {
234    Inline(Vec<u8>),
235    DropBacked {
236        file_len: u64,
237        slices: Vec<PendingSlice>,
238    },
239    Directory(Vec<(String, u64, u8)>),
240}
241
242struct DirNode {
243    entries: Vec<(String, u64, u8)>,
244    bytes: Vec<u8>,
245    hash: [u8; 32],
246}
247
248struct WriteContext {
249    next_inode: u64,
250    inodes: Vec<PendingInode>,
251    dir_nodes: Vec<DirNode>,
252    drops: Vec<PendingDrop>,
253    drop_index: HashMap<[u8; 32], (u32, u32)>,
254    pending_files: Vec<PendingFile>,
255    file_count: usize,
256    dir_count: usize,
257    root_inode_number: u64,
258    chunker: FastCDC,
259    classifier: classifier::Classifier,
260}
261
262impl WriteContext {
263    fn new() -> Self {
264        Self {
265            next_inode: 1,
266            inodes: Vec::new(),
267            dir_nodes: Vec::new(),
268            drops: Vec::new(),
269            drop_index: HashMap::new(),
270            pending_files: Vec::new(),
271            file_count: 0,
272            dir_count: 0,
273            root_inode_number: 0,
274            chunker: FastCDC::default(),
275            classifier: classifier::Classifier,
276        }
277    }
278
279    fn alloc_inode(&mut self) -> u64 {
280        let n = self.next_inode;
281        self.next_inode += 1;
282        n
283    }
284
285    /// Merge a parallel-processed chunked file's results into the
286    /// context. Dedup: only new `DropId`s get added to the drops list.
287    fn merge_chunked_file(&mut self, pf: &PendingFile, result: ChunkedFileResult) {
288        for (drop_id, plaintext, compressed, codec) in result.drops {
289            if !self.drop_index.contains_key(&drop_id) {
290                let offset = self
291                    .drops
292                    .iter()
293                    .map(PendingDrop::len_in_window)
294                    .sum::<u32>();
295                let len = u32::try_from(compressed.len()).unwrap_or(0);
296                self.drops.push(PendingDrop {
297                    id: drop_id,
298                    plaintext,
299                    compressed,
300                    codec,
301                    offset_in_window: offset,
302                });
303                self.drop_index.insert(drop_id, (offset, len));
304            }
305        }
306        self.inodes.push(PendingInode {
307            number: pf.inode_number,
308            mode: 0o100_644,
309            mtime_ns: pf.mtime_ns,
310            content: PendingContent::DropBacked {
311                file_len: pf.file_len,
312                slices: result.slices,
313            },
314        });
315    }
316
317    /// Apply the seine classifier to a chunk and compress it if the
318    /// class is compressible. Text, Code, and Binary drops get LZ4;
319    /// Compressed, Media, and Sparse drops stay as store (re-compressing
320    /// already-compressed data wastes CPU for no gain).
321    /// Apply the seine classifier to a chunk and compress it if the
322    /// class is compressible. Text, Code, and Binary drops get LZ4;
323    /// Compressed, Media, and Sparse drops stay as store.
324    ///
325    /// Kept for API compatibility; the parallel writer uses
326    /// [`process_file`] which inlines this logic.
327    #[allow(dead_code)]
328    fn deepen_drop(&self, drop_id: [u8; 32], plaintext: &[u8]) -> PendingDrop {
329        let class = self.classifier.classify(plaintext);
330        let (codec, compressed) = match class {
331            classifier::Class::Text | classifier::Class::Code | classifier::Class::Binary => {
332                let c = limnifs_core::codec::compress_lz4_with_size(plaintext);
333                (limnifs_core::codec::CODEC_LZ4, c)
334            }
335            _ => (limnifs_core::codec::CODEC_STORE, plaintext.to_vec()),
336        };
337        PendingDrop {
338            id: drop_id,
339            plaintext: plaintext.to_vec(),
340            compressed,
341            codec,
342            offset_in_window: 0,
343        }
344    }
345
346    fn walk(&mut self, path: &Path) -> Result<u64, WriteError> {
347        let meta = std::fs::symlink_metadata(path)?;
348        let file_type = meta.file_type();
349        let mtime_ns = meta
350            .modified()
351            .ok()
352            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
353            .map_or(0u128, |d| d.as_nanos());
354        let mtime_ns: u64 = mtime_ns.try_into().unwrap_or(0);
355
356        if file_type.is_dir() {
357            self.dir_count += 1;
358            let inode_number = self.alloc_inode();
359            let mut entries: Vec<(String, u64, u8)> = Vec::new();
360
361            for entry in std::fs::read_dir(path)? {
362                let entry = entry?;
363                let name = entry.file_name().to_string_lossy().into_owned();
364                let child_path = entry.path();
365                let child_inode = self.walk(&child_path)?;
366                let child_meta = entry.metadata()?;
367                let entry_type = if child_meta.is_dir() { 0x02 } else { 0x01 };
368                entries.push((name, child_inode, entry_type));
369            }
370
371            entries.sort_by(|a, b| a.0.cmp(&b.0));
372            let dir_node = encode_dir_node(&entries);
373            self.dir_nodes.push(dir_node);
374            self.inodes.push(PendingInode {
375                number: inode_number,
376                mode: 0o040_755,
377                mtime_ns,
378                content: PendingContent::Directory(entries),
379            });
380            Ok(inode_number)
381        } else if file_type.is_file() {
382            self.file_count += 1;
383            let inode_number = self.alloc_inode();
384            let file_len = meta.len();
385
386            if file_len <= u64::try_from(INLINE_THRESHOLD).unwrap_or(u64::MAX) {
387                let data = std::fs::read(path)?;
388                self.inodes.push(PendingInode {
389                    number: inode_number,
390                    mode: 0o100_644,
391                    mtime_ns,
392                    content: PendingContent::Inline(data),
393                });
394            } else {
395                // Defer to parallel processing — collect the file info.
396                self.pending_files.push(PendingFile {
397                    inode_number,
398                    path: path.to_path_buf(),
399                    mtime_ns,
400                    file_len,
401                });
402            }
403            Ok(inode_number)
404        } else {
405            Err(WriteError::Io(std::io::Error::new(
406                std::io::ErrorKind::Unsupported,
407                format!("unsupported file type: {}", path.display()),
408            )))
409        }
410    }
411
412    fn assemble(self) -> WriteArtifact {
413        let inode_count = self.inodes.len();
414        let dir_count = self.dir_count;
415        let drop_count = self.drops.len();
416
417        let (slab_bytes, slab_id, slab_locator) = if self.drops.is_empty() {
418            (None, None, None)
419        } else {
420            let (bytes, id) = encode_slab(&self.drops);
421            let locator = "file:slab-0.bin".to_owned();
422            (Some(bytes), Some(id), Some(locator))
423        };
424
425        let mut metadata_blob = Vec::new();
426        metadata_blob.extend_from_slice(&u32::try_from(self.inodes.len()).unwrap().to_le_bytes());
427        for inode in &self.inodes {
428            self.encode_inode(&mut metadata_blob, inode);
429        }
430        metadata_blob
431            .extend_from_slice(&u32::try_from(self.dir_nodes.len()).unwrap().to_le_bytes());
432        for node in &self.dir_nodes {
433            metadata_blob.extend_from_slice(&node.bytes);
434        }
435
436        let mut manifest = Vec::new();
437
438        let header_start = manifest.len();
439        manifest.extend_from_slice(&ManifestHeader::current().to_bytes());
440        let header_end = manifest.len();
441
442        let flags_start = manifest.len();
443        manifest.push(FEATURE_FLAGS_SECTION_VERSION);
444        manifest.extend_from_slice(&0u32.to_le_bytes());
445        let flags_end = manifest.len();
446
447        let meta_ref_start = manifest.len();
448        manifest.push(METADATA_REFERENCE_SECTION_VERSION);
449        let metadata_hash = hash_section(&metadata_blob);
450        manifest.extend_from_slice(&metadata_hash);
451        manifest.extend_from_slice(&0u32.to_le_bytes());
452        let inline_len = u32::try_from(metadata_blob.len()).expect("metadata fits u32");
453        manifest.extend_from_slice(&inline_len.to_le_bytes());
454        manifest.extend_from_slice(&metadata_blob);
455        let meta_ref_end = manifest.len();
456
457        let slab_index_start = manifest.len();
458        manifest.push(SLAB_INDEX_SECTION_VERSION);
459        if let (Some(id), Some(loc)) = (&slab_id, &slab_locator) {
460            manifest.extend_from_slice(&1u32.to_le_bytes());
461            manifest.extend_from_slice(&id.to_bytes());
462            manifest.extend_from_slice(&1u32.to_le_bytes());
463            let loc_bytes = loc.as_bytes();
464            let loc_len = u32::try_from(loc_bytes.len()).expect("locator fits u32");
465            manifest.extend_from_slice(&loc_len.to_le_bytes());
466            manifest.extend_from_slice(loc_bytes);
467        } else {
468            manifest.extend_from_slice(&0u32.to_le_bytes());
469        }
470        let slab_index_end = manifest.len();
471
472        let history_start = manifest.len();
473        manifest.push(HISTORY_SECTION_VERSION);
474        manifest.extend_from_slice(&1u32.to_le_bytes());
475        manifest.push(0x01);
476        manifest.extend_from_slice(&0u64.to_le_bytes());
477        manifest.extend_from_slice(&0u32.to_le_bytes());
478        manifest.extend_from_slice(&0u32.to_le_bytes());
479        let history_end = manifest.len();
480
481        let hashes = SectionHashes {
482            metadata: metadata_hash,
483            format_header: hash_section(&manifest[header_start..header_end]),
484            feature_flags: hash_section(&manifest[flags_start..flags_end]),
485            metadata_reference: hash_section(&manifest[meta_ref_start..meta_ref_end]),
486            slab_index: hash_section(&manifest[slab_index_start..slab_index_end]),
487            crypto_params: hash_empty_section(),
488            ec_params: hash_empty_section(),
489            dms_policy: hash_empty_section(),
490            delta_linkage: hash_empty_section(),
491            history: hash_section(&manifest[history_start..history_end]),
492        };
493        let merkle_root = compute_merkle_root(&hashes);
494
495        WriteArtifact {
496            bytes: manifest,
497            merkle_root,
498            slab_bytes,
499            slab_locator,
500            inode_count,
501            file_count: self.file_count,
502            dir_count,
503            drop_count,
504            root_inode_number: self.root_inode_number,
505        }
506    }
507
508    fn encode_inode(&self, out: &mut Vec<u8>, inode: &PendingInode) {
509        out.extend_from_slice(&inode.number.to_le_bytes());
510        out.extend_from_slice(&inode.mode.to_le_bytes());
511        out.extend_from_slice(&0u32.to_le_bytes());
512        out.extend_from_slice(&0u32.to_le_bytes());
513        out.extend_from_slice(&inode.mtime_ns.to_le_bytes());
514        out.extend_from_slice(&inode.mtime_ns.to_le_bytes());
515        out.extend_from_slice(&1u32.to_le_bytes());
516        match &inode.content {
517            PendingContent::Inline(data) => {
518                out.push(0x04);
519                let len = u32::try_from(data.len()).expect("data fits u32");
520                out.extend_from_slice(&len.to_le_bytes());
521                out.extend_from_slice(data);
522            }
523            PendingContent::DropBacked { file_len, slices } => {
524                out.push(0x00);
525                let slice_count = u32::try_from(slices.len()).expect("slice count fits u32");
526                out.extend_from_slice(&slice_count.to_le_bytes());
527                for slice in slices {
528                    out.extend_from_slice(&slice.file_byte_start.to_le_bytes());
529                    out.extend_from_slice(&slice.file_byte_end.to_le_bytes());
530                    out.extend_from_slice(&slice.drop_id);
531                    // drop_byte_start = 0 (slice covers the whole drop)
532                    out.extend_from_slice(&0u32.to_le_bytes());
533                    // drop_byte_len = the byte length of this slice in the
534                    // drop's decompressed plaintext. Each slice maps to
535                    // exactly one chunk, so this equals the file range.
536                    let drop_byte_len = u32::try_from(slice.file_byte_end - slice.file_byte_start)
537                        .expect("slice range fits u32");
538                    out.extend_from_slice(&drop_byte_len.to_le_bytes());
539                }
540                let _ = file_len;
541            }
542            PendingContent::Directory(entries) => {
543                out.push(0x00);
544                let node = self
545                    .dir_nodes
546                    .iter()
547                    .find(|n| n.entries == *entries)
548                    .expect("directory node must exist");
549                out.extend_from_slice(&node.hash);
550            }
551        }
552    }
553}
554
555fn encode_dir_node(entries: &[(String, u64, u8)]) -> DirNode {
556    let mut bytes = Vec::new();
557    bytes.push(1u8);
558    let count = u32::try_from(entries.len()).expect("entry count fits u32");
559    bytes.extend_from_slice(&count.to_le_bytes());
560    for (name, inode_number, entry_type) in entries {
561        let name_bytes = name.as_bytes();
562        let name_len = u32::try_from(name_bytes.len()).expect("name fits u32");
563        bytes.extend_from_slice(&name_len.to_le_bytes());
564        bytes.extend_from_slice(name_bytes);
565        bytes.extend_from_slice(&inode_number.to_le_bytes());
566        bytes.push(*entry_type);
567    }
568    let hash = hash_section(&bytes);
569    DirNode {
570        entries: entries.to_vec(),
571        bytes,
572        hash,
573    }
574}
575
576fn encode_slab(drops: &[PendingDrop]) -> (Vec<u8>, SlabId) {
577    let mut drop_records = Vec::new();
578    let mut solid_window = Vec::new();
579
580    for drop in drops {
581        let plaintext_len = drop.plaintext_len();
582        let window_len = drop.len_in_window();
583        drop_records.extend_from_slice(&drop.id);
584        drop_records.extend_from_slice(&plaintext_len.to_le_bytes());
585        // representation: (codec, aead=0, ec=0)
586        drop_records.extend_from_slice(&[drop.codec, 0x00, 0x00]);
587        drop_records.push(0x00); // solid_window_index
588        drop_records.extend_from_slice(&drop.offset_in_window.to_le_bytes());
589        drop_records.extend_from_slice(&window_len.to_le_bytes());
590        solid_window.extend_from_slice(&drop.compressed);
591    }
592
593    let slab_content = [&drop_records[..], &solid_window[..]].concat();
594    let slab_hash = hash_section(&slab_content);
595    let slab_id = SlabId::new(0, slab_hash);
596
597    let total_length = 56 + slab_content.len();
598    let mut slab_bytes = Vec::with_capacity(total_length);
599    slab_bytes.extend_from_slice(b"LIM1");
600    slab_bytes.extend_from_slice(&1u16.to_le_bytes());
601    slab_bytes.extend_from_slice(&slab_id.to_bytes());
602    slab_bytes.extend_from_slice(&(total_length as u64).to_le_bytes());
603    slab_bytes.push(0x00);
604    slab_bytes.push(0x00);
605    slab_bytes.extend_from_slice(&slab_content);
606
607    (slab_bytes, slab_id)
608}
609
610#[cfg(test)]
611fn pseudo_random_bytes(seed: u64, count: usize) -> Vec<u8> {
612    let mut state = seed;
613    let mut out = Vec::with_capacity(count);
614    for _ in 0..count {
615        state = state
616            .wrapping_mul(6_364_136_223_846_793_005)
617            .wrapping_add(1_442_695_040_888_963_407);
618        out.push(u8::try_from(state >> 56).expect("fits u8"));
619    }
620    out
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use limnifs_core::ManifestCursor;
627
628    #[test]
629    fn write_empty_directory() {
630        let temp =
631            std::env::temp_dir().join(format!("limnifs-write-test-{}-empty", std::process::id()));
632        std::fs::create_dir_all(&temp).expect("create temp dir");
633        let artifact = write_directory(&temp).expect("write succeeds");
634        std::fs::remove_dir_all(&temp).ok();
635        assert!(artifact.inode_count >= 1);
636        assert_eq!(artifact.file_count, 0);
637        assert_eq!(artifact.dir_count, 1);
638        assert!(artifact.slab_bytes.is_none());
639    }
640
641    #[test]
642    fn write_small_file_inline() {
643        let temp =
644            std::env::temp_dir().join(format!("limnifs-write-test-{}-small", std::process::id()));
645        std::fs::create_dir_all(&temp).expect("create temp dir");
646        std::fs::write(temp.join("hello.txt"), b"hello world").expect("write file");
647        let artifact = write_directory(&temp).expect("write succeeds");
648        std::fs::remove_dir_all(&temp).ok();
649        assert_eq!(artifact.file_count, 1);
650        assert!(artifact.slab_bytes.is_none());
651        assert_eq!(artifact.drop_count, 0);
652    }
653
654    #[test]
655    fn write_large_file_uses_slab() {
656        let temp =
657            std::env::temp_dir().join(format!("limnifs-write-test-{}-large", std::process::id()));
658        std::fs::create_dir_all(&temp).expect("create temp dir");
659        let large_data = vec![0xABu8; INLINE_THRESHOLD + 100];
660        std::fs::write(temp.join("big.bin"), &large_data).expect("write big");
661        let artifact = write_directory(&temp).expect("write succeeds");
662        std::fs::remove_dir_all(&temp).ok();
663        assert_eq!(artifact.drop_count, 1);
664        assert!(artifact.slab_bytes.is_some());
665        assert!(artifact.slab_locator.is_some());
666    }
667
668    #[test]
669    fn write_mixed_inline_and_large() {
670        let temp =
671            std::env::temp_dir().join(format!("limnifs-write-test-{}-mix", std::process::id()));
672        std::fs::create_dir_all(&temp).expect("create temp dir");
673        std::fs::write(temp.join("small.txt"), b"tiny").expect("write small");
674        std::fs::write(temp.join("large.bin"), vec![0xCDu8; INLINE_THRESHOLD * 2])
675            .expect("write large");
676        let artifact = write_directory(&temp).expect("write succeeds");
677        std::fs::remove_dir_all(&temp).ok();
678        assert_eq!(artifact.file_count, 2);
679        assert_eq!(artifact.drop_count, 1);
680        assert!(artifact.slab_bytes.is_some());
681    }
682
683    #[test]
684    fn deduplicates_identical_large_files() {
685        let temp =
686            std::env::temp_dir().join(format!("limnifs-write-test-{}-dedup", std::process::id()));
687        std::fs::create_dir_all(&temp).expect("create temp dir");
688        let data = vec![0x77u8; INLINE_THRESHOLD + 10];
689        std::fs::write(temp.join("a.bin"), &data).expect("write a");
690        std::fs::write(temp.join("b.bin"), &data).expect("write b");
691        let artifact = write_directory(&temp).expect("write succeeds");
692        std::fs::remove_dir_all(&temp).ok();
693        assert_eq!(artifact.drop_count, 1);
694    }
695
696    #[test]
697    fn write_and_verify_roundtrip() {
698        let temp = std::env::temp_dir().join(format!(
699            "limnifs-write-test-{}-roundtrip",
700            std::process::id()
701        ));
702        std::fs::create_dir_all(&temp).expect("create temp dir");
703        std::fs::write(temp.join("a.txt"), b"aaa").expect("write a");
704        std::fs::write(temp.join("b.txt"), b"bbb").expect("write b");
705        std::fs::create_dir_all(temp.join("sub")).expect("create sub");
706        std::fs::write(temp.join("sub").join("c.txt"), b"ccc").expect("write c");
707        let artifact = write_directory(&temp).expect("write succeeds");
708        std::fs::remove_dir_all(&temp).ok();
709        assert_eq!(artifact.file_count, 3);
710        assert_eq!(artifact.dir_count, 2);
711
712        let mut cursor = ManifestCursor::new(&artifact.bytes);
713        limnifs_core::parse_manifest_header(&mut cursor).expect("header");
714        limnifs_core::parse_feature_flags_section(&mut cursor).expect("flags");
715        let meta_ref = limnifs_core::parse_metadata_reference(&mut cursor).expect("meta ref");
716        assert!(meta_ref.is_inlined());
717        let slab_index = limnifs_core::parse_slab_index(&mut cursor).expect("slab index");
718        assert_eq!(slab_index.len(), 0);
719        limnifs_core::parse_history(&mut cursor).expect("history");
720    }
721
722    #[test]
723    fn write_deterministic() {
724        let temp =
725            std::env::temp_dir().join(format!("limnifs-write-test-{}-det", std::process::id()));
726        std::fs::create_dir_all(&temp).expect("create temp dir");
727        std::fs::write(temp.join("x.txt"), b"xxx").expect("write x");
728
729        let a1 = write_directory(&temp).expect("first write");
730        let a2 = write_directory(&temp).expect("second write");
731        std::fs::remove_dir_all(&temp).ok();
732
733        assert_eq!(a1.bytes, a2.bytes);
734        assert_eq!(a1.merkle_root, a2.merkle_root);
735    }
736
737    #[test]
738    fn slab_parses_correctly() {
739        let temp =
740            std::env::temp_dir().join(format!("limnifs-write-test-{}-slab", std::process::id()));
741        std::fs::create_dir_all(&temp).expect("create temp dir");
742        std::fs::write(temp.join("big.bin"), vec![0x11u8; INLINE_THRESHOLD + 1])
743            .expect("write big");
744        let artifact = write_directory(&temp).expect("write succeeds");
745        std::fs::remove_dir_all(&temp).ok();
746
747        let slab_bytes = artifact.slab_bytes.as_ref().expect("slab exists");
748        let mut cursor = ManifestCursor::new(slab_bytes);
749        let slab_header = limnifs_core::parse_slab_header(&mut cursor).expect("slab header parses");
750        assert_eq!(slab_header.format_version, 1);
751        assert!(!slab_header.is_sealed());
752        assert!(!slab_header.has_erasure_coding());
753
754        let drop_record =
755            limnifs_core::parse_drop_record(&mut cursor, &slab_header).expect("drop record parses");
756        assert_eq!(drop_record.plaintext_len as usize, INLINE_THRESHOLD + 1);
757    }
758
759    #[test]
760    fn fastcdc_produces_multiple_chunks_for_large_files() {
761        // A 1 MiB pseudo-random file should produce multiple drops
762        // via FastCDC (default chunker uses 64 KiB min / 256 KiB avg).
763        let temp = std::env::temp_dir().join(format!(
764            "limnifs-write-test-{}-cdc-multi",
765            std::process::id()
766        ));
767        std::fs::create_dir_all(&temp).expect("create temp dir");
768        let data = pseudo_random_bytes(42, 1024 * 1024);
769        std::fs::write(temp.join("big.bin"), &data).expect("write big");
770        let artifact = write_directory(&temp).expect("write succeeds");
771        std::fs::remove_dir_all(&temp).ok();
772        assert!(
773            artifact.drop_count > 1,
774            "expected FastCDC to produce multiple drops for 1 MiB input, got {}",
775            artifact.drop_count
776        );
777    }
778
779    #[test]
780    fn fastcdc_deduplicates_shared_substrings() {
781        // Two files sharing a long middle section should produce
782        // fewer drops than the sum of their individual chunk counts,
783        // because the shared section's chunks deduplicate.
784        let temp = std::env::temp_dir().join(format!(
785            "limnifs-write-test-{}-cdc-dedup",
786            std::process::id()
787        ));
788        std::fs::create_dir_all(&temp).expect("create temp dir");
789        let shared = pseudo_random_bytes(7, 512 * 1024);
790        let mut a = Vec::with_capacity(shared.len() + 1024);
791        a.extend_from_slice(&pseudo_random_bytes(1, 1024));
792        a.extend_from_slice(&shared);
793        let mut b = Vec::with_capacity(shared.len() + 2048);
794        b.extend_from_slice(&pseudo_random_bytes(2, 2048));
795        b.extend_from_slice(&shared);
796        std::fs::write(temp.join("a.bin"), &a).expect("write a");
797        std::fs::write(temp.join("b.bin"), &b).expect("write b");
798
799        // Baseline: each file alone.
800        let temp_a = std::env::temp_dir().join(format!(
801            "limnifs-write-test-{}-cdc-dedup-a",
802            std::process::id()
803        ));
804        std::fs::create_dir_all(&temp_a).expect("create temp_a");
805        std::fs::write(temp_a.join("a.bin"), &a).expect("write a");
806        let artifact_a = write_directory(&temp_a).expect("a writes");
807        std::fs::remove_dir_all(&temp_a).ok();
808
809        let temp_b = std::env::temp_dir().join(format!(
810            "limnifs-write-test-{}-cdc-dedup-b",
811            std::process::id()
812        ));
813        std::fs::create_dir_all(&temp_b).expect("create temp_b");
814        std::fs::write(temp_b.join("b.bin"), &b).expect("write b");
815        let artifact_b = write_directory(&temp_b).expect("b writes");
816        std::fs::remove_dir_all(&temp_b).ok();
817
818        let artifact_both = write_directory(&temp).expect("both write");
819        std::fs::remove_dir_all(&temp).ok();
820
821        let sum_alone = artifact_a.drop_count + artifact_b.drop_count;
822        assert!(
823            artifact_both.drop_count < sum_alone,
824            "expected dedup win: both together = {} drops, sum alone = {} drops",
825            artifact_both.drop_count,
826            sum_alone
827        );
828    }
829}