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 inserted_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 current tagged content before editing"
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.last_used = now;
524 let merged_bytes = version.residency_bytes();
525 self.total_bytes = self
526 .total_bytes
527 .saturating_sub(previous_bytes)
528 .saturating_add(merged_bytes);
529
530 while self.total_bytes > MAX_SNAPSHOT_TOTAL_BYTES {
531 let Some((oldest_path, oldest_index)) = self.least_recent_version() else {
532 break;
533 };
534 evicted.push(self.remove_version_for_eviction(&oldest_path, oldest_index));
535 }
536
537 return PublishOutcome {
538 status: PublishStatus::Stored,
539 snapshot: Some(published_snapshot),
540 evicted,
541 };
542 }
543
544 if !self.paths.contains_key(&path) && self.paths.len() >= MAX_SNAPSHOT_PATHS {
545 if let Some(oldest_path) = self.least_recent_path() {
546 evicted.extend(self.remove_path_for_eviction(&oldest_path));
547 }
548 }
549
550 if let Some(versions) = self.paths.get(&path) {
551 if versions.len() >= MAX_VERSIONS_PER_PATH {
552 if let Some(index) = versions
553 .iter()
554 .enumerate()
555 .min_by_key(|(_, version)| (version.inserted_at, version.last_used))
556 .map(|(index, _)| index)
557 {
558 evicted.push(self.remove_version_for_eviction(&path, index));
559 }
560 }
561 }
562
563 self.remove_history(
564 &EvictionRecord::new(&path, &published_snapshot.tag),
565 &content_fingerprint(&normalized_bytes),
566 );
567 self.total_bytes = self.total_bytes.saturating_add(retained_bytes);
568 self.paths
569 .entry(path.clone())
570 .or_default()
571 .push(StoredSnapshot {
572 snapshot: published_snapshot.clone(),
573 normalized_bytes,
574 inserted_at: now,
575 last_used: now,
576 });
577
578 while self.total_bytes > MAX_SNAPSHOT_TOTAL_BYTES {
579 let Some((oldest_path, index)) = self.least_recent_version() else {
580 break;
581 };
582 evicted.push(self.remove_version_for_eviction(&oldest_path, index));
583 }
584
585 PublishOutcome {
586 status: PublishStatus::Stored,
587 snapshot: Some(published_snapshot),
588 evicted,
589 }
590 }
591
592 pub fn insert(&mut self, path: impl AsRef<Path>, snapshot: Snapshot) -> PublishOutcome {
595 self.publish(path, snapshot)
596 }
597
598 pub fn publish_bytes(
599 &mut self,
600 path: impl AsRef<Path>,
601 bytes: &[u8],
602 coverage: CoverageInput,
603 ) -> PublishOutcome {
604 let snapshot = scan_bytes_with_request(bytes, ScanRequest::new(coverage))
605 .snapshot
606 .expect("in-memory scans always observe EOF");
607 self.publish(path, snapshot)
608 }
609
610 pub fn lookup(
611 &mut self,
612 path: impl AsRef<Path>,
613 tag: &str,
614 ) -> Result<Snapshot, SnapshotLookupError> {
615 let path = canonical_key(path.as_ref());
616 let folded = fold_tag(tag);
617 let Some(versions) = self.paths.get(&path) else {
618 return if self.history_contains(&path, &folded) {
619 Err(SnapshotLookupError::EvictedTag)
620 } else {
621 Err(SnapshotLookupError::UnknownTag)
622 };
623 };
624
625 let indices: Vec<usize> = versions
626 .iter()
627 .enumerate()
628 .filter_map(|(index, version)| {
629 (fold_tag(&version.snapshot.tag) == folded).then_some(index)
630 })
631 .collect();
632 if indices.is_empty() {
633 return if self.history_contains(&path, &folded) {
634 Err(SnapshotLookupError::EvictedTag)
635 } else {
636 Err(SnapshotLookupError::UnknownTag)
637 };
638 }
639
640 let first_index = indices[0];
641 let first_content = &versions[first_index].normalized_bytes;
642 if indices
643 .iter()
644 .skip(1)
645 .any(|index| versions[*index].normalized_bytes != *first_content)
646 {
647 return Err(SnapshotLookupError::AmbiguousTag);
648 }
649 if self.history_contains_different_content(&path, &folded, first_content) {
650 return Err(SnapshotLookupError::EvictedTag);
651 }
652 let resolved = versions[first_index].snapshot.clone();
653
654 self.bump_clock();
655 let now = self.clock;
656 let versions = self
657 .paths
658 .get_mut(&path)
659 .expect("snapshot path remains resident during lookup");
660 for index in indices {
661 versions[index].last_used = now;
662 }
663 Ok(resolved)
664 }
665
666 pub fn resolve(
667 &mut self,
668 path: impl AsRef<Path>,
669 tag: &str,
670 ) -> Result<Snapshot, SnapshotLookupError> {
671 self.lookup(path, tag)
672 }
673
674 pub fn lookup_state(&self, path: impl AsRef<Path>, tag: &str) -> SnapshotLookup {
675 let path = canonical_key(path.as_ref());
676 let folded = fold_tag(tag);
677 let Some(versions) = self.paths.get(&path) else {
678 return if self.history_contains(&path, &folded) {
679 SnapshotLookup::Evicted
680 } else {
681 SnapshotLookup::Unknown
682 };
683 };
684 let candidates: Vec<&StoredSnapshot> = versions
685 .iter()
686 .filter(|version| fold_tag(&version.snapshot.tag) == folded)
687 .collect();
688 if candidates.is_empty() {
689 return if self.history_contains(&path, &folded) {
690 SnapshotLookup::Evicted
691 } else {
692 SnapshotLookup::Unknown
693 };
694 }
695 let first = candidates[0];
696 if candidates
697 .iter()
698 .skip(1)
699 .any(|candidate| candidate.normalized_bytes != first.normalized_bytes)
700 {
701 SnapshotLookup::Ambiguous
702 } else if self.history_contains_different_content(&path, &folded, &first.normalized_bytes) {
703 SnapshotLookup::Evicted
704 } else {
705 SnapshotLookup::Found(first.snapshot.clone())
706 }
707 }
708
709 pub fn contains(&self, path: impl AsRef<Path>, tag: &str) -> bool {
710 matches!(self.lookup_state(path, tag), SnapshotLookup::Found(_))
711 }
712
713 pub fn invalidate_path(&mut self, path: impl AsRef<Path>) -> bool {
717 let path = canonical_key(path.as_ref());
718 let Some(versions) = self.paths.remove(&path) else {
719 return false;
720 };
721 self.total_bytes = self
722 .total_bytes
723 .saturating_sub(versions.iter().map(StoredSnapshot::residency_bytes).sum());
724 true
725 }
726
727 pub fn remove_path(&mut self, path: impl AsRef<Path>) -> bool {
728 self.invalidate_path(path)
729 }
730
731 pub fn eviction_history_contains(&self, path: impl AsRef<Path>, tag: &str) -> bool {
732 self.history_contains(&canonical_key(path.as_ref()), &fold_tag(tag))
733 }
734
735 pub fn iter(&self) -> impl Iterator<Item = (&Path, &Snapshot)> {
736 self.paths.iter().flat_map(|(path, versions)| {
737 versions
738 .iter()
739 .map(move |version| (path.as_path(), &version.snapshot))
740 })
741 }
742
743 fn bump_clock(&mut self) {
744 self.clock = self.clock.saturating_add(1);
745 }
746
747 fn least_recent_path(&self) -> Option<PathBuf> {
748 self.paths
749 .iter()
750 .map(|(path, versions)| {
751 let last_used = versions
752 .iter()
753 .map(|version| version.last_used)
754 .max()
755 .unwrap_or(0);
756 let inserted = versions
757 .iter()
758 .map(|version| version.inserted_at)
759 .min()
760 .unwrap_or(0);
761 (last_used, inserted, path)
762 })
763 .min_by(|left, right| {
764 left.0
765 .cmp(&right.0)
766 .then(left.1.cmp(&right.1))
767 .then(left.2.cmp(right.2))
768 })
769 .map(|(_, _, path)| path.clone())
770 }
771
772 fn least_recent_version(&self) -> Option<(PathBuf, usize)> {
773 self.paths
774 .iter()
775 .flat_map(|(path, versions)| {
776 versions.iter().enumerate().map(move |(index, version)| {
777 (
778 version.last_used,
779 version.inserted_at,
780 path.clone(),
781 index,
782 fold_tag(&version.snapshot.tag),
783 )
784 })
785 })
786 .min_by(|left, right| {
787 left.0
788 .cmp(&right.0)
789 .then(left.1.cmp(&right.1))
790 .then(left.2.cmp(&right.2))
791 .then(left.4.cmp(&right.4))
792 .then(left.3.cmp(&right.3))
793 })
794 .map(|(_, _, path, index, _)| (path, index))
795 }
796
797 fn remove_version_for_eviction(&mut self, path: &Path, index: usize) -> EvictionRecord {
798 let (record, fingerprint, should_remove_path) = {
799 let versions = self.paths.get_mut(path).expect("version path exists");
800 let removed = versions.remove(index);
801 let record = EvictionRecord::new(path, &removed.snapshot.tag);
802 let fingerprint = content_fingerprint(&removed.normalized_bytes);
803 self.total_bytes = self.total_bytes.saturating_sub(removed.residency_bytes());
804 (record, fingerprint, versions.is_empty())
805 };
806 if should_remove_path {
807 self.paths.remove(path);
808 }
809 self.record_eviction(record.clone(), fingerprint);
810 record
811 }
812
813 fn remove_path_for_eviction(&mut self, path: &Path) -> Vec<EvictionRecord> {
814 let Some(versions) = self.paths.remove(path) else {
815 return Vec::new();
816 };
817 let mut records = Vec::with_capacity(versions.len());
818 for version in versions {
819 self.total_bytes = self.total_bytes.saturating_sub(version.residency_bytes());
820 let record = EvictionRecord::new(path, &version.snapshot.tag);
821 let fingerprint = content_fingerprint(&version.normalized_bytes);
822 self.record_eviction(record.clone(), fingerprint);
823 records.push(record);
824 }
825 records
826 }
827
828 fn record_eviction(&mut self, record: EvictionRecord, content_fingerprint: ContentFingerprint) {
829 self.remove_history(&record, &content_fingerprint);
830 self.eviction_history.push_back(EvictionHistoryEntry {
831 record,
832 content_fingerprint,
833 });
834 while self.eviction_history.len() > MAX_EVICTION_RECORDS {
835 self.eviction_history.pop_front();
836 }
837 }
838
839 fn remove_history(
840 &mut self,
841 record: &EvictionRecord,
842 content_fingerprint: &ContentFingerprint,
843 ) {
844 self.eviction_history.retain(|entry| {
845 entry.record != *record || entry.content_fingerprint != *content_fingerprint
846 });
847 }
848
849 fn history_contains(&self, path: &Path, tag: &str) -> bool {
850 self.eviction_history
851 .iter()
852 .any(|entry| entry.record.canonical_path == path && entry.record.tag == tag)
853 }
854
855 fn history_contains_different_content(
856 &self,
857 path: &Path,
858 tag: &str,
859 normalized_bytes: &[u8],
860 ) -> bool {
861 let fingerprint = content_fingerprint(normalized_bytes);
862 self.eviction_history.iter().any(|entry| {
863 entry.record.canonical_path == path
864 && entry.record.tag == tag
865 && entry.content_fingerprint != fingerprint
866 })
867 }
868}
869
870fn content_fingerprint(normalized_bytes: &[u8]) -> ContentFingerprint {
871 *blake3::hash(normalized_bytes).as_bytes()
872}
873
874fn merge_snapshot_evidence(existing: &Snapshot, incoming: &Snapshot) -> Snapshot {
875 let mut merged = existing.clone();
876 merged.records.extend(incoming.records.clone());
877 merged
878 .retained_lines
879 .extend(incoming.retained_lines.clone());
880 merged
881 .coverage
882 .requested_lines
883 .extend(incoming.coverage.requested_lines.iter().copied());
884 merged.coverage.retain_all |= incoming.coverage.retain_all;
885 merged
886 .coverage
887 .retained_lines
888 .extend(incoming.coverage.retained_lines.iter().copied());
889 merged
890 .coverage
891 .seen_lines
892 .extend(incoming.coverage.seen_lines.iter().copied());
893 merged.coverage.scanned_line_count = merged
894 .coverage
895 .scanned_line_count
896 .max(incoming.coverage.scanned_line_count);
897 merged.coverage.total_lines = merged
898 .coverage
899 .total_lines
900 .max(incoming.coverage.total_lines);
901 merged.coverage.byte_count = merged.coverage.byte_count.max(incoming.coverage.byte_count);
902 merged.coverage.eof_observed |= incoming.coverage.eof_observed;
903 merged.boundary.empty_file |= incoming.boundary.empty_file;
904 merged.boundary.bof_observed |= incoming.boundary.bof_observed;
905 merged.boundary.eof_observed |= incoming.boundary.eof_observed;
906 merged.boundary.first_seen = merged.coverage.seen_lines.iter().next().copied();
907 merged.boundary.last_seen = merged.coverage.seen_lines.iter().next_back().copied();
908 merged.total_lines = merged.total_lines.max(incoming.total_lines);
909 merged.byte_count = merged.byte_count.max(incoming.byte_count);
910 merged.provenance = incoming.provenance.clone();
911 merged.capture_provenance = incoming.capture_provenance.clone();
912 merged
913}
914
915pub fn equivalent_snapshots(left: &Snapshot, right: &Snapshot) -> bool {
918 left.records == right.records
919 && left.coverage.retained_lines == right.coverage.retained_lines
920 && left.coverage.seen_lines == right.coverage.seen_lines
921 && left.total_lines == right.total_lines
922 && left.boundary.empty_file == right.boundary.empty_file
923 && left.boundary.bof_observed == right.boundary.bof_observed
924 && left.boundary.first_seen == right.boundary.first_seen
925 && left.boundary.last_seen == right.boundary.last_seen
926}
927
928impl Snapshot {
929 pub fn residency_bytes(&self) -> usize {
932 self.records
933 .values()
934 .map(RawLineRecord::to_bytes)
935 .map(|bytes| bytes.len())
936 .sum()
937 }
938
939 pub fn retained_payload_bytes(&self) -> usize {
940 self.residency_bytes()
941 }
942}
943
944pub fn render_tagged_snapshot(
946 snapshot: &Snapshot,
947 requested_path: impl Into<String>,
948) -> TaggedRendering {
949 render_tagged_snapshot_with_options(snapshot, requested_path, RenderOptions::default())
950}
951
952#[derive(Clone, Copy, Debug, Eq, PartialEq)]
955pub struct RenderOptions {
956 pub max_output_bytes: usize,
957 pub max_line_length: usize,
958}
959
960impl Default for RenderOptions {
961 fn default() -> Self {
962 Self {
963 max_output_bytes: MAX_RENDER_BYTES,
964 max_line_length: MAX_RENDER_LINE_LENGTH,
965 }
966 }
967}
968
969pub fn render_tagged_snapshot_with_options(
970 snapshot: &Snapshot,
971 requested_path: impl Into<String>,
972 options: RenderOptions,
973) -> TaggedRendering {
974 let requested_path = requested_path.into();
975 let mut text = format!("[{requested_path}#{}]\n", snapshot.tag.to_ascii_uppercase());
976 let mut body_bytes = 0usize;
977 let mut rendered_lines = BTreeSet::new();
978 let mut display_truncated_lines = BTreeSet::new();
979 let mut first_elided = None;
980 let mut last_elided = None;
981
982 for (&line_number, record) in &snapshot.records {
983 let content = String::from_utf8_lossy(&record.content);
984 let (display, was_truncated) = truncate_display_line(&content, options.max_line_length);
985 let line = format!("{line_number}:{display}\n");
986 if body_bytes.saturating_add(line.len()) > options.max_output_bytes {
987 first_elided.get_or_insert(line_number);
988 last_elided = Some(line_number);
989 continue;
990 }
991 body_bytes = body_bytes.saturating_add(line.len());
992 text.push_str(&line);
993 rendered_lines.insert(line_number);
994 if was_truncated {
995 display_truncated_lines.insert(line_number);
996 }
997 }
998
999 let elided_range = first_elided.map(|start| {
1000 let end = last_elided.unwrap_or(start);
1001 let notice = format!(
1002 "... (output truncated at {}KB, use start_line/end_line to read sections; lines {start}-{end} are not addressable)\n",
1003 options.max_output_bytes / 1024
1004 );
1005 text.push_str(¬ice);
1006 LineRange::new(start, end)
1007 });
1008
1009 TaggedRendering {
1010 text,
1011 requested_path,
1012 tag: snapshot.tag.to_ascii_uppercase(),
1013 seen_lines: rendered_lines.clone(),
1014 rendered_lines,
1015 elided_range,
1016 display_truncated_lines,
1017 }
1018}
1019
1020pub fn render_tagless_snapshot(
1023 snapshot: &Snapshot,
1024 requested_path: impl Into<String>,
1025) -> TaglessRendering {
1026 let requested_path = requested_path.into();
1027 let mut text = String::new();
1028 let mut body_bytes = 0usize;
1029 let mut rendered_lines = BTreeSet::new();
1030 let mut first_elided = None;
1031 let mut last_elided = None;
1032 for (&line_number, record) in &snapshot.records {
1033 let line = format!(
1034 "{line_number}: {}\n",
1035 String::from_utf8_lossy(&record.content)
1036 );
1037 if body_bytes.saturating_add(line.len()) > MAX_RENDER_BYTES {
1038 first_elided.get_or_insert(line_number);
1039 last_elided = Some(line_number);
1040 continue;
1041 }
1042 body_bytes = body_bytes.saturating_add(line.len());
1043 text.push_str(&line);
1044 rendered_lines.insert(line_number);
1045 }
1046 let elided_range = first_elided.map(|start| {
1047 let end = last_elided.unwrap_or(start);
1048 text.push_str(&format!(
1049 "... (output truncated at {}KB, use start_line/end_line to read sections; lines {start}-{end} are not addressable)\n",
1050 MAX_RENDER_BYTES / 1024
1051 ));
1052 LineRange::new(start, end)
1053 });
1054 TaglessRendering {
1055 text,
1056 requested_path,
1057 rendered_lines,
1058 elided_range,
1059 }
1060}
1061
1062fn truncate_display_line(content: &str, max_length: usize) -> (String, bool) {
1063 if content.chars().count() <= max_length {
1064 return (content.to_string(), false);
1065 }
1066 let truncated: String = content.chars().take(max_length).collect();
1067 (format!("{truncated}... (truncated)"), true)
1068}
1069
1070pub fn capture_taggable_read(
1074 store: &mut SnapshotStore,
1075 canonical_path: impl AsRef<Path>,
1076 requested_path: impl Into<String>,
1077 selection: ReadSelection,
1078) -> io::Result<ReadPublication> {
1079 capture_taggable_read_with_options(
1080 store,
1081 canonical_path,
1082 requested_path,
1083 selection,
1084 RenderOptions::default(),
1085 )
1086}
1087
1088pub fn capture_taggable_read_with_options(
1089 store: &mut SnapshotStore,
1090 canonical_path: impl AsRef<Path>,
1091 requested_path: impl Into<String>,
1092 selection: ReadSelection,
1093 options: RenderOptions,
1094) -> io::Result<ReadPublication> {
1095 let canonical_path = canonical_path.as_ref();
1096 let requested_path = requested_path.into();
1097 let metadata = fs::metadata(canonical_path)?;
1098 if !metadata.is_file() {
1099 return Ok(ReadPublication::Tagless {
1100 rendering: TaglessRendering {
1101 text: String::new(),
1102 requested_path,
1103 rendered_lines: BTreeSet::new(),
1104 elided_range: None,
1105 },
1106 reason: UntaggableReason::NotRegularFile,
1107 });
1108 }
1109 let write_eligible = is_write_eligible(&metadata);
1110 if metadata.len() > MAX_FILE_READ_BYTES {
1111 return Ok(ReadPublication::Tagless {
1112 rendering: TaglessRendering {
1113 text: String::new(),
1114 requested_path,
1115 rendered_lines: BTreeSet::new(),
1116 elided_range: None,
1117 },
1118 reason: UntaggableReason::Oversize {
1119 bytes: metadata.len(),
1120 limit: MAX_FILE_READ_BYTES,
1121 },
1122 });
1123 }
1124
1125 let bytes = fs::read(canonical_path)?;
1126 if is_binary(&bytes) {
1127 return Ok(ReadPublication::Tagless {
1128 rendering: TaglessRendering {
1129 text: String::new(),
1130 requested_path,
1131 rendered_lines: BTreeSet::new(),
1132 elided_range: None,
1133 },
1134 reason: UntaggableReason::Binary,
1135 });
1136 }
1137 if std::str::from_utf8(&bytes).is_err() {
1138 return Ok(ReadPublication::Tagless {
1139 rendering: TaglessRendering {
1140 text: String::new(),
1141 requested_path,
1142 rendered_lines: BTreeSet::new(),
1143 elided_range: None,
1144 },
1145 reason: UntaggableReason::InvalidUtf8,
1146 });
1147 }
1148
1149 let source_snapshot = scan_bytes_with_request(&bytes, selection.scan_request())
1150 .snapshot
1151 .expect("in-memory scans always observe EOF");
1152 let selected = selection.selected_lines(source_snapshot.total_lines);
1153 if !write_eligible {
1154 let tagless_snapshot = snapshot_for_lines(&source_snapshot, &selected);
1155 return Ok(ReadPublication::Tagless {
1156 rendering: render_tagless_snapshot(&tagless_snapshot, requested_path),
1157 reason: UntaggableReason::ReadOnly,
1158 });
1159 }
1160 if selection.is_explicitly_empty()
1161 || (selected.is_empty() && !matches!(selection, ReadSelection::WholeFile))
1162 {
1163 let tagless_snapshot = snapshot_for_lines(&source_snapshot, &selected);
1164 return Ok(ReadPublication::Tagless {
1165 rendering: render_tagless_snapshot(&tagless_snapshot, requested_path.clone()),
1166 reason: if bytes.is_empty() {
1167 UntaggableReason::EmptyRange
1168 } else {
1169 UntaggableReason::BeyondEof
1170 },
1171 });
1172 }
1173
1174 let selected_snapshot = snapshot_for_lines(&source_snapshot, &selected);
1175 let candidate_rendering =
1176 render_tagged_snapshot_with_options(&selected_snapshot, requested_path.clone(), options);
1177 let published_snapshot =
1178 snapshot_for_lines(&selected_snapshot, &candidate_rendering.rendered_lines);
1179 let outcome = store.publish(canonical_path, published_snapshot);
1180 if let PublishStatus::Oversize {
1181 retained_bytes,
1182 limit,
1183 } = outcome.status
1184 {
1185 return Ok(ReadPublication::Tagless {
1186 rendering: render_tagless_snapshot(&selected_snapshot, requested_path),
1187 reason: UntaggableReason::Oversize {
1188 bytes: retained_bytes as u64,
1189 limit: limit as u64,
1190 },
1191 });
1192 }
1193 let published_snapshot = outcome
1198 .snapshot
1199 .expect("a non-oversize publication exposes its accepted snapshot");
1200 Ok(ReadPublication::Tagged {
1201 snapshot: published_snapshot,
1202 rendering: candidate_rendering,
1203 })
1204}
1205
1206pub fn capture_bash_rewrite_read(
1210 store: &mut SnapshotStore,
1211 canonical_path: impl AsRef<Path>,
1212 requested_path: impl Into<String>,
1213 kind: BashReadKind,
1214 experimental_bash_rewrite: bool,
1215 funnel_accepted: bool,
1216 effective_hashline: bool,
1217) -> io::Result<ReadPublication> {
1218 if !(experimental_bash_rewrite && funnel_accepted && effective_hashline) {
1219 return capture_tagless_read(canonical_path, requested_path, kind.selection());
1220 }
1221 capture_taggable_read(store, canonical_path, requested_path, kind.selection())
1222}
1223
1224pub fn capture_tagless_read(
1227 canonical_path: impl AsRef<Path>,
1228 requested_path: impl Into<String>,
1229 selection: ReadSelection,
1230) -> io::Result<ReadPublication> {
1231 let canonical_path = canonical_path.as_ref();
1232 let requested_path = requested_path.into();
1233 let bytes = fs::read(canonical_path)?;
1234 let snapshot = scan_bytes_with_request(&bytes, selection.scan_request())
1235 .snapshot
1236 .expect("in-memory scans always observe EOF");
1237 let selected = selection.selected_lines(snapshot.total_lines);
1238 let snapshot = snapshot_for_lines(&snapshot, &selected);
1239 Ok(ReadPublication::Tagless {
1240 rendering: render_tagless_snapshot(&snapshot, requested_path),
1241 reason: UntaggableReason::VirtualPath,
1242 })
1243}
1244
1245pub fn publish_edit_response_snapshot(
1249 store: &mut SnapshotStore,
1250 canonical_path: impl AsRef<Path>,
1251 requested_path: impl Into<String>,
1252 final_bytes: &[u8],
1253 affected: &AffectedRegion,
1254) -> EditResponseSnapshot {
1255 let canonical_path = canonical_path.as_ref();
1256 let requested_path = requested_path.into();
1257 if final_bytes.len() as u64 > MAX_FILE_READ_BYTES || is_binary(final_bytes) {
1258 return EditResponseSnapshot::unavailable(
1259 requested_path,
1260 "final bytes are not a readable, taggable text file",
1261 );
1262 }
1263 if std::str::from_utf8(final_bytes).is_err() {
1264 return EditResponseSnapshot::unavailable(
1265 requested_path,
1266 "final bytes are not valid UTF-8",
1267 );
1268 }
1269 let whole = scan_bytes_with_request(final_bytes, ScanRequest::whole_file())
1270 .snapshot
1271 .expect("in-memory scans always observe EOF");
1272 let selected = affected_output_lines(&whole, affected);
1273 let selected_snapshot = snapshot_for_lines(&whole, &selected);
1274 let outcome = store.publish(canonical_path, selected_snapshot);
1275 if outcome.oversize() {
1276 return EditResponseSnapshot::unavailable(
1277 requested_path,
1278 "affected snapshot exceeds the session residency budget",
1279 );
1280 }
1281 let selected_snapshot = outcome
1282 .snapshot
1283 .expect("a non-oversize publication exposes its accepted snapshot");
1284 let rendering = render_tagged_snapshot(&selected_snapshot, requested_path.clone());
1285 EditResponseSnapshot {
1286 snapshot: Some(selected_snapshot),
1287 rendering: Some(rendering),
1288 requested_path,
1289 notice: None,
1290 }
1291}
1292
1293#[derive(Clone, Debug, Eq, PartialEq)]
1296pub struct EditResponseSnapshot {
1297 pub snapshot: Option<Snapshot>,
1298 pub rendering: Option<TaggedRendering>,
1299 pub requested_path: String,
1300 pub notice: Option<String>,
1301}
1302
1303impl EditResponseSnapshot {
1304 pub fn unavailable(requested_path: String, reason: &str) -> Self {
1305 Self {
1306 snapshot: None,
1307 rendering: None,
1308 requested_path,
1309 notice: Some(format!(
1310 "No hashline tag is available for the final file; re-read before chaining ({reason})."
1311 )),
1312 }
1313 }
1314
1315 pub fn tag(&self) -> Option<&str> {
1316 self.rendering
1317 .as_ref()
1318 .map(|rendering| rendering.tag.as_str())
1319 }
1320}
1321
1322pub fn invalidate_removed_source(store: &mut SnapshotStore, source: impl AsRef<Path>) -> bool {
1325 store.invalidate_path(source)
1326}
1327
1328fn snapshot_for_lines(snapshot: &Snapshot, lines: &BTreeSet<usize>) -> Snapshot {
1329 let records: BTreeMap<usize, RawLineRecord> = snapshot
1330 .records
1331 .iter()
1332 .filter_map(|(&line, record)| lines.contains(&line).then_some((line, record.clone())))
1333 .collect();
1334 let retained_lines = snapshot
1335 .retained_lines
1336 .iter()
1337 .filter_map(|(&line, record)| lines.contains(&line).then_some((line, record.clone())))
1338 .collect();
1339 let mut coverage = snapshot.coverage.clone();
1340 coverage.retained_lines = lines.clone();
1341 coverage.seen_lines = lines.clone();
1342 let boundary = BoundaryEvidence {
1343 empty_file: snapshot.boundary.empty_file,
1344 bof_observed: snapshot.boundary.bof_observed,
1345 eof_observed: snapshot.boundary.eof_observed,
1346 first_seen: lines.iter().next().copied(),
1347 last_seen: lines.iter().next_back().copied(),
1348 };
1349 Snapshot {
1350 tag: snapshot.tag.clone(),
1351 normalized_bytes: snapshot.normalized_bytes.clone(),
1352 records,
1353 retained_lines,
1354 coverage,
1355 boundary,
1356 total_lines: snapshot.total_lines,
1357 byte_count: snapshot.byte_count,
1358 provenance: snapshot.provenance.clone(),
1359 capture_provenance: snapshot.capture_provenance.clone(),
1360 }
1361}
1362
1363fn affected_output_lines(snapshot: &Snapshot, affected: &AffectedRegion) -> BTreeSet<usize> {
1364 let total = snapshot.total_lines;
1365 if total == 0 {
1366 return BTreeSet::new();
1367 }
1368 let ranges = coalesce_ranges(affected.ranges.iter().copied());
1369 let mut selected = BTreeSet::new();
1370 for range in ranges {
1371 let start = range.start.max(1);
1372 let end = range.end.min(total);
1373 if start <= end {
1374 selected.extend(start..=end);
1375 }
1376 if start > 1 {
1377 selected.insert(start - 1);
1378 }
1379 if end < total {
1380 selected.insert(end.saturating_add(1));
1381 }
1382 }
1383 selected.retain(|line| snapshot.records.contains_key(line));
1384 selected
1385}
1386
1387fn coalesce_ranges<I>(ranges: I) -> Vec<LineRange>
1388where
1389 I: IntoIterator<Item = LineRange>,
1390{
1391 let mut ranges: Vec<LineRange> = ranges
1392 .into_iter()
1393 .filter(|range| !range.is_empty())
1394 .collect();
1395 ranges.sort_by_key(|range| (range.start, range.end));
1396 let mut result: Vec<LineRange> = Vec::new();
1397 for range in ranges {
1398 if let Some(last) = result.last_mut() {
1399 if range.start <= last.end.saturating_add(1) {
1400 last.end = last.end.max(range.end);
1401 continue;
1402 }
1403 }
1404 result.push(range);
1405 }
1406 result
1407}
1408
1409fn canonical_key(path: &Path) -> PathBuf {
1410 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
1411}
1412
1413fn fold_tag(tag: &str) -> String {
1414 tag.to_ascii_lowercase()
1415}
1416
1417fn is_binary(bytes: &[u8]) -> bool {
1418 !bytes.is_empty() && content_inspector::inspect(bytes).is_binary()
1419}
1420
1421fn is_write_eligible(metadata: &fs::Metadata) -> bool {
1422 if metadata.permissions().readonly() {
1423 return false;
1424 }
1425 #[cfg(unix)]
1426 {
1427 use std::os::unix::fs::PermissionsExt;
1428 return metadata.permissions().mode() & 0o222 != 0;
1429 }
1430 #[cfg(not(unix))]
1431 {
1432 true
1433 }
1434}
1435
1436impl From<CaptureError> for UntaggableReason {
1437 fn from(error: CaptureError) -> Self {
1438 Self::Io(error.to_string())
1439 }
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444 use super::*;
1445 use std::fs;
1446 use std::path::Path;
1447
1448 fn snapshot(bytes: &[u8], lines: impl IntoIterator<Item = usize>) -> Snapshot {
1449 scan_bytes_with_request(bytes, ScanRequest::new(CoverageInput::lines(lines)))
1450 .snapshot
1451 .expect("in-memory snapshot")
1452 }
1453
1454 fn snapshot_with_forced_tag(bytes: &[u8], tag: &str) -> Snapshot {
1455 let mut snapshot = snapshot(bytes, [1]);
1456 snapshot.tag = tag.to_string();
1457 snapshot
1458 }
1459
1460 fn writable_fixture(root: &Path, name: &str, bytes: &[u8]) -> PathBuf {
1461 let path = root.join(name);
1462 fs::write(&path, bytes).expect("fixture write");
1463 path
1464 }
1465
1466 #[test]
1467 fn equivalent_re_reads_collapse_even_with_different_provenance() {
1468 let mut left = snapshot(b"one\ntwo\n", [1, 2]);
1469 let mut right = left.clone();
1470 right.provenance = right.provenance.with_label("capture", "second");
1471 right.capture_provenance = right
1472 .capture_provenance
1473 .with_label("descriptor", "different");
1474 assert!(equivalent_snapshots(&left, &right));
1475 left.records.get_mut(&1).unwrap().content = b"changed".to_vec();
1476 assert!(!equivalent_snapshots(&left, &right));
1477 }
1478
1479 #[test]
1480 fn genuine_folded_tag_collision_stays_distinct_and_preserves_evicted_history() {
1481 const COLLIDING_TAG: &str = "C0DE";
1482 let mut store = SnapshotStore::new();
1483 let resident_path = PathBuf::from("/tmp/genuine-collision-resident.txt");
1484
1485 store.publish(
1486 &resident_path,
1487 snapshot_with_forced_tag(b"first collision content\n", COLLIDING_TAG),
1488 );
1489 store.publish(
1490 &resident_path,
1491 snapshot_with_forced_tag(b"second collision content\n", &COLLIDING_TAG.to_lowercase()),
1492 );
1493
1494 assert_eq!(
1495 store.snapshot_count(),
1496 2,
1497 "colliding content must not coalesce"
1498 );
1499 assert_eq!(
1500 store.lookup(&resident_path, COLLIDING_TAG),
1501 Err(SnapshotLookupError::AmbiguousTag)
1502 );
1503 assert!(SnapshotLookupError::AmbiguousTag
1504 .steering()
1505 .contains("apply_patch"));
1506
1507 let evicted_path = PathBuf::from("/tmp/genuine-collision-evicted.txt");
1508 store.publish(
1509 &evicted_path,
1510 snapshot_with_forced_tag(b"evicted collision content\n", COLLIDING_TAG),
1511 );
1512 for index in 0..MAX_VERSIONS_PER_PATH {
1513 store.publish(
1514 &evicted_path,
1515 snapshot_with_forced_tag(
1516 format!("filler content {index}\n").as_bytes(),
1517 &format!("F{index:03X}"),
1518 ),
1519 );
1520 }
1521 assert_eq!(
1522 store.lookup(&evicted_path, COLLIDING_TAG),
1523 Err(SnapshotLookupError::EvictedTag),
1524 "the first colliding content must have entered eviction history"
1525 );
1526
1527 store.publish(
1528 &evicted_path,
1529 snapshot_with_forced_tag(b"replacement collision content\n", COLLIDING_TAG),
1530 );
1531
1532 assert!(store.eviction_history_contains(&evicted_path, COLLIDING_TAG));
1533 assert_eq!(
1534 store.lookup(&evicted_path, COLLIDING_TAG),
1535 Err(SnapshotLookupError::EvictedTag),
1536 "publishing different colliding content must not erase the prior eviction"
1537 );
1538 }
1539
1540 #[test]
1541 fn coalescing_uses_normalized_content_not_retained_window_equality() {
1542 let mut store = SnapshotStore::new();
1543 let path = PathBuf::from("/tmp/normalized-content.txt");
1544 let first = snapshot(b"one \ntwo\n", [1]);
1545 let second = snapshot(b"one\t\ntwo\n", [2]);
1546 let tag = first.tag.clone();
1547
1548 store.publish(&path, first);
1549 store.publish(&path, second);
1550
1551 assert_eq!(store.snapshot_count(), 1);
1552 let resolved = store
1553 .lookup(&path, &tag)
1554 .expect("normalized content matches");
1555 assert_eq!(resolved.coverage.seen_lines, BTreeSet::from([1, 2]));
1556 }
1557
1558 #[test]
1559 fn tagged_rendering_keeps_absolute_numbers_and_display_truncation_seen() {
1560 let long = "x".repeat(MAX_RENDER_LINE_LENGTH + 20);
1561 let snapshot = snapshot(format!("short\n{long}\nlast\n").as_bytes(), [1, 2, 3]);
1562 let rendered = render_tagged_snapshot(&snapshot, "agent/path.txt");
1563 assert!(rendered.text.starts_with("[agent/path.txt#"));
1564 assert!(rendered.text.contains("1:short\n"));
1565 assert!(rendered.text.contains("2:"));
1566 assert!(rendered.text.contains("... (truncated)"));
1567 assert_eq!(rendered.rendered_lines, BTreeSet::from([1, 2, 3]));
1568 assert!(rendered.display_truncated_lines.contains(&2));
1569 }
1570
1571 #[test]
1572 fn output_elision_removes_unrendered_rows_from_published_snapshot() {
1573 let temp = tempfile::tempdir().unwrap();
1574 let bytes = (1..=20)
1575 .map(|line| format!("{line}:{}\n", "x".repeat(20)))
1576 .collect::<String>();
1577 let path = writable_fixture(temp.path(), "large.txt", bytes.as_bytes());
1578 let mut store = SnapshotStore::new();
1579 let publication = capture_taggable_read_with_options(
1580 &mut store,
1581 &path,
1582 "large.txt",
1583 ReadSelection::WholeFile,
1584 RenderOptions {
1585 max_output_bytes: 80,
1586 max_line_length: MAX_RENDER_LINE_LENGTH,
1587 },
1588 )
1589 .unwrap();
1590 let ReadPublication::Tagged {
1591 snapshot,
1592 rendering,
1593 } = publication
1594 else {
1595 panic!("expected tagged publication");
1596 };
1597 assert!(rendering.elided_range.is_some());
1598 assert_eq!(snapshot.coverage.seen_lines, rendering.rendered_lines);
1599 assert!(!snapshot.coverage.is_seen(20));
1600 assert!(snapshot.coverage.is_seen(1));
1601 assert!(store.contains(&path, &snapshot.tag));
1602 }
1603
1604 #[test]
1605 fn ranged_and_bash_tail_publications_have_only_seen_rows() {
1606 let temp = tempfile::tempdir().unwrap();
1607 let path = writable_fixture(temp.path(), "tail.txt", b"one\ntwo\nthree\nfour\n");
1608 let mut store = SnapshotStore::new();
1609 let publication =
1610 capture_taggable_read(&mut store, &path, "tail.txt", ReadSelection::range(2, 3))
1611 .unwrap();
1612 let ReadPublication::Tagged { snapshot, .. } = publication else {
1613 panic!("expected tagged range");
1614 };
1615 assert_eq!(snapshot.coverage.seen_lines, BTreeSet::from([2, 3]));
1616 let publication = capture_bash_rewrite_read(
1617 &mut store,
1618 &path,
1619 "tail.txt",
1620 BashReadKind::Tail { lines: 2 },
1621 true,
1622 true,
1623 true,
1624 )
1625 .unwrap();
1626 let ReadPublication::Tagged { snapshot, .. } = publication else {
1627 panic!("expected tagged tail");
1628 };
1629 assert_eq!(snapshot.coverage.seen_lines, BTreeSet::from([3, 4]));
1630 assert!(snapshot.eof_observed());
1631 }
1632
1633 #[test]
1634 fn empty_or_beyond_eof_ranges_do_not_mint_empty_file_tags() {
1635 let temp = tempfile::tempdir().unwrap();
1636 let empty = writable_fixture(temp.path(), "empty.txt", b"");
1637 let one_line = writable_fixture(temp.path(), "one-line.txt", b"one\n");
1638 let mut store = SnapshotStore::new();
1639 let empty_result =
1640 capture_taggable_read(&mut store, &empty, "empty.txt", ReadSelection::range(1, 1))
1641 .unwrap();
1642 assert!(matches!(empty_result, ReadPublication::Tagless { .. }));
1643 let beyond_result = capture_taggable_read(
1644 &mut store,
1645 &one_line,
1646 "one-line.txt",
1647 ReadSelection::range(2, 2),
1648 )
1649 .unwrap();
1650 assert!(matches!(beyond_result, ReadPublication::Tagless { .. }));
1651 assert_eq!(store.snapshot_count(), 0);
1652 }
1653
1654 #[test]
1655 fn declined_bash_rewrite_is_store_neutral() {
1656 let temp = tempfile::tempdir().unwrap();
1657 let path = writable_fixture(temp.path(), "cat.txt", b"one\ntwo\n");
1658 let mut store = SnapshotStore::new();
1659 let before = store.clone();
1660 let publication = capture_bash_rewrite_read(
1661 &mut store,
1662 &path,
1663 "cat.txt",
1664 BashReadKind::Cat,
1665 false,
1666 true,
1667 true,
1668 )
1669 .unwrap();
1670 assert!(matches!(publication, ReadPublication::Tagless { .. }));
1671 assert_eq!(store.snapshot_count(), before.snapshot_count());
1672 assert_eq!(store.eviction_history_len(), before.eviction_history_len());
1673 }
1674
1675 #[test]
1676 fn edit_response_renders_changed_rows_and_surviving_neighbors() {
1677 let mut store = SnapshotStore::new();
1678 let result = publish_edit_response_snapshot(
1679 &mut store,
1680 "/virtual/edit.txt",
1681 "edit.txt",
1682 b"a\ninserted\nc\nd\n",
1683 &AffectedRegion::from_range(2, 2),
1684 );
1685 let rendering = result.rendering.as_ref().unwrap();
1686 assert!(rendering.text.contains("1:a\n"));
1687 assert!(rendering.text.contains("2:inserted\n"));
1688 assert!(rendering.text.contains("3:c\n"));
1689 assert!(!rendering.text.contains("4:d\n"));
1690 assert!(result.tag().is_some());
1691 }
1692
1693 #[test]
1694 fn edit_response_empty_file_has_boundary_evidence_without_rows() {
1695 let mut store = SnapshotStore::new();
1696 let result = publish_edit_response_snapshot(
1697 &mut store,
1698 "/virtual/empty.txt",
1699 "empty.txt",
1700 b"",
1701 &AffectedRegion::deletion(1, 4),
1702 );
1703 let snapshot = result.snapshot.unwrap();
1704 assert!(snapshot.records.is_empty());
1705 assert!(snapshot.boundary.empty_file);
1706 assert!(result.rendering.unwrap().text.starts_with("[empty.txt#"));
1707 }
1708
1709 #[test]
1710 fn path_and_version_limits_evict_deterministically() {
1711 let mut store = SnapshotStore::new();
1712 for path_number in 0..=MAX_SNAPSHOT_PATHS {
1713 let path = PathBuf::from(format!("/tmp/hashline-{path_number}.txt"));
1714 let result = store.publish(&path, snapshot(format!("{path_number}\n").as_bytes(), [1]));
1715 assert!(result.stored());
1716 }
1717 assert_eq!(store.path_count(), MAX_SNAPSHOT_PATHS);
1718 assert!(matches!(
1719 store.lookup("/tmp/hashline-0.txt", "0000"),
1720 Err(SnapshotLookupError::UnknownTag | SnapshotLookupError::EvictedTag)
1721 ));
1722
1723 let path = PathBuf::from("/tmp/versions.txt");
1724 let mut tags = Vec::new();
1725 for value in 0..=MAX_VERSIONS_PER_PATH {
1726 let current = snapshot(format!("version-{value}\n").as_bytes(), [1]);
1727 tags.push(current.tag.clone());
1728 store.publish(&path, current);
1729 }
1730 assert_eq!(
1731 store.lookup(&path, &tags[0]),
1732 Err(SnapshotLookupError::EvictedTag)
1733 );
1734 assert!(store.lookup(&path, &tags[1]).is_ok());
1735 }
1736
1737 #[test]
1738 fn same_content_publications_coalesce_before_version_eviction() {
1739 let mut store = SnapshotStore::new();
1740 let path = PathBuf::from("/tmp/coalesced-version.txt");
1741 let bytes = b"one\ntwo\nthree\nfour\nfive\nsix\n";
1742 let mut tag = None;
1743
1744 for line in 1..=(MAX_VERSIONS_PER_PATH + 2) {
1745 let current = snapshot(bytes, [line]);
1746 tag.get_or_insert(current.tag.clone());
1747 let outcome = store.publish(&path, current);
1748 assert!(outcome.stored());
1749 assert!(outcome.evicted.is_empty());
1750 }
1751
1752 assert_eq!(store.snapshot_count(), 1);
1753 assert_eq!(store.eviction_history_len(), 0);
1754 let resolved = store
1755 .lookup(&path, tag.as_deref().unwrap())
1756 .expect("coalesced version remains resident");
1757 assert_eq!(
1758 resolved.coverage.seen_lines,
1759 BTreeSet::from([1, 2, 3, 4, 5, 6])
1760 );
1761 }
1762
1763 #[test]
1764 fn overflowing_eviction_history_transitions_evicted_to_unknown() {
1765 let mut store = SnapshotStore::new();
1766 let mut handles = Vec::new();
1767 for path_number in 0..(MAX_EVICTION_RECORDS + MAX_SNAPSHOT_PATHS + 4) {
1770 let path = PathBuf::from(format!("/tmp/history-{path_number}.txt"));
1771 let current = snapshot(format!("history-{path_number}\n").as_bytes(), [1]);
1772 handles.push((path.clone(), current.tag.clone()));
1773 store.publish(path, current);
1774 }
1775 let first = &handles[0];
1776 assert_eq!(store.eviction_history_len(), MAX_EVICTION_RECORDS);
1777 assert!(matches!(
1778 store.lookup(&first.0, &first.1),
1779 Err(SnapshotLookupError::UnknownTag)
1780 ));
1781 let retained = &handles[handles.len() - MAX_SNAPSHOT_PATHS - 1];
1782 assert!(matches!(
1783 store.lookup(&retained.0, &retained.1),
1784 Err(SnapshotLookupError::EvictedTag)
1785 ));
1786 assert_eq!(
1787 SnapshotLookupError::EvictedTag.steering(),
1788 SnapshotLookupError::UnknownTag.steering()
1789 );
1790 }
1791
1792 #[test]
1793 fn oversize_publish_does_not_perturb_store_or_history() {
1794 let mut store = SnapshotStore::new();
1795 let path = PathBuf::from("/tmp/resident.txt");
1796 let resident = snapshot(b"resident\n", [1]);
1797 store.publish(&path, resident.clone());
1798 let before = store.clone();
1799 let mut oversize = resident;
1800 oversize.byte_count = MAX_FILE_READ_BYTES + 1;
1801 let outcome = store.publish("/tmp/oversize.txt", oversize);
1802 assert!(outcome.oversize());
1803 assert_eq!(store.snapshot_count(), before.snapshot_count());
1804 assert_eq!(store.path_count(), before.path_count());
1805 assert_eq!(store.total_bytes(), before.total_bytes());
1806 assert_eq!(store.eviction_history_len(), before.eviction_history_len());
1807 }
1808
1809 #[test]
1810 fn case_insensitive_lookup_and_invalidation_are_path_scoped() {
1811 let mut store = SnapshotStore::new();
1812 let path = PathBuf::from("/tmp/scoped.txt");
1813 let current = snapshot(b"scoped\n", [1]);
1814 let tag = current.tag.clone();
1815 store.publish(&path, current);
1816 assert!(store.lookup(&path, &tag.to_ascii_lowercase()).is_ok());
1817 assert!(store.invalidate_path(&path));
1818 assert_eq!(
1819 store.lookup(&path, &tag),
1820 Err(SnapshotLookupError::UnknownTag)
1821 );
1822 assert_eq!(store.eviction_history_len(), 0);
1823 }
1824}