Skip to main content

aft/hashline/snapshot/
mod.rs

1//! Session-owned hashline snapshot publication, rendering, and residency.
2//!
3//! The scanner owns the byte model and produces a coherent whole-file tag.  This
4//! module owns the part that is deliberately session-local: deciding which
5//! displayed rows are seen, carrying the tag in text, and keeping only a bounded
6//! history of snapshots.  No state in this module is persisted to disk.
7
8use 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
21/// The maximum complete file that may be read for a hashline snapshot.
22///
23/// A tag hashes the whole file, even when only a range is rendered.  Refusing
24/// larger files keeps that invariant explicit instead of silently minting a tag
25/// from a partial byte stream.
26pub const MAX_FILE_READ_BYTES: u64 = 64 * 1024 * 1024;
27/// The existing read response's line-numbered body budget.
28pub const MAX_RENDER_BYTES: usize = 50 * 1024;
29/// The existing read response's display-only line length limit.
30pub const MAX_RENDER_LINE_LENGTH: usize = 2_000;
31/// Maximum number of canonical paths resident in one session.
32pub const MAX_SNAPSHOT_PATHS: usize = 30;
33/// Maximum number of versions retained for one canonical path.
34pub const MAX_VERSIONS_PER_PATH: usize = 4;
35/// Maximum snapshot residency across one session, including exact normalized
36/// content identities and retained raw records.
37pub const MAX_SNAPSHOT_TOTAL_BYTES: usize = 64 * 1024 * 1024;
38/// Maximum bounded history of handles removed by residency eviction.
39pub const MAX_EVICTION_RECORDS: usize = 256;
40
41/// A range of absolute, one-based output rows.
42#[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/// A mutation's output rows that should be carried by an edit response.
63///
64/// Ranges are normalized and coalesced when they are used.  The renderer adds
65/// the nearest surviving predecessor and successor to each range so a chained
66/// edit has a small amount of stable context without publishing the whole file.
67#[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/// A read selection before the scanner knows the final line count.
103#[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/// The three read shapes accepted by the bash rewrite funnel.
184#[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/// A tag carried in the agent-visible text of a successful read or edit.
202#[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/// A tagless rendering used when the read is not eligible to publish a handle.
220#[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/// Reasons that deliberately do not mint a snapshot.
229#[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/// The result of a taggable or tagless read publication attempt.
264#[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/// A deterministic handle removed from snapshot residency.
300#[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/// The outcome of publishing one snapshot into a bounded store.
344#[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    /// The evidence exposed by this publication. The resident store may hold a
354    /// merged superset after coalescing another read of the same content.
355    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/// Lookup failures map directly to the registered hashline rejection codes.
370#[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/// A result which exposes ambiguity separately from evicted and unknown tags.
407#[derive(Clone, Debug, Eq, PartialEq)]
408pub enum SnapshotLookup {
409    Found(Snapshot),
410    Unknown,
411    Evicted,
412    Ambiguous,
413}
414
415/// Bounded, session-owned snapshot and eviction-history storage.
416#[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    /// Publish a snapshot. Oversize publication is a no-op: it cannot evict a
461    /// resident entry, create history, or perturb recency.
462    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        // The four-hex handle is deliberately lossy. Exact normalized bytes are
468        // retained separately so only byte-identical content coalesces; a real
469        // tag collision remains represented by distinct resident versions.
470        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            // A same-content re-read is a fresh retention claim. Refresh both
524            // clocks so the per-path version cap cannot evict the newly exposed
525            // tag using the age of the partial snapshot it coalesced into.
526            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    /// Alias emphasizing that publication is the only way to make a snapshot
597    /// visible to later edits.
598    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    /// Remove a path because its lifecycle ended (for example, an MV source).
718    /// Lifecycle invalidation is intentionally not an eviction and therefore
719    /// must not create an eviction-history record.
720    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
919/// Compares two fully resolved snapshot views. Exact normalized-content
920/// coalescing happens during publication, so this only compares retained evidence.
921pub 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    /// Count retained raw-record bytes. The store adds the exact normalized
934    /// content identity when enforcing the total residency budget.
935    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
948/// Render a snapshot's retained records using the gate-on text carrier.
949pub 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/// Rendering limits are configurable for deterministic unit tests while the
957/// default remains the shipped read contract.
958#[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(&notice);
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
1024/// Render retained records without the hashline carrier. This is used for the
1025/// legacy/gate-off branch and for declined or ineligible bash rewrites.
1026pub 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
1074/// Read a regular, writable UTF-8 file and publish the rows that the agent can
1075/// actually address. A line omitted by the 50 KiB output cap is removed from
1076/// the published snapshot, while display-truncated lines remain eligible.
1077pub 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    // Keep the elision notice from the pre-publication render.  The published
1198    // snapshot intentionally contains only rendered rows, so rendering it a
1199    // second time would lose the information that the tail of the requested
1200    // domain was scanned but left unseen.
1201    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
1210/// Apply the same capture rules to an accepted cat/head/tail rewrite. The
1211/// funnel and experimental gate are checked before this function is allowed to
1212/// publish, so declined rewrites remain store-neutral.
1213pub 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
1228/// Capture a bash read without publication. This path intentionally does not
1229/// touch the store, even when the command shape is valid but the gate is off.
1230pub 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
1249/// Publish an affected-region snapshot from authoritative final bytes. This is
1250/// deliberately separate from a read capture: the edit response owns which
1251/// current rows are relevant, not the caller's original read range.
1252pub 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/// A fresh post-write carrier, or an explicit notice when the final state is
1298/// not safe to chain from.
1299#[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
1326/// A removed MV source has no final state to mint. This invalidates the source
1327/// without turning the handle into an evicted handle.
1328pub 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        // Each path is filled to its version bound, then the path is displaced.
1808        // This produces more than MAX_EVICTION_RECORDS distinct history keys.
1809        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}