Skip to main content

reflex/
content_store.rs

1//! Content store for memory-mapped file access
2//!
3//! This module stores the full contents of all indexed files in a single
4//! memory-mapped file. This enables zero-copy access to file contents for:
5//! - Verifying trigram matches
6//! - Extracting context around matches
7//! - Fast content retrieval without disk I/O
8//!
9//! # Binary Format (content.bin)
10//!
11//! ```text
12//! Header (32 bytes):
13//!   magic: "RFCT" (4 bytes)
14//!   version: 1 (u32)
15//!   num_files: N (u64)
16//!   index_offset: offset to file index (u64)
17//!   reserved: 8 bytes
18//!
19//! File Contents (variable):
20//!   [Concatenated file contents]
21//!
22//! File Index (at index_offset), version 2:
23//!   Entry table, num_files × 28 bytes, addressable by file_id:
24//!     offset: u64   (byte offset of the content, relative to the header end)
25//!     length: u64   (content size in bytes)
26//!     path_pos: u64 (absolute file position of the path bytes)
27//!     path_len: u32
28//!   Path blob: the UTF-8 paths, concatenated, in file_id order
29//! ```
30//!
31//! Version 1 stored `(path_len, path, offset, length)` per file, which forced the
32//! reader to decode the whole index into a `Vec` on open — O(files) work that
33//! was the floor of every CLI query on a 24k-file checkout (12 ms). An entry is
34//! now read from the mmap at `index_offset + file_id * 28`, so open is O(1) and
35//! a query touches only the entries it verifies.
36
37use anyhow::{Context, Result};
38use memmap2::Mmap;
39use std::fs::{File, OpenOptions};
40use std::io::Write;
41use std::path::{Path, PathBuf};
42
43const MAGIC: &[u8; 4] = b"RFCT";
44const VERSION: u32 = 2;
45/// Bytes per file-index entry: offset u64 + length u64 + path_pos u64 + path_len u32.
46const ENTRY_SIZE: usize = 28;
47const HEADER_SIZE: usize = 32; // 4 (magic) + 4 (version) + 8 (num_files) + 8 (index_offset) + 8 (reserved)
48
49/// Metadata for a file in the content store
50#[derive(Debug, Clone)]
51pub struct FileEntry {
52    /// File path
53    pub path: PathBuf,
54    /// Byte offset in content.bin where this file's content starts
55    pub offset: u64,
56    /// Length of this file's content in bytes
57    pub length: u64,
58}
59
60/// Writer for building content.bin
61///
62/// Supports two modes:
63/// 1. **Streaming mode** (init() called): Writes file contents to disk incrementally to avoid RAM buildup
64/// 2. **In-memory mode** (default): Accumulates content in RAM for backward compatibility with tests
65pub struct ContentWriter {
66    files: Vec<FileEntry>,
67    writer: Option<std::io::BufWriter<File>>,
68    current_offset: u64,
69    file_path: Option<PathBuf>,
70    // In-memory content buffer (only used if streaming mode not enabled)
71    content: Vec<u8>,
72    // First streaming write failure; surfaced by finalize() instead of panicking
73    // inside add_file() (whose signature returns the file id, not a Result).
74    write_error: Option<std::io::Error>,
75}
76
77impl ContentWriter {
78    /// Create a new content writer (in-memory mode by default)
79    ///
80    /// Call init() to enable streaming mode before adding files.
81    pub fn new() -> Self {
82        Self {
83            files: Vec::new(),
84            writer: None,
85            current_offset: 0,
86            file_path: None,
87            content: Vec::new(),
88            write_error: None,
89        }
90    }
91
92    /// Initialize the writer by creating the output file and writing header placeholder
93    ///
94    /// Crash safety: bytes are streamed into `<path>.tmp`; `finalize()` renames the
95    /// temp file over `path` only after the header is complete and synced, so a
96    /// reader never sees a short `content.bin`.
97    pub fn init(&mut self, path: PathBuf) -> Result<()> {
98        let tmp_path = crate::atomic_write::tmp_path_for(&path);
99        let file = OpenOptions::new()
100            .create(true)
101            .write(true)
102            .truncate(true)
103            .open(&tmp_path)
104            .with_context(|| format!("Failed to create {}", tmp_path.display()))?;
105
106        // Use a large buffer (16MB) for better write performance
107        let mut writer = std::io::BufWriter::with_capacity(16 * 1024 * 1024, file);
108
109        // Write placeholder header (will be overwritten in finalize())
110        writer.write_all(MAGIC)?;
111        writer.write_all(&VERSION.to_le_bytes())?;
112        writer.write_all(&0u64.to_le_bytes())?; // num_files (placeholder)
113        writer.write_all(&0u64.to_le_bytes())?; // index_offset (placeholder)
114        writer.write_all(&[0u8; 8])?; // reserved
115
116        self.writer = Some(writer);
117        self.current_offset = 0; // Content starts after header
118        self.file_path = Some(path);
119
120        Ok(())
121    }
122
123    /// Add a file to the content store
124    ///
125    /// **Streaming mode** (if init() was called): Writes content to disk immediately.
126    /// **In-memory mode** (default): Accumulates content in RAM.
127    ///
128    /// Returns the file_id (index into files array)
129    pub fn add_file(&mut self, path: PathBuf, content: &str) -> u32 {
130        let file_id = self.files.len() as u32;
131        let content_bytes = content.as_bytes();
132        let length = content_bytes.len() as u64;
133
134        if let Some(ref mut w) = self.writer {
135            // Streaming mode: write content immediately to disk
136            let offset = self.current_offset;
137            if let Err(e) = w.write_all(content_bytes)
138                && self.write_error.is_none()
139            {
140                // Keep going so the caller sees one clear error from finalize()
141                // instead of a panic mid-index; the temp file is discarded.
142                self.write_error = Some(e);
143            }
144            self.current_offset += length;
145
146            self.files.push(FileEntry {
147                path,
148                offset,
149                length,
150            });
151        } else {
152            // In-memory mode: accumulate in RAM (for backward compatibility)
153            let offset = self.content.len() as u64;
154            self.content.extend_from_slice(content_bytes);
155
156            self.files.push(FileEntry {
157                path,
158                offset,
159                length,
160            });
161        }
162
163        file_id
164    }
165
166    /// Write the content store to disk
167    ///
168    /// This is the main entry point for the old API. It initializes the writer (if needed),
169    /// and finalizes the file.
170    pub fn write(&mut self, path: impl AsRef<Path>) -> Result<()> {
171        let path = path.as_ref();
172
173        // Initialize writer if not already done
174        if self.writer.is_none() && self.file_path.is_none() {
175            // Old API: no files written yet, need to write them now in-memory
176            // This is a fallback for tests that don't call init()
177            return self.write_legacy(path);
178        }
179
180        // New streaming API: already been writing, just finalize
181        self.finalize_if_needed()?;
182
183        Ok(())
184    }
185
186    /// Legacy write path for in-memory mode (backward compatibility)
187    ///
188    /// This is only used when write() is called without init() first.
189    /// Content is accumulated in RAM and written all at once.
190    fn write_legacy(&self, path: impl AsRef<Path>) -> Result<()> {
191        let path = path.as_ref();
192        let tmp_path = crate::atomic_write::tmp_path_for(path);
193        let file = OpenOptions::new()
194            .create(true)
195            .write(true)
196            .truncate(true)
197            .open(&tmp_path)
198            .with_context(|| format!("Failed to create {}", tmp_path.display()))?;
199
200        // Use a large buffer (8MB) for better write performance
201        let mut writer = std::io::BufWriter::with_capacity(8 * 1024 * 1024, file);
202
203        // Calculate index offset (after header + content)
204        let index_offset = HEADER_SIZE as u64 + self.content.len() as u64;
205
206        // Write header
207        writer.write_all(MAGIC)?;
208        writer.write_all(&VERSION.to_le_bytes())?;
209        writer.write_all(&(self.files.len() as u64).to_le_bytes())?;
210        writer.write_all(&index_offset.to_le_bytes())?;
211        writer.write_all(&[0u8; 8])?; // reserved
212
213        // Write all accumulated file contents
214        writer.write_all(&self.content)?;
215
216        // Write file index: the fixed-width table, then the path blob.
217        write_file_index(&mut writer, &self.files, index_offset)?;
218
219        writer.flush()?;
220        writer.get_ref().sync_all()?;
221        crate::atomic_write::atomic_replace(&tmp_path, path)
222            .with_context(|| format!("Failed to move {} into place", path.display()))?;
223        Ok(())
224    }
225
226    /// Finalize the content.bin file by writing the file index and updating the header
227    fn finalize(&mut self) -> Result<()> {
228        let mut writer = self
229            .writer
230            .take()
231            .ok_or_else(|| anyhow::anyhow!("ContentWriter not initialized"))?;
232        let final_path = self
233            .file_path
234            .clone()
235            .ok_or_else(|| anyhow::anyhow!("ContentWriter has no output path"))?;
236        let tmp_path = crate::atomic_write::tmp_path_for(&final_path);
237
238        if let Some(e) = self.write_error.take() {
239            let _ = std::fs::remove_file(&tmp_path);
240            return Err(anyhow::Error::new(e).context(format!(
241                "Failed to write file content to {}",
242                tmp_path.display()
243            )));
244        }
245
246        // Write file index at current position: the fixed-width table, then the
247        // path blob.
248        let index_offset = HEADER_SIZE as u64 + self.current_offset;
249        write_file_index(&mut writer, &self.files, index_offset)?;
250
251        // Consume BufWriter and get the underlying File
252        let mut file = writer
253            .into_inner()
254            .map_err(|e| anyhow::anyhow!("Failed to flush BufWriter: {}", e.error()))?;
255
256        // Rewind to header and update with correct values
257        use std::io::Seek;
258        file.seek(std::io::SeekFrom::Start(0))?;
259
260        // Write correct header
261        file.write_all(MAGIC)?;
262        file.write_all(&VERSION.to_le_bytes())?;
263        file.write_all(&(self.files.len() as u64).to_le_bytes())?;
264        file.write_all(&index_offset.to_le_bytes())?;
265        file.write_all(&[0u8; 8])?; // reserved
266
267        // Final sync to disk, then publish atomically: readers see either the
268        // previous complete content.bin or this one, never a partial file.
269        file.sync_all()?;
270        drop(file);
271        crate::atomic_write::atomic_replace(&tmp_path, &final_path)
272            .with_context(|| format!("Failed to move {} into place", final_path.display()))?;
273
274        log::debug!(
275            "Finalized content.bin: {} files, {} bytes of content",
276            self.files.len(),
277            self.current_offset
278        );
279
280        Ok(())
281    }
282
283    /// Get the number of files
284    pub fn file_count(&self) -> usize {
285        self.files.len()
286    }
287
288    /// Get total content size
289    pub fn content_size(&self) -> usize {
290        if self.writer.is_some() || self.file_path.is_some() {
291            // Streaming mode
292            self.current_offset as usize
293        } else {
294            // In-memory mode
295            self.content.len()
296        }
297    }
298
299    /// Finalize content store if it hasn't been finalized yet
300    ///
301    /// This is safe to call multiple times - subsequent calls are no-ops.
302    pub fn finalize_if_needed(&mut self) -> Result<()> {
303        if self.writer.is_some() {
304            self.finalize()?;
305            // Clear writer to mark as finalized
306            self.writer = None;
307        }
308        Ok(())
309    }
310}
311
312impl Default for ContentWriter {
313    fn default() -> Self {
314        Self::new()
315    }
316}
317
318/// Write the version-2 file index: `files.len()` fixed-width entries followed by
319/// the path blob. `index_offset` is where the table starts in the file.
320fn write_file_index<W: Write>(
321    writer: &mut W,
322    files: &[FileEntry],
323    index_offset: u64,
324) -> Result<()> {
325    let blob_start = index_offset + (files.len() * ENTRY_SIZE) as u64;
326    let mut path_pos = blob_start;
327    for entry in files {
328        let path_len = entry.path.to_string_lossy().len() as u64;
329        writer.write_all(&entry.offset.to_le_bytes())?;
330        writer.write_all(&entry.length.to_le_bytes())?;
331        writer.write_all(&path_pos.to_le_bytes())?;
332        writer.write_all(&(path_len as u32).to_le_bytes())?;
333        path_pos += path_len;
334    }
335    for entry in files {
336        writer.write_all(entry.path.to_string_lossy().as_bytes())?;
337    }
338    Ok(())
339}
340
341/// Reader for memory-mapped content.bin
342///
343/// Provides zero-copy access to file contents.
344pub struct ContentReader {
345    _file: File,
346    mmap: Mmap,
347    /// From the header; entries are read from the mmap on demand.
348    num_files: usize,
349    /// Start of the entry table.
350    index_offset: usize,
351}
352
353/// One file-index entry, read in place from the mmap.
354#[derive(Debug, Clone, Copy)]
355struct Entry<'a> {
356    offset: u64,
357    length: u64,
358    path: &'a str,
359}
360
361impl ContentReader {
362    /// Open and memory-map content.bin
363    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
364        let path = path.as_ref();
365
366        let file =
367            File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
368
369        let mmap = unsafe {
370            Mmap::map(&file).with_context(|| format!("Failed to mmap {}", path.display()))?
371        };
372
373        // Validate header
374        if mmap.len() < HEADER_SIZE {
375            anyhow::bail!(
376                "content.bin too small (expected at least {} bytes)",
377                HEADER_SIZE
378            );
379        }
380
381        if &mmap[0..4] != MAGIC {
382            anyhow::bail!("Invalid content.bin (wrong magic bytes)");
383        }
384
385        let version = u32::from_le_bytes([mmap[4], mmap[5], mmap[6], mmap[7]]);
386        if version != VERSION {
387            anyhow::bail!("Unsupported content.bin version: {}", version);
388        }
389
390        let num_files = u64::from_le_bytes([
391            mmap[8], mmap[9], mmap[10], mmap[11], mmap[12], mmap[13], mmap[14], mmap[15],
392        ]);
393
394        let index_offset = u64::from_le_bytes([
395            mmap[16], mmap[17], mmap[18], mmap[19], mmap[20], mmap[21], mmap[22], mmap[23],
396        ]) as usize;
397
398        // Bounds of the entry table, then the first and last entry as a sanity
399        // check. Nothing is decoded: a query reads the entries it verifies.
400        let num_files = num_files as usize;
401        let table_end = index_offset.saturating_add(num_files.saturating_mul(ENTRY_SIZE));
402        if table_end > mmap.len() {
403            anyhow::bail!(
404                "Truncated file index (index_offset={}, num_files={}, mmap.len()={})",
405                index_offset,
406                num_files,
407                mmap.len()
408            );
409        }
410        let reader = Self {
411            _file: file,
412            mmap,
413            num_files,
414            index_offset,
415        };
416        if num_files > 0 {
417            for id in [0u32, (num_files - 1) as u32] {
418                if reader.entry(id).is_none() {
419                    anyhow::bail!("Truncated file entry at file {}", id);
420                }
421            }
422        }
423        Ok(reader)
424    }
425
426    /// The file-index entry for `file_id`, read in place. `None` when the id is
427    /// out of range or the entry points outside the file.
428    fn entry(&self, file_id: u32) -> Option<Entry<'_>> {
429        let id = file_id as usize;
430        if id >= self.num_files {
431            return None;
432        }
433        let at = self.index_offset + id * ENTRY_SIZE;
434        let b = self.mmap.get(at..at + ENTRY_SIZE)?;
435        let u64_at = |i: usize| u64::from_le_bytes(b[i..i + 8].try_into().unwrap());
436        let offset = u64_at(0);
437        let length = u64_at(8);
438        let path_pos = u64_at(16) as usize;
439        let path_len = u32::from_le_bytes(b[24..28].try_into().unwrap()) as usize;
440        let path = std::str::from_utf8(self.mmap.get(path_pos..path_pos + path_len)?).ok()?;
441        Some(Entry {
442            offset,
443            length,
444            path,
445        })
446    }
447
448    /// Get file content by file_id
449    pub fn get_file_content(&self, file_id: u32) -> Result<&str> {
450        let entry = self
451            .entry(file_id)
452            .ok_or_else(|| anyhow::anyhow!("Invalid file_id: {}", file_id))?;
453
454        let start = HEADER_SIZE + entry.offset as usize;
455        let end = start + entry.length as usize;
456
457        if end > self.mmap.len() {
458            anyhow::bail!("File content out of bounds");
459        }
460
461        let bytes = &self.mmap[start..end];
462        std::str::from_utf8(bytes).context("Invalid UTF-8 in file content")
463    }
464
465    /// Get file path by file_id
466    pub fn get_file_path(&self, file_id: u32) -> Option<&Path> {
467        self.entry(file_id).map(|e| Path::new(e.path))
468    }
469
470    /// Get number of files
471    pub fn file_count(&self) -> usize {
472        self.num_files
473    }
474
475    /// Get file_id (array index) by path
476    ///
477    /// This looks up a file by its path and returns the array index, which is the
478    /// correct file_id to use with get_file_content() and other methods.
479    ///
480    /// Note: This is different from database file_ids, which are AUTO INCREMENT values.
481    pub fn get_file_id_by_path(&self, path: &str) -> Option<u32> {
482        // Normalize the input path (strip ./ prefix if present)
483        let normalized_input = path.strip_prefix("./").unwrap_or(path);
484
485        (0..self.num_files as u32).find(|&id| {
486            self.entry(id).is_some_and(|entry| {
487                // Normalize the stored path (strip ./ prefix if present)
488                entry.path.strip_prefix("./").unwrap_or(entry.path) == normalized_input
489            })
490        })
491    }
492
493    /// Get content at a specific byte offset
494    pub fn get_content_at_offset(
495        &self,
496        file_id: u32,
497        byte_offset: u32,
498        length: usize,
499    ) -> Result<&str> {
500        let entry = self
501            .entry(file_id)
502            .ok_or_else(|| anyhow::anyhow!("Invalid file_id: {}", file_id))?;
503
504        let start = HEADER_SIZE + entry.offset as usize + byte_offset as usize;
505        let end = start + length;
506
507        if end > self.mmap.len() {
508            anyhow::bail!("Content out of bounds");
509        }
510
511        let bytes = &self.mmap[start..end];
512        std::str::from_utf8(bytes).context("Invalid UTF-8 in content")
513    }
514
515    /// Get context around a byte offset (for showing match results)
516    ///
517    /// Returns (lines_before, matching_line, lines_after)
518    pub fn get_context(
519        &self,
520        file_id: u32,
521        byte_offset: u32,
522        context_lines: usize,
523    ) -> Result<(Vec<String>, String, Vec<String>)> {
524        let content = self.get_file_content(file_id)?;
525        let lines: Vec<&str> = content.lines().collect();
526
527        // Find which line contains this byte offset
528        let mut current_offset = 0;
529        let mut line_idx = 0;
530
531        for (idx, line) in lines.iter().enumerate() {
532            let line_end = current_offset + line.len() + 1; // +1 for newline
533            if byte_offset as usize >= current_offset && (byte_offset as usize) < line_end {
534                line_idx = idx;
535                break;
536            }
537            current_offset = line_end;
538        }
539
540        // Extract context
541        let start = line_idx.saturating_sub(context_lines);
542        let end = (line_idx + context_lines + 1).min(lines.len());
543
544        let before: Vec<String> = lines[start..line_idx]
545            .iter()
546            .map(|s| s.to_string())
547            .collect();
548
549        let matching = lines
550            .get(line_idx)
551            .map(|s| s.to_string())
552            .unwrap_or_default();
553
554        let after: Vec<String> = lines[line_idx + 1..end]
555            .iter()
556            .map(|s| s.to_string())
557            .collect();
558
559        Ok((before, matching, after))
560    }
561
562    /// Get context around a specific line number (1-indexed)
563    ///
564    /// Returns (lines_before, lines_after)
565    pub fn get_context_by_line(
566        &self,
567        file_id: u32,
568        line_number: usize,
569        context_lines: usize,
570    ) -> Result<(Vec<String>, Vec<String>)> {
571        let content = self.get_file_content(file_id)?;
572        let lines: Vec<&str> = content.lines().collect();
573
574        // Convert from 1-indexed to 0-indexed
575        let line_idx = line_number.saturating_sub(1);
576
577        // Extract context
578        let start = line_idx.saturating_sub(context_lines);
579        let end = (line_idx + context_lines + 1).min(lines.len());
580
581        let before: Vec<String> = lines[start..line_idx]
582            .iter()
583            .map(|s| s.to_string())
584            .collect();
585
586        let after: Vec<String> = lines[line_idx + 1..end]
587            .iter()
588            .map(|s| s.to_string())
589            .collect();
590
591        Ok((before, after))
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598    use tempfile::TempDir;
599
600    #[test]
601    fn test_content_writer_basic() {
602        let mut writer = ContentWriter::new();
603
604        let file1_id = writer.add_file(PathBuf::from("test1.txt"), "Hello, world!");
605        let file2_id = writer.add_file(PathBuf::from("test2.txt"), "Goodbye, world!");
606
607        assert_eq!(file1_id, 0);
608        assert_eq!(file2_id, 1);
609        assert_eq!(writer.file_count(), 2);
610    }
611
612    #[test]
613    fn test_content_roundtrip() {
614        let temp = TempDir::new().unwrap();
615        let content_path = temp.path().join("content.bin");
616
617        // Write
618        let mut writer = ContentWriter::new();
619        writer.add_file(PathBuf::from("file1.txt"), "First file content");
620        writer.add_file(PathBuf::from("file2.txt"), "Second file content");
621        writer.write(&content_path).unwrap();
622
623        // Read
624        let reader = ContentReader::open(&content_path).unwrap();
625
626        assert_eq!(reader.file_count(), 2);
627        assert_eq!(reader.get_file_content(0).unwrap(), "First file content");
628        assert_eq!(reader.get_file_content(1).unwrap(), "Second file content");
629        assert_eq!(reader.get_file_path(0).unwrap(), Path::new("file1.txt"));
630        assert_eq!(reader.get_file_path(1).unwrap(), Path::new("file2.txt"));
631    }
632
633    #[test]
634    fn test_get_context() {
635        let temp = TempDir::new().unwrap();
636        let content_path = temp.path().join("content.bin");
637
638        let mut writer = ContentWriter::new();
639        writer.add_file(
640            PathBuf::from("test.txt"),
641            "Line 1\nLine 2\nLine 3 with match\nLine 4\nLine 5",
642        );
643        writer.write(&content_path).unwrap();
644
645        let reader = ContentReader::open(&content_path).unwrap();
646
647        // Byte offset of "Line 3" (14 = "Line 1\n" + "Line 2\n")
648        let (before, matching, after) = reader.get_context(0, 14, 1).unwrap();
649
650        assert_eq!(before.len(), 1);
651        assert_eq!(before[0], "Line 2");
652        assert_eq!(matching, "Line 3 with match");
653        assert_eq!(after.len(), 1);
654        assert_eq!(after[0], "Line 4");
655    }
656
657    #[test]
658    fn test_streaming_roundtrip() {
659        let temp = TempDir::new().unwrap();
660        let content_path = temp.path().join("content.bin");
661
662        // Use the streaming path: init() -> add_file() -> finalize_if_needed()
663        let mut writer = ContentWriter::new();
664        writer.init(content_path.clone()).unwrap();
665        writer.add_file(PathBuf::from("src/main.rs"), "fn main() {}\n");
666        writer.add_file(
667            PathBuf::from("src/lib.rs"),
668            "pub fn hello() -> &'static str { \"hi\" }\n",
669        );
670        writer.finalize_if_needed().unwrap();
671
672        // Verify the file can be read back correctly
673        let reader = ContentReader::open(&content_path).unwrap();
674        assert_eq!(reader.file_count(), 2);
675        assert_eq!(reader.get_file_content(0).unwrap(), "fn main() {}\n");
676        assert_eq!(
677            reader.get_file_content(1).unwrap(),
678            "pub fn hello() -> &'static str { \"hi\" }\n"
679        );
680        assert_eq!(reader.get_file_path(0).unwrap(), Path::new("src/main.rs"));
681        assert_eq!(reader.get_file_path(1).unwrap(), Path::new("src/lib.rs"));
682    }
683
684    #[test]
685    fn test_multiline_file() {
686        let temp = TempDir::new().unwrap();
687        let content_path = temp.path().join("content.bin");
688
689        let content = "fn main() {\n    println!(\"Hello\");\n}\n";
690
691        let mut writer = ContentWriter::new();
692        writer.add_file(PathBuf::from("main.rs"), content);
693        writer.write(&content_path).unwrap();
694
695        let reader = ContentReader::open(&content_path).unwrap();
696        assert_eq!(reader.get_file_content(0).unwrap(), content);
697    }
698}