1use 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; #[derive(Debug, Clone)]
42pub struct FileEntry {
43 pub path: PathBuf,
45 pub offset: u64,
47 pub length: u64,
49}
50
51pub struct ContentWriter {
57 files: Vec<FileEntry>,
58 writer: Option<std::io::BufWriter<File>>,
59 current_offset: u64,
60 file_path: Option<PathBuf>,
61 content: Vec<u8>,
63 write_error: Option<std::io::Error>,
66}
67
68impl ContentWriter {
69 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 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 let mut writer = std::io::BufWriter::with_capacity(16 * 1024 * 1024, file);
99
100 writer.write_all(MAGIC)?;
102 writer.write_all(&VERSION.to_le_bytes())?;
103 writer.write_all(&0u64.to_le_bytes())?; writer.write_all(&0u64.to_le_bytes())?; writer.write_all(&[0u8; 8])?; self.writer = Some(writer);
108 self.current_offset = 0; self.file_path = Some(path);
110
111 Ok(())
112 }
113
114 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 let offset = self.current_offset;
128 if let Err(e) = w.write_all(content_bytes)
129 && self.write_error.is_none()
130 {
131 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 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 pub fn write(&mut self, path: impl AsRef<Path>) -> Result<()> {
162 let path = path.as_ref();
163
164 if self.writer.is_none() && self.file_path.is_none() {
166 return self.write_legacy(path);
169 }
170
171 self.finalize_if_needed()?;
173
174 Ok(())
175 }
176
177 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 let mut writer = std::io::BufWriter::with_capacity(8 * 1024 * 1024, file);
193
194 let index_offset = HEADER_SIZE as u64 + self.content.len() as u64;
196
197 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])?; writer.write_all(&self.content)?;
206
207 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 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 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 let mut file = writer
260 .into_inner()
261 .map_err(|e| anyhow::anyhow!("Failed to flush BufWriter: {}", e.error()))?;
262
263 use std::io::Seek;
265 file.seek(std::io::SeekFrom::Start(0))?;
266
267 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])?; 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 pub fn file_count(&self) -> usize {
292 self.files.len()
293 }
294
295 pub fn content_size(&self) -> usize {
297 if self.writer.is_some() || self.file_path.is_some() {
298 self.current_offset as usize
300 } else {
301 self.content.len()
303 }
304 }
305
306 pub fn finalize_if_needed(&mut self) -> Result<()> {
310 if self.writer.is_some() {
311 self.finalize()?;
312 self.writer = None;
314 }
315 Ok(())
316 }
317}
318
319impl Default for ContentWriter {
320 fn default() -> Self {
321 Self::new()
322 }
323}
324
325pub struct ContentReader {
329 _file: File,
330 mmap: Mmap,
331 files: Vec<FileEntry>,
332}
333
334impl ContentReader {
335 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 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 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 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 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 pub fn file_count(&self) -> usize {
469 self.files.len()
470 }
471
472 pub fn get_file_id_by_path(&self, path: &str) -> Option<u32> {
479 let normalized_input = path.strip_prefix("./").unwrap_or(path);
481
482 self.files
483 .iter()
484 .position(|entry| {
485 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 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 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 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; 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 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 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 let line_idx = line_number.saturating_sub(1);
577
578 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 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 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 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 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 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}