1use 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;
45const ENTRY_SIZE: usize = 28;
47const HEADER_SIZE: usize = 32; #[derive(Debug, Clone)]
51pub struct FileEntry {
52 pub path: PathBuf,
54 pub offset: u64,
56 pub length: u64,
58}
59
60pub struct ContentWriter {
66 files: Vec<FileEntry>,
67 writer: Option<std::io::BufWriter<File>>,
68 current_offset: u64,
69 file_path: Option<PathBuf>,
70 content: Vec<u8>,
72 write_error: Option<std::io::Error>,
75}
76
77impl ContentWriter {
78 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 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 let mut writer = std::io::BufWriter::with_capacity(16 * 1024 * 1024, file);
108
109 writer.write_all(MAGIC)?;
111 writer.write_all(&VERSION.to_le_bytes())?;
112 writer.write_all(&0u64.to_le_bytes())?; writer.write_all(&0u64.to_le_bytes())?; writer.write_all(&[0u8; 8])?; self.writer = Some(writer);
117 self.current_offset = 0; self.file_path = Some(path);
119
120 Ok(())
121 }
122
123 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 let offset = self.current_offset;
137 if let Err(e) = w.write_all(content_bytes)
138 && self.write_error.is_none()
139 {
140 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 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 pub fn write(&mut self, path: impl AsRef<Path>) -> Result<()> {
171 let path = path.as_ref();
172
173 if self.writer.is_none() && self.file_path.is_none() {
175 return self.write_legacy(path);
178 }
179
180 self.finalize_if_needed()?;
182
183 Ok(())
184 }
185
186 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 let mut writer = std::io::BufWriter::with_capacity(8 * 1024 * 1024, file);
202
203 let index_offset = HEADER_SIZE as u64 + self.content.len() as u64;
205
206 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])?; writer.write_all(&self.content)?;
215
216 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 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 let index_offset = HEADER_SIZE as u64 + self.current_offset;
249 write_file_index(&mut writer, &self.files, index_offset)?;
250
251 let mut file = writer
253 .into_inner()
254 .map_err(|e| anyhow::anyhow!("Failed to flush BufWriter: {}", e.error()))?;
255
256 use std::io::Seek;
258 file.seek(std::io::SeekFrom::Start(0))?;
259
260 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])?; 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 pub fn file_count(&self) -> usize {
285 self.files.len()
286 }
287
288 pub fn content_size(&self) -> usize {
290 if self.writer.is_some() || self.file_path.is_some() {
291 self.current_offset as usize
293 } else {
294 self.content.len()
296 }
297 }
298
299 pub fn finalize_if_needed(&mut self) -> Result<()> {
303 if self.writer.is_some() {
304 self.finalize()?;
305 self.writer = None;
307 }
308 Ok(())
309 }
310}
311
312impl Default for ContentWriter {
313 fn default() -> Self {
314 Self::new()
315 }
316}
317
318fn 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
341pub struct ContentReader {
345 _file: File,
346 mmap: Mmap,
347 num_files: usize,
349 index_offset: usize,
351}
352
353#[derive(Debug, Clone, Copy)]
355struct Entry<'a> {
356 offset: u64,
357 length: u64,
358 path: &'a str,
359}
360
361impl ContentReader {
362 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 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 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 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 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 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 pub fn file_count(&self) -> usize {
472 self.num_files
473 }
474
475 pub fn get_file_id_by_path(&self, path: &str) -> Option<u32> {
482 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 entry.path.strip_prefix("./").unwrap_or(entry.path) == normalized_input
489 })
490 })
491 }
492
493 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 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 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; 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 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 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 let line_idx = line_number.saturating_sub(1);
576
577 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 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 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 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 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 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}