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    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/// 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 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/// 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            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    /// Alias emphasizing that publication is the only way to make a snapshot
593    /// visible to later edits.
594    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    /// Remove a path because its lifecycle ended (for example, an MV source).
714    /// Lifecycle invalidation is intentionally not an eviction and therefore
715    /// must not create an eviction-history record.
716    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
915/// Compares two fully resolved snapshot views. Exact normalized-content
916/// coalescing happens during publication, so this only compares retained evidence.
917pub 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    /// Count retained raw-record bytes. The store adds the exact normalized
930    /// content identity when enforcing the total residency budget.
931    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
944/// Render a snapshot's retained records using the gate-on text carrier.
945pub 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/// Rendering limits are configurable for deterministic unit tests while the
953/// default remains the shipped read contract.
954#[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(&notice);
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
1020/// Render retained records without the hashline carrier. This is used for the
1021/// legacy/gate-off branch and for declined or ineligible bash rewrites.
1022pub 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
1070/// Read a regular, writable UTF-8 file and publish the rows that the agent can
1071/// actually address. A line omitted by the 50 KiB output cap is removed from
1072/// the published snapshot, while display-truncated lines remain eligible.
1073pub 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    // Keep the elision notice from the pre-publication render.  The published
1194    // snapshot intentionally contains only rendered rows, so rendering it a
1195    // second time would lose the information that the tail of the requested
1196    // domain was scanned but left unseen.
1197    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
1206/// Apply the same capture rules to an accepted cat/head/tail rewrite. The
1207/// funnel and experimental gate are checked before this function is allowed to
1208/// publish, so declined rewrites remain store-neutral.
1209pub 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
1224/// Capture a bash read without publication. This path intentionally does not
1225/// touch the store, even when the command shape is valid but the gate is off.
1226pub 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
1245/// Publish an affected-region snapshot from authoritative final bytes. This is
1246/// deliberately separate from a read capture: the edit response owns which
1247/// current rows are relevant, not the caller's original read range.
1248pub 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/// A fresh post-write carrier, or an explicit notice when the final state is
1294/// not safe to chain from.
1295#[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
1322/// A removed MV source has no final state to mint. This invalidates the source
1323/// without turning the handle into an evicted handle.
1324pub 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        // Each path is filled to its version bound, then the path is displaced.
1768        // This produces more than MAX_EVICTION_RECORDS distinct history keys.
1769        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}