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