Skip to main content

dataspool_rs/
spool.rs

1//! Data Spooling: Stitch BP Cards into Single File
2//!
3//! Eliminates filesystem overhead by concatenating all cards into one file.
4//! Provides byte offset index for direct random access.
5
6use crate::error::{DataSpoolError, Result};
7use std::fs::{File, OpenOptions};
8use std::io::{Read, Seek, SeekFrom, Write};
9use std::path::Path;
10
11/// Magic bytes for spool format
12const MAGIC: &[u8; 4] = b"SP01";
13
14/// Spool file format version
15const VERSION: u8 = 1;
16
17/// Entry in the spool index
18#[derive(Debug, Clone)]
19pub struct SpoolEntry {
20    /// Byte offset in spool file where card starts
21    pub offset: u64,
22    /// Length of card in bytes
23    pub length: u32,
24}
25
26/// Builds a spool file from individual BP cards
27pub struct SpoolBuilder {
28    /// Output file
29    output: File,
30    /// Current write position (after header)
31    current_offset: u64,
32    /// Index entries
33    entries: Vec<SpoolEntry>,
34}
35
36impl SpoolBuilder {
37    /// Create a new spool builder
38    pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
39        let mut output = OpenOptions::new()
40            .write(true)
41            .create(true)
42            .truncate(true)
43            .open(path)?;
44
45        // Write header (will update later with index offset)
46        output.write_all(MAGIC)?;
47        output.write_all(&[VERSION])?;
48        output.write_all(&0u32.to_le_bytes())?; // card_count (placeholder)
49        output.write_all(&0u64.to_le_bytes())?; // index_offset (placeholder)
50
51        let current_offset = output.stream_position()?;
52
53        Ok(Self {
54            output,
55            current_offset,
56            entries: Vec::new(),
57        })
58    }
59
60    /// Add a card to the spool
61    ///
62    /// Returns the entry with offset and length
63    pub fn add_card(&mut self, card_data: &[u8]) -> Result<SpoolEntry> {
64        let offset = self.current_offset;
65        let length = card_data.len() as u32;
66
67        // Write card data
68        self.output.write_all(card_data)?;
69        self.current_offset += card_data.len() as u64;
70
71        let entry = SpoolEntry { offset, length };
72        self.entries.push(entry.clone());
73
74        Ok(entry)
75    }
76
77    /// Finalize the spool and write index
78    pub fn finalize(mut self) -> Result<()> {
79        let index_offset = self.current_offset;
80        let card_count = self.entries.len() as u32;
81
82        // Write index at end of file
83        for entry in &self.entries {
84            self.output.write_all(&entry.offset.to_le_bytes())?;
85            self.output.write_all(&entry.length.to_le_bytes())?;
86        }
87
88        // Update header with card count and index offset
89        self.output.seek(SeekFrom::Start(5))?; // Skip magic + version
90        self.output.write_all(&card_count.to_le_bytes())?;
91        self.output.write_all(&index_offset.to_le_bytes())?;
92
93        self.output.sync_all()?;
94
95        Ok(())
96    }
97}
98
99/// Reads cards from a spool file
100pub struct SpoolReader {
101    /// Input file
102    file: File,
103    /// Index entries loaded from spool
104    entries: Vec<SpoolEntry>,
105}
106
107impl SpoolReader {
108    /// Open a spool file
109    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
110        let mut file = File::open(path)?;
111
112        // Read and verify header
113        let mut magic = [0u8; 4];
114        file.read_exact(&mut magic)?;
115        if &magic != MAGIC {
116            return Err(DataSpoolError::Decompression(
117                "Invalid spool magic bytes".into(),
118            ));
119        }
120
121        let mut version = [0u8; 1];
122        file.read_exact(&mut version)?;
123        if version[0] != VERSION {
124            return Err(DataSpoolError::InvalidFormat);
125        }
126
127        let mut card_count_bytes = [0u8; 4];
128        file.read_exact(&mut card_count_bytes)?;
129        let card_count = u32::from_le_bytes(card_count_bytes);
130
131        let mut index_offset_bytes = [0u8; 8];
132        file.read_exact(&mut index_offset_bytes)?;
133        let index_offset = u64::from_le_bytes(index_offset_bytes);
134
135        // Read index
136        file.seek(SeekFrom::Start(index_offset))?;
137        let mut entries = Vec::with_capacity(card_count as usize);
138
139        for _ in 0..card_count {
140            let mut offset_bytes = [0u8; 8];
141            file.read_exact(&mut offset_bytes)?;
142            let offset = u64::from_le_bytes(offset_bytes);
143
144            let mut length_bytes = [0u8; 4];
145            file.read_exact(&mut length_bytes)?;
146            let length = u32::from_le_bytes(length_bytes);
147
148            entries.push(SpoolEntry { offset, length });
149        }
150
151        Ok(Self { file, entries })
152    }
153
154    /// Open a spool embedded within a larger file at a given byte offset.
155    ///
156    /// Reads the SP01 header starting at `base_offset`, then adjusts all
157    /// internal offsets so that `read_card()` seeks to the correct position
158    /// within the host file. This enables direct access to spool data
159    /// stitched into an Engram archive without temp extraction.
160    pub fn open_embedded<P: AsRef<Path>>(path: P, base_offset: u64) -> Result<Self> {
161        let mut file = File::open(path)?;
162
163        // Seek to spool start within the host file.
164        file.seek(SeekFrom::Start(base_offset))?;
165
166        // Read and verify header.
167        let mut magic = [0u8; 4];
168        file.read_exact(&mut magic)?;
169        if &magic != MAGIC {
170            return Err(DataSpoolError::Decompression(
171                "Invalid spool magic bytes".into(),
172            ));
173        }
174
175        let mut version = [0u8; 1];
176        file.read_exact(&mut version)?;
177        if version[0] != VERSION {
178            return Err(DataSpoolError::InvalidFormat);
179        }
180
181        let mut card_count_bytes = [0u8; 4];
182        file.read_exact(&mut card_count_bytes)?;
183        let card_count = u32::from_le_bytes(card_count_bytes);
184
185        let mut index_offset_bytes = [0u8; 8];
186        file.read_exact(&mut index_offset_bytes)?;
187        let index_offset = u64::from_le_bytes(index_offset_bytes);
188
189        // index_offset is relative to spool start — adjust to host file position.
190        file.seek(SeekFrom::Start(base_offset + index_offset))?;
191        let mut entries = Vec::with_capacity(card_count as usize);
192
193        for _ in 0..card_count {
194            let mut offset_bytes = [0u8; 8];
195            file.read_exact(&mut offset_bytes)?;
196            let offset = u64::from_le_bytes(offset_bytes);
197
198            let mut length_bytes = [0u8; 4];
199            file.read_exact(&mut length_bytes)?;
200            let length = u32::from_le_bytes(length_bytes);
201
202            // Adjust card offset to host file position.
203            entries.push(SpoolEntry {
204                offset: base_offset + offset,
205                length,
206            });
207        }
208
209        Ok(Self { file, entries })
210    }
211
212    /// Get number of cards in spool
213    pub fn card_count(&self) -> usize {
214        self.entries.len()
215    }
216
217    /// Read a card by index
218    pub fn read_card(&mut self, index: usize) -> Result<Vec<u8>> {
219        if index >= self.entries.len() {
220            return Err(DataSpoolError::Decompression(format!(
221                "Card index {} out of bounds (max: {})",
222                index,
223                self.entries.len() - 1
224            )));
225        }
226
227        let entry = &self.entries[index];
228        self.read_card_at(entry.offset, entry.length as usize)
229    }
230
231    /// Read a card at specific offset and length
232    pub fn read_card_at(&mut self, offset: u64, length: usize) -> Result<Vec<u8>> {
233        self.file.seek(SeekFrom::Start(offset))?;
234
235        let mut buffer = vec![0u8; length];
236        self.file.read_exact(&mut buffer)?;
237
238        Ok(buffer)
239    }
240
241    /// Get entry for a card index
242    pub fn get_entry(&self, index: usize) -> Option<&SpoolEntry> {
243        self.entries.get(index)
244    }
245
246    /// Get all entries
247    pub fn entries(&self) -> &[SpoolEntry] {
248        &self.entries
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use std::fs;
256
257    #[test]
258    fn test_spool_roundtrip() {
259        let temp_path = "test_spool.spool";
260
261        // Create test cards
262        let card1 = b"BP01\x01\x08code:apisome data 1";
263        let card2 = b"BP01\x01\x08code:apisome data 2 longer";
264        let card3 = b"BP01\x01\x08code:apidata 3";
265
266        // Build spool
267        {
268            let mut builder = SpoolBuilder::new(temp_path).unwrap();
269            let entry1 = builder.add_card(card1).unwrap();
270            let entry2 = builder.add_card(card2).unwrap();
271            let entry3 = builder.add_card(card3).unwrap();
272
273            assert_eq!(entry1.length, card1.len() as u32);
274            assert_eq!(entry2.length, card2.len() as u32);
275            assert_eq!(entry3.length, card3.len() as u32);
276
277            builder.finalize().unwrap();
278        }
279
280        // Read spool
281        {
282            let mut reader = SpoolReader::open(temp_path).unwrap();
283            assert_eq!(reader.card_count(), 3);
284
285            let read1 = reader.read_card(0).unwrap();
286            let read2 = reader.read_card(1).unwrap();
287            let read3 = reader.read_card(2).unwrap();
288
289            assert_eq!(&read1, card1);
290            assert_eq!(&read2, card2);
291            assert_eq!(&read3, card3);
292        }
293
294        // Cleanup
295        fs::remove_file(temp_path).unwrap();
296    }
297
298    #[test]
299    fn test_spool_direct_access() {
300        let temp_path = "test_spool_direct.spool";
301
302        let card = b"BP01\x01\x08code:apitest data";
303
304        {
305            let mut builder = SpoolBuilder::new(temp_path).unwrap();
306            let entry = builder.add_card(card).unwrap();
307
308            // Store offset and length for later
309            let offset = entry.offset;
310            let length = entry.length;
311
312            builder.finalize().unwrap();
313
314            // Reopen and read by offset
315            let mut reader = SpoolReader::open(temp_path).unwrap();
316            let read = reader.read_card_at(offset, length as usize).unwrap();
317            assert_eq!(&read, card);
318        }
319
320        fs::remove_file(temp_path).unwrap();
321    }
322
323    #[test]
324    fn test_spool_open_embedded() {
325        // Simulate a spool embedded within a larger file by prepending junk bytes.
326        let temp_spool = "test_embedded_source.spool";
327        let temp_host = "test_embedded_host.bin";
328
329        let card1 = b"BP01\x01\x08code:apicard one data";
330        let card2 = b"BP01\x01\x08code:apicard two longer data here";
331
332        // Build a normal spool.
333        {
334            let mut builder = SpoolBuilder::new(temp_spool).unwrap();
335            builder.add_card(card1).unwrap();
336            builder.add_card(card2).unwrap();
337            builder.finalize().unwrap();
338        }
339
340        // Create a host file: [64 bytes junk][spool data][32 bytes junk]
341        let spool_bytes = fs::read(temp_spool).unwrap();
342        let prefix = vec![0xAA; 64];
343        let suffix = vec![0xBB; 32];
344        let mut host = Vec::new();
345        host.extend_from_slice(&prefix);
346        host.extend_from_slice(&spool_bytes);
347        host.extend_from_slice(&suffix);
348        fs::write(temp_host, &host).unwrap();
349
350        // Open embedded at offset 64.
351        {
352            let mut reader = SpoolReader::open_embedded(temp_host, 64).unwrap();
353            assert_eq!(reader.card_count(), 2);
354
355            let read1 = reader.read_card(0).unwrap();
356            let read2 = reader.read_card(1).unwrap();
357            assert_eq!(&read1, card1);
358            assert_eq!(&read2, card2);
359        }
360
361        fs::remove_file(temp_spool).unwrap();
362        fs::remove_file(temp_host).unwrap();
363    }
364}