1use std::io::{self, Write};
26use std::path::{Path, PathBuf};
27
28use crate::error::ParseError;
29use crate::iterators::ConFrameIterator;
30
31const MAGIC: &[u8; 8] = b"RCIDX\0\0\0";
32const VERSION: u32 = 2;
33const HEADER_LEN: usize = 8 + 4 + 4 + 8 + 8 + 8;
34const COUNT_AT: usize = HEADER_LEN - 8;
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct FrameIndex {
39 pub offsets: Vec<u64>,
41 pub data_len: u64,
43}
44
45impl FrameIndex {
46 pub fn len(&self) -> usize {
48 self.offsets.len()
49 }
50
51 pub fn is_empty(&self) -> bool {
53 self.offsets.is_empty()
54 }
55
56 pub fn range(&self, i: usize) -> Option<std::ops::Range<usize>> {
58 let start = *self.offsets.get(i)? as usize;
59 let end = self.offsets.get(i + 1).copied().unwrap_or(self.data_len) as usize;
60 Some(start..end)
61 }
62
63 pub fn scan(text: &str) -> Result<Self, ParseError> {
65 Ok(FrameIndex {
66 offsets: frame_start_offsets_checked(text)?
67 .into_iter()
68 .map(|o| o as u64)
69 .collect(),
70 data_len: text.len() as u64,
71 })
72 }
73}
74
75pub fn frame_start_offsets_checked(text: &str) -> Result<Vec<usize>, ParseError> {
80 let mut out = Vec::new();
81 let mut scanner = ConFrameIterator::new(text);
82 loop {
83 let start = scanner.byte_pos();
84 if no_frame_at(text, start) {
85 break;
86 }
87 out.push(start);
88 match scanner.forward_fast() {
89 Some(Ok(())) => {}
90 Some(Err(e)) => return Err(e),
91 None => return Err(ParseError::IncompleteFrame),
92 }
93 }
94 Ok(out)
95}
96
97fn no_frame_at(text: &str, pos: usize) -> bool {
99 pos >= text.len() || text.as_bytes()[pos..].iter().all(u8::is_ascii_whitespace)
100}
101
102fn offsets_match_scan(text: &str, offsets: &[u64]) -> bool {
110 let Some(&first) = offsets.first() else {
111 return no_frame_at(text, 0);
112 };
113 let frame_ok = |i: usize| {
114 let start = offsets[i] as usize;
115 let starts_line = start == 0 || text.as_bytes().get(start - 1) == Some(&b'\n');
117 if !starts_line || no_frame_at(text, start) {
118 return false;
119 }
120 let end = offsets.get(i + 1).map_or(text.len(), |&o| o as usize);
121 let mut skip = ConFrameIterator::starting_at(text, start);
122 if !matches!(skip.forward_fast_to(Some(end)), Some(Ok(()))) {
123 return false;
124 }
125 match offsets.get(i + 1) {
126 Some(&next) => skip.byte_pos() as u64 == next,
127 None => no_frame_at(text, skip.byte_pos()),
128 }
129 };
130 if first != 0 {
131 return false;
132 }
133 #[cfg(feature = "parallel")]
134 {
135 use rayon::prelude::*;
136 (0..offsets.len()).into_par_iter().all(frame_ok)
137 }
138 #[cfg(not(feature = "parallel"))]
139 {
140 (0..offsets.len()).all(frame_ok)
141 }
142}
143
144const UTF8_SLICE_MIN: usize = 8 << 20;
146
147fn utf8_slice_target(len: usize, index: usize, n_slices: usize) -> usize {
152 let n = n_slices.max(1) as u128;
153 let target = (index as u128) * (len as u128) / n;
154 usize::try_from(target).unwrap_or(len).min(len)
155}
156
157fn reject_with_absolute_utf8(
164 bytes: &[u8],
165 start: usize,
166 err: std::str::Utf8Error,
167) -> Result<(), std::str::Utf8Error> {
168 let end = match err.error_len() {
169 Some(len) => start
170 .saturating_add(err.valid_up_to())
171 .saturating_add(len)
172 .saturating_add(1),
173 None => bytes.len(),
174 };
175 let end = end.min(bytes.len());
176 if let Err(adjusted) = std::str::from_utf8(&bytes[..end]) {
177 return Err(adjusted);
178 }
179 std::str::from_utf8(bytes).map(|_| ())
180}
181
182pub fn validate_utf8_sliced(bytes: &[u8]) -> Result<&str, std::str::Utf8Error> {
187 let n_slices = (bytes.len() / UTF8_SLICE_MIN).max(1);
188 let mut cuts = Vec::with_capacity(n_slices + 1);
189 cuts.push(0usize);
190 for k in 1..n_slices {
191 let target = utf8_slice_target(bytes.len(), k, n_slices);
192 let prev = *cuts.last().unwrap();
193 if target <= prev || target >= bytes.len() {
194 continue;
195 }
196 match memchr::memchr(b'\n', &bytes[target..]) {
197 Some(p) => cuts.push(target + p + 1),
198 None => break,
199 }
200 }
201 cuts.push(bytes.len());
202 cuts.dedup();
203 let check = |w: &[usize]| -> Result<(), std::str::Utf8Error> {
204 match std::str::from_utf8(&bytes[w[0]..w[1]]) {
205 Ok(_) => Ok(()),
206 Err(err) => reject_with_absolute_utf8(bytes, w[0], err),
207 }
208 };
209 #[cfg(feature = "parallel")]
210 {
211 use rayon::prelude::*;
212 let windows: Vec<&[usize]> = cuts.windows(2).collect();
213 windows.par_iter().try_for_each(|w| check(w))?;
214 }
215 #[cfg(not(feature = "parallel"))]
216 {
217 for w in cuts.windows(2) {
218 check(w)?;
219 }
220 }
221 Ok(unsafe { std::str::from_utf8_unchecked(bytes) })
224}
225
226pub fn sidecar_path(path: &Path) -> PathBuf {
228 let mut s = path.as_os_str().to_owned();
229 s.push(".rcidx");
230 PathBuf::from(s)
231}
232
233fn fnv1a(mut h: u64, bytes: &[u8]) -> u64 {
234 for &b in bytes {
235 h ^= b as u64;
236 h = h.wrapping_mul(0x0000_0100_0000_01b3);
237 }
238 h
239}
240const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
241
242const HASH_BLOCK: usize = 1 << 20;
245
246fn block_hash(block: &[u8]) -> u64 {
251 const K: u64 = 0x9e37_79b9_7f4a_7c15;
252 let mut lanes: [u64; 8] = std::array::from_fn(|k| FNV_OFFSET ^ k as u64);
253 let (strides, rest) = block.as_chunks::<64>();
254 for stride in strides {
255 for (lane, w) in lanes.iter_mut().zip(stride.as_chunks::<8>().0) {
256 *lane = (*lane ^ u64::from_le_bytes(*w)).wrapping_mul(K).rotate_left(31);
257 }
258 }
259 let tail = fnv1a(FNV_OFFSET, rest);
260 lanes.iter().fold(tail, |h, l| fnv1a(h, &l.to_le_bytes()))
261}
262
263fn content_hash(bytes: &[u8]) -> u64 {
266 #[cfg(feature = "parallel")]
267 let blocks: Vec<u64> = {
268 use rayon::prelude::*;
269 bytes.par_chunks(HASH_BLOCK).map(block_hash).collect()
270 };
271 #[cfg(not(feature = "parallel"))]
272 let blocks: Vec<u64> = bytes.chunks(HASH_BLOCK).map(block_hash).collect();
273 let seed = fnv1a(FNV_OFFSET, &(bytes.len() as u64).to_le_bytes());
274 blocks.iter().fold(seed, |h, b| fnv1a(h, &b.to_le_bytes()))
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278struct Fingerprint {
279 size: u64,
280 content: u64,
281}
282
283impl Fingerprint {
284 fn of(data: &[u8]) -> Self {
285 Fingerprint {
286 size: data.len() as u64,
287 content: content_hash(data),
288 }
289 }
290}
291
292fn with_mapped<T>(path: &Path, f: impl FnOnce(&[u8]) -> T) -> io::Result<T> {
294 let file = std::fs::File::open(path)?;
295 if file.metadata()?.len() == 0 {
296 return Ok(f(&[]));
297 }
298 let map = unsafe { memmap2::Mmap::map(&file)? };
301 Ok(f(&map))
302}
303
304fn fingerprint(path: &Path) -> io::Result<Fingerprint> {
305 with_mapped(path, Fingerprint::of)
306}
307
308fn encode(fp: Fingerprint, idx: &FrameIndex) -> Vec<u8> {
309 let mut out = Vec::with_capacity(HEADER_LEN + 8 * idx.offsets.len() + 8);
310 out.extend_from_slice(MAGIC);
311 out.extend_from_slice(&VERSION.to_le_bytes());
312 out.extend_from_slice(&0u32.to_le_bytes());
313 out.extend_from_slice(&fp.size.to_le_bytes());
314 out.extend_from_slice(&fp.content.to_le_bytes());
315 out.extend_from_slice(&(idx.offsets.len() as u64).to_le_bytes());
316 for o in &idx.offsets {
317 out.extend_from_slice(&o.to_le_bytes());
318 }
319 let sum = fnv1a(FNV_OFFSET, &out);
320 out.extend_from_slice(&sum.to_le_bytes());
321 out
322}
323
324fn u64_at(b: &[u8], at: usize) -> u64 {
325 u64::from_le_bytes(b[at..at + 8].try_into().unwrap())
326}
327
328fn decode(bytes: &[u8], text: &str) -> Option<FrameIndex> {
332 if bytes.len() < HEADER_LEN + 8 || &bytes[..8] != MAGIC {
333 return None;
334 }
335 if u32::from_le_bytes(bytes[8..12].try_into().unwrap()) != VERSION {
336 return None;
337 }
338 let body = &bytes[..bytes.len() - 8];
339 if fnv1a(FNV_OFFSET, body) != u64_at(bytes, bytes.len() - 8) {
340 return None;
341 }
342 let size = u64_at(bytes, 16);
343 if size != text.len() as u64 {
344 return None;
345 }
346 let n = usize::try_from(u64_at(bytes, COUNT_AT)).ok()?;
347 let table = n.checked_mul(8)?;
348 let expected = HEADER_LEN.checked_add(table)?;
349 if body.len() != expected {
350 return None;
351 }
352 let offsets: Vec<u64> = (0..n).map(|i| u64_at(bytes, HEADER_LEN + i * 8)).collect();
353 let ordered = offsets.windows(2).all(|w| w[0] < w[1]);
354 if !ordered || offsets.last().is_some_and(|&o| o >= size) {
355 return None;
356 }
357 if !offsets_match_scan(text, &offsets) {
360 return None;
361 }
362 Some(FrameIndex {
363 offsets,
364 data_len: size,
365 })
366}
367
368pub fn read_sidecar(path: &Path) -> io::Result<Option<FrameIndex>> {
370 let bytes = match std::fs::read(sidecar_path(path)) {
371 Ok(b) => b,
372 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
373 Err(e) => return Err(e),
374 };
375 with_mapped(path, |data| {
376 decode(&bytes, validate_utf8_sliced(data).ok()?)
377 })
378}
379
380fn write_encoded(path: &Path, bytes: &[u8]) -> io::Result<()> {
381 let dst = sidecar_path(path);
382 let mut tmp = dst.as_os_str().to_owned();
383 tmp.push(".tmp");
384 let tmp = PathBuf::from(tmp);
385 {
386 let mut f = std::fs::File::create(&tmp)?;
387 f.write_all(bytes)?;
388 f.sync_all()?;
389 }
390 std::fs::rename(&tmp, &dst)
391}
392
393pub fn write_sidecar(path: &Path, idx: &FrameIndex) -> io::Result<()> {
400 let fp = fingerprint(path)?;
401 if fp.size != idx.data_len {
402 return Err(io::Error::new(
403 io::ErrorKind::InvalidInput,
404 "frame index length does not match the data file size",
405 ));
406 }
407 write_encoded(path, &encode(fp, idx))
408}
409
410pub fn load_or_build(path: &Path, text: &str, write_back: bool) -> Result<FrameIndex, ParseError> {
417 if let Ok(bytes) = std::fs::read(sidecar_path(path))
418 && let Some(idx) = decode(&bytes, text)
419 {
420 return Ok(idx);
421 }
422 let idx = FrameIndex::scan(text)?;
423 if write_back {
424 let fp = Fingerprint::of(text.as_bytes());
425 let _ = write_encoded(path, &encode(fp, &idx));
426 }
427 Ok(idx)
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 fn fixture(n: usize) -> String {
435 let one = std::fs::read_to_string(concat!(
436 env!("CARGO_MANIFEST_DIR"),
437 "/resources/test/tiny_cuh2.con"
438 ))
439 .unwrap();
440 one.repeat(n)
441 }
442
443 #[test]
444 fn checked_scan_matches_legacy_scan() {
445 let text = fixture(7);
446 let legacy = crate::iterators::frame_start_offsets(&text);
447 assert_eq!(frame_start_offsets_checked(&text).unwrap(), legacy);
448 assert_eq!(legacy.len(), 7);
449 }
450
451 #[test]
452 fn checked_scan_accepts_final_line_without_newline() {
453 for n in [1usize, 3] {
454 let text = fixture(n);
455 let bare = text
456 .strip_suffix('\n')
457 .expect("fixture frame ends with a newline");
458 let parsed = crate::iterators::ConFrameIterator::new(bare)
459 .collect::<Result<Vec<_>, _>>()
460 .expect("parser accepts the final line");
461 assert_eq!(parsed.len(), n);
462 let offsets =
463 frame_start_offsets_checked(bare).expect("checked scan accepts the final line");
464 assert_eq!(offsets, crate::iterators::frame_start_offsets(bare));
465 assert_eq!(offsets.len(), n);
466 let mut scanner = crate::iterators::ConFrameIterator::new(bare);
467 for _ in 0..n {
468 scanner
469 .forward_fast()
470 .unwrap()
471 .expect("skip accepts the final line");
472 }
473 assert!(scanner.forward_fast().is_none());
474 }
475 }
476
477 #[test]
478 fn checked_scan_reports_truncation() {
479 let text = fixture(3);
480 let end = text.strip_suffix('\n').unwrap().rfind('\n').unwrap() + 1;
483 let cut = &text[..end];
484 assert!(frame_start_offsets_checked(cut).is_err());
485 assert!(crate::iterators::ConFrameIterator::new(cut).any(|r| r.is_err()));
486 }
487
488 #[test]
489 fn sliced_utf8_matches_std() {
490 let mut s = "é\n".repeat(5 << 20);
491 s.push_str("ü end\n");
492 assert!(validate_utf8_sliced(s.as_bytes()).is_ok());
493 let mut bad = s.into_bytes();
494 let mid = bad.len() / 2;
495 bad[mid] = 0xff;
496 assert!(validate_utf8_sliced(&bad).is_err());
497 }
498
499 #[test]
500 fn sliced_utf8_error_offset_matches_std() {
501 let mut buf = vec![b'\n'; UTF8_SLICE_MIN * 2 + 64];
502 let at = UTF8_SLICE_MIN + UTF8_SLICE_MIN / 2;
503 buf[at] = 0xff;
504 let err = validate_utf8_sliced(&buf).unwrap_err();
505 let std_err = std::str::from_utf8(&buf).unwrap_err();
506 assert_eq!(err.valid_up_to(), std_err.valid_up_to());
507 assert_eq!(err.error_len(), std_err.error_len());
508 assert_eq!(err.valid_up_to(), at);
509 assert_eq!(err.error_len(), Some(1));
510
511 buf[at] = b'\n';
512 buf.push(0xc2);
513 let err = validate_utf8_sliced(&buf).unwrap_err();
514 let std_err = std::str::from_utf8(&buf).unwrap_err();
515 assert_eq!(err.valid_up_to(), std_err.valid_up_to());
516 assert_eq!(err.error_len(), std_err.error_len());
517 assert_eq!(err.valid_up_to(), buf.len() - 1);
518 assert_eq!(err.error_len(), None);
519 }
520
521 #[test]
522 fn sliced_utf8_lead_plus_non_continuation_is_not_truncated() {
523 let mut buf = b"head\n".to_vec();
527 let at = buf.len();
528 buf.push(0xC2);
529 buf.extend_from_slice(b"A tail\n");
530 let err = validate_utf8_sliced(&buf).unwrap_err();
531 let std_err = std::str::from_utf8(&buf).unwrap_err();
532 assert_eq!(err.valid_up_to(), at);
533 assert_eq!(err.valid_up_to(), std_err.valid_up_to());
534 assert_eq!(err.error_len(), Some(1));
535 assert_eq!(err.error_len(), std_err.error_len());
536
537 let mut buf = b"head\n".to_vec();
541 let at = buf.len();
542 buf.extend_from_slice(&[0xE1, 0x80, b'Z']);
543 buf.extend_from_slice(b" tail\n");
544 let err = validate_utf8_sliced(&buf).unwrap_err();
545 let std_err = std::str::from_utf8(&buf).unwrap_err();
546 assert_eq!(err.valid_up_to(), at);
547 assert_eq!(err.valid_up_to(), std_err.valid_up_to());
548 assert_eq!(err.error_len(), Some(2));
549 assert_eq!(err.error_len(), std_err.error_len());
550 }
551
552 #[test]
553 fn utf8_slice_target_stays_inside_huge_buffers() {
554 let len = 20 << 20;
555 let n = (len / UTF8_SLICE_MIN).max(1);
556 for k in 1..n {
557 assert_eq!(utf8_slice_target(len, k, n), k * len / n);
558 assert!(utf8_slice_target(len, k, n) <= len);
559 }
560 let len = usize::MAX - 1024;
561 let n_slices = (len / UTF8_SLICE_MIN).max(2);
562 let k = n_slices - 1;
563 assert!(k.checked_mul(len).is_none());
564 let target = utf8_slice_target(len, k, n_slices);
565 assert!(target <= len);
566 let expected = (k as u128 * len as u128 / n_slices as u128) as usize;
567 assert_eq!(target, expected);
568 }
569
570 #[test]
571 fn sidecar_roundtrip_and_staleness() {
572 let dir = std::env::temp_dir().join(format!("rcidx-test-{}", std::process::id()));
573 std::fs::create_dir_all(&dir).unwrap();
574 let con = dir.join("t.con");
575 let text = fixture(5);
576 std::fs::write(&con, &text).unwrap();
577 let idx = load_or_build(&con, &text, true).unwrap();
578 assert_eq!(read_sidecar(&con).unwrap(), Some(idx.clone()));
579 for i in 0..idx.len() {
580 let r = idx.range(i).unwrap();
581 assert!(
582 crate::iterators::ConFrameIterator::new(&text[r])
583 .next()
584 .unwrap()
585 .is_ok()
586 );
587 }
588 let side = sidecar_path(&con);
590 let mut b = std::fs::read(&side).unwrap();
591 b[HEADER_LEN] ^= 1;
592 std::fs::write(&side, &b).unwrap();
593 assert_eq!(read_sidecar(&con).unwrap(), None);
594 write_sidecar(&con, &idx).unwrap();
596 let text2 = fixture(6);
597 std::fs::write(&con, &text2).unwrap();
598 assert_eq!(read_sidecar(&con).unwrap(), None);
599 assert_eq!(load_or_build(&con, &text2, true).unwrap().len(), 6);
600 let _ = std::fs::remove_dir_all(&dir);
601 }
602
603 #[test]
604 fn content_hash_matches_file_fingerprint() {
605 let dir = std::env::temp_dir().join(format!("rcidx-hash-{}", std::process::id()));
606 std::fs::create_dir_all(&dir).unwrap();
607 let con = dir.join("t.con");
608 for text in [String::new(), fixture(40)] {
609 std::fs::write(&con, &text).unwrap();
610 assert_eq!(fingerprint(&con).unwrap(), Fingerprint::of(text.as_bytes()));
611 }
612 let _ = std::fs::remove_dir_all(&dir);
613 }
614
615 #[test]
616 fn content_hash_sees_every_byte_and_block_order() {
617 let base: Vec<u8> = (0..HASH_BLOCK * 3 + 77).map(|i| (i % 251) as u8).collect();
618 let h = content_hash(&base);
619 for at in [0, 63, 64, HASH_BLOCK - 1, HASH_BLOCK, 2 * HASH_BLOCK + 5, base.len() - 1] {
620 let mut b = base.clone();
621 b[at] ^= 0x20;
622 assert_ne!(content_hash(&b), h, "byte {at}");
623 }
624 let mut swapped = base.clone();
625 swapped[..2 * HASH_BLOCK].rotate_left(HASH_BLOCK);
626 assert_ne!(content_hash(&swapped), h);
627 assert_ne!(content_hash(&base[..base.len() - 1]), h);
628 let serial = base.chunks(HASH_BLOCK).map(block_hash).fold(
629 fnv1a(FNV_OFFSET, &(base.len() as u64).to_le_bytes()),
630 |acc, b| fnv1a(acc, &b.to_le_bytes()),
631 );
632 assert_eq!(serial, h);
633 }
634
635 fn frames_via(idx: &FrameIndex, text: &str) -> Vec<crate::types::ConFrame> {
637 (0..idx.len())
638 .map(|i| {
639 let r = idx.range(i).unwrap();
640 ConFrameIterator::new(&text[r]).next().unwrap().unwrap()
641 })
642 .collect()
643 }
644
645 fn scanned_frames(text: &str) -> Vec<crate::types::ConFrame> {
646 ConFrameIterator::new(text)
647 .collect::<Result<Vec<_>, _>>()
648 .unwrap()
649 }
650
651 fn reopen_after_edit(name: &str, old: &str, new: &str) -> Vec<FrameIndex> {
654 assert_eq!(old.len(), new.len());
655 let dir = std::env::temp_dir().join(format!("rcidx-{name}-{}", std::process::id()));
656 std::fs::create_dir_all(&dir).unwrap();
657 let con = dir.join("t.con");
658 std::fs::write(&con, old).unwrap();
659 load_or_build(&con, old, true).unwrap();
660 assert!(read_sidecar(&con).unwrap().is_some());
661 std::fs::write(&con, new).unwrap();
662 let opened = vec![
663 load_or_build(&con, new, false).unwrap(),
664 load_or_build(&con, new, true).unwrap(),
665 load_or_build(&con, new, true).unwrap(),
666 ];
667 let _ = std::fs::remove_dir_all(&dir);
668 opened
669 }
670
671 #[test]
672 fn same_length_middle_change_reads_match_a_scan() {
673 let text = fixture(40);
674 let mut changed = text.as_bytes().to_vec();
675 let at = (4096..changed.len() - 4096)
676 .find(|&i| changed[i].is_ascii_alphabetic())
677 .expect("middle of the file has a letter");
678 changed[at] = if changed[at] == b'A' { b'B' } else { b'A' };
679 let new_text = std::str::from_utf8(&changed).unwrap();
680 let scan = FrameIndex::scan(new_text).unwrap();
681 let expected = scanned_frames(new_text);
682 assert_ne!(expected, scanned_frames(&text));
683 for idx in reopen_after_edit("letter", &text, new_text) {
684 assert_eq!(idx, scan);
685 assert_eq!(frames_via(&idx, new_text), expected);
686 }
687 }
688
689 #[test]
690 fn same_length_change_that_moves_a_boundary_is_rescanned() {
691 let text = fixture(40);
692 let old = FrameIndex::scan(&text).unwrap();
693 let frame = |i: usize| &text[old.range(i).unwrap()];
694 let row_at = frame(21).find("Coordinates of Component 2\n").unwrap()
697 + "Coordinates of Component 2\n".len();
698 let row_len = frame(21)[row_at..].find('\n').unwrap() + 1;
699 let row = &frame(21)[row_at..row_at + row_len];
700 let mut moved = String::new();
701 for i in 0..old.len() {
702 let f = frame(i);
703 match i {
704 20 => {
705 moved.push_str(&f.replacen("\n2 2\n", "\n2 3\n", 1));
706 moved.push_str(row);
707 }
708 21 => {
709 let f = f.replacen("\n2 2\n", "\n2 1\n", 1);
710 let at = f.find(row).unwrap();
711 moved.push_str(&f[..at]);
712 moved.push_str(&f[at + row_len..]);
713 }
714 _ => moved.push_str(f),
715 }
716 }
717 let scan = FrameIndex::scan(&moved).unwrap();
718 assert_eq!(scan.len(), old.len());
719 assert_ne!(scan, old);
720 assert!(
721 old.offsets
722 .iter()
723 .all(|&o| o == 0 || moved.as_bytes()[o as usize - 1] == b'\n')
724 );
725 assert!(!offsets_match_scan(&moved, &old.offsets));
726 let fp = Fingerprint::of(moved.as_bytes());
727 assert_eq!(decode(&encode(fp, &old), &moved), None);
728 let expected = scanned_frames(&moved);
729 assert_eq!(expected[20].atom_data.len(), 5);
730 assert_eq!(expected[21].atom_data.len(), 3);
731 for idx in reopen_after_edit("moved", &text, &moved) {
732 assert_eq!(idx, scan);
733 assert_eq!(frames_via(&idx, &moved), expected);
734 }
735 }
736
737 #[test]
738 fn only_the_scanned_table_matches() {
739 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/resources/test");
740 let mut checked = 0;
741 for entry in std::fs::read_dir(dir).unwrap() {
742 let path = entry.unwrap().path();
743 let Ok(one) = std::fs::read_to_string(&path) else {
744 continue;
745 };
746 let text = one.repeat(3);
747 let Ok(scan) = frame_start_offsets_checked(&text) else {
748 continue;
749 };
750 let good: Vec<u64> = scan.iter().map(|&o| o as u64).collect();
751 assert!(offsets_match_scan(&text, &good), "{}", path.display());
752 for drop in 0..good.len() {
753 let mut t = good.clone();
754 t.remove(drop);
755 assert!(
756 !offsets_match_scan(&text, &t),
757 "{} drop {drop}",
758 path.display()
759 );
760 }
761 let mut longer = good.clone();
762 longer.push(text.len() as u64);
763 assert!(!offsets_match_scan(&text, &longer), "{}", path.display());
764 checked += 1;
765 }
766 assert!(checked >= 10, "{checked} fixtures");
767 assert!(offsets_match_scan(" \n\n", &[]));
768 assert!(!offsets_match_scan(&fixture(1), &[]));
769 }
770
771 #[test]
772 fn end_hint_never_changes_the_skip() {
773 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/resources/test");
774 let mut checked = 0;
775 for entry in std::fs::read_dir(dir).unwrap() {
776 let path = entry.unwrap().path();
777 let Ok(one) = std::fs::read_to_string(&path) else {
778 continue;
779 };
780 let text = one.repeat(3);
781 let line_starts: Vec<usize> = std::iter::once(0)
782 .chain(text.match_indices('\n').map(|(i, _)| i + 1))
783 .collect();
784 let mut starts = crate::iterators::frame_start_offsets(&text);
785 starts.extend(line_starts.iter().copied().take(40));
786 starts.retain(|&s| s < text.len());
787 for start in starts {
788 let mut plain = ConFrameIterator::starting_at(&text, start);
789 let want = format!("{:?}", plain.forward_fast());
790 let end = plain.byte_pos();
791 let mut hints = vec![None, Some(0), Some(text.len() + 1)];
792 hints.extend([end.saturating_sub(1), end + 1].map(Some));
793 hints.extend(line_starts.iter().map(|&s| Some(s)));
794 for &hint in &hints {
795 let mut hinted = ConFrameIterator::starting_at(&text, start);
796 let got = format!("{:?}", hinted.forward_fast_to(hint));
797 assert_eq!(got, want, "{} at {start}, hint {hint:?}", path.display());
798 assert_eq!(hinted.byte_pos(), plain.byte_pos());
799 }
800 }
801 checked += 1;
802 }
803 assert!(checked >= 10, "{checked} fixtures");
804 }
805
806 #[test]
807 fn version_1_sidecar_is_rebuilt() {
808 let text = fixture(2);
809 let idx = FrameIndex::scan(&text).unwrap();
810 let fp = Fingerprint::of(text.as_bytes());
811 let mut bytes = encode(fp, &idx);
812 assert_eq!(decode(&bytes, &text), Some(idx.clone()));
813 bytes[8..12].copy_from_slice(&1u32.to_le_bytes());
814 rechecksum(&mut bytes);
815 assert_eq!(decode(&bytes, &text), None);
816 }
817
818 fn rechecksum(bytes: &mut [u8]) {
819 let body_len = bytes.len() - 8;
820 let sum = fnv1a(FNV_OFFSET, &bytes[..body_len]);
821 bytes[body_len..].copy_from_slice(&sum.to_le_bytes());
822 }
823
824 #[test]
825 fn checksum_valid_short_table_is_rejected() {
826 let text = fixture(2);
827 let idx = FrameIndex::scan(&text).unwrap();
828 let fp = Fingerprint::of(text.as_bytes());
829 let mut bytes = encode(fp, &idx);
830 let n = (idx.len() as u64).saturating_add(1);
831 bytes[COUNT_AT..COUNT_AT + 8].copy_from_slice(&n.to_le_bytes());
832 rechecksum(&mut bytes);
833 assert_eq!(decode(&bytes, &text), None);
834 }
835
836 #[test]
837 fn wrapping_frame_count_is_rejected() {
838 let text = fixture(2);
839 let idx = FrameIndex::scan(&text).unwrap();
840 let fp = Fingerprint::of(text.as_bytes());
841 let mut bytes = encode(fp, &idx);
842 let payload = bytes.len() - 8 - HEADER_LEN;
843 let n = (1usize << (usize::BITS - 3)).wrapping_add(payload / 8);
844 assert_eq!(8usize.wrapping_mul(n), payload);
845 assert!(n > idx.len());
846 bytes[COUNT_AT..COUNT_AT + 8].copy_from_slice(&(n as u64).to_le_bytes());
847 rechecksum(&mut bytes);
848 assert_eq!(decode(&bytes, &text), None);
849 }
850}