1use std::collections::{BTreeMap, BTreeSet, VecDeque};
9use std::fmt;
10use std::fs;
11use std::io;
12use std::path::{Path, PathBuf};
13
14use crate::hashline::scan::{scan_bytes_with_request, CaptureError};
15
16pub use crate::hashline::scan::{
17 BoundaryEvidence, CoverageInput, RawLineRecord, RetainedLine, ScanCoverage, ScanRequest,
18 ScanResult, Snapshot, Terminator, TerminatorKind,
19};
20
21pub const MAX_FILE_READ_BYTES: u64 = 64 * 1024 * 1024;
27pub const MAX_RENDER_BYTES: usize = 50 * 1024;
29pub const MAX_RENDER_LINE_LENGTH: usize = 2_000;
31pub const MAX_SNAPSHOT_PATHS: usize = 30;
33pub const MAX_VERSIONS_PER_PATH: usize = 4;
35pub const MAX_SNAPSHOT_TOTAL_BYTES: usize = 64 * 1024 * 1024;
38pub const MAX_EVICTION_RECORDS: usize = 256;
40
41#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
43pub struct LineRange {
44 pub start: usize,
45 pub end: usize,
46}
47
48impl LineRange {
49 pub const fn new(start: usize, end: usize) -> Self {
50 Self { start, end }
51 }
52
53 pub fn is_empty(self) -> bool {
54 self.start == 0 || self.start > self.end
55 }
56
57 pub fn contains(self, line: usize) -> bool {
58 !self.is_empty() && (self.start..=self.end).contains(&line)
59 }
60}
61
62#[derive(Clone, Debug, Default, Eq, PartialEq)]
68pub struct AffectedRegion {
69 pub ranges: Vec<LineRange>,
70}
71
72impl AffectedRegion {
73 pub fn new(ranges: impl IntoIterator<Item = LineRange>) -> Self {
74 Self {
75 ranges: coalesce_ranges(ranges),
76 }
77 }
78
79 pub fn from_range(start: usize, end: usize) -> Self {
80 Self::new([LineRange::new(start, end)])
81 }
82
83 pub fn insertion(start: usize, inserted_lines: usize) -> Self {
84 if inserted_lines == 0 {
85 return Self::default();
86 }
87 Self::from_range(
88 start,
89 start.saturating_add(inserted_lines).saturating_sub(1),
90 )
91 }
92
93 pub fn deletion(start: usize, end: usize) -> Self {
94 Self::from_range(start, end)
95 }
96
97 pub fn is_empty(&self) -> bool {
98 self.ranges.is_empty()
99 }
100}
101
102#[derive(Clone, Debug, Eq, PartialEq)]
104pub enum ReadSelection {
105 WholeFile,
106 Range { start: usize, end: usize },
107 Lines(BTreeSet<usize>),
108 Head(usize),
109 Tail(usize),
110}
111
112impl Default for ReadSelection {
113 fn default() -> Self {
114 Self::WholeFile
115 }
116}
117
118impl ReadSelection {
119 pub const fn whole_file() -> Self {
120 Self::WholeFile
121 }
122
123 pub const fn range(start: usize, end: usize) -> Self {
124 Self::Range { start, end }
125 }
126
127 pub const fn head(lines: usize) -> Self {
128 Self::Head(lines)
129 }
130
131 pub const fn tail(lines: usize) -> Self {
132 Self::Tail(lines)
133 }
134
135 pub fn lines<I>(lines: I) -> Self
136 where
137 I: IntoIterator<Item = usize>,
138 {
139 Self::Lines(lines.into_iter().collect())
140 }
141
142 fn scan_request(&self) -> ScanRequest {
143 match self {
144 Self::WholeFile | Self::Tail(_) => ScanRequest::whole_file(),
145 Self::Range { start, end } => ScanRequest::new(CoverageInput::range(*start, *end)),
146 Self::Lines(lines) => ScanRequest::new(CoverageInput::lines(lines.iter().copied())),
147 Self::Head(lines) => ScanRequest::new(CoverageInput::range(1, *lines)),
148 }
149 }
150
151 fn selected_lines(&self, total_lines: usize) -> BTreeSet<usize> {
152 match self {
153 Self::WholeFile => (1..=total_lines).collect(),
154 Self::Range { start, end } if *start > *end || *start == 0 => BTreeSet::new(),
155 Self::Range { start, end } => (*start..=(*end).min(total_lines)).collect(),
156 Self::Lines(lines) => lines
157 .iter()
158 .copied()
159 .filter(|line| *line > 0 && *line <= total_lines)
160 .collect(),
161 Self::Head(lines) => (1..=(*lines).min(total_lines)).collect(),
162 Self::Tail(lines) => {
163 let first = total_lines.saturating_sub(*lines).saturating_add(1);
164 if *lines == 0 || first > total_lines {
165 BTreeSet::new()
166 } else {
167 (first..=total_lines).collect()
168 }
169 }
170 }
171 }
172
173 fn is_explicitly_empty(&self) -> bool {
174 match self {
175 Self::Range { start, end } => *start == 0 || *start > *end,
176 Self::Head(lines) | Self::Tail(lines) => *lines == 0,
177 Self::Lines(lines) => lines.is_empty(),
178 Self::WholeFile => false,
179 }
180 }
181}
182
183#[derive(Clone, Copy, Debug, Eq, PartialEq)]
185pub enum BashReadKind {
186 Cat,
187 Head { lines: usize },
188 Tail { lines: usize },
189}
190
191impl BashReadKind {
192 pub const fn selection(self) -> ReadSelection {
193 match self {
194 Self::Cat => ReadSelection::WholeFile,
195 Self::Head { lines } => ReadSelection::Head(lines),
196 Self::Tail { lines } => ReadSelection::Tail(lines),
197 }
198 }
199}
200
201#[derive(Clone, Debug, Eq, PartialEq)]
203pub struct TaggedRendering {
204 pub text: String,
205 pub requested_path: String,
206 pub tag: String,
207 pub seen_lines: BTreeSet<usize>,
208 pub rendered_lines: BTreeSet<usize>,
209 pub elided_range: Option<LineRange>,
210 pub display_truncated_lines: BTreeSet<usize>,
211}
212
213impl TaggedRendering {
214 pub fn is_empty_body(&self) -> bool {
215 self.rendered_lines.is_empty()
216 }
217}
218
219#[derive(Clone, Debug, Eq, PartialEq)]
221pub struct TaglessRendering {
222 pub text: String,
223 pub requested_path: String,
224 pub rendered_lines: BTreeSet<usize>,
225 pub elided_range: Option<LineRange>,
226}
227
228#[derive(Clone, Debug, Eq, PartialEq)]
230pub enum UntaggableReason {
231 NotRegularFile,
232 ReadOnly,
233 VirtualPath,
234 Binary,
235 InvalidUtf8,
236 Oversize { bytes: u64, limit: u64 },
237 EmptyRange,
238 BeyondEof,
239 Io(String),
240}
241
242impl fmt::Display for UntaggableReason {
243 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
244 match self {
245 Self::NotRegularFile => formatter.write_str("path is not a regular file"),
246 Self::ReadOnly => formatter.write_str("path is not write-eligible"),
247 Self::VirtualPath => formatter.write_str("virtual paths cannot carry snapshots"),
248 Self::Binary => formatter.write_str("binary files cannot carry snapshots"),
249 Self::InvalidUtf8 => formatter.write_str("file is not valid UTF-8"),
250 Self::Oversize { bytes, limit } => {
251 write!(
252 formatter,
253 "file is too large for a snapshot ({bytes} > {limit} bytes)"
254 )
255 }
256 Self::EmptyRange => formatter.write_str("requested range is empty"),
257 Self::BeyondEof => formatter.write_str("requested range is beyond EOF"),
258 Self::Io(reason) => write!(formatter, "read failed: {reason}"),
259 }
260 }
261}
262
263#[derive(Clone, Debug, Eq, PartialEq)]
265pub enum ReadPublication {
266 Tagged {
267 snapshot: Snapshot,
268 rendering: TaggedRendering,
269 },
270 Tagless {
271 rendering: TaglessRendering,
272 reason: UntaggableReason,
273 },
274}
275
276impl ReadPublication {
277 pub fn snapshot(&self) -> Option<&Snapshot> {
278 match self {
279 Self::Tagged { snapshot, .. } => Some(snapshot),
280 Self::Tagless { .. } => None,
281 }
282 }
283
284 pub fn tagged_rendering(&self) -> Option<&TaggedRendering> {
285 match self {
286 Self::Tagged { rendering, .. } => Some(rendering),
287 Self::Tagless { .. } => None,
288 }
289 }
290
291 pub fn text(&self) -> &str {
292 match self {
293 Self::Tagged { rendering, .. } => &rendering.text,
294 Self::Tagless { rendering, .. } => &rendering.text,
295 }
296 }
297}
298
299#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
301pub struct EvictionRecord {
302 pub canonical_path: PathBuf,
303 pub tag: String,
304}
305
306impl EvictionRecord {
307 fn new(path: &Path, tag: &str) -> Self {
308 Self {
309 canonical_path: path.to_path_buf(),
310 tag: fold_tag(tag),
311 }
312 }
313}
314
315type ContentFingerprint = [u8; 32];
316
317#[derive(Clone, Debug)]
318struct EvictionHistoryEntry {
319 record: EvictionRecord,
320 content_fingerprint: ContentFingerprint,
321}
322
323#[derive(Clone, Debug)]
324struct StoredSnapshot {
325 snapshot: Snapshot,
326 normalized_bytes: Vec<u8>,
327 published_at: u64,
328 last_used: u64,
329}
330
331impl StoredSnapshot {
332 fn residency_bytes(&self) -> usize {
333 self.snapshot
334 .residency_bytes()
335 .saturating_add(self.normalized_bytes.len())
336 }
337
338 fn has_same_normalized_content(&self, tag: &str, normalized_bytes: &[u8]) -> bool {
339 fold_tag(&self.snapshot.tag) == fold_tag(tag) && self.normalized_bytes == normalized_bytes
340 }
341}
342
343#[derive(Clone, Debug, Eq, PartialEq)]
345pub enum PublishStatus {
346 Stored,
347 Oversize { retained_bytes: usize, limit: usize },
348}
349
350#[derive(Clone, Debug, Eq, PartialEq)]
351pub struct PublishOutcome {
352 pub status: PublishStatus,
353 pub snapshot: Option<Snapshot>,
356 pub evicted: Vec<EvictionRecord>,
357}
358
359impl PublishOutcome {
360 pub fn stored(&self) -> bool {
361 matches!(self.status, PublishStatus::Stored)
362 }
363
364 pub fn oversize(&self) -> bool {
365 matches!(self.status, PublishStatus::Oversize { .. })
366 }
367}
368
369#[derive(Clone, Debug, Eq, PartialEq)]
371pub enum SnapshotLookupError {
372 UnknownTag,
373 EvictedTag,
374 AmbiguousTag,
375}
376
377impl SnapshotLookupError {
378 pub const fn code(&self) -> &'static str {
379 match self {
380 Self::UnknownTag => "hashline_unknown_tag",
381 Self::EvictedTag => "hashline_evicted_tag",
382 Self::AmbiguousTag => "hashline_ambiguous_tag",
383 }
384 }
385
386 pub const fn steering(&self) -> &'static str {
387 match self {
388 Self::AmbiguousTag => {
389 "use apply_patch or another available non-hashline edit surface; re-reading preserves this colliding four-hex tag"
390 }
391 Self::UnknownTag | Self::EvictedTag => {
392 "re-read the file to mint a fresh tag, then retry the edit"
393 }
394 }
395 }
396}
397
398impl fmt::Display for SnapshotLookupError {
399 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
400 formatter.write_str(self.code())
401 }
402}
403
404impl std::error::Error for SnapshotLookupError {}
405
406#[derive(Clone, Debug, Eq, PartialEq)]
408pub enum SnapshotLookup {
409 Found(Snapshot),
410 Unknown,
411 Evicted,
412 Ambiguous,
413}
414
415#[derive(Clone, Debug, Default)]
417pub struct SnapshotStore {
418 paths: BTreeMap<PathBuf, Vec<StoredSnapshot>>,
419 total_bytes: usize,
420 clock: u64,
421 eviction_history: VecDeque<EvictionHistoryEntry>,
422}
423
424impl SnapshotStore {
425 pub const fn new() -> Self {
426 Self {
427 paths: BTreeMap::new(),
428 total_bytes: 0,
429 clock: 0,
430 eviction_history: VecDeque::new(),
431 }
432 }
433
434 pub fn snapshot_count(&self) -> usize {
435 self.paths.values().map(Vec::len).sum()
436 }
437
438 pub fn path_count(&self) -> usize {
439 self.paths.len()
440 }
441
442 pub fn total_bytes(&self) -> usize {
443 self.total_bytes
444 }
445
446 pub fn eviction_history_len(&self) -> usize {
447 self.eviction_history.len()
448 }
449
450 pub fn is_empty(&self) -> bool {
451 self.snapshot_count() == 0
452 }
453
454 pub fn clear(&mut self) {
455 self.paths.clear();
456 self.total_bytes = 0;
457 self.eviction_history.clear();
458 }
459
460 pub fn publish(&mut self, path: impl AsRef<Path>, snapshot: Snapshot) -> PublishOutcome {
463 let path = canonical_key(path.as_ref());
464 let mut published_snapshot = snapshot;
465 let normalized_bytes = std::mem::take(&mut published_snapshot.normalized_bytes);
466
467 let coalesced = self.paths.get(&path).and_then(|versions| {
471 versions
472 .iter()
473 .position(|version| {
474 version.has_same_normalized_content(&published_snapshot.tag, &normalized_bytes)
475 })
476 .map(|index| {
477 (
478 index,
479 merge_snapshot_evidence(&versions[index].snapshot, &published_snapshot),
480 )
481 })
482 });
483 let retained_bytes = coalesced.as_ref().map_or_else(
484 || {
485 published_snapshot
486 .residency_bytes()
487 .saturating_add(normalized_bytes.len())
488 },
489 |(_, merged)| {
490 merged
491 .residency_bytes()
492 .saturating_add(normalized_bytes.len())
493 },
494 );
495 if published_snapshot.byte_count > MAX_FILE_READ_BYTES
496 || retained_bytes > MAX_SNAPSHOT_TOTAL_BYTES
497 {
498 return PublishOutcome {
499 status: PublishStatus::Oversize {
500 retained_bytes,
501 limit: MAX_SNAPSHOT_TOTAL_BYTES,
502 },
503 snapshot: None,
504 evicted: Vec::new(),
505 };
506 }
507
508 let mut evicted = Vec::new();
509 self.bump_clock();
510 let now = self.clock;
511
512 if let Some((index, merged)) = coalesced {
513 let previous_bytes = self.paths[&path][index].residency_bytes();
514 self.remove_history(
515 &EvictionRecord::new(&path, &published_snapshot.tag),
516 &content_fingerprint(&normalized_bytes),
517 );
518 let version = &mut self
519 .paths
520 .get_mut(&path)
521 .expect("coalesced snapshot path remains resident")[index];
522 version.snapshot = merged;
523 version.published_at = now;
527 version.last_used = now;
528 let merged_bytes = version.residency_bytes();
529 self.total_bytes = self
530 .total_bytes
531 .saturating_sub(previous_bytes)
532 .saturating_add(merged_bytes);
533
534 while self.total_bytes > MAX_SNAPSHOT_TOTAL_BYTES {
535 let Some((oldest_path, oldest_index)) = self.least_recent_version() else {
536 break;
537 };
538 evicted.push(self.remove_version_for_eviction(&oldest_path, oldest_index));
539 }
540
541 return PublishOutcome {
542 status: PublishStatus::Stored,
543 snapshot: Some(published_snapshot),
544 evicted,
545 };
546 }
547
548 if !self.paths.contains_key(&path) && self.paths.len() >= MAX_SNAPSHOT_PATHS {
549 if let Some(oldest_path) = self.least_recent_path() {
550 evicted.extend(self.remove_path_for_eviction(&oldest_path));
551 }
552 }
553
554 if let Some(versions) = self.paths.get(&path) {
555 if versions.len() >= MAX_VERSIONS_PER_PATH {
556 if let Some(index) = versions
557 .iter()
558 .enumerate()
559 .min_by_key(|(_, version)| (version.published_at, version.last_used))
560 .map(|(index, _)| index)
561 {
562 evicted.push(self.remove_version_for_eviction(&path, index));
563 }
564 }
565 }
566
567 self.remove_history(
568 &EvictionRecord::new(&path, &published_snapshot.tag),
569 &content_fingerprint(&normalized_bytes),
570 );
571 self.total_bytes = self.total_bytes.saturating_add(retained_bytes);
572 self.paths
573 .entry(path.clone())
574 .or_default()
575 .push(StoredSnapshot {
576 snapshot: published_snapshot.clone(),
577 normalized_bytes,
578 published_at: now,
579 last_used: now,
580 });
581
582 while self.total_bytes > MAX_SNAPSHOT_TOTAL_BYTES {
583 let Some((oldest_path, index)) = self.least_recent_version() else {
584 break;
585 };
586 evicted.push(self.remove_version_for_eviction(&oldest_path, index));
587 }
588
589 PublishOutcome {
590 status: PublishStatus::Stored,
591 snapshot: Some(published_snapshot),
592 evicted,
593 }
594 }
595
596 pub fn insert(&mut self, path: impl AsRef<Path>, snapshot: Snapshot) -> PublishOutcome {
599 self.publish(path, snapshot)
600 }
601
602 pub fn publish_bytes(
603 &mut self,
604 path: impl AsRef<Path>,
605 bytes: &[u8],
606 coverage: CoverageInput,
607 ) -> PublishOutcome {
608 let snapshot = scan_bytes_with_request(bytes, ScanRequest::new(coverage))
609 .snapshot
610 .expect("in-memory scans always observe EOF");
611 self.publish(path, snapshot)
612 }
613
614 pub fn lookup(
615 &mut self,
616 path: impl AsRef<Path>,
617 tag: &str,
618 ) -> Result<Snapshot, SnapshotLookupError> {
619 let path = canonical_key(path.as_ref());
620 let folded = fold_tag(tag);
621 let Some(versions) = self.paths.get(&path) else {
622 return if self.history_contains(&path, &folded) {
623 Err(SnapshotLookupError::EvictedTag)
624 } else {
625 Err(SnapshotLookupError::UnknownTag)
626 };
627 };
628
629 let indices: Vec<usize> = versions
630 .iter()
631 .enumerate()
632 .filter_map(|(index, version)| {
633 (fold_tag(&version.snapshot.tag) == folded).then_some(index)
634 })
635 .collect();
636 if indices.is_empty() {
637 return if self.history_contains(&path, &folded) {
638 Err(SnapshotLookupError::EvictedTag)
639 } else {
640 Err(SnapshotLookupError::UnknownTag)
641 };
642 }
643
644 let first_index = indices[0];
645 let first_content = &versions[first_index].normalized_bytes;
646 if indices
647 .iter()
648 .skip(1)
649 .any(|index| versions[*index].normalized_bytes != *first_content)
650 {
651 return Err(SnapshotLookupError::AmbiguousTag);
652 }
653 if self.history_contains_different_content(&path, &folded, first_content) {
654 return Err(SnapshotLookupError::EvictedTag);
655 }
656 let resolved = versions[first_index].snapshot.clone();
657
658 self.bump_clock();
659 let now = self.clock;
660 let versions = self
661 .paths
662 .get_mut(&path)
663 .expect("snapshot path remains resident during lookup");
664 for index in indices {
665 versions[index].last_used = now;
666 }
667 Ok(resolved)
668 }
669
670 pub fn resolve(
671 &mut self,
672 path: impl AsRef<Path>,
673 tag: &str,
674 ) -> Result<Snapshot, SnapshotLookupError> {
675 self.lookup(path, tag)
676 }
677
678 pub fn lookup_state(&self, path: impl AsRef<Path>, tag: &str) -> SnapshotLookup {
679 let path = canonical_key(path.as_ref());
680 let folded = fold_tag(tag);
681 let Some(versions) = self.paths.get(&path) else {
682 return if self.history_contains(&path, &folded) {
683 SnapshotLookup::Evicted
684 } else {
685 SnapshotLookup::Unknown
686 };
687 };
688 let candidates: Vec<&StoredSnapshot> = versions
689 .iter()
690 .filter(|version| fold_tag(&version.snapshot.tag) == folded)
691 .collect();
692 if candidates.is_empty() {
693 return if self.history_contains(&path, &folded) {
694 SnapshotLookup::Evicted
695 } else {
696 SnapshotLookup::Unknown
697 };
698 }
699 let first = candidates[0];
700 if candidates
701 .iter()
702 .skip(1)
703 .any(|candidate| candidate.normalized_bytes != first.normalized_bytes)
704 {
705 SnapshotLookup::Ambiguous
706 } else if self.history_contains_different_content(&path, &folded, &first.normalized_bytes) {
707 SnapshotLookup::Evicted
708 } else {
709 SnapshotLookup::Found(first.snapshot.clone())
710 }
711 }
712
713 pub fn contains(&self, path: impl AsRef<Path>, tag: &str) -> bool {
714 matches!(self.lookup_state(path, tag), SnapshotLookup::Found(_))
715 }
716
717 pub fn invalidate_path(&mut self, path: impl AsRef<Path>) -> bool {
721 let path = canonical_key(path.as_ref());
722 let Some(versions) = self.paths.remove(&path) else {
723 return false;
724 };
725 self.total_bytes = self
726 .total_bytes
727 .saturating_sub(versions.iter().map(StoredSnapshot::residency_bytes).sum());
728 true
729 }
730
731 pub fn remove_path(&mut self, path: impl AsRef<Path>) -> bool {
732 self.invalidate_path(path)
733 }
734
735 pub fn eviction_history_contains(&self, path: impl AsRef<Path>, tag: &str) -> bool {
736 self.history_contains(&canonical_key(path.as_ref()), &fold_tag(tag))
737 }
738
739 pub fn iter(&self) -> impl Iterator<Item = (&Path, &Snapshot)> {
740 self.paths.iter().flat_map(|(path, versions)| {
741 versions
742 .iter()
743 .map(move |version| (path.as_path(), &version.snapshot))
744 })
745 }
746
747 fn bump_clock(&mut self) {
748 self.clock = self.clock.saturating_add(1);
749 }
750
751 fn least_recent_path(&self) -> Option<PathBuf> {
752 self.paths
753 .iter()
754 .map(|(path, versions)| {
755 let last_used = versions
756 .iter()
757 .map(|version| version.last_used)
758 .max()
759 .unwrap_or(0);
760 let inserted = versions
761 .iter()
762 .map(|version| version.published_at)
763 .min()
764 .unwrap_or(0);
765 (last_used, inserted, path)
766 })
767 .min_by(|left, right| {
768 left.0
769 .cmp(&right.0)
770 .then(left.1.cmp(&right.1))
771 .then(left.2.cmp(right.2))
772 })
773 .map(|(_, _, path)| path.clone())
774 }
775
776 fn least_recent_version(&self) -> Option<(PathBuf, usize)> {
777 self.paths
778 .iter()
779 .flat_map(|(path, versions)| {
780 versions.iter().enumerate().map(move |(index, version)| {
781 (
782 version.last_used,
783 version.published_at,
784 path.clone(),
785 index,
786 fold_tag(&version.snapshot.tag),
787 )
788 })
789 })
790 .min_by(|left, right| {
791 left.0
792 .cmp(&right.0)
793 .then(left.1.cmp(&right.1))
794 .then(left.2.cmp(&right.2))
795 .then(left.4.cmp(&right.4))
796 .then(left.3.cmp(&right.3))
797 })
798 .map(|(_, _, path, index, _)| (path, index))
799 }
800
801 fn remove_version_for_eviction(&mut self, path: &Path, index: usize) -> EvictionRecord {
802 let (record, fingerprint, should_remove_path) = {
803 let versions = self.paths.get_mut(path).expect("version path exists");
804 let removed = versions.remove(index);
805 let record = EvictionRecord::new(path, &removed.snapshot.tag);
806 let fingerprint = content_fingerprint(&removed.normalized_bytes);
807 self.total_bytes = self.total_bytes.saturating_sub(removed.residency_bytes());
808 (record, fingerprint, versions.is_empty())
809 };
810 if should_remove_path {
811 self.paths.remove(path);
812 }
813 self.record_eviction(record.clone(), fingerprint);
814 record
815 }
816
817 fn remove_path_for_eviction(&mut self, path: &Path) -> Vec<EvictionRecord> {
818 let Some(versions) = self.paths.remove(path) else {
819 return Vec::new();
820 };
821 let mut records = Vec::with_capacity(versions.len());
822 for version in versions {
823 self.total_bytes = self.total_bytes.saturating_sub(version.residency_bytes());
824 let record = EvictionRecord::new(path, &version.snapshot.tag);
825 let fingerprint = content_fingerprint(&version.normalized_bytes);
826 self.record_eviction(record.clone(), fingerprint);
827 records.push(record);
828 }
829 records
830 }
831
832 fn record_eviction(&mut self, record: EvictionRecord, content_fingerprint: ContentFingerprint) {
833 self.remove_history(&record, &content_fingerprint);
834 self.eviction_history.push_back(EvictionHistoryEntry {
835 record,
836 content_fingerprint,
837 });
838 while self.eviction_history.len() > MAX_EVICTION_RECORDS {
839 self.eviction_history.pop_front();
840 }
841 }
842
843 fn remove_history(
844 &mut self,
845 record: &EvictionRecord,
846 content_fingerprint: &ContentFingerprint,
847 ) {
848 self.eviction_history.retain(|entry| {
849 entry.record != *record || entry.content_fingerprint != *content_fingerprint
850 });
851 }
852
853 fn history_contains(&self, path: &Path, tag: &str) -> bool {
854 self.eviction_history
855 .iter()
856 .any(|entry| entry.record.canonical_path == path && entry.record.tag == tag)
857 }
858
859 fn history_contains_different_content(
860 &self,
861 path: &Path,
862 tag: &str,
863 normalized_bytes: &[u8],
864 ) -> bool {
865 let fingerprint = content_fingerprint(normalized_bytes);
866 self.eviction_history.iter().any(|entry| {
867 entry.record.canonical_path == path
868 && entry.record.tag == tag
869 && entry.content_fingerprint != fingerprint
870 })
871 }
872}
873
874fn content_fingerprint(normalized_bytes: &[u8]) -> ContentFingerprint {
875 *blake3::hash(normalized_bytes).as_bytes()
876}
877
878fn merge_snapshot_evidence(existing: &Snapshot, incoming: &Snapshot) -> Snapshot {
879 let mut merged = existing.clone();
880 merged.records.extend(incoming.records.clone());
881 merged
882 .retained_lines
883 .extend(incoming.retained_lines.clone());
884 merged
885 .coverage
886 .requested_lines
887 .extend(incoming.coverage.requested_lines.iter().copied());
888 merged.coverage.retain_all |= incoming.coverage.retain_all;
889 merged
890 .coverage
891 .retained_lines
892 .extend(incoming.coverage.retained_lines.iter().copied());
893 merged
894 .coverage
895 .seen_lines
896 .extend(incoming.coverage.seen_lines.iter().copied());
897 merged.coverage.scanned_line_count = merged
898 .coverage
899 .scanned_line_count
900 .max(incoming.coverage.scanned_line_count);
901 merged.coverage.total_lines = merged
902 .coverage
903 .total_lines
904 .max(incoming.coverage.total_lines);
905 merged.coverage.byte_count = merged.coverage.byte_count.max(incoming.coverage.byte_count);
906 merged.coverage.eof_observed |= incoming.coverage.eof_observed;
907 merged.boundary.empty_file |= incoming.boundary.empty_file;
908 merged.boundary.bof_observed |= incoming.boundary.bof_observed;
909 merged.boundary.eof_observed |= incoming.boundary.eof_observed;
910 merged.boundary.first_seen = merged.coverage.seen_lines.iter().next().copied();
911 merged.boundary.last_seen = merged.coverage.seen_lines.iter().next_back().copied();
912 merged.total_lines = merged.total_lines.max(incoming.total_lines);
913 merged.byte_count = merged.byte_count.max(incoming.byte_count);
914 merged.provenance = incoming.provenance.clone();
915 merged.capture_provenance = incoming.capture_provenance.clone();
916 merged
917}
918
919pub fn equivalent_snapshots(left: &Snapshot, right: &Snapshot) -> bool {
922 left.records == right.records
923 && left.coverage.retained_lines == right.coverage.retained_lines
924 && left.coverage.seen_lines == right.coverage.seen_lines
925 && left.total_lines == right.total_lines
926 && left.boundary.empty_file == right.boundary.empty_file
927 && left.boundary.bof_observed == right.boundary.bof_observed
928 && left.boundary.first_seen == right.boundary.first_seen
929 && left.boundary.last_seen == right.boundary.last_seen
930}
931
932impl Snapshot {
933 pub fn residency_bytes(&self) -> usize {
936 self.records
937 .values()
938 .map(RawLineRecord::to_bytes)
939 .map(|bytes| bytes.len())
940 .sum()
941 }
942
943 pub fn retained_payload_bytes(&self) -> usize {
944 self.residency_bytes()
945 }
946}
947
948pub fn render_tagged_snapshot(
950 snapshot: &Snapshot,
951 requested_path: impl Into<String>,
952) -> TaggedRendering {
953 render_tagged_snapshot_with_options(snapshot, requested_path, RenderOptions::default())
954}
955
956#[derive(Clone, Copy, Debug, Eq, PartialEq)]
959pub struct RenderOptions {
960 pub max_output_bytes: usize,
961 pub max_line_length: usize,
962}
963
964impl Default for RenderOptions {
965 fn default() -> Self {
966 Self {
967 max_output_bytes: MAX_RENDER_BYTES,
968 max_line_length: MAX_RENDER_LINE_LENGTH,
969 }
970 }
971}
972
973pub fn render_tagged_snapshot_with_options(
974 snapshot: &Snapshot,
975 requested_path: impl Into<String>,
976 options: RenderOptions,
977) -> TaggedRendering {
978 let requested_path = requested_path.into();
979 let mut text = format!("[{requested_path}#{}]\n", snapshot.tag.to_ascii_uppercase());
980 let mut body_bytes = 0usize;
981 let mut rendered_lines = BTreeSet::new();
982 let mut display_truncated_lines = BTreeSet::new();
983 let mut first_elided = None;
984 let mut last_elided = None;
985
986 for (&line_number, record) in &snapshot.records {
987 let content = String::from_utf8_lossy(&record.content);
988 let (display, was_truncated) = truncate_display_line(&content, options.max_line_length);
989 let line = format!("{line_number}:{display}\n");
990 if body_bytes.saturating_add(line.len()) > options.max_output_bytes {
991 first_elided.get_or_insert(line_number);
992 last_elided = Some(line_number);
993 continue;
994 }
995 body_bytes = body_bytes.saturating_add(line.len());
996 text.push_str(&line);
997 rendered_lines.insert(line_number);
998 if was_truncated {
999 display_truncated_lines.insert(line_number);
1000 }
1001 }
1002
1003 let elided_range = first_elided.map(|start| {
1004 let end = last_elided.unwrap_or(start);
1005 let notice = format!(
1006 "... (output truncated at {}KB, use start_line/end_line to read sections; lines {start}-{end} are not addressable)\n",
1007 options.max_output_bytes / 1024
1008 );
1009 text.push_str(¬ice);
1010 LineRange::new(start, end)
1011 });
1012
1013 TaggedRendering {
1014 text,
1015 requested_path,
1016 tag: snapshot.tag.to_ascii_uppercase(),
1017 seen_lines: rendered_lines.clone(),
1018 rendered_lines,
1019 elided_range,
1020 display_truncated_lines,
1021 }
1022}
1023
1024pub fn render_tagless_snapshot(
1027 snapshot: &Snapshot,
1028 requested_path: impl Into<String>,
1029) -> TaglessRendering {
1030 let requested_path = requested_path.into();
1031 let mut text = String::new();
1032 let mut body_bytes = 0usize;
1033 let mut rendered_lines = BTreeSet::new();
1034 let mut first_elided = None;
1035 let mut last_elided = None;
1036 for (&line_number, record) in &snapshot.records {
1037 let line = format!(
1038 "{line_number}: {}\n",
1039 String::from_utf8_lossy(&record.content)
1040 );
1041 if body_bytes.saturating_add(line.len()) > MAX_RENDER_BYTES {
1042 first_elided.get_or_insert(line_number);
1043 last_elided = Some(line_number);
1044 continue;
1045 }
1046 body_bytes = body_bytes.saturating_add(line.len());
1047 text.push_str(&line);
1048 rendered_lines.insert(line_number);
1049 }
1050 let elided_range = first_elided.map(|start| {
1051 let end = last_elided.unwrap_or(start);
1052 text.push_str(&format!(
1053 "... (output truncated at {}KB, use start_line/end_line to read sections; lines {start}-{end} are not addressable)\n",
1054 MAX_RENDER_BYTES / 1024
1055 ));
1056 LineRange::new(start, end)
1057 });
1058 TaglessRendering {
1059 text,
1060 requested_path,
1061 rendered_lines,
1062 elided_range,
1063 }
1064}
1065
1066fn truncate_display_line(content: &str, max_length: usize) -> (String, bool) {
1067 if content.chars().count() <= max_length {
1068 return (content.to_string(), false);
1069 }
1070 let truncated: String = content.chars().take(max_length).collect();
1071 (format!("{truncated}... (truncated)"), true)
1072}
1073
1074pub fn capture_taggable_read(
1078 store: &mut SnapshotStore,
1079 canonical_path: impl AsRef<Path>,
1080 requested_path: impl Into<String>,
1081 selection: ReadSelection,
1082) -> io::Result<ReadPublication> {
1083 capture_taggable_read_with_options(
1084 store,
1085 canonical_path,
1086 requested_path,
1087 selection,
1088 RenderOptions::default(),
1089 )
1090}
1091
1092pub fn capture_taggable_read_with_options(
1093 store: &mut SnapshotStore,
1094 canonical_path: impl AsRef<Path>,
1095 requested_path: impl Into<String>,
1096 selection: ReadSelection,
1097 options: RenderOptions,
1098) -> io::Result<ReadPublication> {
1099 let canonical_path = canonical_path.as_ref();
1100 let requested_path = requested_path.into();
1101 let metadata = fs::metadata(canonical_path)?;
1102 if !metadata.is_file() {
1103 return Ok(ReadPublication::Tagless {
1104 rendering: TaglessRendering {
1105 text: String::new(),
1106 requested_path,
1107 rendered_lines: BTreeSet::new(),
1108 elided_range: None,
1109 },
1110 reason: UntaggableReason::NotRegularFile,
1111 });
1112 }
1113 let write_eligible = is_write_eligible(&metadata);
1114 if metadata.len() > MAX_FILE_READ_BYTES {
1115 return Ok(ReadPublication::Tagless {
1116 rendering: TaglessRendering {
1117 text: String::new(),
1118 requested_path,
1119 rendered_lines: BTreeSet::new(),
1120 elided_range: None,
1121 },
1122 reason: UntaggableReason::Oversize {
1123 bytes: metadata.len(),
1124 limit: MAX_FILE_READ_BYTES,
1125 },
1126 });
1127 }
1128
1129 let bytes = fs::read(canonical_path)?;
1130 if is_binary(&bytes) {
1131 return Ok(ReadPublication::Tagless {
1132 rendering: TaglessRendering {
1133 text: String::new(),
1134 requested_path,
1135 rendered_lines: BTreeSet::new(),
1136 elided_range: None,
1137 },
1138 reason: UntaggableReason::Binary,
1139 });
1140 }
1141 if std::str::from_utf8(&bytes).is_err() {
1142 return Ok(ReadPublication::Tagless {
1143 rendering: TaglessRendering {
1144 text: String::new(),
1145 requested_path,
1146 rendered_lines: BTreeSet::new(),
1147 elided_range: None,
1148 },
1149 reason: UntaggableReason::InvalidUtf8,
1150 });
1151 }
1152
1153 let source_snapshot = scan_bytes_with_request(&bytes, selection.scan_request())
1154 .snapshot
1155 .expect("in-memory scans always observe EOF");
1156 let selected = selection.selected_lines(source_snapshot.total_lines);
1157 if !write_eligible {
1158 let tagless_snapshot = snapshot_for_lines(&source_snapshot, &selected);
1159 return Ok(ReadPublication::Tagless {
1160 rendering: render_tagless_snapshot(&tagless_snapshot, requested_path),
1161 reason: UntaggableReason::ReadOnly,
1162 });
1163 }
1164 if selection.is_explicitly_empty()
1165 || (selected.is_empty() && !matches!(selection, ReadSelection::WholeFile))
1166 {
1167 let tagless_snapshot = snapshot_for_lines(&source_snapshot, &selected);
1168 return Ok(ReadPublication::Tagless {
1169 rendering: render_tagless_snapshot(&tagless_snapshot, requested_path.clone()),
1170 reason: if bytes.is_empty() {
1171 UntaggableReason::EmptyRange
1172 } else {
1173 UntaggableReason::BeyondEof
1174 },
1175 });
1176 }
1177
1178 let selected_snapshot = snapshot_for_lines(&source_snapshot, &selected);
1179 let candidate_rendering =
1180 render_tagged_snapshot_with_options(&selected_snapshot, requested_path.clone(), options);
1181 let published_snapshot =
1182 snapshot_for_lines(&selected_snapshot, &candidate_rendering.rendered_lines);
1183 let outcome = store.publish(canonical_path, published_snapshot);
1184 if let PublishStatus::Oversize {
1185 retained_bytes,
1186 limit,
1187 } = outcome.status
1188 {
1189 return Ok(ReadPublication::Tagless {
1190 rendering: render_tagless_snapshot(&selected_snapshot, requested_path),
1191 reason: UntaggableReason::Oversize {
1192 bytes: retained_bytes as u64,
1193 limit: limit as u64,
1194 },
1195 });
1196 }
1197 let published_snapshot = outcome
1202 .snapshot
1203 .expect("a non-oversize publication exposes its accepted snapshot");
1204 Ok(ReadPublication::Tagged {
1205 snapshot: published_snapshot,
1206 rendering: candidate_rendering,
1207 })
1208}
1209
1210pub fn capture_bash_rewrite_read(
1214 store: &mut SnapshotStore,
1215 canonical_path: impl AsRef<Path>,
1216 requested_path: impl Into<String>,
1217 kind: BashReadKind,
1218 experimental_bash_rewrite: bool,
1219 funnel_accepted: bool,
1220 effective_hashline: bool,
1221) -> io::Result<ReadPublication> {
1222 if !(experimental_bash_rewrite && funnel_accepted && effective_hashline) {
1223 return capture_tagless_read(canonical_path, requested_path, kind.selection());
1224 }
1225 capture_taggable_read(store, canonical_path, requested_path, kind.selection())
1226}
1227
1228pub fn capture_tagless_read(
1231 canonical_path: impl AsRef<Path>,
1232 requested_path: impl Into<String>,
1233 selection: ReadSelection,
1234) -> io::Result<ReadPublication> {
1235 let canonical_path = canonical_path.as_ref();
1236 let requested_path = requested_path.into();
1237 let bytes = fs::read(canonical_path)?;
1238 let snapshot = scan_bytes_with_request(&bytes, selection.scan_request())
1239 .snapshot
1240 .expect("in-memory scans always observe EOF");
1241 let selected = selection.selected_lines(snapshot.total_lines);
1242 let snapshot = snapshot_for_lines(&snapshot, &selected);
1243 Ok(ReadPublication::Tagless {
1244 rendering: render_tagless_snapshot(&snapshot, requested_path),
1245 reason: UntaggableReason::VirtualPath,
1246 })
1247}
1248
1249pub fn publish_edit_response_snapshot(
1253 store: &mut SnapshotStore,
1254 canonical_path: impl AsRef<Path>,
1255 requested_path: impl Into<String>,
1256 final_bytes: &[u8],
1257 affected: &AffectedRegion,
1258) -> EditResponseSnapshot {
1259 let canonical_path = canonical_path.as_ref();
1260 let requested_path = requested_path.into();
1261 if final_bytes.len() as u64 > MAX_FILE_READ_BYTES || is_binary(final_bytes) {
1262 return EditResponseSnapshot::unavailable(
1263 requested_path,
1264 "final bytes are not a readable, taggable text file",
1265 );
1266 }
1267 if std::str::from_utf8(final_bytes).is_err() {
1268 return EditResponseSnapshot::unavailable(
1269 requested_path,
1270 "final bytes are not valid UTF-8",
1271 );
1272 }
1273 let whole = scan_bytes_with_request(final_bytes, ScanRequest::whole_file())
1274 .snapshot
1275 .expect("in-memory scans always observe EOF");
1276 let selected = affected_output_lines(&whole, affected);
1277 let selected_snapshot = snapshot_for_lines(&whole, &selected);
1278 let outcome = store.publish(canonical_path, selected_snapshot);
1279 if outcome.oversize() {
1280 return EditResponseSnapshot::unavailable(
1281 requested_path,
1282 "affected snapshot exceeds the session residency budget",
1283 );
1284 }
1285 let selected_snapshot = outcome
1286 .snapshot
1287 .expect("a non-oversize publication exposes its accepted snapshot");
1288 let rendering = render_tagged_snapshot(&selected_snapshot, requested_path.clone());
1289 EditResponseSnapshot {
1290 snapshot: Some(selected_snapshot),
1291 rendering: Some(rendering),
1292 requested_path,
1293 notice: None,
1294 }
1295}
1296
1297#[derive(Clone, Debug, Eq, PartialEq)]
1300pub struct EditResponseSnapshot {
1301 pub snapshot: Option<Snapshot>,
1302 pub rendering: Option<TaggedRendering>,
1303 pub requested_path: String,
1304 pub notice: Option<String>,
1305}
1306
1307impl EditResponseSnapshot {
1308 pub fn unavailable(requested_path: String, reason: &str) -> Self {
1309 Self {
1310 snapshot: None,
1311 rendering: None,
1312 requested_path,
1313 notice: Some(format!(
1314 "No hashline tag is available for the final file; re-read before chaining ({reason})."
1315 )),
1316 }
1317 }
1318
1319 pub fn tag(&self) -> Option<&str> {
1320 self.rendering
1321 .as_ref()
1322 .map(|rendering| rendering.tag.as_str())
1323 }
1324}
1325
1326pub fn invalidate_removed_source(store: &mut SnapshotStore, source: impl AsRef<Path>) -> bool {
1329 store.invalidate_path(source)
1330}
1331
1332fn snapshot_for_lines(snapshot: &Snapshot, lines: &BTreeSet<usize>) -> Snapshot {
1333 let records: BTreeMap<usize, RawLineRecord> = snapshot
1334 .records
1335 .iter()
1336 .filter_map(|(&line, record)| lines.contains(&line).then_some((line, record.clone())))
1337 .collect();
1338 let retained_lines = snapshot
1339 .retained_lines
1340 .iter()
1341 .filter_map(|(&line, record)| lines.contains(&line).then_some((line, record.clone())))
1342 .collect();
1343 let mut coverage = snapshot.coverage.clone();
1344 coverage.retained_lines = lines.clone();
1345 coverage.seen_lines = lines.clone();
1346 let boundary = BoundaryEvidence {
1347 empty_file: snapshot.boundary.empty_file,
1348 bof_observed: snapshot.boundary.bof_observed,
1349 eof_observed: snapshot.boundary.eof_observed,
1350 first_seen: lines.iter().next().copied(),
1351 last_seen: lines.iter().next_back().copied(),
1352 };
1353 Snapshot {
1354 tag: snapshot.tag.clone(),
1355 normalized_bytes: snapshot.normalized_bytes.clone(),
1356 records,
1357 retained_lines,
1358 coverage,
1359 boundary,
1360 total_lines: snapshot.total_lines,
1361 byte_count: snapshot.byte_count,
1362 provenance: snapshot.provenance.clone(),
1363 capture_provenance: snapshot.capture_provenance.clone(),
1364 }
1365}
1366
1367fn affected_output_lines(snapshot: &Snapshot, affected: &AffectedRegion) -> BTreeSet<usize> {
1368 let total = snapshot.total_lines;
1369 if total == 0 {
1370 return BTreeSet::new();
1371 }
1372 let ranges = coalesce_ranges(affected.ranges.iter().copied());
1373 let mut selected = BTreeSet::new();
1374 for range in ranges {
1375 let start = range.start.max(1);
1376 let end = range.end.min(total);
1377 if start <= end {
1378 selected.extend(start..=end);
1379 }
1380 if start > 1 {
1381 selected.insert(start - 1);
1382 }
1383 if end < total {
1384 selected.insert(end.saturating_add(1));
1385 }
1386 }
1387 selected.retain(|line| snapshot.records.contains_key(line));
1388 selected
1389}
1390
1391fn coalesce_ranges<I>(ranges: I) -> Vec<LineRange>
1392where
1393 I: IntoIterator<Item = LineRange>,
1394{
1395 let mut ranges: Vec<LineRange> = ranges
1396 .into_iter()
1397 .filter(|range| !range.is_empty())
1398 .collect();
1399 ranges.sort_by_key(|range| (range.start, range.end));
1400 let mut result: Vec<LineRange> = Vec::new();
1401 for range in ranges {
1402 if let Some(last) = result.last_mut() {
1403 if range.start <= last.end.saturating_add(1) {
1404 last.end = last.end.max(range.end);
1405 continue;
1406 }
1407 }
1408 result.push(range);
1409 }
1410 result
1411}
1412
1413fn canonical_key(path: &Path) -> PathBuf {
1414 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
1415}
1416
1417fn fold_tag(tag: &str) -> String {
1418 tag.to_ascii_lowercase()
1419}
1420
1421fn is_binary(bytes: &[u8]) -> bool {
1422 !bytes.is_empty() && content_inspector::inspect(bytes).is_binary()
1423}
1424
1425fn is_write_eligible(metadata: &fs::Metadata) -> bool {
1426 if metadata.permissions().readonly() {
1427 return false;
1428 }
1429 #[cfg(unix)]
1430 {
1431 use std::os::unix::fs::PermissionsExt;
1432 return metadata.permissions().mode() & 0o222 != 0;
1433 }
1434 #[cfg(not(unix))]
1435 {
1436 true
1437 }
1438}
1439
1440impl From<CaptureError> for UntaggableReason {
1441 fn from(error: CaptureError) -> Self {
1442 Self::Io(error.to_string())
1443 }
1444}
1445
1446#[cfg(test)]
1447mod tests {
1448 use super::*;
1449 use std::fs;
1450 use std::path::Path;
1451
1452 fn snapshot(bytes: &[u8], lines: impl IntoIterator<Item = usize>) -> Snapshot {
1453 scan_bytes_with_request(bytes, ScanRequest::new(CoverageInput::lines(lines)))
1454 .snapshot
1455 .expect("in-memory snapshot")
1456 }
1457
1458 fn snapshot_with_forced_tag(bytes: &[u8], tag: &str) -> Snapshot {
1459 let mut snapshot = snapshot(bytes, [1]);
1460 snapshot.tag = tag.to_string();
1461 snapshot
1462 }
1463
1464 fn writable_fixture(root: &Path, name: &str, bytes: &[u8]) -> PathBuf {
1465 let path = root.join(name);
1466 fs::write(&path, bytes).expect("fixture write");
1467 path
1468 }
1469
1470 #[test]
1471 fn equivalent_re_reads_collapse_even_with_different_provenance() {
1472 let mut left = snapshot(b"one\ntwo\n", [1, 2]);
1473 let mut right = left.clone();
1474 right.provenance = right.provenance.with_label("capture", "second");
1475 right.capture_provenance = right
1476 .capture_provenance
1477 .with_label("descriptor", "different");
1478 assert!(equivalent_snapshots(&left, &right));
1479 left.records.get_mut(&1).unwrap().content = b"changed".to_vec();
1480 assert!(!equivalent_snapshots(&left, &right));
1481 }
1482
1483 #[test]
1484 fn genuine_folded_tag_collision_stays_distinct_and_preserves_evicted_history() {
1485 const COLLIDING_TAG: &str = "C0DE";
1486 let mut store = SnapshotStore::new();
1487 let resident_path = PathBuf::from("/tmp/genuine-collision-resident.txt");
1488
1489 store.publish(
1490 &resident_path,
1491 snapshot_with_forced_tag(b"first collision content\n", COLLIDING_TAG),
1492 );
1493 store.publish(
1494 &resident_path,
1495 snapshot_with_forced_tag(b"second collision content\n", &COLLIDING_TAG.to_lowercase()),
1496 );
1497
1498 assert_eq!(
1499 store.snapshot_count(),
1500 2,
1501 "colliding content must not coalesce"
1502 );
1503 assert_eq!(
1504 store.lookup(&resident_path, COLLIDING_TAG),
1505 Err(SnapshotLookupError::AmbiguousTag)
1506 );
1507 assert!(SnapshotLookupError::AmbiguousTag
1508 .steering()
1509 .contains("apply_patch"));
1510
1511 let evicted_path = PathBuf::from("/tmp/genuine-collision-evicted.txt");
1512 store.publish(
1513 &evicted_path,
1514 snapshot_with_forced_tag(b"evicted collision content\n", COLLIDING_TAG),
1515 );
1516 for index in 0..MAX_VERSIONS_PER_PATH {
1517 store.publish(
1518 &evicted_path,
1519 snapshot_with_forced_tag(
1520 format!("filler content {index}\n").as_bytes(),
1521 &format!("F{index:03X}"),
1522 ),
1523 );
1524 }
1525 assert_eq!(
1526 store.lookup(&evicted_path, COLLIDING_TAG),
1527 Err(SnapshotLookupError::EvictedTag),
1528 "the first colliding content must have entered eviction history"
1529 );
1530
1531 store.publish(
1532 &evicted_path,
1533 snapshot_with_forced_tag(b"replacement collision content\n", COLLIDING_TAG),
1534 );
1535
1536 assert!(store.eviction_history_contains(&evicted_path, COLLIDING_TAG));
1537 assert_eq!(
1538 store.lookup(&evicted_path, COLLIDING_TAG),
1539 Err(SnapshotLookupError::EvictedTag),
1540 "publishing different colliding content must not erase the prior eviction"
1541 );
1542 }
1543
1544 #[test]
1545 fn coalescing_uses_normalized_content_not_retained_window_equality() {
1546 let mut store = SnapshotStore::new();
1547 let path = PathBuf::from("/tmp/normalized-content.txt");
1548 let first = snapshot(b"one \ntwo\n", [1]);
1549 let second = snapshot(b"one\t\ntwo\n", [2]);
1550 let tag = first.tag.clone();
1551
1552 store.publish(&path, first);
1553 store.publish(&path, second);
1554
1555 assert_eq!(store.snapshot_count(), 1);
1556 let resolved = store
1557 .lookup(&path, &tag)
1558 .expect("normalized content matches");
1559 assert_eq!(resolved.coverage.seen_lines, BTreeSet::from([1, 2]));
1560 }
1561
1562 #[test]
1563 fn tagged_rendering_keeps_absolute_numbers_and_display_truncation_seen() {
1564 let long = "x".repeat(MAX_RENDER_LINE_LENGTH + 20);
1565 let snapshot = snapshot(format!("short\n{long}\nlast\n").as_bytes(), [1, 2, 3]);
1566 let rendered = render_tagged_snapshot(&snapshot, "agent/path.txt");
1567 assert!(rendered.text.starts_with("[agent/path.txt#"));
1568 assert!(rendered.text.contains("1:short\n"));
1569 assert!(rendered.text.contains("2:"));
1570 assert!(rendered.text.contains("... (truncated)"));
1571 assert_eq!(rendered.rendered_lines, BTreeSet::from([1, 2, 3]));
1572 assert!(rendered.display_truncated_lines.contains(&2));
1573 }
1574
1575 #[test]
1576 fn output_elision_removes_unrendered_rows_from_published_snapshot() {
1577 let temp = tempfile::tempdir().unwrap();
1578 let bytes = (1..=20)
1579 .map(|line| format!("{line}:{}\n", "x".repeat(20)))
1580 .collect::<String>();
1581 let path = writable_fixture(temp.path(), "large.txt", bytes.as_bytes());
1582 let mut store = SnapshotStore::new();
1583 let publication = capture_taggable_read_with_options(
1584 &mut store,
1585 &path,
1586 "large.txt",
1587 ReadSelection::WholeFile,
1588 RenderOptions {
1589 max_output_bytes: 80,
1590 max_line_length: MAX_RENDER_LINE_LENGTH,
1591 },
1592 )
1593 .unwrap();
1594 let ReadPublication::Tagged {
1595 snapshot,
1596 rendering,
1597 } = publication
1598 else {
1599 panic!("expected tagged publication");
1600 };
1601 assert!(rendering.elided_range.is_some());
1602 assert_eq!(snapshot.coverage.seen_lines, rendering.rendered_lines);
1603 assert!(!snapshot.coverage.is_seen(20));
1604 assert!(snapshot.coverage.is_seen(1));
1605 assert!(store.contains(&path, &snapshot.tag));
1606 }
1607
1608 #[test]
1609 fn ranged_and_bash_tail_publications_have_only_seen_rows() {
1610 let temp = tempfile::tempdir().unwrap();
1611 let path = writable_fixture(temp.path(), "tail.txt", b"one\ntwo\nthree\nfour\n");
1612 let mut store = SnapshotStore::new();
1613 let publication =
1614 capture_taggable_read(&mut store, &path, "tail.txt", ReadSelection::range(2, 3))
1615 .unwrap();
1616 let ReadPublication::Tagged { snapshot, .. } = publication else {
1617 panic!("expected tagged range");
1618 };
1619 assert_eq!(snapshot.coverage.seen_lines, BTreeSet::from([2, 3]));
1620 let publication = capture_bash_rewrite_read(
1621 &mut store,
1622 &path,
1623 "tail.txt",
1624 BashReadKind::Tail { lines: 2 },
1625 true,
1626 true,
1627 true,
1628 )
1629 .unwrap();
1630 let ReadPublication::Tagged { snapshot, .. } = publication else {
1631 panic!("expected tagged tail");
1632 };
1633 assert_eq!(snapshot.coverage.seen_lines, BTreeSet::from([3, 4]));
1634 assert!(snapshot.eof_observed());
1635 }
1636
1637 #[test]
1638 fn empty_or_beyond_eof_ranges_do_not_mint_empty_file_tags() {
1639 let temp = tempfile::tempdir().unwrap();
1640 let empty = writable_fixture(temp.path(), "empty.txt", b"");
1641 let one_line = writable_fixture(temp.path(), "one-line.txt", b"one\n");
1642 let mut store = SnapshotStore::new();
1643 let empty_result =
1644 capture_taggable_read(&mut store, &empty, "empty.txt", ReadSelection::range(1, 1))
1645 .unwrap();
1646 assert!(matches!(empty_result, ReadPublication::Tagless { .. }));
1647 let beyond_result = capture_taggable_read(
1648 &mut store,
1649 &one_line,
1650 "one-line.txt",
1651 ReadSelection::range(2, 2),
1652 )
1653 .unwrap();
1654 assert!(matches!(beyond_result, ReadPublication::Tagless { .. }));
1655 assert_eq!(store.snapshot_count(), 0);
1656 }
1657
1658 #[test]
1659 fn declined_bash_rewrite_is_store_neutral() {
1660 let temp = tempfile::tempdir().unwrap();
1661 let path = writable_fixture(temp.path(), "cat.txt", b"one\ntwo\n");
1662 let mut store = SnapshotStore::new();
1663 let before = store.clone();
1664 let publication = capture_bash_rewrite_read(
1665 &mut store,
1666 &path,
1667 "cat.txt",
1668 BashReadKind::Cat,
1669 false,
1670 true,
1671 true,
1672 )
1673 .unwrap();
1674 assert!(matches!(publication, ReadPublication::Tagless { .. }));
1675 assert_eq!(store.snapshot_count(), before.snapshot_count());
1676 assert_eq!(store.eviction_history_len(), before.eviction_history_len());
1677 }
1678
1679 #[test]
1680 fn edit_response_renders_changed_rows_and_surviving_neighbors() {
1681 let mut store = SnapshotStore::new();
1682 let result = publish_edit_response_snapshot(
1683 &mut store,
1684 "/virtual/edit.txt",
1685 "edit.txt",
1686 b"a\ninserted\nc\nd\n",
1687 &AffectedRegion::from_range(2, 2),
1688 );
1689 let rendering = result.rendering.as_ref().unwrap();
1690 assert!(rendering.text.contains("1:a\n"));
1691 assert!(rendering.text.contains("2:inserted\n"));
1692 assert!(rendering.text.contains("3:c\n"));
1693 assert!(!rendering.text.contains("4:d\n"));
1694 assert!(result.tag().is_some());
1695 }
1696
1697 #[test]
1698 fn edit_response_empty_file_has_boundary_evidence_without_rows() {
1699 let mut store = SnapshotStore::new();
1700 let result = publish_edit_response_snapshot(
1701 &mut store,
1702 "/virtual/empty.txt",
1703 "empty.txt",
1704 b"",
1705 &AffectedRegion::deletion(1, 4),
1706 );
1707 let snapshot = result.snapshot.unwrap();
1708 assert!(snapshot.records.is_empty());
1709 assert!(snapshot.boundary.empty_file);
1710 assert!(result.rendering.unwrap().text.starts_with("[empty.txt#"));
1711 }
1712
1713 #[test]
1714 fn path_and_version_limits_evict_deterministically() {
1715 let mut store = SnapshotStore::new();
1716 for path_number in 0..=MAX_SNAPSHOT_PATHS {
1717 let path = PathBuf::from(format!("/tmp/hashline-{path_number}.txt"));
1718 let result = store.publish(&path, snapshot(format!("{path_number}\n").as_bytes(), [1]));
1719 assert!(result.stored());
1720 }
1721 assert_eq!(store.path_count(), MAX_SNAPSHOT_PATHS);
1722 assert!(matches!(
1723 store.lookup("/tmp/hashline-0.txt", "0000"),
1724 Err(SnapshotLookupError::UnknownTag | SnapshotLookupError::EvictedTag)
1725 ));
1726
1727 let path = PathBuf::from("/tmp/versions.txt");
1728 let mut tags = Vec::new();
1729 for value in 0..=MAX_VERSIONS_PER_PATH {
1730 let current = snapshot(format!("version-{value}\n").as_bytes(), [1]);
1731 tags.push(current.tag.clone());
1732 store.publish(&path, current);
1733 }
1734 assert_eq!(
1735 store.lookup(&path, &tags[0]),
1736 Err(SnapshotLookupError::EvictedTag)
1737 );
1738 assert!(store.lookup(&path, &tags[1]).is_ok());
1739 }
1740
1741 #[test]
1742 fn same_content_publications_coalesce_before_version_eviction() {
1743 let mut store = SnapshotStore::new();
1744 let path = PathBuf::from("/tmp/coalesced-version.txt");
1745 let bytes = b"one\ntwo\nthree\nfour\nfive\nsix\n";
1746 let mut tag = None;
1747
1748 for line in 1..=(MAX_VERSIONS_PER_PATH + 2) {
1749 let current = snapshot(bytes, [line]);
1750 tag.get_or_insert(current.tag.clone());
1751 let outcome = store.publish(&path, current);
1752 assert!(outcome.stored());
1753 assert!(outcome.evicted.is_empty());
1754 }
1755
1756 assert_eq!(store.snapshot_count(), 1);
1757 assert_eq!(store.eviction_history_len(), 0);
1758 let resolved = store
1759 .lookup(&path, tag.as_deref().unwrap())
1760 .expect("coalesced version remains resident");
1761 assert_eq!(
1762 resolved.coverage.seen_lines,
1763 BTreeSet::from([1, 2, 3, 4, 5, 6])
1764 );
1765 }
1766
1767 #[test]
1768 fn full_reread_refreshes_coalesced_version_age() {
1769 let mut store = SnapshotStore::new();
1770 let path = PathBuf::from("/tmp/coalesced-reread.py");
1771 let original = (1..=130)
1772 .map(|line| format!("line_{line} = {line}\n"))
1773 .collect::<String>();
1774 let mut first = snapshot(original.as_bytes(), [1, 2]);
1775 first.tag = "A001".into();
1776 let first_tag = first.tag.clone();
1777 store.publish(&path, first);
1778
1779 let mut older_tags = Vec::new();
1780 for version in 2..=MAX_VERSIONS_PER_PATH {
1781 let bytes = format!("version_{version} = {version}\n");
1782 let tag = format!("A{version:03}");
1783 older_tags.push(tag.clone());
1784 store.publish(&path, snapshot_with_forced_tag(bytes.as_bytes(), &tag));
1785 }
1786
1787 let mut reread = snapshot(original.as_bytes(), 1..=130);
1788 reread.tag = first_tag.clone();
1789 store.publish(&path, reread);
1790 store.publish(&path, snapshot_with_forced_tag(b"newest = 5\n", "A005"));
1791
1792 let retained = store
1793 .lookup(&path, &first_tag)
1794 .expect("the fresh full reread must survive the next version publication");
1795 assert!(retained.is_seen(16));
1796 assert_eq!(
1797 store.lookup(&path, &older_tags[0]),
1798 Err(SnapshotLookupError::EvictedTag),
1799 "the least recently published version should be evicted instead"
1800 );
1801 }
1802
1803 #[test]
1804 fn overflowing_eviction_history_transitions_evicted_to_unknown() {
1805 let mut store = SnapshotStore::new();
1806 let mut handles = Vec::new();
1807 for path_number in 0..(MAX_EVICTION_RECORDS + MAX_SNAPSHOT_PATHS + 4) {
1810 let path = PathBuf::from(format!("/tmp/history-{path_number}.txt"));
1811 let current = snapshot(format!("history-{path_number}\n").as_bytes(), [1]);
1812 handles.push((path.clone(), current.tag.clone()));
1813 store.publish(path, current);
1814 }
1815 let first = &handles[0];
1816 assert_eq!(store.eviction_history_len(), MAX_EVICTION_RECORDS);
1817 assert!(matches!(
1818 store.lookup(&first.0, &first.1),
1819 Err(SnapshotLookupError::UnknownTag)
1820 ));
1821 let retained = &handles[handles.len() - MAX_SNAPSHOT_PATHS - 1];
1822 assert!(matches!(
1823 store.lookup(&retained.0, &retained.1),
1824 Err(SnapshotLookupError::EvictedTag)
1825 ));
1826 assert_eq!(
1827 SnapshotLookupError::EvictedTag.steering(),
1828 SnapshotLookupError::UnknownTag.steering()
1829 );
1830 }
1831
1832 #[test]
1833 fn oversize_publish_does_not_perturb_store_or_history() {
1834 let mut store = SnapshotStore::new();
1835 let path = PathBuf::from("/tmp/resident.txt");
1836 let resident = snapshot(b"resident\n", [1]);
1837 store.publish(&path, resident.clone());
1838 let before = store.clone();
1839 let mut oversize = resident;
1840 oversize.byte_count = MAX_FILE_READ_BYTES + 1;
1841 let outcome = store.publish("/tmp/oversize.txt", oversize);
1842 assert!(outcome.oversize());
1843 assert_eq!(store.snapshot_count(), before.snapshot_count());
1844 assert_eq!(store.path_count(), before.path_count());
1845 assert_eq!(store.total_bytes(), before.total_bytes());
1846 assert_eq!(store.eviction_history_len(), before.eviction_history_len());
1847 }
1848
1849 #[test]
1850 fn case_insensitive_lookup_and_invalidation_are_path_scoped() {
1851 let mut store = SnapshotStore::new();
1852 let path = PathBuf::from("/tmp/scoped.txt");
1853 let current = snapshot(b"scoped\n", [1]);
1854 let tag = current.tag.clone();
1855 store.publish(&path, current);
1856 assert!(store.lookup(&path, &tag.to_ascii_lowercase()).is_ok());
1857 assert!(store.invalidate_path(&path));
1858 assert_eq!(
1859 store.lookup(&path, &tag),
1860 Err(SnapshotLookupError::UnknownTag)
1861 );
1862 assert_eq!(store.eviction_history_len(), 0);
1863 }
1864}