Skip to main content

fdu_core/
index.rs

1//! The in-memory hierarchical index.
2//!
3//! The index is a parent-pointer tree in a flat arena. Entries store their **name only**
4//! and paths are reconstructed by walking parents, so a path like
5//! `srv/data/project/src/lib/utils.rs` costs six name strings across six entries with no
6//! duplication — the fsearch/ncdu layout, deliberately not dut's full-path-per-entry.
7//!
8//! Every directory carries pre-computed roll-up state for its whole subtree, so a query
9//! reads a field and never traverses. Applying an [`Observation`] re-merges that state up the
10//! ancestor chain only. Producers submit observations; only effective, arbitrated fact
11//! or state changes become exact clocked commits.
12//!
13//! Reducers split into two classes and the split is visible in the code, because it
14//! decides the cost of an update:
15//!
16//! - **Invertible** (counts, byte sums, per-extension tallies) apply differentially in
17//!   O(depth): add the new contribution, subtract the old one.
18//! - **Non-invertible** ([`RollUp::newest_mtime_ns`]) absorb *additions* in O(depth) by
19//!   taking a max, but a *removal* may need the directory's value rebuilt from its direct
20//!   children — standard incremental-view-maintenance behaviour. Metabrowser's
21//!   per-parent newest-mtime heaps are exactly this workaround, hand-written for one
22//!   metric.
23//!
24//! # Concurrency
25//!
26//! This type is a single-writer structure. The intended deployment is one writer
27//! applying commits behind a `RwLock` with readers taking the read side: writes are short
28//! (O(depth) applies) and reads are field lookups rather than queries that walk. The
29//! delta contract being the only mutation path means escalating later to epoch or
30//! arc-swap snapshots stays contained rather than becoming a rewrite.
31
32use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
33use std::ffi::{OsStr, OsString};
34use std::path::{Component, Path, PathBuf};
35use std::sync::{Arc, RwLock};
36
37use crate::content::{
38    AnalysisApplyOutcome, AnalysisCandidate, AnalysisObservation, AnalysisSet, ContentIndex,
39    ContentRollUp, RestoreCandidate,
40};
41use crate::engine_contract::{
42    Attrs, Clock, Commit, Coverage, CoverageReason, DiscoveryProgress, EffectiveChange,
43    EntryIdentity, EntryKind, Expectation, Freshness, Impact, ImpactDomain, IndexState,
44    InvalidateReason, Issue, LifecyclePhase, MAX_DIRTY_PATHS, MAX_RETAINED_ISSUES, Observation,
45    ObservationOp, Op, PathExpectation, PathState, Provenance, ScanScope, Source, StateTransition,
46    Status, Work,
47};
48
49/// Verification intervals kept before the oldest are dropped.
50///
51/// Bounds the memory a long-lived session can accumulate through repeated scoped
52/// reconciliation. Dropping an interval only ever moves a path back to reporting
53/// `Cached`, so the bound costs precision, never correctness.
54const MAX_VERIFIED_INTERVALS: usize = 256;
55
56fn same_issue_cause(left: &Issue, right: &Issue) -> bool {
57    left.kind == right.kind && left.path == right.path && (right.path.is_some() || left == right)
58}
59
60fn compare_issues(left: &Issue, right: &Issue) -> std::cmp::Ordering {
61    left.path
62        .cmp(&right.path)
63        .then_with(|| issue_kind_rank(left.kind).cmp(&issue_kind_rank(right.kind)))
64        .then_with(|| left.message.cmp(&right.message))
65        .then_with(|| left.os_error.cmp(&right.os_error))
66}
67
68const fn issue_kind_rank(kind: crate::IssueKind) -> u8 {
69    match kind {
70        crate::IssueKind::Permission => 0,
71        crate::IssueKind::Disappeared => 1,
72        crate::IssueKind::InvalidMetadata => 2,
73        crate::IssueKind::ResourceBudget => 3,
74        crate::IssueKind::ObservationGap => 4,
75        crate::IssueKind::ProviderFailure => 5,
76    }
77}
78
79#[cfg(test)]
80std::thread_local! {
81    /// Entries the control reclassification walk has visited on this thread.
82    ///
83    /// The walk changes nothing when no bit moves, so a test cannot see it through the
84    /// index. Per thread, because tests run in parallel and a load runs on its caller's.
85    pub(crate) static RECLASSIFY_VISITS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
86
87    /// Control tables copied to project a batch, on this thread.
88    ///
89    /// A projection that changes nothing is indistinguishable from one that was never
90    /// made, through the index; this is how a test sees which one happened.
91    pub(crate) static CONTROL_PROJECTION_CLONES: std::cell::Cell<u64> =
92        const { std::cell::Cell::new(0) };
93
94    /// Children a batched analysis walk has stepped over on this thread, resumed or not.
95    ///
96    /// Every batch walks the same files either way, so only this tells a walk that
97    /// resumes where it stopped from one that steps over the listing again.
98    pub(crate) static ANALYSIS_CHILD_STEPS: std::cell::Cell<u64> =
99        const { std::cell::Cell::new(0) };
100}
101
102/// Approximate bytes the exact commit history used by [`Index::since`] may retain.
103///
104/// Bounded on purpose: an unbounded journal is a memory leak in a long-lived server. A
105/// consumer that falls further behind than this is told so ([`Since::truncated`]) and is
106/// expected to re-read state rather than silently miss changes. The bound is stated in
107/// bytes, as [`Commit::retained_cost`] estimates them, because the question it answers is
108/// how much memory history may hold, and a budget counted in items would let long paths
109/// hold many times as much. An opened root lifts it through `journal_capacity_bytes`;
110/// there is no unbounded setting, since truncation is always announced and a journal that
111/// never truncates would grow for the life of the session.
112pub const DEFAULT_JOURNAL_CAPACITY_BYTES: usize = 8 * 1024 * 1024;
113
114/// Identifier for an entry within an [`Index`] arena.
115#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
116pub struct EntryId {
117    slot: u32,
118    generation: u64,
119}
120
121impl EntryId {
122    /// The root entry. Always present, never removed.
123    pub const ROOT: EntryId = EntryId { slot: 0, generation: 0 };
124
125    #[inline]
126    const fn idx(self) -> usize {
127        self.slot as usize
128    }
129}
130
131/// Index-private extension identity.
132type ExtId = u32;
133
134/// Per-extension tally within a roll-up.
135#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
136pub struct ExtTally {
137    /// Files with this extension.
138    pub files: u64,
139    /// Apparent bytes across those files.
140    pub bytes: u64,
141    /// Allocated bytes across those files.
142    ///
143    /// Carried alongside `bytes` so a per-type report can answer in either metric. A
144    /// tally that tracked only apparent size would force a report asked for allocated
145    /// bytes to either switch metrics silently or drop the breakdown.
146    pub allocated: u64,
147}
148
149/// Pre-computed aggregate state for one directory's entire subtree.
150///
151/// # What is counted
152///
153/// `bytes` and `allocated` sum **files only**. Directories contribute their own subtree
154/// plus one to `dirs`, but their own inode block usage is not added — unlike `du`, which
155/// counts directory blocks. The difference is small and constant per directory, and
156/// making it configurable is deferred rather than guessed at.
157///
158/// `newest_mtime_ns` is the newest modification time among descendant **files**.
159/// Directory mtimes are excluded because they change on every child add or remove, which
160/// makes "what changed recently" answer with directories instead of the edits a user
161/// actually made.
162#[derive(Clone, PartialEq, Eq, Debug, Default)]
163pub struct RollUp {
164    /// Descendant files.
165    pub files: u64,
166    /// Descendant directories, not counting the directory that owns this roll-up.
167    pub dirs: u64,
168    /// Apparent bytes across descendant files.
169    pub bytes: u64,
170    /// Allocated bytes across descendant files.
171    pub allocated: u64,
172    /// Newest mtime among descendant files, or 0 when there are none.
173    pub newest_mtime_ns: i64,
174    /// Per-extension file and byte tallies across the subtree.
175    pub by_ext: BTreeMap<String, ExtTally>,
176}
177
178/// The two fixed aggregate partitions maintained for inventory reads.
179#[derive(Clone, PartialEq, Eq, Debug, Default)]
180pub struct PartitionRollUp {
181    /// Every retained descendant.
182    pub all: RollUp,
183    /// Retained descendants outside the effective ignored partition.
184    pub unignored: RollUp,
185}
186
187/// Constant-size directory totals suitable for bounded interactive rows.
188#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
189pub struct RollUpSummary {
190    /// Descendant regular files.
191    pub files: u64,
192    /// Descendant directories, excluding the directory that owns this summary.
193    pub dirs: u64,
194    /// Apparent bytes across descendant regular files.
195    pub bytes: u64,
196    /// Allocated bytes across descendant regular files.
197    pub allocated: u64,
198    /// Newest descendant-file modification time, or `None` for an empty subtree.
199    pub newest_mtime_ns: Option<i64>,
200}
201
202/// Constant-size totals for the fixed all and unignored partitions.
203#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
204pub struct PartitionRollUpSummary {
205    /// Every retained descendant.
206    pub all: RollUpSummary,
207    /// Retained descendants outside the effective ignored partition.
208    pub unignored: RollUpSummary,
209}
210
211/// Hot-path aggregate state owned by one index.
212///
213/// Integer extension keys make ancestor merges cheap, but they are meaningful only
214/// while held by the index that issued them. Public query methods convert this into a
215/// self-describing [`RollUp`] so a retained result cannot be relabelled when an interner
216/// slot is reused.
217#[derive(Clone, PartialEq, Eq, Debug, Default)]
218struct InternedRollUp {
219    files: u64,
220    dirs: u64,
221    bytes: u64,
222    allocated: u64,
223    newest_mtime_ns: i64,
224    by_ext: BTreeMap<ExtId, ExtTally>,
225}
226
227/// Hot-path form of the fixed `all` and `unignored` partitions.
228///
229/// Dereferencing yields `all`, keeping existing unrestricted query code direct while
230/// mutation helpers update both partitions explicitly.
231#[derive(Clone, PartialEq, Eq, Debug, Default)]
232struct InternedPartitionRollUp {
233    all: InternedRollUp,
234    unignored: InternedRollUp,
235}
236
237impl std::ops::Deref for InternedPartitionRollUp {
238    type Target = InternedRollUp;
239
240    fn deref(&self) -> &Self::Target {
241        &self.all
242    }
243}
244
245impl std::ops::DerefMut for InternedPartitionRollUp {
246    fn deref_mut(&mut self) -> &mut Self::Target {
247        &mut self.all
248    }
249}
250
251impl InternedPartitionRollUp {
252    fn merge(&mut self, other: &Self) {
253        self.all.merge(&other.all);
254        self.unignored.merge(&other.unignored);
255    }
256
257    fn unmerge(&mut self, other: &Self) {
258        self.all.unmerge(&other.all);
259        self.unignored.unmerge(&other.unignored);
260    }
261}
262
263fn rollup_summary(rollup: &InternedRollUp) -> RollUpSummary {
264    RollUpSummary {
265        files: rollup.files,
266        dirs: rollup.dirs,
267        bytes: rollup.bytes,
268        allocated: rollup.allocated,
269        newest_mtime_ns: (rollup.files > 0).then_some(rollup.newest_mtime_ns),
270    }
271}
272
273fn partition_summary(rollup: &InternedPartitionRollUp) -> PartitionRollUpSummary {
274    PartitionRollUpSummary {
275        all: rollup_summary(&rollup.all),
276        unignored: rollup_summary(&rollup.unignored),
277    }
278}
279
280/// Map-free roll-up fields for internal reports that do not need extension names.
281///
282/// Keeping this view separate avoids cloning every extension string for summary and
283/// tree queries while the public [`RollUp`] remains safe to retain independently.
284#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
285pub(crate) struct RollUpScalars {
286    pub(crate) files: u64,
287    pub(crate) dirs: u64,
288    pub(crate) bytes: u64,
289    pub(crate) allocated: u64,
290    pub(crate) newest_mtime_ns: i64,
291}
292
293impl From<&InternedRollUp> for RollUpScalars {
294    fn from(rollup: &InternedRollUp) -> Self {
295        Self {
296            files: rollup.files,
297            dirs: rollup.dirs,
298            bytes: rollup.bytes,
299            allocated: rollup.allocated,
300            newest_mtime_ns: rollup.newest_mtime_ns,
301        }
302    }
303}
304
305impl RollUpScalars {
306    /// What one entry adds to each of its ancestors' roll-ups on its own: a file's count
307    /// and bytes, a directory's count, nothing for a symlink or other object.
308    fn leaf(kind: EntryKind, attrs: &Attrs) -> Self {
309        match kind {
310            EntryKind::File => {
311                Self { files: 1, bytes: attrs.size, allocated: attrs.allocated, ..Self::default() }
312            }
313            EntryKind::Dir => Self { dirs: 1, ..Self::default() },
314            EntryKind::Symlink | EntryKind::Other => Self::default(),
315        }
316    }
317
318    /// The sum, or the name of the first counter a `u64` cannot hold it in.
319    fn checked_add(self, other: &Self) -> Result<Self, &'static str> {
320        Ok(Self {
321            files: self.files.checked_add(other.files).ok_or("files")?,
322            dirs: self.dirs.checked_add(other.dirs).ok_or("directories")?,
323            bytes: self.bytes.checked_add(other.bytes).ok_or("bytes")?,
324            allocated: self.allocated.checked_add(other.allocated).ok_or("allocated bytes")?,
325            newest_mtime_ns: self.newest_mtime_ns,
326        })
327    }
328
329    /// The difference, saturating at zero as [`InternedRollUp::unmerge`] does.
330    fn saturating_sub(self, other: &Self) -> Self {
331        Self {
332            files: self.files.saturating_sub(other.files),
333            dirs: self.dirs.saturating_sub(other.dirs),
334            bytes: self.bytes.saturating_sub(other.bytes),
335            allocated: self.allocated.saturating_sub(other.allocated),
336            newest_mtime_ns: self.newest_mtime_ns,
337        }
338    }
339}
340
341/// Add one file's sizes to a whole-tree running total, or refuse the file whose sizes no
342/// `u64` can hold the sum of ([`crate::Error::UnrepresentableTotal`]), leaving the total
343/// as it was.
344///
345/// The routes that count a tree without committing observations keep this total where
346/// the apply lane runs [`Index::preflight_totals`]: the summary fold and the detached
347/// builder, full or folded. One total at the root is enough, for the reason the preflight
348/// gives: every directory's roll-up, extension tally, `unignored` partition, and folded
349/// tally is a sub-sum of the root's, so none of them can overflow once the root's fits,
350/// and the merges that build them add plainly. Only the two byte totals are checked: a
351/// count of files or directories cannot reach `u64::MAX`, since each one is an entry a
352/// walk visited. The byte totals can: a sparse file on tmpfs claims up to 8 EiB apparent
353/// and allocates nothing, so three of them carry the sum past `u64::MAX`, and they are
354/// refused alike on every route.
355#[inline]
356pub(crate) fn add_file_sizes(
357    bytes: &mut u64,
358    allocated: &mut u64,
359    attrs: &Attrs,
360    path: impl FnOnce() -> PathBuf,
361) -> crate::Result<()> {
362    match (bytes.checked_add(attrs.size), allocated.checked_add(attrs.allocated)) {
363        (Some(next_bytes), Some(next_allocated)) => {
364            *bytes = next_bytes;
365            *allocated = next_allocated;
366            Ok(())
367        }
368        (None, _) => Err(unrepresentable_file(path(), "bytes")),
369        (Some(_), None) => Err(unrepresentable_file(path(), "allocated bytes")),
370    }
371}
372
373#[cold]
374#[inline(never)]
375fn unrepresentable_file(path: PathBuf, counter: &'static str) -> crate::Error {
376    crate::Error::UnrepresentableTotal { path, counter }
377}
378
379/// What a batch has done to the tree so far, for [`Index::replay_totals`]: which of the
380/// index's subtrees it has cut away and which entries it has put in their place.
381///
382/// The index is not touched; this is the projection a preflight reads instead. A cut is
383/// an index subtree the batch removed or replaced, recorded with what it contributed to
384/// every ancestor, and no cut lies beneath another. A live entry is one the batch
385/// inserted, recorded with what it contributes on its own; a directory the batch
386/// inserted has children only among the live entries beneath it.
387#[derive(Default)]
388struct TotalsOverlay {
389    cuts: BTreeMap<PathBuf, RollUpScalars>,
390    live: BTreeMap<PathBuf, (EntryKind, RollUpScalars)>,
391}
392
393impl TotalsOverlay {
394    /// The index's entry at `path`, if it still counts: it exists and no cut covers it.
395    fn index_alive(&self, index: &Index, path: &Path) -> Option<EntryId> {
396        let id = index.lookup(path)?;
397        let mut ancestor = Some(path);
398        while let Some(current) = ancestor {
399            if self.cuts.contains_key(current) {
400                return None;
401            }
402            ancestor = current.parent();
403        }
404        Some(id)
405    }
406
407    /// The kind the tree holds at `path` right now, if anything.
408    fn kind_at(&self, index: &Index, path: &Path) -> Option<EntryKind> {
409        if let Some((kind, _)) = self.live.get(path) {
410            return Some(*kind);
411        }
412        self.index_alive(index, path).map(|id| index.entry(id).kind)
413    }
414
415    /// The entries of `map` strictly beneath `path`. Paths order by component, so a
416    /// path's descendants follow it contiguously.
417    fn beneath<'a, V>(
418        map: &'a BTreeMap<PathBuf, V>,
419        path: &'a Path,
420    ) -> impl Iterator<Item = (&'a PathBuf, &'a V)> {
421        map.range::<Path, _>((std::ops::Bound::Excluded(path), std::ops::Bound::Unbounded))
422            .take_while(move |(candidate, _)| candidate.starts_with(path))
423    }
424
425    /// What the tree holds at and beneath `path` right now: the index's subtree there
426    /// less the cuts inside it, plus the batch's entries there. Every part of it is
427    /// already inside the running total, so the sum cannot leave `u64`.
428    fn subtree_at(&self, index: &Index, path: &Path) -> RollUpScalars {
429        let mut total = RollUpScalars::default();
430        if let Some(id) = self.index_alive(index, path) {
431            total = index.subtree_scalars(id);
432            for (_, cut) in Self::beneath(&self.cuts, path) {
433                total = total.saturating_sub(cut);
434            }
435        }
436        let live = self
437            .live
438            .range::<Path, _>((std::ops::Bound::Included(path), std::ops::Bound::Unbounded))
439            .take_while(|(candidate, _)| candidate.starts_with(path));
440        for (_, (_, own)) in live {
441            total = total.checked_add(own).unwrap_or_else(|_| {
442                unreachable!("a subtree the tree holds is within its representable total")
443            });
444        }
445        total
446    }
447
448    /// The batch has removed whatever the tree held at `path`, subtree and all.
449    fn cut(&mut self, index: &Index, path: &Path) {
450        if let Some(id) = self.index_alive(index, path) {
451            let covered: Vec<PathBuf> =
452                Self::beneath(&self.cuts, path).map(|(cut, _)| cut.clone()).collect();
453            for cut in covered {
454                self.cuts.remove(&cut);
455            }
456            self.cuts.insert(path.to_path_buf(), index.subtree_scalars(id));
457        }
458        let gone: Vec<PathBuf> = self
459            .live
460            .range::<Path, _>((std::ops::Bound::Included(path), std::ops::Bound::Unbounded))
461            .take_while(|(candidate, _)| candidate.starts_with(path))
462            .map(|(live, _)| live.clone())
463            .collect();
464        for live in gone {
465            self.live.remove(&live);
466        }
467    }
468
469    /// The batch has put an entry of `kind` at `path` in place of whatever was there.
470    fn replace(&mut self, index: &Index, path: &Path, kind: EntryKind, own: RollUpScalars) {
471        self.cut(index, path);
472        self.live.insert(path.to_path_buf(), (kind, own));
473    }
474}
475
476/// Where a batched walk of the files an analysis may read has got to
477/// ([`Index::next_analysis_candidates`]): the directories still to list, each with its
478/// path, and the listing in progress with where it stopped ([`ListingResume`]).
479///
480/// Resuming a listing where it stopped is sound because nothing moves the tree between
481/// two batches of one walk: an analysis pass holds the index exclusively, and applying a
482/// result moves its content tier only.
483#[derive(Debug)]
484pub(crate) struct AnalysisWalk {
485    pending: Vec<(EntryId, PathBuf)>,
486    listing: Option<(EntryId, PathBuf, ListingResume)>,
487}
488
489impl AnalysisWalk {
490    /// A walk that has visited nothing.
491    pub(crate) fn start() -> Self {
492        Self { pending: vec![(EntryId::ROOT, PathBuf::new())], listing: None }
493    }
494}
495
496/// Where a batched walk stopped in one directory's listing: how many children it has
497/// visited, and the name of the last.
498///
499/// Sorted storage resumes at the position and map storage after the name, each without
500/// stepping over what the walk already visited (R164-6). Stepping over them cost a
501/// directory of N files about N²/(2·batch) child visits, each loading an entry, which is
502/// 10^8 for one directory of a million files.
503#[derive(Debug)]
504struct ListingResume {
505    position: usize,
506    last: OsString,
507}
508
509/// A directory's children in name order from where a batched walk stopped
510/// ([`Index::resumed_children`]).
511enum ResumedChildren<'a> {
512    Empty,
513    Sorted { index: &'a Index, ids: std::slice::Iter<'a, EntryId> },
514    Mutable(std::collections::btree_map::Range<'a, OsString, EntryId>),
515}
516
517impl<'a> Iterator for ResumedChildren<'a> {
518    type Item = (&'a OsStr, EntryId);
519
520    fn next(&mut self) -> Option<Self::Item> {
521        match self {
522            Self::Empty => None,
523            Self::Sorted { index, ids } => {
524                let id = *ids.next()?;
525                Some((index.entry(id).name.as_os_str(), id))
526            }
527            Self::Mutable(children) => children.next().map(|(name, id)| (name.as_os_str(), *id)),
528        }
529    }
530}
531
532/// The regular files a folded index counted directly in one directory without keeping
533/// them as entries ([`crate::execution::RetainedState::Tree`]).
534///
535/// They are in the directory's roll-ups like any other file. This tally is the rest of
536/// what a tree needs of them: they are rows its share threshold omits, and an omission
537/// states exactly the entries, files, and sizes it stands for, with their ignored part,
538/// which is what [`Index::folded_children`] supplies for them. Each is a single file, so
539/// the entries omitted are the files counted.
540#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
541pub(crate) struct FoldedFiles {
542    /// Files folded.
543    pub(crate) files: u64,
544    /// Their apparent bytes.
545    pub(crate) bytes: u64,
546    /// Their allocated bytes.
547    pub(crate) allocated: u64,
548    /// The apparent and allocated bytes of those `.gitignore` rules ignore, whether or
549    /// not a report may state them: a row's own classification is withheld where its
550    /// governing rules are unknown, and so is this ([`Index::children_classification_known`]).
551    pub(crate) ignored: crate::query::IgnoredSize,
552}
553
554impl FoldedFiles {
555    fn add(&mut self, attrs: &Attrs, ignored: bool) {
556        self.files += 1;
557        self.bytes += attrs.size;
558        self.allocated += attrs.allocated;
559        if ignored {
560            self.ignored.bytes += attrs.size;
561            self.ignored.allocated += attrs.allocated;
562        }
563    }
564}
565
566impl InternedRollUp {
567    /// Fold another roll-up into this one. Commutative and associative, which is what
568    /// lets the walk merge subtrees in whatever order threads finish them.
569    ///
570    /// The additions are plain because every sum they form is a sub-sum of a root total
571    /// already proved representable: by [`Index::preflight_totals`] on the apply lane, by
572    /// the running total [`add_file_sizes`] keeps in the detached builder, and by the sum
573    /// check a snapshot load makes.
574    fn merge(&mut self, other: &InternedRollUp) {
575        let had_files = self.files > 0;
576        self.files += other.files;
577        self.dirs += other.dirs;
578        self.bytes += other.bytes;
579        self.allocated += other.allocated;
580        if other.files > 0 {
581            self.newest_mtime_ns = if had_files {
582                self.newest_mtime_ns.max(other.newest_mtime_ns)
583            } else {
584                other.newest_mtime_ns
585            };
586        }
587        for (ext, tally) in &other.by_ext {
588            let slot = self.by_ext.entry(*ext).or_default();
589            slot.files += tally.files;
590            slot.bytes += tally.bytes;
591            slot.allocated += tally.allocated;
592        }
593    }
594
595    /// Remove another roll-up's contribution from this one.
596    ///
597    /// Only the invertible reducers are corrected here. `newest_mtime_ns` is left stale
598    /// on purpose and repaired by [`Index::recompute_newest_upward`], because a max
599    /// cannot be un-merged without knowing what else contributed it.
600    fn unmerge(&mut self, other: &InternedRollUp) {
601        self.files = self.files.saturating_sub(other.files);
602        self.dirs = self.dirs.saturating_sub(other.dirs);
603        self.bytes = self.bytes.saturating_sub(other.bytes);
604        self.allocated = self.allocated.saturating_sub(other.allocated);
605        for (ext, tally) in &other.by_ext {
606            if let Some(slot) = self.by_ext.get_mut(ext) {
607                slot.files = slot.files.saturating_sub(tally.files);
608                slot.bytes = slot.bytes.saturating_sub(tally.bytes);
609                slot.allocated = slot.allocated.saturating_sub(tally.allocated);
610                if slot.files == 0 && slot.bytes == 0 && slot.allocated == 0 {
611                    self.by_ext.remove(ext);
612                }
613            }
614        }
615    }
616}
617
618#[derive(Clone, Debug)]
619enum DirectoryChildren {
620    /// Name order without a second copy of each retained name.
621    Sorted(Vec<EntryId>),
622    /// Incrementally mutable topology for opened and arbitrary public indexes.
623    Mutable(BTreeMap<OsString, EntryId>),
624}
625
626impl DirectoryChildren {
627    #[cfg(test)]
628    fn is_sorted(&self) -> bool {
629        matches!(self, Self::Sorted(_))
630    }
631
632    #[cfg(test)]
633    fn is_mutable(&self) -> bool {
634        matches!(self, Self::Mutable(_))
635    }
636
637    fn ids(&self) -> ChildIds<'_> {
638        match self {
639            Self::Sorted(ids) => ChildIds::Sorted(ids.iter()),
640            Self::Mutable(children) => ChildIds::Mutable(children.values()),
641        }
642    }
643}
644
645#[derive(Clone, Debug)]
646struct DirectoryEntry {
647    children: DirectoryChildren,
648    rollup: InternedPartitionRollUp,
649    children_revision: u64,
650    children_complete: bool,
651}
652
653impl DirectoryEntry {
654    fn new(children_complete: bool) -> Self {
655        Self {
656            children: DirectoryChildren::Mutable(BTreeMap::new()),
657            rollup: InternedPartitionRollUp::default(),
658            children_revision: 0,
659            children_complete,
660        }
661    }
662}
663
664#[derive(Clone, Debug)]
665struct Entry {
666    parent: Option<EntryId>,
667    name: OsString,
668    /// Interned extension, computed once at insert. Files only; `None` elsewhere and
669    /// for files without an extension. Precomputing it here is what lets
670    /// `contribution` run without a string allocation or an interner borrow.
671    ext_id: Option<ExtId>,
672    /// Effective fixed-control classification, including an ignored ancestor.
673    ignored: bool,
674    /// Where this entry's metadata came from.
675    ///
676    /// One byte, not a `Provenance` struct: the timestamps that complete the picture
677    /// are shared by nearly every entry in a tree, so they live once on the index
678    /// while only the source genuinely varies per entry. See `Index::provenance`.
679    source: Source,
680    kind: EntryKind,
681    attrs: Attrs,
682    /// Changes on direct metadata updates. Together with the arena generation this
683    /// detects present-state ABA races.
684    revision: u64,
685    /// Child topology, subtree roll-ups, and discovery state exist only for directories.
686    /// Keeping them behind one pointer prevents every file from paying for two roll-up
687    /// planes and an empty child map.
688    directory: Option<Box<DirectoryEntry>>,
689}
690
691struct NewEntry {
692    parent: Option<EntryId>,
693    name: OsString,
694    ext_id: Option<ExtId>,
695    ignored: bool,
696    source: Source,
697    kind: EntryKind,
698    attrs: Attrs,
699}
700
701impl Entry {
702    fn new(entry: NewEntry, children_complete: bool) -> Self {
703        let NewEntry { parent, name, ext_id, ignored, source, kind, attrs } = entry;
704        Self {
705            parent,
706            name,
707            ext_id,
708            ignored,
709            source,
710            kind,
711            attrs,
712            revision: 0,
713            directory: kind.is_dir().then(|| Box::new(DirectoryEntry::new(children_complete))),
714        }
715    }
716
717    fn new_detached(new_entry: NewEntry, children_complete: bool) -> Self {
718        let mut entry = Self::new(new_entry, children_complete);
719        if let Some(directory) = entry.directory.as_deref_mut() {
720            directory.children = DirectoryChildren::Sorted(Vec::new());
721        }
722        entry
723    }
724
725    fn directory(&self) -> &DirectoryEntry {
726        self.directory.as_deref().expect("directory entry must retain directory state")
727    }
728
729    fn directory_mut(&mut self) -> &mut DirectoryEntry {
730        self.directory.as_deref_mut().expect("directory entry must retain directory state")
731    }
732
733    fn rollup(&self) -> &InternedPartitionRollUp {
734        &self.directory().rollup
735    }
736
737    fn rollup_mut(&mut self) -> &mut InternedPartitionRollUp {
738        &mut self.directory_mut().rollup
739    }
740}
741
742/// Portable direct children retained in the order interactive tree pages emit them.
743#[derive(Clone, PartialEq, Eq, Debug, Default)]
744pub(crate) struct PortableChildren {
745    pub(crate) directories: BTreeMap<String, EntryId>,
746    pub(crate) nondirectories: BTreeMap<String, EntryId>,
747}
748
749/// Commit-maintained orders and diagnostics used only while serving an opened root.
750///
751/// A detached [`Index`] is the storage and one-shot execution shape used by the CLI,
752/// snapshots, and ordinary library callers. Keeping these maps behind one optional
753/// allocation makes interactive reads additive without charging those paths one copied
754/// portable string and child-map node per entry.
755#[derive(Clone, PartialEq, Eq, Debug, Default)]
756struct ServingIndexes {
757    portable_children: BTreeMap<PathBuf, PortableChildren>,
758    portable_entries: BTreeMap<crate::PortablePath, EntryId>,
759    recent_files: BTreeSet<RecentKey>,
760    semantic_names: Vec<Option<String>>,
761    semantic_ids: BTreeMap<String, u32>,
762    semantic_refcounts: Vec<u64>,
763    free_semantic_ids: Vec<u32>,
764    semantic_by_directory: BTreeMap<EntryId, InternedSemanticPartitions>,
765    exact_name_ids: BTreeMap<String, u32>,
766    exact_names: Vec<String>,
767    exact_name_by_directory: BTreeMap<EntryId, InternedSemanticPartitions>,
768}
769
770#[derive(Clone, PartialEq, Eq, Debug, Default)]
771struct InternedSemanticPartitions {
772    all: BTreeMap<u32, ExtTally>,
773    unignored: BTreeMap<u32, ExtTally>,
774}
775
776/// One regular file in global newest-first order.
777#[derive(Clone, PartialEq, Eq, Debug)]
778struct RecentKey {
779    mtime_ns: i64,
780    portable_path: crate::PortablePath,
781    id: EntryId,
782}
783
784impl PartialOrd for RecentKey {
785    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
786        Some(self.cmp(other))
787    }
788}
789
790impl Ord for RecentKey {
791    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
792        other
793            .mtime_ns
794            .cmp(&self.mtime_ns)
795            .then_with(|| self.portable_path.cmp(&other.portable_path))
796            .then_with(|| self.id.cmp(&other.id))
797    }
798}
799
800impl ServingIndexes {
801    fn for_types(types: &crate::classify::TypeRegistry) -> Self {
802        let exact_names: Vec<_> = types
803            .exact_filenames()
804            .map(str::to_ascii_lowercase)
805            .collect::<BTreeSet<_>>()
806            .into_iter()
807            .collect();
808        let exact_name_ids = exact_names
809            .iter()
810            .enumerate()
811            .map(|(index, name)| {
812                let id = u32::try_from(index)
813                    .expect("a registry declares fewer than four billion exact filenames");
814                (name.clone(), id)
815            })
816            .collect();
817        Self { exact_name_ids, exact_names, ..Self::default() }
818    }
819
820    fn exact_name_id(&self, name: &OsStr) -> Option<u32> {
821        let name = name.to_str()?;
822        if let Some(id) = self.exact_name_ids.get(name) {
823            return Some(*id);
824        }
825        name.bytes()
826            .any(|byte| byte.is_ascii_uppercase())
827            .then(|| name.to_ascii_lowercase())
828            .and_then(|name| self.exact_name_ids.get(&name).copied())
829    }
830
831    fn intern_semantic(&mut self, name: &str) -> u32 {
832        if let Some(id) = self.semantic_ids.get(name).copied() {
833            let refcount = self
834                .semantic_refcounts
835                .get_mut(id as usize)
836                .expect("a live semantic id has a refcount");
837            *refcount = refcount.checked_add(1).expect("semantic refcount exhausted");
838            return id;
839        }
840        let id = if let Some(id) = self.free_semantic_ids.pop() {
841            self.semantic_names[id as usize] = Some(name.to_string());
842            self.semantic_refcounts[id as usize] = 1;
843            id
844        } else {
845            let id = u32::try_from(self.semantic_names.len())
846                .expect("fewer than four billion semantic types are live");
847            self.semantic_names.push(Some(name.to_string()));
848            self.semantic_refcounts.push(1);
849            id
850        };
851        self.semantic_ids.insert(name.to_string(), id);
852        id
853    }
854
855    fn release_semantic(&mut self, id: u32, count: u64) {
856        let slot = self
857            .semantic_refcounts
858            .get_mut(id as usize)
859            .expect("a live semantic id has a refcount");
860        *slot = slot.checked_sub(count).expect("semantic reference released twice");
861        if *slot != 0 {
862            return;
863        }
864        let name =
865            self.semantic_names[id as usize].take().expect("a referenced semantic id has a name");
866        let removed = self.semantic_ids.remove(&name);
867        debug_assert_eq!(removed, Some(id), "the semantic interner's two maps disagreed");
868        self.free_semantic_ids.push(id);
869    }
870}
871
872fn merge_semantic(map: &mut BTreeMap<u32, ExtTally>, id: u32, attrs: Attrs) {
873    let tally = map.entry(id).or_default();
874    tally.files = tally.files.saturating_add(1);
875    tally.bytes = tally.bytes.saturating_add(attrs.size);
876    tally.allocated = tally.allocated.saturating_add(attrs.allocated);
877}
878
879fn unmerge_semantic(map: &mut BTreeMap<u32, ExtTally>, id: u32, attrs: Attrs) {
880    let tally = map.get_mut(&id).expect("a semantic contribution must exist before removal");
881    tally.files = tally.files.saturating_sub(1);
882    tally.bytes = tally.bytes.saturating_sub(attrs.size);
883    tally.allocated = tally.allocated.saturating_sub(attrs.allocated);
884    if tally.files == 0 && tally.bytes == 0 && tally.allocated == 0 {
885        map.remove(&id);
886    }
887}
888
889fn unmerge_semantic_map(
890    destination: &mut BTreeMap<u32, ExtTally>,
891    contribution: &BTreeMap<u32, ExtTally>,
892) {
893    for (id, removed) in contribution {
894        let tally = destination
895            .get_mut(id)
896            .expect("a semantic subtree contribution must exist before removal");
897        tally.files = tally.files.saturating_sub(removed.files);
898        tally.bytes = tally.bytes.saturating_sub(removed.bytes);
899        tally.allocated = tally.allocated.saturating_sub(removed.allocated);
900        if tally.files == 0 && tally.bytes == 0 && tally.allocated == 0 {
901            destination.remove(id);
902        }
903    }
904}
905
906#[derive(Clone, Debug)]
907enum Slot {
908    Occupied { generation: u64, entry: Entry },
909    Free { generation: u64, next_free: Option<u32> },
910}
911
912fn retained_parent(arena: &[Slot], id: EntryId) -> Option<EntryId> {
913    match arena.get(id.idx()) {
914        Some(Slot::Occupied { generation, entry }) if *generation == id.generation => entry.parent,
915        Some(Slot::Occupied { .. } | Slot::Free { .. }) | None => {
916            panic!("internal entry handle must be live: {id:?}")
917        }
918    }
919}
920
921/// Result of [`Index::since`].
922#[derive(Clone, PartialEq, Eq, Debug, Default)]
923#[must_use]
924pub struct Since {
925    /// Exact commits applied strictly after the requested clock, oldest first.
926    pub commits: Vec<Commit>,
927    /// Terminal clock captured under the same read boundary as `commits`.
928    pub clock: Clock,
929    /// Complete public state at `clock`.
930    pub state: IndexState,
931    /// True when the requested clock is older than the retained journal, meaning the
932    /// caller has missed commits and must re-read state rather than trust either view.
933    pub truncated: bool,
934}
935
936/// Summary of what one [`Index::apply`] call did.
937#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
938pub struct ApplyStats {
939    /// Entries created.
940    pub inserted: u64,
941    /// Entries whose attributes changed.
942    pub updated: u64,
943    /// Entries removed, including cascaded descendants.
944    pub removed: u64,
945    /// Operations whose complete observed state already matched, so nothing changed.
946    pub unchanged: u64,
947    /// Subtrees escalated for re-scan.
948    pub invalidated: u64,
949    /// Exact control sources inserted, replaced, or removed.
950    pub controls: u64,
951    /// Retained entries moved between ignored and unignored partitions.
952    pub reclassified: u64,
953    /// Conditional observations rejected because the indexed state changed after the
954    /// producer captured its baseline.
955    pub stale: u64,
956    /// File upserts refused because their exact effect would exceed an opened-root
957    /// resource budget.
958    pub resource_refused: u64,
959}
960
961impl ApplyStats {
962    /// True when any operation changed indexed state.
963    pub const fn mutated(&self) -> bool {
964        self.inserted > 0
965            || self.updated > 0
966            || self.removed > 0
967            || self.invalidated > 0
968            || self.controls > 0
969            || self.reclassified > 0
970    }
971}
972
973/// Result of arbitrating and applying one producer observation.
974#[derive(Clone, PartialEq, Eq, Debug, Default)]
975pub struct ApplyOutcome {
976    /// Per-operation arbitration and mutation counts.
977    pub stats: ApplyStats,
978    /// Present only when at least one exact fact or state transition was committed.
979    pub commit: Option<Commit>,
980}
981
982/// One direct child captured from a shared index at a single read boundary.
983///
984/// Every field is owned so retaining this value never retains an index lock. The
985/// optional roll-up is present for directories; non-directories carry only `attrs`.
986#[derive(Clone, PartialEq, Eq, Debug)]
987pub struct ChildSnapshot {
988    /// Generation-safe arena identity at the capture boundary.
989    pub id: EntryId,
990    /// Entry name relative to its direct parent.
991    pub name: OsString,
992    /// Filesystem entry kind.
993    pub kind: EntryKind,
994    /// Last observed metadata.
995    pub attrs: Attrs,
996    /// Effective fixed-control classification, or `None` when the index did not observe
997    /// control state ([`Index::observes_controls`]).
998    ///
999    /// Such an index read no rule, so `Some(false)` would claim the child is not ignored
1000    /// when nobody looked.
1001    pub ignored: Option<bool>,
1002    /// Pre-computed subtree totals for a directory.
1003    pub rollup: Option<RollUp>,
1004    /// Both maintained aggregate partitions for a directory, or `None` for a
1005    /// non-directory and for any child of an index that did not observe control state,
1006    /// whose unignored partition would only repeat `rollup` as if no rule applied.
1007    pub partitions: Option<PartitionRollUp>,
1008}
1009
1010impl std::ops::Deref for ApplyOutcome {
1011    type Target = ApplyStats;
1012
1013    fn deref(&self) -> &Self::Target {
1014        &self.stats
1015    }
1016}
1017
1018impl ApplyOutcome {
1019    fn from_commit(stats: ApplyStats, commit: Option<Commit>) -> Self {
1020        Self { stats, commit }
1021    }
1022}
1023
1024#[derive(Clone, Copy)]
1025enum BatchProvenance {
1026    Baseline,
1027    Opened,
1028    Public,
1029}
1030
1031fn record_batch(provenance: BatchProvenance, observed: usize, stats: ApplyStats) {
1032    let observed = u64::try_from(observed).unwrap_or(u64::MAX);
1033    let accepted = observed.saturating_sub(stats.stale);
1034    crate::counters::bump(|counts| match provenance {
1035        BatchProvenance::Baseline => {
1036            counts.baseline_batches = counts.baseline_batches.saturating_add(1);
1037            counts.baseline_accepted_ops = counts.baseline_accepted_ops.saturating_add(accepted);
1038        }
1039        BatchProvenance::Opened => {
1040            counts.opened_batches = counts.opened_batches.saturating_add(1);
1041            counts.opened_accepted_ops = counts.opened_accepted_ops.saturating_add(accepted);
1042        }
1043        BatchProvenance::Public => {
1044            counts.public_batches = counts.public_batches.saturating_add(1);
1045            counts.public_accepted_ops = counts.public_accepted_ops.saturating_add(accepted);
1046        }
1047    });
1048}
1049
1050fn elapsed_micros(started: std::time::Instant) -> u64 {
1051    u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX)
1052}
1053
1054/// Validated, canonical producer input ready for arbitration under the write guard.
1055#[derive(Clone, Debug)]
1056struct PreparedObservation {
1057    ops: Vec<ObservationOp>,
1058    ancestry: PreparedAncestry,
1059    #[cfg(test)]
1060    reject_before_apply: bool,
1061}
1062
1063#[derive(Clone, Debug)]
1064enum PreparedAncestry {
1065    General,
1066    Scanner { parents: Vec<ResolvedParent>, has_batch_parents: bool },
1067}
1068
1069/// Parent identity proved for one operation in a private scanner batch.
1070#[derive(Clone, Copy, Debug)]
1071enum ResolvedParent {
1072    /// The parent is already live in the index at the preparation boundary.
1073    Existing(EntryId),
1074    /// The parent is the directory produced by this earlier operation in the batch.
1075    Earlier(usize),
1076}
1077
1078#[derive(Default)]
1079struct ExactConsequences {
1080    changes: Vec<EffectiveChange>,
1081    state: Vec<StateTransition>,
1082}
1083
1084impl ExactConsequences {
1085    fn is_empty(&self) -> bool {
1086        self.changes.is_empty() && self.state.is_empty()
1087    }
1088}
1089
1090#[derive(Default)]
1091struct NoConsequences;
1092
1093/// Batch-selected destination for facts that escape the shared reducer.
1094///
1095/// The closure is intentional: `NoConsequences` never evaluates it, so path copies and
1096/// effect construction compile out of detached baseline application rather than hiding
1097/// behind a branch in the per-entry loop.
1098trait ConsequenceSink {
1099    fn change(&mut self, change: impl FnOnce() -> EffectiveChange);
1100    fn state(&mut self, transition: impl FnOnce() -> StateTransition);
1101}
1102
1103impl ConsequenceSink for ExactConsequences {
1104    #[inline]
1105    fn change(&mut self, change: impl FnOnce() -> EffectiveChange) {
1106        self.changes.push(change());
1107    }
1108
1109    #[inline]
1110    fn state(&mut self, transition: impl FnOnce() -> StateTransition) {
1111        self.state.push(transition());
1112    }
1113}
1114
1115impl ConsequenceSink for NoConsequences {
1116    #[inline]
1117    fn change(&mut self, _change: impl FnOnce() -> EffectiveChange) {}
1118
1119    #[inline]
1120    fn state(&mut self, _transition: impl FnOnce() -> StateTransition) {}
1121}
1122
1123/// The in-memory hierarchical index.
1124#[derive(Clone, Debug)]
1125pub struct Index {
1126    root_path: PathBuf,
1127    scope: ScanScope,
1128    arena: Vec<Slot>,
1129    free_head: Option<u32>,
1130    live: u64,
1131    clock: Clock,
1132    journal: VecDeque<Commit>,
1133    journal_cost: usize,
1134    journal_capacity_bytes: usize,
1135    /// Oldest clock still represented in `journal`.
1136    journal_floor: Clock,
1137    pending_invalidations: Vec<(PathBuf, InvalidateReason)>,
1138    /// Source recorded on entries that incoming deltas create or update.
1139    ///
1140    /// Producers do not carry provenance in the delta itself — an observation says
1141    /// what it saw, not how much to trust it — so the consumer stamps it, and a
1142    /// caller loading a snapshot sets this to `Cached` for the duration.
1143    applying_source: Source,
1144    /// When this session observed the filesystem, in nanoseconds since the epoch.
1145    scanned_at_ns: i64,
1146    /// When the snapshot this index was loaded from captured the tree. Zero when the
1147    /// index was never loaded from one.
1148    captured_at_ns: i64,
1149    /// The start of the pass a snapshot of this index records as the one that last wrote
1150    /// its image: construction for an index built by a walk, which precedes the walk, and
1151    /// the stamp a loaded snapshot carried for one loaded from a snapshot.
1152    ///
1153    /// A lower bound on when the facts were last verified, never later than the truth. A
1154    /// later pass that verifies the same facts keeps the image on disk and its stamp, and a
1155    /// reconciliation that verifies a loaded index again leaves this at the snapshot's
1156    /// stamp, so the value can predate many verifying passes until P1.4.4 stamps completed
1157    /// passes.
1158    writing_pass_started_at_ns: i64,
1159    /// Whether this index holds facts no completed metadata write has recorded.
1160    ///
1161    /// True from construction, because a walked index has been written nowhere; cleared
1162    /// when the index is loaded from a snapshot or a metadata write of it completes; set
1163    /// again by a pass that mutated the entry tier. A partial pass mutates without being
1164    /// writable, so this is what carries its verified facts to the next complete write
1165    /// rather than keying that write to the one pass that happened to change nothing.
1166    persistence_owed: bool,
1167    /// Wall-clock starts of in-flight full-root passes, keyed by their freshness epoch.
1168    active_root_reconciles: BTreeMap<u64, i64>,
1169    /// Scopes and newer verification evidence for filesystem passes still in flight.
1170    active_reconciles: BTreeMap<u64, ActiveReconcile>,
1171    /// Subtrees a completed reconciliation has verified, with when it finished.
1172    ///
1173    /// Kept as intervals rather than per-entry flags because a sweep verifies
1174    /// everything beneath a path at once, including entries the producer elided as
1175    /// no-ops, and because one record per sweep costs nothing against millions of
1176    /// entries. Nested and repeated sweeps collapse: a new record replaces any it
1177    /// covers.
1178    verified: Vec<(PathBuf, i64)>,
1179    /// Interner storage: id → live name. Ids are indexes into this vector, and a
1180    /// vacant slot holds `None` until it is reissued.
1181    ext_names: Vec<Option<String>>,
1182    /// Interner lookup: name → id.
1183    ext_ids: BTreeMap<String, ExtId>,
1184    /// Live file entries holding each extension id, parallel to `ext_names`.
1185    ///
1186    /// Interning without a matching release is a leak in the case this engine is built
1187    /// for: a watched tree that churns through editor temporaries, build outputs, and
1188    /// content-hashed asset names keeps minting extensions the tree no longer contains,
1189    /// and both maps grow for the life of the process.
1190    ext_refcounts: Vec<u64>,
1191    /// Slots whose last referencing file went away, available for reissue.
1192    free_ext_ids: Vec<ExtId>,
1193    /// Sparse derived-data tier, allocated only after analysis is enabled.
1194    content: Option<Box<ContentIndex>>,
1195    /// File-type rules this index classifies against.
1196    ///
1197    /// Held rather than reached for globally, because a caller may run two indexes under
1198    /// different taxonomies in one process. It must agree with `scope`'s type-rule
1199    /// fingerprint: an index that classified under one set of rules while claiming
1200    /// another would serve a snapshot that is wrong in a way nothing checks.
1201    types: std::sync::Arc<crate::classify::TypeRegistry>,
1202    /// Exact fixed control sources and their derived matchers.
1203    controls: crate::control::ControlTable,
1204    /// Control files whose text could not be read in the latest owning pass.
1205    /// Their governing descendants have unknown ignore classification.
1206    unreadable_control_paths: BTreeSet<PathBuf>,
1207    freshness_epoch: u64,
1208    freshness_marks: BTreeMap<PathBuf, FreshnessMark>,
1209    /// Coherent opened-root state. Detached indexes retain the settled default and do
1210    /// not acquire live identity or worker ownership by carrying this value.
1211    state: IndexState,
1212    /// Bounded diagnostic details summarized by `state.issues`.
1213    issues: Vec<Issue>,
1214    /// The freshness epoch at which each retained issue was last observed, in step with
1215    /// `issues`. A reconciliation only disproves an issue observed before it began.
1216    issue_epochs: Vec<u64>,
1217    /// Omitted issue counts grouped by the active reconciliation boundary that owns them.
1218    /// At most one group exists before/between each active boundary, so this is bounded by
1219    /// concurrent passes rather than by the number of failures.
1220    omitted_issue_epochs: BTreeMap<u64, u64>,
1221    /// Optional commit-maintained state for interactive opened-root projections.
1222    ///
1223    /// Detached indexes deliberately carry `None`, including the standalone CLI's
1224    /// one-shot scan. Only [`crate::OpenedIndex`] enables this allocation.
1225    serving: Option<Box<ServingIndexes>>,
1226    /// The files a folded index counted in each directory without keeping them, by the
1227    /// directory's arena slot, or `None` for an index that keeps every file.
1228    ///
1229    /// Only the transient tree tier builds a folded index, and it answers that one report
1230    /// from it and frees it: such an index is never returned, persisted, or mutated
1231    /// ([`Self::is_folded`]), so its slots never change hands.
1232    folded: Option<Vec<FoldedFiles>>,
1233}
1234
1235#[derive(Clone, Copy)]
1236pub(crate) struct ReconcileErrors<'a> {
1237    pub(crate) errors: &'a [crate::Error],
1238    pub(crate) terminal: Option<&'a crate::Error>,
1239    pub(crate) disproves_old: bool,
1240}
1241
1242#[derive(Clone, Debug)]
1243struct ActiveReconcile {
1244    path: PathBuf,
1245    // One scope per entry present when this pass began. This bounds concurrent
1246    // verification history by retained state, including on a root-only index.
1247    scope_budget: usize,
1248    evidence: ReconcileEvidence,
1249}
1250
1251#[derive(Clone, Debug)]
1252enum ReconcileEvidence {
1253    Scopes(BTreeSet<PathBuf>),
1254    Retry,
1255}
1256
1257impl ActiveReconcile {
1258    fn supersede(&mut self, path: &Path) {
1259        if self.path.starts_with(path) {
1260            self.evidence = ReconcileEvidence::Scopes(BTreeSet::from([path.to_path_buf()]));
1261            return;
1262        }
1263        let ReconcileEvidence::Scopes(scopes) = &mut self.evidence else {
1264            return;
1265        };
1266        if scopes.iter().any(|newer| path.starts_with(newer)) {
1267            return;
1268        }
1269        scopes.retain(|newer| !newer.starts_with(path));
1270        if scopes.len() == self.scope_budget {
1271            // Discard proof, never widen it: the caller must retry this pass.
1272            self.evidence = ReconcileEvidence::Retry;
1273        } else {
1274            scopes.insert(path.to_path_buf());
1275        }
1276    }
1277
1278    fn refuses(&self, observation: &Observation, index: &Index) -> bool {
1279        match &self.evidence {
1280            ReconcileEvidence::Retry => true,
1281            ReconcileEvidence::Scopes(scopes) => observation.ops.iter().any(|op| {
1282                scopes
1283                    .iter()
1284                    .any(|path| op.op.path().starts_with(path) || path.starts_with(op.op.path()))
1285                    && !index.holds_target(&op.op, index.path_state(op.op.path()))
1286            }),
1287        }
1288    }
1289}
1290
1291pub(crate) struct ReconcileFinish {
1292    pub(crate) commit: Option<Commit>,
1293    pub(crate) retry: bool,
1294}
1295
1296enum ChildIds<'a> {
1297    Sorted(std::slice::Iter<'a, EntryId>),
1298    Mutable(std::collections::btree_map::Values<'a, OsString, EntryId>),
1299}
1300
1301impl Iterator for ChildIds<'_> {
1302    type Item = EntryId;
1303
1304    fn next(&mut self) -> Option<Self::Item> {
1305        match self {
1306            Self::Sorted(ids) => ids.next().copied(),
1307            Self::Mutable(ids) => ids.next().copied(),
1308        }
1309    }
1310
1311    fn size_hint(&self) -> (usize, Option<usize>) {
1312        let len = self.len();
1313        (len, Some(len))
1314    }
1315}
1316
1317impl DoubleEndedIterator for ChildIds<'_> {
1318    fn next_back(&mut self) -> Option<Self::Item> {
1319        match self {
1320            Self::Sorted(ids) => ids.next_back().copied(),
1321            Self::Mutable(ids) => ids.next_back().copied(),
1322        }
1323    }
1324}
1325
1326impl ExactSizeIterator for ChildIds<'_> {
1327    fn len(&self) -> usize {
1328        match self {
1329            Self::Sorted(ids) => ids.len(),
1330            Self::Mutable(ids) => ids.len(),
1331        }
1332    }
1333}
1334
1335enum IndexChildren<'a> {
1336    Empty,
1337    Sorted { index: &'a Index, ids: std::slice::Iter<'a, EntryId> },
1338    Mutable(std::collections::btree_map::Iter<'a, OsString, EntryId>),
1339}
1340
1341impl<'a> IndexChildren<'a> {
1342    fn new(index: &'a Index, entry: &'a Entry) -> Self {
1343        match entry.directory.as_deref().map(|directory| &directory.children) {
1344            None => Self::Empty,
1345            Some(DirectoryChildren::Sorted(ids)) => Self::Sorted { index, ids: ids.iter() },
1346            Some(DirectoryChildren::Mutable(children)) => Self::Mutable(children.iter()),
1347        }
1348    }
1349}
1350
1351impl<'a> Iterator for IndexChildren<'a> {
1352    type Item = (&'a OsStr, EntryId);
1353
1354    fn next(&mut self) -> Option<Self::Item> {
1355        match self {
1356            Self::Empty => None,
1357            Self::Sorted { index, ids } => {
1358                let id = *ids.next()?;
1359                Some((index.entry(id).name.as_os_str(), id))
1360            }
1361            Self::Mutable(children) => children.next().map(|(name, id)| (name.as_os_str(), *id)),
1362        }
1363    }
1364
1365    fn size_hint(&self) -> (usize, Option<usize>) {
1366        let len = self.len();
1367        (len, Some(len))
1368    }
1369}
1370
1371impl DoubleEndedIterator for IndexChildren<'_> {
1372    fn next_back(&mut self) -> Option<Self::Item> {
1373        match self {
1374            Self::Empty => None,
1375            Self::Sorted { index, ids } => {
1376                let id = *ids.next_back()?;
1377                Some((index.entry(id).name.as_os_str(), id))
1378            }
1379            Self::Mutable(children) => {
1380                children.next_back().map(|(name, id)| (name.as_os_str(), *id))
1381            }
1382        }
1383    }
1384}
1385
1386impl ExactSizeIterator for IndexChildren<'_> {
1387    fn len(&self) -> usize {
1388        match self {
1389            Self::Empty => 0,
1390            Self::Sorted { ids, .. } => ids.len(),
1391            Self::Mutable(children) => children.len(),
1392        }
1393    }
1394}
1395
1396#[derive(Clone, Copy, Debug)]
1397struct FreshnessMark {
1398    state: Freshness,
1399    epoch: u64,
1400}
1401
1402/// Shareable owner for serving readers while reconciliation applies short writes.
1403#[derive(Clone, Debug)]
1404pub struct IndexHandle {
1405    inner: Arc<RwLock<Index>>,
1406}
1407
1408/// Index-owned part of one progressive discovery commit.
1409///
1410/// The producer may combine one of these with entry observations; the opened commit
1411/// policy updates exact file progress and publishes one atomic fact-and-state commit.
1412#[derive(Clone, Debug, Default)]
1413pub(crate) struct DiscoveryCommit {
1414    pub(crate) directory_complete: Option<PathBuf>,
1415    pub(crate) transition: Option<DiscoveryTransition>,
1416}
1417
1418#[derive(Clone, Debug)]
1419pub(crate) enum DiscoveryTransition {
1420    Begin,
1421    Finish,
1422    BudgetRefused(Issue),
1423    Inaccessible { issues: Vec<Issue>, omitted: u64 },
1424    Cancelled,
1425    Failed(Issue),
1426}
1427
1428/// Index-owned lifecycle transitions for the optional observation producer.
1429#[derive(Clone, Debug)]
1430#[cfg_attr(not(feature = "watch"), allow(dead_code))]
1431pub(crate) enum ObservationTransition {
1432    /// Baseline discovery finished and the observer is closing its registration gap.
1433    Reconciling,
1434    /// The observer is active and its baseline handoff has been verified.
1435    ///
1436    /// Persistent inaccessible boundaries do not prevent observation of the readable
1437    /// scope, but they keep coverage partial and their causes remain inspectable.
1438    Watching { issues: Vec<Issue>, omitted: u64 },
1439    /// A reconciliation while watching could not read part of the scope.
1440    ///
1441    /// The subtree it covered is already partial and is not retried on every later event,
1442    /// so its causes are retained here, where partial freshness can be explained. Both
1443    /// watch drivers publish it: the opened root's observer and `Watcher::apply_next`.
1444    Unreadable { issues: Vec<Issue>, omitted: u64 },
1445    /// Observation could not establish or retain a trustworthy live boundary.
1446    Failed(Issue),
1447}
1448
1449impl IndexHandle {
1450    /// Wrap an owned index in the shared single-writer owner.
1451    pub fn new(index: Index) -> Self {
1452        Self { inner: Arc::new(RwLock::new(index)) }
1453    }
1454
1455    fn read_index(&self) -> crate::Result<std::sync::RwLockReadGuard<'_, Index>> {
1456        self.inner.read().map_err(|_| crate::Error::IndexLockPoisoned)
1457    }
1458
1459    fn write_index(&self) -> crate::Result<std::sync::RwLockWriteGuard<'_, Index>> {
1460        self.inner.write().map_err(|_| crate::Error::IndexLockPoisoned)
1461    }
1462
1463    /// Evaluate one owned result while holding exactly one coherent read boundary.
1464    pub(crate) fn read_with<T>(&self, read: impl FnOnce(&Index) -> T) -> crate::Result<T> {
1465        let index = self.read_index()?;
1466        Ok(read(&index))
1467    }
1468
1469    #[cfg(test)]
1470    pub(crate) fn poison_for_test(&self) {
1471        let handle = self.clone();
1472        std::thread::spawn(move || handle.panic_holding_the_write_lock_for_test())
1473            .join()
1474            .expect_err("injected index panic");
1475    }
1476
1477    /// Panic on this thread while holding the write lock, as a commit that panics does,
1478    /// leaving the lock poisoned.
1479    #[cfg(test)]
1480    pub(crate) fn panic_holding_the_write_lock_for_test(&self) -> ! {
1481        let _guard = self.inner.write().expect("test index write lock");
1482        panic!("inject index poison");
1483    }
1484
1485    /// Arbitrate and apply one observation under the single-writer lock.
1486    pub fn apply(&self, observation: &Observation) -> crate::Result<ApplyOutcome> {
1487        let prepared = prepare_observation(observation)?;
1488        let outcome = self.write_index()?.commit_prepared(prepared, true)?;
1489        record_batch(BatchProvenance::Public, observation.len(), outcome.stats);
1490        Ok(outcome)
1491    }
1492
1493    pub(crate) fn apply_discovery(
1494        &self,
1495        observation: &Observation,
1496        discovery: DiscoveryCommit,
1497    ) -> crate::Result<ApplyOutcome> {
1498        let prepared = prepare_observation(observation)?;
1499        let outcome = self.write_index()?.commit_prepared_with(
1500            prepared,
1501            true,
1502            Some(discovery),
1503            None,
1504            None,
1505            true,
1506        )?;
1507        record_batch(BatchProvenance::Opened, observation.len(), outcome.stats);
1508        Ok(outcome)
1509    }
1510
1511    #[cfg(feature = "watch")]
1512    pub(crate) fn apply_opened(
1513        &self,
1514        observation: &Observation,
1515        max_files: Option<u64>,
1516    ) -> crate::Result<ApplyOutcome> {
1517        let prepared = prepare_observation(observation)?;
1518        let outcome = self
1519            .write_index()?
1520            .commit_prepared_with(prepared, true, None, None, max_files, true)?;
1521        record_batch(BatchProvenance::Opened, observation.len(), outcome.stats);
1522        Ok(outcome)
1523    }
1524
1525    pub(crate) fn apply_scanner_discovery_bounded(
1526        &self,
1527        batch: crate::scan::ScannerBatch,
1528        discovery: DiscoveryCommit,
1529        max_files: Option<u64>,
1530    ) -> crate::Result<ApplyOutcome> {
1531        let observed = batch.len();
1532        let mut index = self.write_index()?;
1533        let prepared = index.prepare_scanner_batch(batch)?;
1534        let outcome =
1535            index.commit_prepared_with(prepared, true, Some(discovery), None, max_files, true)?;
1536        record_batch(BatchProvenance::Opened, observed, outcome.stats);
1537        Ok(outcome)
1538    }
1539
1540    pub(crate) fn transition_discovery(
1541        &self,
1542        transition: DiscoveryTransition,
1543    ) -> crate::Result<ApplyOutcome> {
1544        self.apply_discovery(
1545            &Observation::new(Vec::new()),
1546            DiscoveryCommit { directory_complete: None, transition: Some(transition) },
1547        )
1548    }
1549
1550    #[cfg(feature = "watch")]
1551    pub(crate) fn transition_observation(
1552        &self,
1553        transition: ObservationTransition,
1554    ) -> crate::Result<ApplyOutcome> {
1555        let prepared = prepare_observation(&Observation::default())?;
1556        let outcome = self.write_index()?.commit_prepared_with(
1557            prepared,
1558            true,
1559            None,
1560            Some(transition),
1561            None,
1562            true,
1563        )?;
1564        record_batch(BatchProvenance::Opened, 0, outcome.stats);
1565        Ok(outcome)
1566    }
1567
1568    /// Absolute filesystem root, copied without retaining the read lock.
1569    pub fn root_path(&self) -> crate::Result<PathBuf> {
1570        Ok(self.read_index()?.root_path().to_path_buf())
1571    }
1572
1573    /// Semantic scan scope represented by the shared index.
1574    pub fn scope(&self) -> crate::Result<ScanScope> {
1575        Ok(self.read_index()?.scope())
1576    }
1577
1578    /// Trust state for the whole index.
1579    pub fn freshness(&self) -> crate::Result<Freshness> {
1580        Ok(self.read_index()?.freshness())
1581    }
1582
1583    /// Trust state for one subtree.
1584    pub fn freshness_at(&self, path: &Path) -> crate::Result<Freshness> {
1585        Ok(self.read_index()?.freshness_at(path))
1586    }
1587
1588    /// Clock of the most recently committed delta.
1589    pub fn clock(&self) -> crate::Result<Clock> {
1590        Ok(self.read_index()?.clock())
1591    }
1592
1593    /// Number of live entries, including the root.
1594    pub fn len(&self) -> crate::Result<u64> {
1595        Ok(self.read_index()?.len())
1596    }
1597
1598    /// Whether the index contains only its root.
1599    pub fn is_empty(&self) -> crate::Result<bool> {
1600        Ok(self.read_index()?.is_empty())
1601    }
1602
1603    /// Owned roll-up totals for the whole tree.
1604    pub fn total(&self) -> crate::Result<RollUp> {
1605        Ok(self.read_index()?.total())
1606    }
1607
1608    /// Coherent opened-root state at the returned clock.
1609    pub(crate) fn state(&self) -> crate::Result<IndexState> {
1610        Ok(self.read_index()?.state())
1611    }
1612
1613    #[allow(dead_code)] // Consumed by the opened-root coherent read checkpoint.
1614    pub(crate) fn issues(&self) -> crate::Result<Vec<Issue>> {
1615        Ok(self.read_index()?.issues().to_vec())
1616    }
1617
1618    /// Whether a known directory has an authoritative in-scope child set.
1619    #[allow(dead_code)] // Consumed by the opened-root coherent read checkpoint.
1620    pub(crate) fn directory_complete(&self, path: &Path) -> crate::Result<Option<bool>> {
1621        Ok(self.read_index()?.directory_complete(path))
1622    }
1623
1624    /// Owned roll-up state for a relative directory path.
1625    pub fn rollup(&self, path: &Path) -> crate::Result<Option<RollUp>> {
1626        Ok(self.read_index()?.rollup(path))
1627    }
1628
1629    /// Owned metadata for a relative path.
1630    pub fn attrs(&self, path: &Path) -> crate::Result<Option<Attrs>> {
1631        Ok(self.read_index()?.attrs(path).copied())
1632    }
1633
1634    /// Entry kind for a relative path.
1635    pub fn kind(&self, path: &Path) -> crate::Result<Option<EntryKind>> {
1636        Ok(self.read_index()?.kind(path))
1637    }
1638
1639    /// Current visible state for a relative path.
1640    pub fn path_state(&self, path: &Path) -> crate::Result<PathState> {
1641        Ok(self.read_index()?.path_state(path))
1642    }
1643
1644    /// Conditional baseline for a producer operating on a shared index.
1645    pub fn expectation(&self, path: &Path) -> crate::Result<PathExpectation> {
1646        Ok(self.read_index()?.expectation(path))
1647    }
1648
1649    /// Owned exact commits after `clock`.
1650    pub fn since(&self, clock: Clock) -> crate::Result<Since> {
1651        Ok(self.read_index()?.since(clock))
1652    }
1653
1654    /// Direct children captured coherently at one read boundary.
1655    ///
1656    /// On an index that did not observe control state each child's
1657    /// [`ChildSnapshot::ignored`] and [`ChildSnapshot::partitions`] are `None`, the way
1658    /// [`Index::is_ignored`] refuses; the names, metadata, and roll-ups still answer.
1659    pub fn children(&self, path: &Path) -> crate::Result<Option<Vec<ChildSnapshot>>> {
1660        let index = self.read_index()?;
1661        let Some(children) = index.children(path) else {
1662            return Ok(None);
1663        };
1664        let observed = index.observes_controls();
1665        Ok(Some(
1666            children
1667                .map(|(name, id)| {
1668                    let entry = index.entry(id);
1669                    ChildSnapshot {
1670                        id,
1671                        name: name.to_os_string(),
1672                        kind: entry.kind,
1673                        attrs: entry.attrs,
1674                        ignored: observed.then_some(entry.ignored),
1675                        rollup: entry
1676                            .kind
1677                            .is_dir()
1678                            .then(|| index.named_rollup(&entry.rollup().all)),
1679                        partitions: (observed && entry.kind.is_dir())
1680                            .then(|| index.named_partitions(entry.rollup())),
1681                    }
1682                })
1683                .collect(),
1684        ))
1685    }
1686
1687    /// Capture one coherent owned index image, releasing the lock before callers do
1688    /// serialization, filesystem I/O, conversion, or other potentially blocking work.
1689    pub fn snapshot(&self) -> crate::Result<Index> {
1690        let mut snapshot = self.read_index()?.clone();
1691        snapshot.serving = None;
1692        Ok(snapshot)
1693    }
1694
1695    pub(crate) fn child_states(
1696        &self,
1697        path: &Path,
1698    ) -> crate::Result<BTreeMap<OsString, PathExpectation>> {
1699        let index = self.read_index()?;
1700        Ok(collect_child_expectations(&index, path))
1701    }
1702
1703    /// Child baselines for one opened-root listing, with whether the index does not yet
1704    /// hold that directory's child set as complete, read at one boundary.
1705    pub(crate) fn listing_baseline(
1706        &self,
1707        path: &Path,
1708    ) -> crate::Result<(BTreeMap<OsString, PathExpectation>, bool)> {
1709        let index = self.read_index()?;
1710        Ok((collect_child_expectations(&index, path), index.directory_complete(path) != Some(true)))
1711    }
1712
1713    pub(crate) fn has_control(&self, path: &Path) -> crate::Result<bool> {
1714        Ok(self.read_index()?.control_table().contains(path))
1715    }
1716
1717    pub(crate) fn take_pending_invalidations(
1718        &self,
1719    ) -> crate::Result<Vec<(PathBuf, InvalidateReason)>> {
1720        Ok(self.write_index()?.take_pending_invalidations())
1721    }
1722
1723    pub(crate) fn restore_pending_invalidations(
1724        &self,
1725        invalidations: Vec<(PathBuf, InvalidateReason)>,
1726    ) -> crate::Result<()> {
1727        self.write_index()?.restore_pending_invalidations(invalidations);
1728        Ok(())
1729    }
1730
1731    pub(crate) fn begin_reconcile(&self, path: &Path) -> crate::Result<(u64, Option<Commit>)> {
1732        self.write_index()?.begin_reconcile(path)
1733    }
1734
1735    pub(crate) fn finish_reconcile(
1736        &self,
1737        path: &Path,
1738        started_at: u64,
1739        complete: bool,
1740        listed_incomplete: &[PathBuf],
1741        failed_paths: &[PathBuf],
1742        errors: ReconcileErrors<'_>,
1743    ) -> crate::Result<ReconcileFinish> {
1744        self.write_index()?.finish_reconcile(
1745            path,
1746            started_at,
1747            complete,
1748            listed_incomplete,
1749            failed_paths,
1750            errors,
1751        )
1752    }
1753
1754    pub(crate) fn apply_reconcile(
1755        &self,
1756        started_at: u64,
1757        observation: &Observation,
1758    ) -> crate::Result<ApplyOutcome> {
1759        self.apply_reconcile_with(started_at, observation, None, BatchProvenance::Public)
1760    }
1761
1762    pub(crate) fn apply_opened_reconcile(
1763        &self,
1764        started_at: u64,
1765        observation: &Observation,
1766        max_files: Option<u64>,
1767    ) -> crate::Result<ApplyOutcome> {
1768        self.apply_reconcile_with(started_at, observation, max_files, BatchProvenance::Opened)
1769    }
1770
1771    fn apply_reconcile_with(
1772        &self,
1773        started_at: u64,
1774        observation: &Observation,
1775        max_files: Option<u64>,
1776        provenance: BatchProvenance,
1777    ) -> crate::Result<ApplyOutcome> {
1778        let prepared = prepare_observation(observation)?;
1779        let mut index = self.write_index()?;
1780        if index
1781            .active_reconciles
1782            .get(&started_at)
1783            .is_some_and(|active| active.refuses(observation, &index))
1784        {
1785            let stats = ApplyStats {
1786                stale: u64::try_from(observation.len()).unwrap_or(u64::MAX),
1787                ..ApplyStats::default()
1788            };
1789            record_batch(provenance, observation.len(), stats);
1790            return Ok(ApplyOutcome { stats, commit: None });
1791        }
1792        let outcome = index.commit_prepared_with(
1793            prepared,
1794            true,
1795            None,
1796            None,
1797            max_files,
1798            matches!(provenance, BatchProvenance::Opened),
1799        )?;
1800        record_batch(provenance, observation.len(), outcome.stats);
1801        Ok(outcome)
1802    }
1803
1804    #[cfg(feature = "watch")]
1805    pub(crate) fn apply_if_clock(
1806        &self,
1807        clock: Clock,
1808        observation: &Observation,
1809    ) -> crate::Result<Option<ApplyOutcome>> {
1810        let prepared = prepare_observation(observation)?;
1811        let mut index = self.write_index()?;
1812        if index.clock() != clock {
1813            return Ok(None);
1814        }
1815        index.commit_prepared(prepared, true).map(Some)
1816    }
1817
1818    #[cfg(feature = "watch")]
1819    pub(crate) fn apply_opened_if_clock(
1820        &self,
1821        clock: Clock,
1822        observation: &Observation,
1823        max_files: Option<u64>,
1824    ) -> crate::Result<Option<ApplyOutcome>> {
1825        let prepared = prepare_observation(observation)?;
1826        let mut index = self.write_index()?;
1827        if index.clock() != clock {
1828            return Ok(None);
1829        }
1830        index.commit_prepared_with(prepared, true, None, None, max_files, true).map(Some)
1831    }
1832
1833    #[cfg(feature = "watch")]
1834    pub(crate) fn unknown_ancestry(
1835        &self,
1836        observation: &Observation,
1837    ) -> crate::Result<Vec<(PathBuf, PathBuf)>> {
1838        let prepared = prepare_observation(observation)?;
1839        let index = self.read_index()?;
1840        let accepted = index.accepted_operations(&prepared.ops);
1841        Ok(index.unknown_ancestry(&prepared.ops, &accepted))
1842    }
1843
1844    #[cfg(feature = "watch")]
1845    pub(crate) fn watch_boundary(&self) -> crate::Result<(PathBuf, ScanScope, Clock)> {
1846        let index = self.read_index()?;
1847        Ok((index.root_path().to_path_buf(), index.scope(), index.clock()))
1848    }
1849
1850    #[cfg(feature = "watch")]
1851    pub(crate) fn invalidate_root(&self, reason: InvalidateReason) -> crate::Result<ApplyOutcome> {
1852        self.apply(&Observation::new(vec![Op::InvalidateSubtree { path: PathBuf::new(), reason }]))
1853    }
1854}
1855
1856/// Private parent-first builder for a cold index that is not yet externally visible.
1857///
1858/// Directory groups arrive while filesystem workers are still running. Applying each
1859/// group here overlaps structural construction with the walk without turning the cold
1860/// path back into public observations or manufacturing one full path per file.
1861pub(crate) struct DetachedIndexBuilder {
1862    index: Index,
1863    /// Each directory listed but not yet itself consumed, with the controls governing its
1864    /// parent's children: its own chain is that one plus its own control, if its listing
1865    /// brings one (H175).
1866    directory_ids: HashMap<PathBuf, (EntryId, Arc<crate::control::ControlChain>)>,
1867    /// Directories whose name one listing repeated. The walker lists each of them, and
1868    /// everything below it, once per observation.
1869    repeated_directories: Vec<PathBuf>,
1870    inserted: u64,
1871    /// The root's apparent and allocated bytes so far, which [`add_file_sizes`] refuses
1872    /// to carry past `u64::MAX` before any roll-up merges a file.
1873    total_bytes: u64,
1874    total_allocated: u64,
1875    /// What a folded index has kept and folded so far, when this builds one.
1876    tree: Option<TreeFold>,
1877}
1878
1879/// A folded index in construction ([`DetachedIndexBuilder::folding`]).
1880///
1881/// Every regular file is counted in its directory's roll-ups as it arrives, and offered
1882/// to a bounded heap of the largest by the retention's size metric. A file the heap turns
1883/// away, or later displaces, is counted in its directory's folded tally instead. The heap
1884/// is final only when the walk is, so the files it holds become entries in
1885/// [`DetachedIndexBuilder::finish`].
1886struct TreeFold {
1887    retention: crate::execution::TreeRetention,
1888    /// The largest files offered so far, the smallest on top.
1889    largest: std::collections::BinaryHeap<std::cmp::Reverse<KeptFile>>,
1890    /// Each directory's folded files, by arena slot.
1891    folded: Vec<FoldedFiles>,
1892}
1893
1894/// A file a folded index keeps while it stays among the largest, ordered by its size in
1895/// the retention's metric alone. Which of several equal files is kept cannot change an
1896/// answer: the smallest kept is never shown ([`crate::query::ShareThreshold`]'s bound).
1897struct KeptFile {
1898    value: u64,
1899    parent: EntryId,
1900    name: OsString,
1901    attrs: Attrs,
1902    ignored: bool,
1903}
1904
1905impl PartialEq for KeptFile {
1906    fn eq(&self, other: &Self) -> bool {
1907        self.value == other.value
1908    }
1909}
1910
1911impl Eq for KeptFile {}
1912
1913impl PartialOrd for KeptFile {
1914    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
1915        Some(self.cmp(other))
1916    }
1917}
1918
1919impl Ord for KeptFile {
1920    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
1921        self.value.cmp(&other.value)
1922    }
1923}
1924
1925impl TreeFold {
1926    /// Count one file of `parent` that is not kept.
1927    fn fold(folded: &mut Vec<FoldedFiles>, parent: EntryId, attrs: &Attrs, ignored: bool) {
1928        let slot = parent.idx();
1929        if folded.len() <= slot {
1930            folded.resize(slot + 1, FoldedFiles::default());
1931        }
1932        folded[slot].add(attrs, ignored);
1933    }
1934
1935    /// Offer one file of `parent` for keeping. Its name is taken from the listing only
1936    /// if it is kept, so a file folded at once leaves its name for the walker that
1937    /// allocated it to free (H159).
1938    fn offer(&mut self, parent: EntryId, name: &mut OsString, attrs: Attrs, ignored: bool) {
1939        let value = self.retention.size.of(&attrs);
1940        let capacity = usize::try_from(self.retention.largest_files).unwrap_or(usize::MAX);
1941        if self.largest.len() < capacity {
1942            let name = std::mem::take(name);
1943            self.largest.push(std::cmp::Reverse(KeptFile { value, parent, name, attrs, ignored }));
1944            return;
1945        }
1946        match self.largest.peek_mut() {
1947            Some(mut smallest) if value > smallest.0.value => {
1948                let name = std::mem::take(name);
1949                let displaced = std::mem::replace(
1950                    &mut smallest.0,
1951                    KeptFile { value, parent, name, attrs, ignored },
1952                );
1953                drop(smallest);
1954                Self::fold(&mut self.folded, displaced.parent, &displaced.attrs, displaced.ignored);
1955            }
1956            _ => Self::fold(&mut self.folded, parent, &attrs, ignored),
1957        }
1958    }
1959}
1960
1961impl DetachedIndexBuilder {
1962    pub(crate) fn new(
1963        root_path: impl Into<PathBuf>,
1964        scope: ScanScope,
1965        types: std::sync::Arc<crate::classify::TypeRegistry>,
1966    ) -> Self {
1967        let mut index = Index::new_with_scope_and_types(root_path, scope, types);
1968        index.entry_mut(EntryId::ROOT).directory_mut().children =
1969            DirectoryChildren::Sorted(Vec::new());
1970        Self {
1971            index,
1972            directory_ids: HashMap::from([(PathBuf::new(), (EntryId::ROOT, Arc::default()))]),
1973            repeated_directories: Vec::new(),
1974            inserted: 0,
1975            total_bytes: 0,
1976            total_allocated: 0,
1977            tree: None,
1978        }
1979    }
1980
1981    /// Refuse control sources past either of `limits` while building.
1982    pub(crate) fn with_control_limits(mut self, limits: crate::control::ControlLimits) -> Self {
1983        self.index.set_control_limits(limits);
1984        self
1985    }
1986
1987    /// Build a folded index ([`crate::execution::RetainedState::Tree`]): every directory,
1988    /// symlink, and other entry, but only the largest regular files `retention` names,
1989    /// and no extension tallies, which no tree reads (H176).
1990    pub(crate) fn folding(mut self, retention: crate::execution::TreeRetention) -> Self {
1991        self.tree = Some(TreeFold {
1992            retention,
1993            largest: std::collections::BinaryHeap::new(),
1994            folded: Vec::new(),
1995        });
1996        self
1997    }
1998
1999    /// Consume one listing after its parent listing has already been consumed.
2000    ///
2001    /// An enumerator can repeat a name while its directory is modified, which the
2002    /// streaming reducer absorbs as a re-upsert. Here a listing keeps the last observation
2003    /// of each name. A directory observed twice is also listed twice, and so is everything
2004    /// below it: the first listing to arrive for each such directory builds it, and a
2005    /// repeat is accepted without being applied again. A filesystem race must not fail
2006    /// the scan.
2007    ///
2008    /// The listing is drained, not consumed: an applied listing is left with its path
2009    /// and an empty child buffer, so the caller can hand both back to the worker that
2010    /// allocated them (H159). A listing that is not applied keeps its children, and one a
2011    /// folded index applied keeps the names of the files it folded, for that worker to
2012    /// free as well.
2013    ///
2014    /// A file that would carry the tree's apparent or allocated bytes past `u64::MAX` is
2015    /// refused with [`crate::Error::UnrepresentableTotal`], and the build with it, as the
2016    /// apply lane refuses the batch that holds it ([`add_file_sizes`]).
2017    pub(crate) fn push_directory(
2018        &mut self,
2019        directory: &mut crate::scan::DetachedDirectory,
2020    ) -> crate::Result<()> {
2021        let crate::scan::DetachedDirectory { path, children, control } = directory;
2022        // No descendant can become claimable until its parent's listing has been sent,
2023        // so the first listing of a directory finds its lookup entry. Retire the entry
2024        // now instead of retaining every walked directory path until the end of the scan.
2025        let Some((parent, above)) = self.directory_ids.remove(path.as_path()) else {
2026            if self.repeated_directories.iter().any(|repeated| path.starts_with(repeated)) {
2027                return Ok(());
2028            }
2029            return Err(crate::Error::UnknownAncestry {
2030                path: path.clone(),
2031                reconcile_from: PathBuf::new(),
2032            });
2033        };
2034
2035        // The worker publishes this listing before descendants become claimable. Apply
2036        // its complete fixed-control state before classifying any sibling, and every
2037        // later child listing will therefore inherit all governing controls without a
2038        // post-build subtree reclassification pass.
2039        let listed_control = control.is_some();
2040        if let Some(control) = control.take() {
2041            debug_assert!(
2042                matches!(&control, Op::ControlUpsert { path: control_path, .. }
2043                    | Op::ControlRemove { path: control_path }
2044                    if control_path.parent() == Some(path.as_path())),
2045                "a listing carries only its own directory's control"
2046            );
2047            match control {
2048                Op::ControlUpsert { path, source } => {
2049                    self.index.controls.upsert(&path, source)?;
2050                }
2051                Op::ControlRemove { path } => {
2052                    self.index.controls.remove(&path)?;
2053                }
2054                _ => unreachable!("detached directory retains only fixed-control operations"),
2055            }
2056            self.inserted = self.inserted.saturating_add(1);
2057        }
2058
2059        // Allocate in name order, the order the directory retains its children in, keeping
2060        // the last observation of a repeated name. The sort is unstable so that it needs
2061        // no scratch allocation; the enumeration position is what keeps "last" exact.
2062        children.sort_unstable_by(|left, right| {
2063            left.name.cmp(&right.name).then(left.position.cmp(&right.position))
2064        });
2065        let repeated_directories = &mut self.repeated_directories;
2066        children.dedup_by(|later, kept| {
2067            if later.name != kept.name {
2068                return false;
2069            }
2070            if later.kind.is_dir() || kept.kind.is_dir() {
2071                let repeated = path.join(&kept.name);
2072                if repeated_directories.last() != Some(&repeated) {
2073                    repeated_directories.push(repeated);
2074                }
2075            }
2076            std::mem::swap(later, kept);
2077            true
2078        });
2079
2080        let parent_ignored = self.index.entry(parent).ignored;
2081        // Every child shares this directory's governing controls, so they are resolved
2082        // once here rather than looked up per child (H163), and the directory's path is
2083        // split once for all of them rather than again for each child (H171). Nor are
2084        // they resolved from the table: they are the parent's, plus this directory's own
2085        // control when its listing brought one, the only control that can have changed
2086        // since the parent's listing (H175). Every other listing shares its parent's.
2087        let chain =
2088            if listed_control { self.index.controls.chain_below(&above, path) } else { above };
2089        debug_assert!(
2090            chain.same_as(&self.index.controls.chain_for(path)),
2091            "a derived control chain is the one the table resolves for {}",
2092            path.display()
2093        );
2094        let classifying = !parent_ignored && !chain.is_empty();
2095        let entries = match self.tree {
2096            None => children.len(),
2097            // Files are kept, if at all, only once the walk is over.
2098            Some(_) => children.iter().filter(|child| child.kind != EntryKind::File).count(),
2099        };
2100        self.index.reserve_detached_children(parent, entries);
2101        let path: &Path = path;
2102        let mut classify_children = |directory: &[&[u8]]| -> crate::Result<()> {
2103            for child in children.iter_mut() {
2104                let &mut crate::scan::DetachedChild { ref mut name, kind, attrs, .. } = child;
2105                crate::counters::bump(|counts| counts.upserts += 1);
2106                if kind == EntryKind::File {
2107                    add_file_sizes(
2108                        &mut self.total_bytes,
2109                        &mut self.total_allocated,
2110                        &attrs,
2111                        || path.join(&*name),
2112                    )?;
2113                }
2114                let ext_id = (kind == EntryKind::File && self.tree.is_none())
2115                    .then(|| self.index.intern_ext(&crate::classify::ext_bucket(name)));
2116                let ignored = if classifying {
2117                    chain.is_ignored_within(directory, name.as_encoded_bytes(), kind.is_dir())
2118                } else {
2119                    parent_ignored
2120                };
2121                if let (EntryKind::File, Some(tree)) = (kind, &mut self.tree) {
2122                    // Counted in its directory whether or not it is kept, as the file it is.
2123                    let direct = Index::file_contribution(&attrs, None, ignored);
2124                    crate::counters::bump(|counts| counts.rollup_merges += 1);
2125                    self.index.entry_mut(parent).rollup_mut().merge(&direct);
2126                    tree.offer(parent, name, attrs, ignored);
2127                    self.inserted = self.inserted.saturating_add(1);
2128                    continue;
2129                }
2130                let name = std::mem::take(name);
2131                let child_path = kind.is_dir().then(|| path.join(&name));
2132                // Listed in full unless the walk reports otherwise, which only a failure
2133                // does (`Index::set_initial_detached_scan_freshness`).
2134                let child_id = self.index.alloc(Entry::new_detached(
2135                    NewEntry {
2136                        parent: Some(parent),
2137                        name,
2138                        ext_id,
2139                        ignored,
2140                        source: Source::Scanned,
2141                        kind,
2142                        attrs,
2143                    },
2144                    true,
2145                ));
2146                self.index.push_detached_child(parent, child_id);
2147                // Fold the child's own direct contribution while filesystem work is still
2148                // in flight. Files are now complete; directories will add only their
2149                // descendant roll-up in the short bottom-up finish pass.
2150                let direct = self.index.contribution(child_id);
2151                crate::counters::bump(|counts| counts.rollup_merges += 1);
2152                self.index.entry_mut(parent).rollup_mut().merge(&direct);
2153                if let Some(child_path) = child_path {
2154                    self.directory_ids.insert(child_path, (child_id, Arc::clone(&chain)));
2155                }
2156                self.inserted = self.inserted.saturating_add(1);
2157            }
2158            Ok(())
2159        };
2160        if classifying {
2161            crate::control::with_directory_components(path, classify_children)?;
2162        } else {
2163            classify_children(&[])?;
2164        }
2165        // Every name an entry kept has been taken. Only a folded index leaves any behind.
2166        if self.tree.is_none() {
2167            children.clear();
2168        }
2169        Ok(())
2170    }
2171
2172    /// Allocate the files a folded index kept, now that the walk is over and the heap of
2173    /// the largest is final, and hand the index what it folded.
2174    ///
2175    /// Each kept file was already counted in its directory's roll-ups when it arrived, so
2176    /// this only makes it an entry: it joins its directory's children, and the directories
2177    /// that gained one are put back in name order, the order a listing allocates in.
2178    fn keep_largest_files(&mut self, tree: TreeFold) {
2179        let TreeFold { largest, folded, .. } = tree;
2180        let mut gained = Vec::with_capacity(largest.len());
2181        for std::cmp::Reverse(file) in largest {
2182            let KeptFile { parent, name, attrs, ignored, .. } = file;
2183            let id = self.index.alloc(Entry::new_detached(
2184                NewEntry {
2185                    parent: Some(parent),
2186                    name,
2187                    ext_id: None,
2188                    ignored,
2189                    source: Source::Scanned,
2190                    kind: EntryKind::File,
2191                    attrs,
2192                },
2193                true,
2194            ));
2195            self.index.push_detached_child(parent, id);
2196            gained.push(parent.idx());
2197        }
2198        gained.sort_unstable();
2199        gained.dedup();
2200        for slot in gained {
2201            let parent = EntryId {
2202                slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
2203                generation: 0,
2204            };
2205            self.index.sort_detached_children(parent);
2206        }
2207        self.index.folded = Some(folded);
2208    }
2209
2210    /// Complete the private baseline after every directory listing has arrived.
2211    pub(crate) fn finish(mut self) -> Index {
2212        if let Some(tree) = self.tree.take() {
2213            self.keep_largest_files(tree);
2214        }
2215        // Parents are allocated before descendants, so reverse arena order is a valid
2216        // bottom-up traversal. Direct contributions were merged during the pipelined
2217        // build; only completed directory descendants remain to propagate here.
2218        for slot in (1..self.index.arena.len()).rev() {
2219            let id = EntryId {
2220                slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
2221                generation: 0,
2222            };
2223            if !self.index.entry(id).kind.is_dir() {
2224                continue;
2225            }
2226            let parent = self.index.entry(id).parent.expect("every non-root entry has a parent");
2227            // The directory's own count was merged when its parent listing arrived.
2228            crate::counters::bump(|counts| counts.rollup_merges += 1);
2229            self.index.merge_detached_descendants(parent, id);
2230        }
2231
2232        crate::counters::bump(|counts| {
2233            counts.baseline_batches = counts.baseline_batches.saturating_add(1);
2234            counts.baseline_accepted_ops =
2235                counts.baseline_accepted_ops.saturating_add(self.inserted);
2236        });
2237        self.index.establish_baseline();
2238        self.index
2239    }
2240}
2241
2242impl Index {
2243    /// Create an empty index rooted at `root_path`, under [`ScanScope::default`].
2244    ///
2245    /// That is the scope of [`ScanConfig::default`](crate::ScanConfig), which observes
2246    /// control state, so this index answers [`Self::is_ignored`], [`Self::controls`], and
2247    /// the partition accessors ([`Self::partition_total`], [`Self::partition_rollup`], and
2248    /// [`Self::partition_rollup_summary`]), and it accepts control input. Build any other
2249    /// scope, including one that turns control observation off, with
2250    /// [`Self::new_with_scope`].
2251    pub fn new(root_path: impl Into<PathBuf>) -> Self {
2252        Self::new_with_scope(root_path, ScanScope::default())
2253    }
2254
2255    /// Create an empty index with an explicit semantic scan scope.
2256    ///
2257    /// Its control table applies the default
2258    /// [`ControlLimits`](crate::control::ControlLimits), whatever limits the scope was
2259    /// taken under, so a snapshot of it saves only when those agree. Build an index for any
2260    /// other limits with [`Self::new_with_config`].
2261    pub fn new_with_scope(root_path: impl Into<PathBuf>, scope: ScanScope) -> Self {
2262        Self::new_with_scope_and_types(
2263            root_path,
2264            scope,
2265            crate::classify::TypeRegistry::compiled_shared(),
2266        )
2267    }
2268
2269    /// Create an empty index with the scope, file-type rules, and control limits of
2270    /// `config`, as the scans behind [`crate::open`] and [`crate::OpenedIndex`] do.
2271    ///
2272    /// The scope and the control table come from one configuration, so the table enforces
2273    /// exactly the limits the scope's ignore-rules identity claims.
2274    pub fn new_with_config(root_path: impl Into<PathBuf>, config: &crate::ScanConfig) -> Self {
2275        let mut index =
2276            Self::new_with_scope_and_types(root_path, config.scope(), config.types_shared());
2277        index.set_control_limits(config.control_limits);
2278        index
2279    }
2280
2281    /// Create an index whose registry is part of its validated semantic scope.
2282    pub(crate) fn new_with_scope_and_types(
2283        root_path: impl Into<PathBuf>,
2284        scope: ScanScope,
2285        types: std::sync::Arc<crate::classify::TypeRegistry>,
2286    ) -> Self {
2287        Self::new_with_scope_types_and_journal_capacity_bytes(
2288            root_path,
2289            scope,
2290            types,
2291            DEFAULT_JOURNAL_CAPACITY_BYTES,
2292        )
2293    }
2294
2295    pub(crate) fn new_with_scope_types_and_journal_capacity_bytes(
2296        root_path: impl Into<PathBuf>,
2297        scope: ScanScope,
2298        types: std::sync::Arc<crate::classify::TypeRegistry>,
2299        journal_capacity_bytes: usize,
2300    ) -> Self {
2301        assert_eq!(
2302            scope.type_rules_fingerprint,
2303            types.fingerprint(),
2304            "an index's registry must match its semantic scope"
2305        );
2306        Self::new_with_journal_capacity_bytes(root_path, scope, journal_capacity_bytes, types, None)
2307    }
2308
2309    /// Create the retained index behind an opened root, including its serving orders.
2310    pub(crate) fn new_opened_with_scope_types_and_journal_capacity_bytes(
2311        root_path: impl Into<PathBuf>,
2312        scope: ScanScope,
2313        types: std::sync::Arc<crate::classify::TypeRegistry>,
2314        journal_capacity_bytes: usize,
2315    ) -> Self {
2316        assert_eq!(
2317            scope.type_rules_fingerprint,
2318            types.fingerprint(),
2319            "an index's registry must match its semantic scope"
2320        );
2321        let serving = ServingIndexes::for_types(&types);
2322        Self::new_with_journal_capacity_bytes(
2323            root_path,
2324            scope,
2325            journal_capacity_bytes,
2326            types,
2327            Some(Box::new(serving)),
2328        )
2329    }
2330
2331    fn new_with_journal_capacity_bytes(
2332        root_path: impl Into<PathBuf>,
2333        scope: ScanScope,
2334        journal_capacity_bytes: usize,
2335        types: std::sync::Arc<crate::classify::TypeRegistry>,
2336        serving: Option<Box<ServingIndexes>>,
2337    ) -> Self {
2338        let root = Entry::new(
2339            NewEntry {
2340                parent: None,
2341                name: OsString::new(),
2342                ext_id: None,
2343                ignored: false,
2344                source: Source::Scanned,
2345                kind: EntryKind::Dir,
2346                attrs: Attrs::default(),
2347            },
2348            true,
2349        );
2350        let constructed_at_ns = Self::now_unix_nanos();
2351        Self {
2352            root_path: root_path.into(),
2353            scope,
2354            arena: vec![Slot::Occupied { generation: 0, entry: root }],
2355            free_head: None,
2356            live: 1,
2357            clock: Clock::ZERO,
2358            journal: VecDeque::new(),
2359            journal_cost: 0,
2360            journal_capacity_bytes,
2361            journal_floor: Clock::ZERO,
2362            pending_invalidations: Vec::new(),
2363            freshness_epoch: 0,
2364            freshness_marks: BTreeMap::new(),
2365            state: IndexState::default(),
2366            issues: Vec::new(),
2367            issue_epochs: Vec::new(),
2368            omitted_issue_epochs: BTreeMap::new(),
2369            serving,
2370            applying_source: Source::Scanned,
2371            scanned_at_ns: constructed_at_ns,
2372            captured_at_ns: 0,
2373            writing_pass_started_at_ns: constructed_at_ns,
2374            persistence_owed: true,
2375            active_root_reconciles: BTreeMap::new(),
2376            active_reconciles: BTreeMap::new(),
2377            verified: Vec::new(),
2378            ext_names: Vec::new(),
2379            ext_ids: BTreeMap::new(),
2380            ext_refcounts: Vec::new(),
2381            free_ext_ids: Vec::new(),
2382            content: None,
2383            types,
2384            controls: crate::control::ControlTable::default(),
2385            unreadable_control_paths: BTreeSet::new(),
2386            folded: None,
2387        }
2388    }
2389
2390    /// The file-type rules this index classifies against.
2391    pub fn types(&self) -> &crate::classify::TypeRegistry {
2392        self.types.as_ref()
2393    }
2394
2395    /// Exact fixed control state retained by this detached index.
2396    ///
2397    /// # Errors
2398    ///
2399    /// [`crate::Error::ControlStateNotObserved`] when the index was built without
2400    /// observing control state ([`ScanScope::observes_controls`]). Its table is empty
2401    /// because nothing was read, and returning it would claim the tree has no control
2402    /// files.
2403    pub fn controls(&self) -> crate::Result<&crate::control::ControlTable> {
2404        self.require_observed_controls()?;
2405        Ok(&self.controls)
2406    }
2407
2408    /// Whether this index observed `.gitignore` control state, and so can answer
2409    /// [`Self::is_ignored`] and [`Self::controls`].
2410    pub const fn observes_controls(&self) -> bool {
2411        self.scope.observes_controls()
2412    }
2413
2414    /// Whether this index observed `.gitignore` control state, and under which limits.
2415    pub fn control_identity(&self) -> crate::ControlTierIdentity {
2416        if self.observes_controls() {
2417            crate::ControlTierIdentity::Observed { limits: self.controls.limits() }
2418        } else {
2419            crate::ControlTierIdentity::NotObserved
2420        }
2421    }
2422
2423    /// The identity of every tier a snapshot of this index holds.
2424    pub fn snapshot_identity(&self) -> crate::SnapshotIdentity {
2425        crate::SnapshotIdentity {
2426            entries: crate::EntryTierIdentity::of_scope(self.scope),
2427            controls: self.control_identity(),
2428        }
2429    }
2430
2431    fn require_observed_controls(&self) -> crate::Result<()> {
2432        if self.observes_controls() { Ok(()) } else { Err(crate::Error::ControlStateNotObserved) }
2433    }
2434
2435    /// Whether a batch carries control input this index must refuse because its scope
2436    /// observes no control state.
2437    ///
2438    /// Accepted, such input installed a table and reclassified entries under a scope that
2439    /// says no rule was read: `is_ignored` refused over classification the index held, and
2440    /// a snapshot saved from it loaded into an open that turned observation off as an exact
2441    /// scope match (`fdu-agb6`). Every operation counts, accepted or stale, so the refusal
2442    /// does not depend on the index's state.
2443    fn carries_unobserved_control_input(&self, ops: &[ObservationOp]) -> bool {
2444        !self.observes_controls()
2445            && ops.iter().any(|observed| {
2446                matches!(observed.op, Op::ControlUpsert { .. } | Op::ControlRemove { .. })
2447            })
2448    }
2449
2450    /// The retained control table whatever the scope: empty when nothing was observed.
2451    ///
2452    /// For the engine's own maintenance, which compares what it retains against what it
2453    /// reads and so needs no claim about coverage.
2454    pub(crate) fn control_table(&self) -> &crate::control::ControlTable {
2455        &self.controls
2456    }
2457
2458    /// Whether this index's ignore classification applies every control file in scope.
2459    ///
2460    /// [`crate::control::ControlCoverage::NotObserved`] when the index read no control
2461    /// file. Otherwise the limits, the applied and refused counts, and the first refused
2462    /// files. Sizes and counts are exact either way; only the ignored and unignored split
2463    /// below a refused file is not.
2464    pub fn control_coverage(&self) -> crate::control::ControlCoverage {
2465        if self.observes_controls() {
2466            crate::control::ControlCoverage::Observed(self.controls.observation())
2467        } else {
2468            crate::control::ControlCoverage::NotObserved
2469        }
2470    }
2471
2472    /// Refuse control sources past either of `limits`, as the scan configuration that
2473    /// builds this index asks. Set once, before any control input arrives: a table's
2474    /// refusals are only meaningful under the limits that made them.
2475    pub(crate) fn set_control_limits(&mut self, limits: crate::control::ControlLimits) {
2476        debug_assert!(self.controls.is_vacant(), "the control limits are set before any control");
2477        self.controls = crate::control::ControlTable::with_limits(limits);
2478    }
2479
2480    /// Refuse `limits` unless they are the ones this index's scope was taken under.
2481    ///
2482    /// A scope that observes control state names its limits in its ignore-rules identity,
2483    /// and an index's scope and its table must never disagree: a table refusing under other
2484    /// limits would be served, and saved, as if it applied the scope's. A scope that
2485    /// observes nothing retains no table, so its limits decide nothing.
2486    ///
2487    /// The guard is for [`crate::snapshot::save`], whose index may have been built with a
2488    /// scope and a table set apart (as [`Self::new_with_scope`] does), and for callers of
2489    /// [`Self::install_controls`] other than the loader. On the load path it cannot fail,
2490    /// because the loader builds the scope and the table from the same header limits.
2491    pub(crate) fn require_control_limits_in_scope(
2492        &self,
2493        limits: crate::control::ControlLimits,
2494    ) -> crate::Result<()> {
2495        if self.scope.observes_controls()
2496            && (crate::ControlTierIdentity::Observed { limits }).ignore_rules_fingerprint()
2497                != self.scope.ignore_rules_fingerprint
2498        {
2499            return Err(crate::Error::ControlLimitsOutsideScope { limits });
2500        }
2501        Ok(())
2502    }
2503
2504    /// Install a complete control table while restoring a detached snapshot.
2505    pub(crate) fn install_controls(
2506        &mut self,
2507        controls: crate::control::ControlTable,
2508    ) -> crate::Result<()> {
2509        self.require_control_limits_in_scope(controls.limits())?;
2510        // Every source a table retains was admitted under its own budget, and the charge
2511        // does not depend on admission order, so a larger total was not written by one.
2512        if controls.limits().budget.is_some_and(|budget| controls.retained_cost() > budget) {
2513            return Err(crate::Error::Snapshot(
2514                "a snapshot's control table exceeds its own control budget".into(),
2515            ));
2516        }
2517        // A scope that observed no control state retains no table and refuses nothing; a
2518        // snapshot carrying either under such a scope was not written by a scan that
2519        // honoured it.
2520        if !controls.is_vacant() {
2521            self.require_observed_controls()?;
2522        }
2523        // Every entry's ignored bit agrees with the table it replaces, so when neither table
2524        // governs anything no bit can move. Walking the tree to confirm it allocated a path
2525        // per entry on every snapshot load, including the common load with no controls.
2526        let unchanged = controls.is_empty() && self.controls.is_empty();
2527        self.controls = controls;
2528        if unchanged {
2529            return Ok(());
2530        }
2531        let mut stats = ApplyStats::default();
2532        let mut effects = NoConsequences;
2533        self.reclassify_controlled_subtrees(&[PathBuf::new()], &mut stats, &mut effects);
2534        Ok(())
2535    }
2536
2537    /// Share the registry with background analysis workers.
2538    pub(crate) fn types_shared(&self) -> std::sync::Arc<crate::classify::TypeRegistry> {
2539        std::sync::Arc::clone(&self.types)
2540    }
2541
2542    /// Classify one relative path under this index's rules, without opening the file.
2543    pub fn classify(&self, relative_path: &Path) -> crate::classify::Classification {
2544        crate::classify::classify_with(&self.types, relative_path, None)
2545    }
2546
2547    #[cfg(test)]
2548    fn with_journal_capacity_bytes(
2549        root_path: impl Into<PathBuf>,
2550        journal_capacity_bytes: usize,
2551    ) -> Self {
2552        Self::new_with_journal_capacity_bytes(
2553            root_path,
2554            ScanScope::default(),
2555            journal_capacity_bytes,
2556            crate::classify::TypeRegistry::compiled_shared(),
2557            None,
2558        )
2559    }
2560
2561    /// The absolute path this index is rooted at.
2562    pub fn root_path(&self) -> &Path {
2563        &self.root_path
2564    }
2565
2566    /// Semantic scope represented by this index and any snapshot written from it.
2567    pub const fn scope(&self) -> ScanScope {
2568        self.scope
2569    }
2570
2571    /// Trust state for the whole index.
2572    pub fn freshness(&self) -> Freshness {
2573        self.freshness_at(Path::new(""))
2574    }
2575
2576    /// The freshness the coherent [`IndexState`] publishes for the root.
2577    ///
2578    /// Subtree marks decide it, with one exception: while the observation handoff owns the
2579    /// root -- the `Reconciling` phase -- the root does not become `Fresh` until `Watching`
2580    /// says the handoff verified it. The handoff's own full pass clears the root's mark
2581    /// before the hints captured behind it are drained, and `Fresh` beside `Reconciling`
2582    /// promised a verified root the handoff had not delivered yet. Stale and partial marks
2583    /// still show through, since they say something the handoff has not yet disproved.
2584    fn published_freshness(&self) -> Freshness {
2585        let derived = self.freshness();
2586        if self.state.phase == LifecyclePhase::Reconciling && derived == Freshness::Fresh {
2587            Freshness::Reconciling
2588        } else {
2589            derived
2590        }
2591    }
2592
2593    /// Coherent state at the current clock.
2594    pub(crate) const fn state(&self) -> IndexState {
2595        self.state
2596    }
2597
2598    #[allow(dead_code)] // Consumed through `IndexHandle` by the next vertical slice.
2599    pub(crate) fn issues(&self) -> &[Issue] {
2600        &self.issues
2601    }
2602
2603    /// Whether the complete in-scope child set of a known directory is authoritative.
2604    #[allow(dead_code)] // Consumed through `IndexHandle` by the next vertical slice.
2605    pub(crate) fn directory_complete(&self, path: &Path) -> Option<bool> {
2606        let id = self.lookup(path)?;
2607        let entry = self.entry(id);
2608        (entry.kind == EntryKind::Dir).then(|| entry.directory().children_complete)
2609    }
2610
2611    /// Trust state for one subtree, including any stale descendant it contains.
2612    pub fn freshness_at(&self, path: &Path) -> Freshness {
2613        self.freshness_marks
2614            .iter()
2615            .filter(|(marked, _)| path.starts_with(marked) || marked.starts_with(path))
2616            .map(|(_, mark)| mark.state)
2617            .max_by_key(|state| state.rank())
2618            .unwrap_or(Freshness::Fresh)
2619    }
2620
2621    /// The clock of the most recently applied commit.
2622    pub fn clock(&self) -> Clock {
2623        self.clock
2624    }
2625
2626    /// Number of live entries, including the root.
2627    pub fn len(&self) -> u64 {
2628        self.live
2629    }
2630
2631    /// True when the index holds nothing but its root.
2632    pub fn is_empty(&self) -> bool {
2633        self.live <= 1
2634    }
2635
2636    /// Owned, self-describing roll-up state for the whole tree.
2637    pub fn total(&self) -> RollUp {
2638        self.named_rollup(&self.entry(EntryId::ROOT).rollup().all)
2639    }
2640
2641    /// Both fixed aggregate partitions for the complete tree.
2642    ///
2643    /// # Errors
2644    ///
2645    /// [`crate::Error::ControlStateNotObserved`] when the index did not observe control
2646    /// state: its unignored partition equals `all` only because no rule was read.
2647    /// [`Self::total`] answers the `all` partition for any index.
2648    pub fn partition_total(&self) -> crate::Result<PartitionRollUp> {
2649        self.require_observed_controls()?;
2650        Ok(self.named_partitions(self.entry(EntryId::ROOT).rollup()))
2651    }
2652
2653    /// Map-free whole-tree totals for in-crate reporting paths.
2654    pub(crate) fn total_scalars(&self) -> RollUpScalars {
2655        RollUpScalars::from(&self.entry(EntryId::ROOT).rollup().all)
2656    }
2657
2658    /// Arbitrate a producer observation and commit its effective mutations.
2659    ///
2660    /// Conditional operations are accepted only while their baseline still matches.
2661    /// No-ops and stale operations do not advance the clock or enter the journal.
2662    ///
2663    /// A control operation on an index that does not observe control state
2664    /// ([`Self::observes_controls`]) fails the whole batch with
2665    /// [`crate::Error::ControlStateNotObserved`], whatever its baseline.
2666    pub fn apply(&mut self, observation: &Observation) -> crate::Result<ApplyOutcome> {
2667        let prepared = prepare_observation(observation)?;
2668        let outcome = self.commit_prepared(prepared, true)?;
2669        record_batch(BatchProvenance::Public, observation.len(), outcome.stats);
2670        Ok(outcome)
2671    }
2672
2673    /// Arbitrate and atomically apply normalized producer input.
2674    fn commit_prepared(
2675        &mut self,
2676        prepared: PreparedObservation,
2677        journal: bool,
2678    ) -> crate::Result<ApplyOutcome> {
2679        self.commit_prepared_with(prepared, journal, None, None, None, false)
2680    }
2681
2682    fn commit_prepared_with(
2683        &mut self,
2684        prepared: PreparedObservation,
2685        journal: bool,
2686        discovery: Option<DiscoveryCommit>,
2687        observation: Option<ObservationTransition>,
2688        max_files: Option<u64>,
2689        track_file_progress: bool,
2690    ) -> crate::Result<ApplyOutcome> {
2691        debug_assert!(!self.is_folded(), "a folded index answers one report and is never mutated");
2692        if prepared.ops.is_empty()
2693            && discovery.as_ref().is_none_or(|discovery| {
2694                discovery.directory_complete.is_none() && discovery.transition.is_none()
2695            })
2696            && observation.is_none()
2697        {
2698            return Ok(ApplyOutcome::default());
2699        }
2700
2701        // A stopped or failed root is terminal for discovery. A listing that lands after
2702        // the stop -- a refresh can trip the shared budget while discovery is mid-walk --
2703        // may neither expand the retained set nor carry a transition that reopens the
2704        // phase: `Finish` would declare the root `Ready`, and an inaccessible boundary
2705        // would relabel why its coverage is partial.
2706        if discovery.is_some()
2707            && matches!(self.state.phase, LifecyclePhase::Stopped | LifecyclePhase::Failed)
2708        {
2709            return Err(crate::Error::OpenedIndexStopped);
2710        }
2711
2712        let mut discovery = discovery;
2713        if let Some(path) =
2714            discovery.as_mut().and_then(|discovery| discovery.directory_complete.as_mut())
2715        {
2716            // The transition this commit publishes carries the canonical relative path,
2717            // not the producer's spelling: a `DirectoryComplete` was only ever canonical
2718            // because discovery happened to build it that way.
2719            let canonical = canonical_relative_path(path)?;
2720            let Some(id) = self.lookup(&canonical) else {
2721                return Err(crate::Error::InvalidDirectoryCompletion(canonical));
2722            };
2723            if self.entry(id).kind != EntryKind::Dir {
2724                return Err(crate::Error::InvalidDirectoryCompletion(canonical));
2725            }
2726            *path = canonical;
2727        }
2728
2729        #[cfg(test)]
2730        if prepared.reject_before_apply {
2731            return Err(crate::Error::CommitRejected("injected reducer preflight"));
2732        }
2733
2734        let Some(next_clock) = self.clock.checked_next() else {
2735            // At the terminal clock, an all-no-op or all-stale batch is still a valid
2736            // observation. Probe on a detached clone to distinguish it from a real
2737            // change without touching the original index.
2738            let mut probe = self.clone();
2739            probe.clock = Clock(self.clock.0 - 1);
2740            let outcome = probe.commit_prepared_with(
2741                prepared,
2742                false,
2743                discovery,
2744                observation,
2745                max_files,
2746                track_file_progress,
2747            )?;
2748            return if outcome.commit.is_some() {
2749                Err(crate::Error::ClockExhausted)
2750            } else {
2751                Ok(outcome)
2752            };
2753        };
2754
2755        let observed = u64::try_from(prepared.ops.len()).unwrap_or(u64::MAX);
2756        let mut effects = ExactConsequences::default();
2757        let stats = self.reduce_prepared(
2758            &prepared,
2759            discovery,
2760            observation,
2761            max_files,
2762            track_file_progress,
2763            &mut effects,
2764        )?;
2765
2766        if effects.is_empty() {
2767            return Ok(ApplyOutcome::from_commit(stats, None));
2768        }
2769
2770        let commit =
2771            self.publish_effects(next_clock, effects, commit_work(observed, stats), journal);
2772        Ok(ApplyOutcome::from_commit(stats, Some(commit)))
2773    }
2774
2775    /// Apply one prepared batch through the shared fact and roll-up reducer.
2776    ///
2777    /// `C` is selected once by the caller. The exact instantiation retains closures as
2778    /// commits; the detached instantiation erases them, including their path copies.
2779    fn reduce_prepared<C: ConsequenceSink>(
2780        &mut self,
2781        prepared: &PreparedObservation,
2782        discovery: Option<DiscoveryCommit>,
2783        observation: Option<ObservationTransition>,
2784        max_files: Option<u64>,
2785        track_file_progress: bool,
2786        effects: &mut C,
2787    ) -> crate::Result<ApplyStats> {
2788        if self.carries_unobserved_control_input(&prepared.ops) {
2789            return Err(crate::Error::ControlStateNotObserved);
2790        }
2791        if matches!(prepared.ancestry, PreparedAncestry::Scanner { .. }) {
2792            debug_assert!(observation.is_none());
2793            return self.reduce_scanner_prepared(
2794                prepared,
2795                discovery,
2796                max_files,
2797                track_file_progress,
2798                effects,
2799            );
2800        }
2801        let mut stats = ApplyStats::default();
2802        let mut parent_memo = ParentMemo::default();
2803        let accepted = self.accepted_operations(&prepared.ops);
2804        stats.stale = u64::try_from(accepted.iter().filter(|accepted| !**accepted).count())
2805            .unwrap_or(u64::MAX);
2806        self.validate_known_ancestry(&prepared.ops, &accepted)?;
2807        let projected_controls = self.projected_controls(&prepared.ops, &accepted)?;
2808        self.preflight_totals(&prepared.ops, Some(&accepted), max_files)?;
2809
2810        for (observed, accepted) in prepared.ops.iter().zip(accepted) {
2811            if !accepted {
2812                continue;
2813            }
2814            let op = &observed.op;
2815            if let (Some(max_files), Op::Upsert { path, kind, .. }) = (max_files, op) {
2816                if self.files_after_upsert(path, *kind) > max_files {
2817                    stats.resource_refused = stats.resource_refused.saturating_add(1);
2818                    continue;
2819                }
2820            }
2821            match op {
2822                Op::Upsert { path, kind, attrs } => {
2823                    self.apply_upsert(path, *kind, *attrs, &mut stats, effects, &mut parent_memo);
2824                }
2825                Op::Remove { path } => {
2826                    // A removal takes a subtree with it, so a remembered id inside that
2827                    // subtree would dangle. Both of the non-upsert arms drop the memo
2828                    // rather than reason about whether this particular path could be an
2829                    // ancestor of it: the memo is refilled by the next upsert, so the
2830                    // cost of being conservative is one path resolution.
2831                    parent_memo.clear();
2832                    self.apply_remove(path, &mut stats, effects);
2833                }
2834                Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {
2835                    // The complete table was already prepared above. It is installed
2836                    // once, after ordinary structural mutations, so classification and
2837                    // both reducer partitions become visible atomically.
2838                    parent_memo.clear();
2839                }
2840                Op::InvalidateSubtree { path, reason } => {
2841                    parent_memo.clear();
2842                    let previous_index_state = self.state;
2843                    let previous = self.freshness_at(path);
2844                    self.pending_invalidations.push((path.clone(), *reason));
2845                    self.mark_unfresh(path, Freshness::Stale);
2846                    let current = self.freshness_at(path);
2847                    self.state.freshness = self.published_freshness();
2848                    if matches!(
2849                        reason,
2850                        InvalidateReason::WatchOverflow
2851                            | InvalidateReason::UnpairedRename
2852                            | InvalidateReason::WatchSetupRace
2853                            | InvalidateReason::VerificationFailed
2854                            | InvalidateReason::UnknownAncestry
2855                            | InvalidateReason::WatchContention
2856                    ) {
2857                        self.retain_issue(Issue::observation_gap(path, *reason));
2858                    }
2859                    stats.invalidated += 1;
2860                    effects.change(|| EffectiveChange::Invalidated {
2861                        path: path.clone(),
2862                        reason: *reason,
2863                    });
2864                    if previous != current {
2865                        effects.state(|| StateTransition::Freshness {
2866                            path: path.clone(),
2867                            previous,
2868                            current,
2869                        });
2870                    }
2871                    if previous_index_state != self.state {
2872                        effects.state(|| StateTransition::IndexState {
2873                            previous: previous_index_state,
2874                            current: self.state,
2875                        });
2876                    }
2877                }
2878            }
2879        }
2880
2881        self.finish_reduction(
2882            projected_controls,
2883            &mut stats,
2884            discovery,
2885            observation,
2886            max_files,
2887            track_file_progress,
2888            effects,
2889        );
2890
2891        Ok(stats)
2892    }
2893
2894    /// Apply one scanner batch using only the parent identities proved above.
2895    fn reduce_scanner_prepared<C: ConsequenceSink>(
2896        &mut self,
2897        prepared: &PreparedObservation,
2898        discovery: Option<DiscoveryCommit>,
2899        max_files: Option<u64>,
2900        track_file_progress: bool,
2901        effects: &mut C,
2902    ) -> crate::Result<ApplyStats> {
2903        let PreparedAncestry::Scanner { parents, has_batch_parents } = &prepared.ancestry else {
2904            unreachable!("scanner reduction requires scanner ancestry");
2905        };
2906        debug_assert_eq!(prepared.ops.len(), parents.len());
2907        let projection_started = crate::counters::enabled().then(std::time::Instant::now);
2908        let projected_controls =
2909            self.projected_controls_from(prepared.ops.iter().map(|observed| &observed.op))?;
2910        if let Some(started) = projection_started {
2911            let elapsed = elapsed_micros(started);
2912            crate::counters::bump(|counts| {
2913                counts.scanner_control_projection_us =
2914                    counts.scanner_control_projection_us.saturating_add(elapsed);
2915            });
2916        }
2917        self.preflight_totals(&prepared.ops, None, max_files)?;
2918        let mut stats = ApplyStats::default();
2919        let mut applied_ids = has_batch_parents.then(|| vec![None; prepared.ops.len()]);
2920
2921        for (op_index, (observed, parent)) in prepared.ops.iter().zip(parents.iter()).enumerate() {
2922            match &observed.op {
2923                Op::Upsert { path, kind, attrs } => {
2924                    if let Some(max_files) = max_files {
2925                        if self.files_after_upsert(path, *kind) > max_files {
2926                            stats.resource_refused = stats.resource_refused.saturating_add(1);
2927                            continue;
2928                        }
2929                    }
2930                    let parent = match parent {
2931                        ResolvedParent::Existing(parent) => *parent,
2932                        ResolvedParent::Earlier(parent_op) => applied_ids
2933                            .as_ref()
2934                            .and_then(|ids| ids.get(*parent_op))
2935                            .copied()
2936                            .flatten()
2937                            .expect("a proved parent directory was applied earlier"),
2938                    };
2939                    let name = path.file_name().expect("scanner upserts are not root mutations");
2940                    crate::counters::bump(|counts| counts.upserts += 1);
2941                    self.upsert_beneath(parent, name, path, *kind, *attrs, &mut stats, effects);
2942                    if kind.is_dir() {
2943                        if let Some(ids) = &mut applied_ids {
2944                            ids[op_index] = self.child(parent, name);
2945                        }
2946                    }
2947                }
2948                Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {}
2949                Op::Remove { .. } | Op::InvalidateSubtree { .. } => {
2950                    unreachable!("scanner preparation rejects non-discovery operations");
2951                }
2952            }
2953        }
2954
2955        self.finish_reduction(
2956            projected_controls,
2957            &mut stats,
2958            discovery,
2959            None,
2960            max_files,
2961            track_file_progress,
2962            effects,
2963        );
2964
2965        Ok(stats)
2966    }
2967
2968    #[allow(clippy::too_many_arguments)] // One shared tail keeps both reducer lanes identical.
2969    fn finish_reduction<C: ConsequenceSink>(
2970        &mut self,
2971        projected_controls: Option<crate::control::ControlTable>,
2972        stats: &mut ApplyStats,
2973        discovery: Option<DiscoveryCommit>,
2974        observation: Option<ObservationTransition>,
2975        max_files: Option<u64>,
2976        track_file_progress: bool,
2977        effects: &mut C,
2978    ) {
2979        self.apply_control_transition(projected_controls, stats, effects);
2980        let mut discovery = discovery;
2981        if stats.resource_refused > 0 {
2982            let max_files = max_files.expect("resource refusal requires a file limit");
2983            let discovery = discovery.get_or_insert_with(DiscoveryCommit::default);
2984            discovery.directory_complete = None;
2985            discovery.transition =
2986                Some(DiscoveryTransition::BudgetRefused(Issue::resource_budget(max_files)));
2987        }
2988        self.apply_opened_state(discovery, observation, track_file_progress, effects);
2989    }
2990
2991    fn apply_opened_state<C: ConsequenceSink>(
2992        &mut self,
2993        discovery: Option<DiscoveryCommit>,
2994        observation: Option<ObservationTransition>,
2995        track_file_progress: bool,
2996        effects: &mut C,
2997    ) {
2998        if discovery.is_none() && observation.is_none() && !track_file_progress {
2999            return;
3000        }
3001        let previous = self.state;
3002        if track_file_progress {
3003            self.state.progress.files_retained = self.total_scalars().files;
3004        }
3005
3006        if let Some(discovery) = discovery {
3007            if let Some(path) = discovery.directory_complete {
3008                let id = self.lookup(&path).expect("discovery completion was preflighted");
3009                if !self.entry(id).directory().children_complete {
3010                    self.entry_mut(id).directory_mut().children_complete = true;
3011                    self.state.progress.directories_complete =
3012                        self.state.progress.directories_complete.saturating_add(1);
3013                    effects.state(|| StateTransition::DirectoryComplete { path });
3014                }
3015            }
3016
3017            if let Some(transition) = discovery.transition {
3018                match transition {
3019                    DiscoveryTransition::Begin => {
3020                        for slot in &mut self.arena {
3021                            if let Slot::Occupied { entry, .. } = slot {
3022                                if entry.kind == EntryKind::Dir {
3023                                    entry.directory_mut().children_complete = false;
3024                                }
3025                            }
3026                        }
3027                        self.state = IndexState {
3028                            phase: LifecyclePhase::Discovering,
3029                            coverage: Coverage::Partial(CoverageReason::Building),
3030                            freshness: Freshness::Fresh,
3031                            source: Source::Scanned,
3032                            progress: DiscoveryProgress::default(),
3033                            issues: crate::IssueSummary::default(),
3034                        };
3035                        self.issues.clear();
3036                        self.issue_epochs.clear();
3037                        self.omitted_issue_epochs.clear();
3038                    }
3039                    DiscoveryTransition::Finish => {
3040                        self.state.phase = LifecyclePhase::Ready;
3041                        if self.state.coverage == Coverage::Partial(CoverageReason::Building) {
3042                            self.state.coverage = Coverage::Complete;
3043                        }
3044                        self.state.freshness = if self.state.coverage == Coverage::Complete {
3045                            Freshness::Fresh
3046                        } else {
3047                            Freshness::Partial
3048                        };
3049                    }
3050                    DiscoveryTransition::BudgetRefused(issue) => {
3051                        let already_stopped_for_budget = self.state.phase
3052                            == LifecyclePhase::Stopped
3053                            && self.state.coverage == Coverage::Partial(CoverageReason::Budget);
3054                        self.state.phase = LifecyclePhase::Stopped;
3055                        self.state.coverage = Coverage::Partial(CoverageReason::Budget);
3056                        self.state.freshness = Freshness::Fresh;
3057                        if !already_stopped_for_budget {
3058                            self.retain_issue(issue);
3059                        }
3060                    }
3061                    DiscoveryTransition::Inaccessible { issues, omitted } => {
3062                        if self.state.coverage != Coverage::Partial(CoverageReason::Budget) {
3063                            self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
3064                            self.state.freshness = Freshness::Partial;
3065                        }
3066                        for issue in issues {
3067                            self.retain_issue(issue);
3068                        }
3069                        self.retain_omitted(omitted);
3070                    }
3071                    DiscoveryTransition::Cancelled => {
3072                        self.state.phase = LifecyclePhase::Stopped;
3073                        if self.state.coverage != Coverage::Complete {
3074                            self.state.coverage = Coverage::Partial(CoverageReason::Cancelled);
3075                        }
3076                    }
3077                    DiscoveryTransition::Failed(issue) => {
3078                        self.state.phase = LifecyclePhase::Failed;
3079                        self.state.coverage = Coverage::Partial(CoverageReason::Failed);
3080                        self.state.freshness = Freshness::Partial;
3081                        self.retain_issue(issue);
3082                    }
3083                }
3084            }
3085        }
3086
3087        if let Some(observation) = observation {
3088            match observation {
3089                ObservationTransition::Reconciling => {
3090                    if self.state.phase == LifecyclePhase::Ready {
3091                        self.state.phase = LifecyclePhase::Reconciling;
3092                        self.state.freshness = Freshness::Reconciling;
3093                    }
3094                }
3095                ObservationTransition::Watching { issues, omitted } => {
3096                    if self.state.phase == LifecyclePhase::Reconciling {
3097                        self.state.phase = LifecyclePhase::Watching;
3098                        if !issues.is_empty() || omitted > 0 {
3099                            self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
3100                            for issue in issues {
3101                                self.retain_issue(issue);
3102                            }
3103                            self.retain_omitted(omitted);
3104                        } else if self.state.coverage
3105                            == Coverage::Partial(CoverageReason::Inaccessible)
3106                        {
3107                            // The handoff has just read the whole root without one error, so
3108                            // a boundary discovery could not read no longer exists. Coverage
3109                            // says what can be known now. That complete pass has already
3110                            // dropped the issues it disproved and recorded completeness for
3111                            // every directory it listed, so nothing below contradicts it.
3112                            self.state.coverage = Coverage::Complete;
3113                        }
3114                        self.state.freshness = self.freshness();
3115                        if self.state.coverage != Coverage::Complete {
3116                            self.state.freshness = Freshness::Partial;
3117                        }
3118                    }
3119                }
3120                ObservationTransition::Unreadable { issues, omitted } => {
3121                    // `Ready` is a shared index watched without an opened-root lifecycle,
3122                    // which never leaves that phase; a stopped or failed root keeps nothing.
3123                    if matches!(self.state.phase, LifecyclePhase::Watching | LifecyclePhase::Ready)
3124                    {
3125                        for issue in issues {
3126                            self.retain_issue(issue);
3127                        }
3128                        self.retain_omitted(omitted);
3129                    }
3130                }
3131                ObservationTransition::Failed(issue) => {
3132                    if self.state.phase != LifecyclePhase::Stopped {
3133                        self.state.phase = LifecyclePhase::Failed;
3134                        self.state.freshness = Freshness::Partial;
3135                        self.retain_issue(issue);
3136                    }
3137                }
3138            }
3139        }
3140
3141        if previous != self.state {
3142            effects.state(|| StateTransition::IndexState { previous, current: self.state });
3143        }
3144    }
3145
3146    /// What `id` and everything beneath it contribute to each ancestor's roll-up.
3147    fn subtree_scalars(&self, id: EntryId) -> RollUpScalars {
3148        let entry = self.entry(id);
3149        match entry.kind {
3150            EntryKind::Dir => {
3151                let mut scalars = RollUpScalars::from(&entry.rollup().all);
3152                scalars.dirs = scalars.dirs.saturating_add(1);
3153                scalars
3154            }
3155            EntryKind::File => RollUpScalars::leaf(EntryKind::File, &entry.attrs),
3156            EntryKind::Symlink | EntryKind::Other => RollUpScalars::default(),
3157        }
3158    }
3159
3160    /// Refuse a batch that would leave a whole-tree total no `u64` can hold, before it
3161    /// moves anything ([`crate::Error::UnrepresentableTotal`]).
3162    ///
3163    /// The root's `all` roll-up bounds every other: each directory's is a sub-sum of it,
3164    /// `unignored` is a part of `all`, an extension tally is a part of its directory's,
3165    /// and a folded file's tally is a part of the roll-up that counted it. So the batch is
3166    /// safe when the root's four counters stay representable after each accepted
3167    /// operation, applied in order, which is exactly what the reducer computes.
3168    ///
3169    /// Two passes, because this runs for every scanner batch. The first adds what each
3170    /// accepted upsert would contribute as a new entry and subtracts nothing, so it bounds
3171    /// every intermediate total from above at four checked additions per upsert; when it
3172    /// fits, nothing else is computed. Only a batch that fails that bound is replayed
3173    /// exactly, which is where an exact-fit replacement or a removal that makes room is
3174    /// told apart from an overflow.
3175    fn preflight_totals(
3176        &self,
3177        ops: &[ObservationOp],
3178        accepted: Option<&[bool]>,
3179        max_files: Option<u64>,
3180    ) -> crate::Result<()> {
3181        let mut bound = self.total_scalars();
3182        for (index, observed) in ops.iter().enumerate() {
3183            if accepted.is_some_and(|accepted| !accepted[index]) {
3184                continue;
3185            }
3186            if let Op::Upsert { kind, attrs, .. } = &observed.op {
3187                match bound.checked_add(&RollUpScalars::leaf(*kind, attrs)) {
3188                    Ok(next) => bound = next,
3189                    Err(_) => return self.replay_totals(ops, accepted, max_files),
3190                }
3191            }
3192        }
3193        Ok(())
3194    }
3195
3196    /// The exact pass of [`Self::preflight_totals`]: the root's totals after each
3197    /// accepted operation, with replacements, kind changes, and removals netted in order
3198    /// against a projection of the tree ([`TotalsOverlay`]) rather than the tree itself.
3199    fn replay_totals(
3200        &self,
3201        ops: &[ObservationOp],
3202        accepted: Option<&[bool]>,
3203        max_files: Option<u64>,
3204    ) -> crate::Result<()> {
3205        let mut total = self.total_scalars();
3206        let mut overlay = TotalsOverlay::default();
3207        for (index, observed) in ops.iter().enumerate() {
3208            if accepted.is_some_and(|accepted| !accepted[index]) {
3209                continue;
3210            }
3211            match &observed.op {
3212                Op::Upsert { path, kind, attrs } => {
3213                    // The root only ever changes its own attributes, and so does any
3214                    // entry re-observed with its kind unless it is a file: only a file's
3215                    // attributes reach its ancestors' roll-ups.
3216                    if path.as_os_str().is_empty()
3217                        || (overlay.kind_at(self, path) == Some(*kind) && *kind != EntryKind::File)
3218                    {
3219                        continue;
3220                    }
3221                    let old = overlay.subtree_at(self, path);
3222                    let new = RollUpScalars::leaf(*kind, attrs);
3223                    let after =
3224                        total.saturating_sub(&old).checked_add(&new).map_err(|counter| {
3225                            crate::Error::UnrepresentableTotal { path: path.clone(), counter }
3226                        })?;
3227                    if max_files.is_some_and(|max_files| after.files > max_files) {
3228                        // The reducer refuses this upsert for its file budget and moves on.
3229                        continue;
3230                    }
3231                    overlay.replace(self, path, *kind, new);
3232                    total = after;
3233                }
3234                Op::Remove { path } => {
3235                    if path.as_os_str().is_empty() {
3236                        continue;
3237                    }
3238                    let old = overlay.subtree_at(self, path);
3239                    overlay.cut(self, path);
3240                    total = total.saturating_sub(&old);
3241                }
3242                Op::ControlUpsert { .. }
3243                | Op::ControlRemove { .. }
3244                | Op::InvalidateSubtree { .. } => {}
3245            }
3246        }
3247        Ok(())
3248    }
3249
3250    /// Exact regular-file total that would remain after one upsert at the current
3251    /// commit boundary.
3252    fn files_after_upsert(&self, path: &Path, kind: EntryKind) -> u64 {
3253        let current_total = self.total_scalars().files;
3254        let Some(id) = self.lookup(path) else {
3255            return current_total.saturating_add(u64::from(kind == EntryKind::File));
3256        };
3257        let current = self.entry(id);
3258        if current.kind == kind {
3259            return current_total;
3260        }
3261        let removed = match current.kind {
3262            EntryKind::File => 1,
3263            EntryKind::Dir => current.rollup().all.files,
3264            _ => 0,
3265        };
3266        current_total.saturating_sub(removed).saturating_add(u64::from(kind == EntryKind::File))
3267    }
3268
3269    /// Retain one issue, once per cause.
3270    ///
3271    /// A cause is its kind and path: a boundary a producer meets again on every re-walk --
3272    /// an unreadable directory, a gap the observer keeps reporting at one place -- is one
3273    /// issue. Without a key, routine repeats filled the bounded list and every later
3274    /// distinct issue was omitted with no text. A repeat only records that the cause was
3275    /// seen again, which a later reconciliation needs; the retained text stays as it was,
3276    /// since changing what a read returns without a commit would let one version answer two
3277    /// ways. An issue without a path has nothing to key on, so only an identical one counts
3278    /// as a repeat.
3279    fn retain_issue(&mut self, issue: Issue) {
3280        self.retain_issue_at(issue, self.freshness_epoch);
3281    }
3282
3283    fn retain_issue_at(&mut self, issue: Issue, epoch: u64) {
3284        let repeat = self.issues.iter().position(|retained| same_issue_cause(retained, &issue));
3285        if let Some(position) = repeat {
3286            self.issue_epochs[position] = epoch;
3287        } else {
3288            let position = self
3289                .issues
3290                .binary_search_by(|retained| compare_issues(retained, &issue))
3291                .unwrap_or_else(|position| position);
3292            if position < MAX_RETAINED_ISSUES {
3293                self.issues.insert(position, issue);
3294                self.issue_epochs.insert(position, epoch);
3295                if self.issues.len() > MAX_RETAINED_ISSUES {
3296                    self.issues.pop();
3297                    let omitted_epoch =
3298                        self.issue_epochs.pop().expect("issue epochs stay parallel");
3299                    self.retain_omitted_at(1, omitted_epoch);
3300                }
3301            } else {
3302                self.retain_omitted_at(1, epoch);
3303            }
3304            self.state.issues.retained = u64::try_from(self.issues.len()).unwrap_or(u64::MAX);
3305        }
3306    }
3307
3308    fn retain_omitted(&mut self, count: u64) {
3309        if count == 0 {
3310            return;
3311        }
3312        let epoch =
3313            self.active_reconciles.keys().next_back().copied().unwrap_or(self.freshness_epoch);
3314        self.retain_omitted_at(count, epoch);
3315    }
3316
3317    fn retain_omitted_at(&mut self, count: u64, epoch: u64) {
3318        let retained = self.omitted_issue_epochs.entry(epoch).or_default();
3319        *retained = retained.saturating_add(count);
3320        self.state.issues.omitted = self.state.issues.omitted.saturating_add(count);
3321    }
3322
3323    fn drop_disproven_omitted(&mut self, started_at: u64) {
3324        self.omitted_issue_epochs.retain(|epoch, _| *epoch >= started_at);
3325        self.state.issues.omitted =
3326            self.omitted_issue_epochs.values().fold(0_u64, |sum, count| sum.saturating_add(*count));
3327    }
3328
3329    fn compact_omitted_epochs(&mut self) {
3330        let mut compact = BTreeMap::new();
3331        for (epoch, count) in std::mem::take(&mut self.omitted_issue_epochs) {
3332            let owner =
3333                self.active_reconciles.range(..=epoch).next_back().map_or(0, |(epoch, _)| *epoch);
3334            let retained = compact.entry(owner).or_insert(0_u64);
3335            *retained = retained.saturating_add(count);
3336        }
3337        self.omitted_issue_epochs = compact;
3338    }
3339
3340    /// Drop retained issues a reconciliation that visited `path` has disproved.
3341    ///
3342    /// An issue about a path at or below `path`, published before the pass began, described
3343    /// something the pass has just read without an error: a directory that could not be
3344    /// listed, an entry whose metadata could not be read, a control the index refused. It is
3345    /// no longer true, and keeping it would explain a state the root is not in. Three kinds
3346    /// of issue survive. An observation gap records that the observer lost precision and had
3347    /// to recover, which the recovery does not undo. An issue without a path cannot be placed
3348    /// under the pass. And an issue published at or after `started_at` came from a pass
3349    /// that closed while this one ran, whose `Partial` mark this pass leaves in place: the
3350    /// issue is stamped with the same epoch as that mark so the two survive together. The
3351    /// omitted count stays: what it counted was never retained.
3352    fn drop_disproven_issues(&mut self, path: &Path, started_at: u64) {
3353        let mut position = 0;
3354        while position < self.issues.len() {
3355            let issue = &self.issues[position];
3356            let disproven = issue.kind != crate::IssueKind::ObservationGap
3357                && issue.path.as_deref().is_some_and(|issue_path| issue_path.starts_with(path))
3358                && self.issue_epochs[position] < started_at;
3359            if disproven {
3360                self.issues.remove(position);
3361                self.issue_epochs.remove(position);
3362            } else {
3363                position += 1;
3364            }
3365        }
3366        self.state.issues.retained = u64::try_from(self.issues.len()).unwrap_or(u64::MAX);
3367    }
3368
3369    /// Mint and optionally retain one fully evaluated transition.
3370    ///
3371    /// Every fact-only, state-only, or combined mutation reaches this function after
3372    /// its fallible validation and preflight work is complete.
3373    fn publish_effects(
3374        &mut self,
3375        next_clock: Clock,
3376        effects: ExactConsequences,
3377        work: Work,
3378        journal: bool,
3379    ) -> Commit {
3380        debug_assert!(!effects.is_empty());
3381        if crate::counters::enabled() {
3382            let effect_paths = u64::try_from(effects.changes.len()).unwrap_or(u64::MAX);
3383            let effect_path_bytes = effects.changes.iter().fold(0_u64, |total, change| {
3384                total.saturating_add(
3385                    u64::try_from(change.path().as_os_str().as_encoded_bytes().len())
3386                        .unwrap_or(u64::MAX),
3387                )
3388            });
3389            crate::counters::bump(|counts| {
3390                counts.effect_paths = counts.effect_paths.saturating_add(effect_paths);
3391                counts.effect_path_bytes =
3392                    counts.effect_path_bytes.saturating_add(effect_path_bytes);
3393            });
3394        }
3395        let commit = Commit {
3396            clock: next_clock,
3397            impact: derive_impact(&effects.changes, &effects.state),
3398            changes: effects.changes,
3399            state: effects.state,
3400            work,
3401        };
3402        self.clock = next_clock;
3403        if journal {
3404            self.retain_commit(&commit);
3405        }
3406        commit
3407    }
3408
3409    fn retain_commit(&mut self, commit: &Commit) {
3410        let cost = commit.retained_cost();
3411        if cost > self.journal_capacity_bytes {
3412            let dropped = u64::try_from(self.journal.len()).unwrap_or(u64::MAX);
3413            crate::counters::bump(|counts| {
3414                counts.journal_oversized_commits =
3415                    counts.journal_oversized_commits.saturating_add(1);
3416                counts.journal_dropped_commits =
3417                    counts.journal_dropped_commits.saturating_add(dropped);
3418            });
3419            self.journal.clear();
3420            self.journal_cost = 0;
3421            self.journal_floor = commit.clock;
3422            return;
3423        }
3424
3425        while self.journal_cost + cost > self.journal_capacity_bytes {
3426            if let Some(dropped) = self.journal.pop_front() {
3427                crate::counters::bump(|counts| {
3428                    counts.journal_dropped_commits =
3429                        counts.journal_dropped_commits.saturating_add(1);
3430                });
3431                self.journal_cost -= dropped.retained_cost();
3432                self.journal_floor = dropped.clock;
3433            }
3434        }
3435        self.journal_cost += cost;
3436        self.journal.push_back(commit.clone());
3437        crate::counters::bump(|counts| {
3438            counts.journal_cloned_commits = counts.journal_cloned_commits.saturating_add(1);
3439            counts.journal_retained_commits = counts.journal_retained_commits.saturating_add(1);
3440        });
3441    }
3442
3443    /// Apply trusted bootstrap data without exposing it as live change history.
3444    pub(crate) fn apply_baseline(
3445        &mut self,
3446        observation: &Observation,
3447    ) -> crate::Result<ApplyStats> {
3448        let prepared = prepare_observation(observation)?;
3449        #[cfg(test)]
3450        if prepared.reject_before_apply {
3451            return Err(crate::Error::CommitRejected("injected reducer preflight"));
3452        }
3453        let mut effects = NoConsequences;
3454        let stats = self.reduce_prepared(&prepared, None, None, None, false, &mut effects)?;
3455        record_batch(BatchProvenance::Baseline, observation.len(), stats);
3456        self.establish_baseline();
3457        Ok(stats)
3458    }
3459
3460    /// Apply one owned filesystem-walker batch without constructing public history.
3461    pub(crate) fn apply_scanner_baseline(
3462        &mut self,
3463        batch: crate::scan::ScannerBatch,
3464    ) -> crate::Result<ApplyStats> {
3465        let observed = batch.len();
3466        let prepare_started = crate::counters::enabled().then(std::time::Instant::now);
3467        let prepared = self.prepare_scanner_batch(batch)?;
3468        if let Some(started) = prepare_started {
3469            let elapsed = elapsed_micros(started);
3470            crate::counters::bump(|counts| {
3471                counts.scanner_prepare_us = counts.scanner_prepare_us.saturating_add(elapsed);
3472            });
3473        }
3474        let mut effects = NoConsequences;
3475        let reduce_started = crate::counters::enabled().then(std::time::Instant::now);
3476        // Dispatch on the prepared lane: a batch that replaces a kind is general.
3477        let stats = self.reduce_prepared(&prepared, None, None, None, false, &mut effects)?;
3478        if let Some(started) = reduce_started {
3479            let elapsed = elapsed_micros(started);
3480            crate::counters::bump(|counts| {
3481                counts.scanner_reduce_us = counts.scanner_reduce_us.saturating_add(elapsed);
3482            });
3483        }
3484        record_batch(BatchProvenance::Baseline, observed, stats);
3485        self.establish_baseline();
3486        Ok(stats)
3487    }
3488
3489    #[cfg(test)]
3490    pub(crate) fn apply_ok(&mut self, observation: &Observation) -> ApplyOutcome {
3491        self.apply(observation).expect("test observation must be valid")
3492    }
3493
3494    #[cfg(test)]
3495    pub(crate) fn apply_baseline_ok(&mut self, observation: &Observation) -> ApplyStats {
3496        self.apply_baseline(observation).expect("test baseline must be valid")
3497    }
3498
3499    /// Mark the current tree as the process baseline.
3500    pub(crate) fn establish_baseline(&mut self) {
3501        self.clock = Clock::ZERO;
3502        self.journal.clear();
3503        self.journal_cost = 0;
3504        self.journal_floor = Clock::ZERO;
3505        self.pending_invalidations.clear();
3506    }
3507
3508    /// Mark the whole index as not verified against the filesystem.
3509    ///
3510    /// Used by the cache-only open path: a snapshot records the freshness it had when it
3511    /// was written, and replaying that verbatim would let an unverified answer claim
3512    /// currency it has not earned.
3513    pub(crate) fn mark_unverified(&mut self) {
3514        self.freshness_marks.clear();
3515        self.mark_unfresh(Path::new(""), Freshness::Stale);
3516        self.state.source = Source::Cached;
3517        self.state.freshness = Freshness::Stale;
3518    }
3519
3520    pub(crate) fn set_initial_freshness(&mut self, complete: bool) {
3521        if complete {
3522            for slot in &mut self.arena {
3523                if let Slot::Occupied { entry, .. } = slot {
3524                    if entry.kind == EntryKind::Dir {
3525                        entry.directory_mut().children_complete = true;
3526                    }
3527                }
3528            }
3529        }
3530        self.set_initial_state(complete);
3531    }
3532
3533    /// Finish a detached cold walk ([`DetachedIndexBuilder`]) as
3534    /// [`Self::set_initial_scan_freshness`] does.
3535    ///
3536    /// The builder allocates every directory as listed in full, so a walk without failures
3537    /// leaves each one as it is rather than marking the whole arena complete a second time
3538    /// (F6e), and a walk with failures withdraws completeness exactly as any cold walk's
3539    /// does.
3540    pub(crate) fn set_initial_detached_scan_freshness(&mut self, errors: &[crate::Error]) {
3541        if !errors.is_empty() {
3542            self.set_initial_scan_freshness(errors);
3543            return;
3544        }
3545        debug_assert!(
3546            self.arena.iter().all(|slot| match slot {
3547                Slot::Occupied { entry, .. } if entry.kind.is_dir() => {
3548                    entry.directory().children_complete
3549                }
3550                Slot::Occupied { .. } | Slot::Free { .. } => true,
3551            }),
3552            "a detached builder allocates every directory as listed in full"
3553        );
3554        self.set_initial_state(true);
3555    }
3556
3557    /// The index-wide lifecycle state a first pass leaves, whatever it records of each
3558    /// directory's own listing.
3559    fn set_initial_state(&mut self, complete: bool) {
3560        self.freshness_marks.clear();
3561        if complete {
3562            self.state.phase = LifecyclePhase::Ready;
3563            self.state.coverage = Coverage::Complete;
3564            self.state.freshness = Freshness::Fresh;
3565        } else {
3566            self.mark_unfresh(Path::new(""), Freshness::Partial);
3567            self.state.phase = LifecyclePhase::Ready;
3568            self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
3569            self.state.freshness = Freshness::Partial;
3570        }
3571    }
3572
3573    /// Finish a cold walk using all of its failures, before diagnostic retention bounds
3574    /// discard any paths. A scoped failure withdraws its unverified listing boundary;
3575    /// ancestors still have their own listings, and readers fold eligible descendants.
3576    /// An unscoped failure cannot establish completeness anywhere in the walk.
3577    pub(crate) fn set_initial_scan_freshness(&mut self, errors: &[crate::Error]) {
3578        self.set_initial_freshness(errors.is_empty());
3579        if errors.is_empty() {
3580            return;
3581        }
3582        for slot in &mut self.arena {
3583            if let Slot::Occupied { entry, .. } = slot {
3584                if entry.kind.is_dir() {
3585                    entry.directory_mut().children_complete = false;
3586                }
3587            }
3588        }
3589        let mut failed = Vec::with_capacity(errors.len());
3590        for error in errors {
3591            let Some(path) = Issue::from_error_under(&self.root_path, error).path else {
3592                return;
3593            };
3594            if path.is_absolute() || path.as_os_str().is_empty() {
3595                return;
3596            }
3597            failed.push(path);
3598        }
3599        failed.sort();
3600        failed.dedup();
3601        let listings: Vec<_> =
3602            failed.iter().map(|path| self.failed_listing_boundary(path)).collect();
3603        self.freshness_marks.clear();
3604        for path in &failed {
3605            self.mark_unfresh(path, Freshness::Partial);
3606        }
3607        // The walk has terminated and each failure is scoped above. Every retained
3608        // directory outside those boundaries therefore has its complete in-scope
3609        // listing. In particular, never promote descendants of a failed listing.
3610        for slot in 0..self.arena.len() {
3611            let Slot::Occupied { generation, entry } = &self.arena[slot] else {
3612                continue;
3613            };
3614            if !entry.kind.is_dir() {
3615                continue;
3616            }
3617            let id = EntryId {
3618                slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
3619                generation: *generation,
3620            };
3621            let Some(path) = self.path_of(id) else {
3622                continue;
3623            };
3624            self.entry_mut(id).directory_mut().children_complete =
3625                !listings.iter().zip(&failed).any(|(boundary, failure)| {
3626                    path == *boundary || (boundary == failure && path.starts_with(boundary))
3627                });
3628        }
3629    }
3630
3631    /// A retained failed directory has an unknown child set. If the failed entry was
3632    /// omitted (for example after a metadata failure), its nearest retained directory
3633    /// cannot claim a complete listing either.
3634    fn failed_listing_boundary(&self, failed: &Path) -> PathBuf {
3635        let mut boundary = failed.to_path_buf();
3636        loop {
3637            if self.lookup(&boundary).is_some_and(|id| self.entry(id).kind.is_dir()) {
3638                return boundary;
3639            }
3640            if !boundary.pop() {
3641                return PathBuf::new();
3642            }
3643        }
3644    }
3645
3646    pub(crate) fn record_walk_errors(&mut self, errors: &mut Vec<crate::Error>) {
3647        self.issues.clear();
3648        self.issue_epochs.clear();
3649        self.omitted_issue_epochs.clear();
3650        self.state.issues = crate::IssueSummary::default();
3651        let root = self.root_path.clone();
3652        crate::scan::normalize_walk_errors(&root, errors);
3653        self.unreadable_control_paths.clear();
3654        for error in errors {
3655            if let Some(path) = crate::control::unreadable_control(&root, error) {
3656                self.unreadable_control_paths.insert(path);
3657            }
3658            self.retain_issue(Issue::from_error_under(&root, error));
3659        }
3660    }
3661
3662    pub(crate) fn begin_reconcile(&mut self, path: &Path) -> crate::Result<(u64, Option<Commit>)> {
3663        let path = canonical_relative_path(path)?;
3664        let next_clock = self.clock.checked_next().ok_or(crate::Error::ClockExhausted)?;
3665        let previous_index_state = self.state;
3666        let previous = self.freshness_at(&path);
3667        let epoch = self.mark_unfresh(&path, Freshness::Reconciling);
3668        self.active_reconciles.insert(
3669            epoch,
3670            ActiveReconcile {
3671                path: path.clone(),
3672                scope_budget: usize::try_from(self.len()).unwrap_or(usize::MAX),
3673                evidence: ReconcileEvidence::Scopes(BTreeSet::new()),
3674            },
3675        );
3676        if path.as_os_str().is_empty() {
3677            self.active_root_reconciles.insert(epoch, Self::now_unix_nanos());
3678        }
3679        let current = self.freshness_at(&path);
3680        self.state.freshness = self.published_freshness();
3681        let commit = if previous == current && previous_index_state == self.state {
3682            None
3683        } else {
3684            let mut state = Vec::new();
3685            if previous != current {
3686                state.push(StateTransition::Freshness { path, previous, current });
3687            }
3688            if previous_index_state != self.state {
3689                state.push(StateTransition::IndexState {
3690                    previous: previous_index_state,
3691                    current: self.state,
3692                });
3693            }
3694            let effects = ExactConsequences { state, ..ExactConsequences::default() };
3695            Some(self.publish_effects(next_clock, effects, Work::default(), true))
3696        };
3697        Ok((epoch, commit))
3698    }
3699
3700    /// Close one reconciliation opened by [`Self::begin_reconcile`].
3701    ///
3702    /// `listed_incomplete` names the directories the pass listed in full, with no error
3703    /// inside them, that the index did not hold as complete when it listed them. Each is
3704    /// recorded as complete in this commit whether or not the whole pass completed, exactly
3705    /// as discovery's listing commit records the directories it lists, unless a producer
3706    /// invalidated or began verifying it after this pass started: that producer's own pass
3707    /// owns its listing now. A caller passes none when a conditional commit lost a race.
3708    pub(crate) fn finish_reconcile(
3709        &mut self,
3710        path: &Path,
3711        started_at: u64,
3712        complete: bool,
3713        listed_incomplete: &[PathBuf],
3714        failed_paths: &[PathBuf],
3715        errors: ReconcileErrors<'_>,
3716    ) -> crate::Result<ReconcileFinish> {
3717        let path = canonical_relative_path(path)?;
3718        let next_clock = self.clock.checked_next().ok_or(crate::Error::ClockExhausted)?;
3719        let previous_index_state = self.state;
3720        let previous = self.freshness_at(&path);
3721        // Retired evidence is needed only until every older overlapping pass closes.
3722        let evidence = self.active_reconciles.get(&started_at).map_or_else(
3723            || ReconcileEvidence::Scopes(BTreeSet::new()),
3724            |active| active.evidence.clone(),
3725        );
3726        let superseded = match evidence {
3727            ReconcileEvidence::Scopes(scopes) => scopes,
3728            ReconcileEvidence::Retry => {
3729                self.active_root_reconciles.remove(&started_at);
3730                self.active_reconciles.remove(&started_at);
3731                self.compact_omitted_epochs();
3732                self.mark_unfresh(&path, Freshness::Partial);
3733                if self.state.coverage == Coverage::Complete {
3734                    self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
3735                }
3736                self.state.freshness = self.published_freshness();
3737                self.retain_issue(Issue::provider_failure(
3738                    Some(&path),
3739                    "reconciliation interrupted by newer verification; retry this scope"
3740                        .to_string(),
3741                ));
3742                let current = self.freshness_at(&path);
3743                let mut state = Vec::new();
3744                if previous != current {
3745                    state.push(StateTransition::Freshness { path, previous, current });
3746                }
3747                if previous_index_state != self.state {
3748                    state.push(StateTransition::IndexState {
3749                        previous: previous_index_state,
3750                        current: self.state,
3751                    });
3752                }
3753                let commit = if state.is_empty() {
3754                    None
3755                } else {
3756                    let effects = ExactConsequences { state, ..ExactConsequences::default() };
3757                    Some(self.publish_effects(next_clock, effects, Work::default(), true))
3758                };
3759                return Ok(ReconcileFinish { commit, retry: true });
3760            }
3761        };
3762        let fully_superseded = superseded.iter().any(|newer| path.starts_with(newer));
3763        if errors.disproves_old && !fully_superseded {
3764            for (epoch, active) in &mut self.active_reconciles {
3765                if *epoch < started_at
3766                    && (active.path.starts_with(&path) || path.starts_with(&active.path))
3767                {
3768                    active.supersede(&path);
3769                }
3770            }
3771        }
3772        self.freshness_marks
3773            .retain(|marked, mark| !marked.starts_with(&path) || mark.epoch > started_at);
3774        if fully_superseded {
3775            self.active_root_reconciles.remove(&started_at);
3776            self.active_reconciles.remove(&started_at);
3777            self.compact_omitted_epochs();
3778            let current = self.freshness_at(&path);
3779            self.state.freshness = self.published_freshness();
3780            if self.state.coverage == Coverage::Partial(CoverageReason::Inaccessible) {
3781                self.state.freshness = Freshness::Partial;
3782            }
3783            let mut state = Vec::new();
3784            if previous != current {
3785                state.push(StateTransition::Freshness { path, previous, current });
3786            }
3787            if previous_index_state != self.state {
3788                state.push(StateTransition::IndexState {
3789                    previous: previous_index_state,
3790                    current: self.state,
3791                });
3792            }
3793            if state.is_empty() {
3794                return Ok(ReconcileFinish { commit: None, retry: false });
3795            }
3796            let effects = ExactConsequences { state, ..ExactConsequences::default() };
3797            return Ok(ReconcileFinish {
3798                commit: Some(self.publish_effects(next_clock, effects, Work::default(), true)),
3799                retry: false,
3800            });
3801        }
3802        let still_owned =
3803            |candidate: &Path| !superseded.iter().any(|newer| candidate.starts_with(newer));
3804        // A pass over one spelling of a directory's control file, `.GITIGNORE` included,
3805        // verified that directory's control, which is recorded under its canonical path.
3806        let verified_control = crate::control::governing_control(&path);
3807        let in_scope = |control: &Path| {
3808            control.starts_with(&path) || verified_control.as_deref() == Some(control)
3809        };
3810        if errors.disproves_old {
3811            self.unreadable_control_paths
3812                .retain(|control| !in_scope(control) || !still_owned(control));
3813        }
3814        for error in errors.errors.iter().chain(errors.terminal) {
3815            if let Some(control) = crate::control::unreadable_control(&self.root_path, error) {
3816                if in_scope(&control) && still_owned(&control) {
3817                    self.unreadable_control_paths.insert(control);
3818                }
3819            }
3820        }
3821        // Each listed directory is recorded on its own listing, complete pass or not: the
3822        // walk names only those it listed in full with no error inside them, as discovery
3823        // decides per directory, and the caller passes none when a commit lost a race. A
3824        // directory another producer invalidated or began verifying after this pass
3825        // started is left to that producer's pass, so decide here, before this pass's own
3826        // partial mark below would read as such a newer claim.
3827        let recordable: Vec<&PathBuf> = listed_incomplete
3828            .iter()
3829            .filter(|directory| {
3830                directory.starts_with(&path)
3831                    && still_owned(directory)
3832                    && !self.freshness_marks.iter().any(|(marked, mark)| {
3833                        mark.epoch > started_at && directory.starts_with(marked)
3834                    })
3835            })
3836            .collect();
3837        let scoped_failures: Vec<&PathBuf> = failed_paths
3838            .iter()
3839            .filter(|failed| failed.starts_with(&path) && still_owned(failed))
3840            .collect();
3841        // If every failure in this scope was subsequently verified, the older
3842        // pass's remaining evidence is complete. Arbitration/resource refusals do
3843        // not qualify: their caller cannot disprove prior state.
3844        let complete = complete
3845            || (errors.disproves_old
3846                && failed_paths.iter().any(|failed| failed.starts_with(&path))
3847                && scoped_failures.is_empty()
3848                && errors.errors.iter().chain(errors.terminal).all(|error| {
3849                    Issue::from_error_under(&self.root_path, error)
3850                        .path
3851                        .is_some_and(|failed| !failed.starts_with(&path) || !still_owned(&failed))
3852                }));
3853        if errors.disproves_old && self.state.phase != LifecyclePhase::Failed {
3854            self.drop_disproven_issues(&path, started_at);
3855        }
3856        if errors.disproves_old
3857            && self.state.phase != LifecyclePhase::Failed
3858            && path.as_os_str().is_empty()
3859        {
3860            self.drop_disproven_omitted(started_at);
3861        }
3862        let mut state = Vec::new();
3863        // Completeness describes this directory's own listing, not its descendants.
3864        // Withdraw old listing evidence at failures before publishing the partial pass.
3865        // Do not touch successful ancestors: readers compose only eligible descendants,
3866        // and an excluded failed child must not poison an otherwise complete subtree.
3867        if !complete && (!errors.errors.is_empty() || errors.terminal.is_some()) {
3868            let boundaries: Vec<_> = if scoped_failures.is_empty() {
3869                vec![(path.clone(), true)]
3870            } else {
3871                scoped_failures
3872                    .iter()
3873                    .map(|failed| {
3874                        let boundary = self.failed_listing_boundary(failed);
3875                        let subtree = boundary == **failed;
3876                        (boundary, subtree)
3877                    })
3878                    .collect()
3879            };
3880            for slot in 0..self.arena.len() {
3881                let Slot::Occupied { generation, entry } = &self.arena[slot] else {
3882                    continue;
3883                };
3884                if !entry.kind.is_dir() || !entry.directory().children_complete {
3885                    continue;
3886                }
3887                let id = EntryId {
3888                    slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
3889                    generation: *generation,
3890                };
3891                let Some(directory) = self.path_of(id) else {
3892                    continue;
3893                };
3894                if boundaries.iter().any(|(boundary, subtree)| {
3895                    directory == *boundary || (*subtree && directory.starts_with(boundary))
3896                }) && still_owned(&directory)
3897                    && !self.freshness_marks.iter().any(|(marked, mark)| {
3898                        mark.epoch > started_at && directory.starts_with(marked)
3899                    })
3900                {
3901                    self.entry_mut(id).directory_mut().children_complete = false;
3902                    state.push(StateTransition::DirectoryIncomplete { path: directory });
3903                }
3904            }
3905        }
3906        // A complete older walk plus successful newer child verification still proves
3907        // the entire scope. A newer failed child must keep its own evidence and mark.
3908        let verified_scope = superseded.is_empty()
3909            || (complete
3910                && superseded.iter().all(|newer| self.freshness_at(newer) == Freshness::Fresh));
3911        if verified_scope && (complete || !scoped_failures.is_empty()) {
3912            // A sweep stat'd every entry beneath `path` except the precise failure paths,
3913            // which carry stronger `Partial` marks below. Record the successful interval
3914            // once rather than manufacturing millions of unchanged entry updates.
3915            let now = Self::now_unix_nanos();
3916            self.verified.retain(|(verified_path, _)| !verified_path.starts_with(&path));
3917            self.verified.push((path.clone(), now));
3918            if self.verified.len() > MAX_VERIFIED_INTERVALS {
3919                let excess = self.verified.len() - MAX_VERIFIED_INTERVALS;
3920                self.verified.sort_by_key(|(_, at)| *at);
3921                self.verified.drain(..excess);
3922            }
3923            state.push(StateTransition::Verified { path: path.clone() });
3924        }
3925        if complete {
3926            if path.as_os_str().is_empty() {
3927                if let Some(started) = self.active_root_reconciles.remove(&started_at) {
3928                    self.writing_pass_started_at_ns = started;
3929                }
3930                self.state.source = self.applying_source;
3931            }
3932        } else {
3933            if scoped_failures.is_empty() {
3934                self.mark_unfresh(&path, Freshness::Partial);
3935            } else {
3936                for failed in scoped_failures {
3937                    self.mark_unfresh(failed, Freshness::Partial);
3938                }
3939            }
3940            if !errors.errors.is_empty() || errors.terminal.is_some() {
3941                self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
3942            }
3943            if path.as_os_str().is_empty() {
3944                if let Some(started) = self.active_root_reconciles.remove(&started_at) {
3945                    self.writing_pass_started_at_ns = started;
3946                }
3947                self.state.source = self.applying_source;
3948            }
3949        }
3950        // Retain this pass's failures at the epoch its `Partial` marks were just minted at,
3951        // not at `started_at`: a pass that began after this one and closes clean later
3952        // keeps the marks (minted after it began) and must keep the issues that explain
3953        // them, or a partial subtree would have no cause until the next root pass.
3954        // `started_at` is only this pass's own disproof threshold, applied above.
3955        for error in errors.errors.iter().chain(errors.terminal) {
3956            let issue = Issue::from_error_under(&self.root_path, error);
3957            if issue
3958                .path
3959                .as_deref()
3960                .is_none_or(|issue_path| issue_path.starts_with(&path) && still_owned(issue_path))
3961            {
3962                self.retain_issue(issue);
3963            }
3964        }
3965        for directory in recordable {
3966            let Some(id) = self.lookup(directory) else {
3967                continue;
3968            };
3969            let entry = self.entry_mut(id);
3970            if entry.kind != EntryKind::Dir || entry.directory().children_complete {
3971                continue;
3972            }
3973            entry.directory_mut().children_complete = true;
3974            self.state.progress.directories_complete =
3975                self.state.progress.directories_complete.saturating_add(1);
3976            state.push(StateTransition::DirectoryComplete { path: directory.clone() });
3977        }
3978
3979        if complete
3980            && self.state.coverage == Coverage::Partial(CoverageReason::Inaccessible)
3981            && !self.issues.iter().any(|issue| issue.kind != crate::IssueKind::ObservationGap)
3982            && self.state.issues.omitted == 0
3983            && self.arena.iter().all(|slot| {
3984                let Slot::Occupied { entry, .. } = slot else {
3985                    return true;
3986                };
3987                entry.kind != EntryKind::Dir || entry.directory().children_complete
3988            })
3989        {
3990            self.state.coverage = Coverage::Complete;
3991        }
3992
3993        let current = self.freshness_at(&path);
3994        self.state.freshness = self.published_freshness();
3995        if self.state.coverage == Coverage::Partial(CoverageReason::Inaccessible) {
3996            self.state.freshness = Freshness::Partial;
3997        }
3998        self.active_reconciles.remove(&started_at);
3999        self.compact_omitted_epochs();
4000        if previous != current {
4001            state.push(StateTransition::Freshness { path: path.clone(), previous, current });
4002        }
4003        if previous_index_state != self.state {
4004            state.push(StateTransition::IndexState {
4005                previous: previous_index_state,
4006                current: self.state,
4007            });
4008        }
4009        if state.is_empty() {
4010            return Ok(ReconcileFinish { commit: None, retry: false });
4011        }
4012        let effects = ExactConsequences { state, ..ExactConsequences::default() };
4013        Ok(ReconcileFinish {
4014            commit: Some(self.publish_effects(next_clock, effects, Work::default(), true)),
4015            retry: false,
4016        })
4017    }
4018
4019    /// When a completed reconciliation last covered this path, if one did.
4020    fn verified_at(&self, path: &Path) -> Option<i64> {
4021        self.verified
4022            .iter()
4023            .filter(|(covered, _)| path.starts_with(covered))
4024            .map(|(_, at)| *at)
4025            .max()
4026    }
4027
4028    fn mark_unfresh(&mut self, path: &Path, state: Freshness) -> u64 {
4029        self.freshness_epoch =
4030            self.freshness_epoch.checked_add(1).expect("freshness epoch exhausted");
4031        let epoch = self.freshness_epoch;
4032        self.freshness_marks.insert(path.to_path_buf(), FreshnessMark { state, epoch });
4033        epoch
4034    }
4035
4036    /// Current user-visible state for one path.
4037    ///
4038    /// Conditional producers should capture [`Self::expectation`] so ABA and structural
4039    /// races cannot return to the same visible state unnoticed.
4040    pub fn path_state(&self, path: &Path) -> PathState {
4041        let Some(id) = self.lookup(path) else {
4042            return PathState::Absent;
4043        };
4044        let entry = self.entry(id);
4045        PathState::Present { kind: entry.kind, attrs: entry.attrs }
4046    }
4047
4048    /// Conditional baseline with target and nearest-ancestor ABA protection.
4049    pub fn expectation(&self, path: &Path) -> PathExpectation {
4050        let entry = self.entry_identity(path);
4051        PathExpectation::new(
4052            self.path_state(path),
4053            entry,
4054            entry.is_none().then(|| self.absence_guard_identity(path)).flatten(),
4055        )
4056    }
4057
4058    pub(crate) fn relaxed_expectation(&self, path: &Path) -> PathExpectation {
4059        PathExpectation::new(self.path_state(path), self.entry_identity(path), None)
4060    }
4061
4062    /// Exact commits applied since `clock`, oldest first.
4063    pub fn since(&self, clock: Clock) -> Since {
4064        let commits: Vec<Commit> =
4065            self.journal.iter().filter(|commit| commit.clock > clock).cloned().collect();
4066        Since {
4067            commits,
4068            clock: self.clock,
4069            state: self.state,
4070            truncated: clock < self.journal_floor,
4071        }
4072    }
4073
4074    /// Take the subtrees that producers escalated for re-scan.
4075    ///
4076    /// The caller is expected to hand these to the scan layer, which turns them back
4077    /// into precise commits. Escalation is closed-loop: draining this list without
4078    /// re-scanning is what makes an index silently diverge.
4079    pub fn take_pending_invalidations(&mut self) -> Vec<(PathBuf, InvalidateReason)> {
4080        std::mem::take(&mut self.pending_invalidations)
4081    }
4082
4083    /// Put unresolved invalidations back without minting a second public change.
4084    pub(crate) fn restore_pending_invalidations(
4085        &mut self,
4086        invalidations: Vec<(PathBuf, InvalidateReason)>,
4087    ) {
4088        self.pending_invalidations.extend(invalidations);
4089    }
4090
4091    /// Look up an entry id by path relative to the root.
4092    pub fn lookup(&self, path: &Path) -> Option<EntryId> {
4093        let mut current = EntryId::ROOT;
4094        for part in normalize(path)? {
4095            current = self.child(current, part)?;
4096        }
4097        Some(current)
4098    }
4099
4100    /// Owned, self-describing roll-up state for a directory by relative path.
4101    /// The empty path is the root.
4102    pub fn rollup(&self, path: &Path) -> Option<RollUp> {
4103        let id = self.lookup(path)?;
4104        let entry = self.entry(id);
4105        entry.kind.is_dir().then(|| self.named_rollup(&entry.rollup().all))
4106    }
4107
4108    /// Both fixed aggregate partitions for a directory by relative path.
4109    ///
4110    /// `Ok(None)` when the path is absent or not a directory.
4111    ///
4112    /// # Errors
4113    ///
4114    /// [`crate::Error::ControlStateNotObserved`] when the index did not observe control
4115    /// state, whatever the path, as [`Self::partition_total`] refuses.
4116    pub fn partition_rollup(&self, path: &Path) -> crate::Result<Option<PartitionRollUp>> {
4117        self.require_observed_controls()?;
4118        Ok(self
4119            .lookup(path)
4120            .map(|id| self.entry(id))
4121            .filter(|entry| entry.kind.is_dir())
4122            .map(|entry| self.named_partitions(entry.rollup())))
4123    }
4124
4125    /// Both constant-size aggregate partitions for a directory.
4126    ///
4127    /// `Ok(None)` when the path is absent or not a directory.
4128    ///
4129    /// # Errors
4130    ///
4131    /// [`crate::Error::ControlStateNotObserved`] when the index did not observe control
4132    /// state, whatever the path, as [`Self::partition_total`] refuses.
4133    pub fn partition_rollup_summary(
4134        &self,
4135        path: &Path,
4136    ) -> crate::Result<Option<PartitionRollUpSummary>> {
4137        self.require_observed_controls()?;
4138        Ok(self
4139            .lookup(path)
4140            .map(|id| self.entry(id))
4141            .filter(|entry| entry.kind.is_dir())
4142            .map(|entry| partition_summary(entry.rollup())))
4143    }
4144
4145    /// Whether a live entry is ignored, for the opened-root tree projection, without the
4146    /// observation check [`Self::is_ignored`] makes.
4147    ///
4148    /// An opened root always observes control state, so the retained bit is the exact
4149    /// classification; the assertion checks that invariant where it is cheap to.
4150    pub(crate) fn opened_is_ignored(&self, id: EntryId) -> bool {
4151        debug_assert!(self.observes_controls(), "an opened root observes control state");
4152        self.entry(id).ignored
4153    }
4154
4155    /// Capture one retained entry without repeating path lookup in a consumer.
4156    pub(crate) fn entry_value(&self, path: &Path) -> Option<crate::EntryValue> {
4157        let id = self.lookup(path)?;
4158        Some(self.entry_value_of(id, path))
4159    }
4160
4161    pub(crate) fn entry_value_of(&self, id: EntryId, path: &Path) -> crate::EntryValue {
4162        let entry = self.entry(id);
4163        crate::EntryValue {
4164            path: path.to_path_buf(),
4165            portable_path: crate::opened::read::portable_path(path),
4166            kind: entry.kind,
4167            attrs: entry.attrs,
4168            ignored: entry.ignored,
4169            classification: (entry.kind == EntryKind::File)
4170                .then(|| self.types.classify_name(path.file_name().unwrap_or_default())),
4171            rollup: entry.kind.is_dir().then(|| partition_summary(entry.rollup())),
4172            children_complete: entry.kind.is_dir().then(|| entry.directory().children_complete),
4173        }
4174    }
4175
4176    pub(crate) fn portable_children(&self, path: &Path) -> Option<&PortableChildren> {
4177        self.serving.as_ref()?.portable_children.get(path)
4178    }
4179
4180    pub(crate) fn portable_entries(&self) -> &BTreeMap<crate::PortablePath, EntryId> {
4181        &self.serving.as_ref().expect("opened-root reads require serving indexes").portable_entries
4182    }
4183
4184    #[cfg(test)]
4185    pub(crate) const fn serving_indexes_enabled(&self) -> bool {
4186        self.serving.is_some()
4187    }
4188
4189    fn insert_serving_entry(&mut self, path: &Path, kind: EntryKind, attrs: Attrs, id: EntryId) {
4190        if path.as_os_str().is_empty() || self.serving.is_none() {
4191            return;
4192        }
4193        let file = (kind == EntryKind::File).then(|| {
4194            (
4195                self.classify(path).file_type.as_str().to_string(),
4196                path.file_name(),
4197                self.entry(id).ignored,
4198                self.entry(id).parent,
4199            )
4200        });
4201        let arena = &self.arena;
4202        let Some(serving) = self.serving.as_mut() else {
4203            return;
4204        };
4205        if let Some((name, exact_name, ignored, parent)) = file {
4206            let semantic = serving.intern_semantic(&name);
4207            let mut ancestor = parent;
4208            while let Some(directory) = ancestor {
4209                let partition = serving.semantic_by_directory.entry(directory).or_default();
4210                merge_semantic(&mut partition.all, semantic, attrs);
4211                if !ignored {
4212                    merge_semantic(&mut partition.unignored, semantic, attrs);
4213                }
4214                ancestor = retained_parent(arena, directory);
4215            }
4216            if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
4217                let mut ancestor = parent;
4218                while let Some(directory) = ancestor {
4219                    let partition = serving.exact_name_by_directory.entry(directory).or_default();
4220                    merge_semantic(&mut partition.all, exact_name, attrs);
4221                    if !ignored {
4222                        merge_semantic(&mut partition.unignored, exact_name, attrs);
4223                    }
4224                    ancestor = retained_parent(arena, directory);
4225                }
4226            }
4227        }
4228        let portable = crate::opened::read::portable_path(path);
4229        serving.portable_entries.insert(portable.clone(), id);
4230        if kind == EntryKind::File {
4231            serving.recent_files.insert(RecentKey {
4232                mtime_ns: attrs.mtime_ns,
4233                portable_path: portable,
4234                id,
4235            });
4236        }
4237        let parent = path.parent().unwrap_or_else(|| Path::new("")).to_path_buf();
4238        let Some(name) = path.file_name().map(crate::opened::read::portable_component) else {
4239            return;
4240        };
4241        let children = serving.portable_children.entry(parent).or_default();
4242        if kind.is_dir() {
4243            children.directories.insert(name, id);
4244        } else {
4245            children.nondirectories.insert(name, id);
4246        }
4247    }
4248
4249    fn remove_serving_entry(&mut self, path: &Path, kind: EntryKind, attrs: Attrs, id: EntryId) {
4250        if path.as_os_str().is_empty() {
4251            return;
4252        }
4253        let Some(serving) = self.serving.as_mut() else {
4254            return;
4255        };
4256        let portable = crate::opened::read::portable_path(path);
4257        serving.portable_entries.remove(&portable);
4258        if kind == EntryKind::File {
4259            serving.recent_files.remove(&RecentKey {
4260                mtime_ns: attrs.mtime_ns,
4261                portable_path: portable,
4262                id,
4263            });
4264        }
4265        let parent = path.parent().unwrap_or_else(|| Path::new("")).to_path_buf();
4266        let remove_parent = if let Some(children) = serving.portable_children.get_mut(&parent) {
4267            if let Some(name) = path.file_name().map(crate::opened::read::portable_component) {
4268                if kind.is_dir() {
4269                    children.directories.remove(&name);
4270                } else {
4271                    children.nondirectories.remove(&name);
4272                }
4273            }
4274            children.directories.is_empty() && children.nondirectories.is_empty()
4275        } else {
4276            false
4277        };
4278        if remove_parent {
4279            serving.portable_children.remove(&parent);
4280        }
4281        if kind.is_dir() {
4282            serving.portable_children.remove(path);
4283        }
4284    }
4285
4286    fn remove_serving_file_semantics(&mut self, path: &Path, id: EntryId, attrs: Attrs) {
4287        // Only a regular file is interned and tallied (`insert_serving_entry`). A symlink
4288        // or special entry of the same classification would otherwise find a real file's
4289        // type and subtract from its tally, or find none and panic under the write guard.
4290        if self.serving.is_none() || self.entry(id).kind != EntryKind::File {
4291            return;
4292        }
4293        let name = self.classify(path).file_type.as_str().to_string();
4294        let exact_name = path.file_name();
4295        let ignored = self.entry(id).ignored;
4296        let parent = self.entry(id).parent;
4297        let arena = &self.arena;
4298        let serving = self.serving.as_mut().expect("checked above");
4299        let semantic = *serving
4300            .semantic_ids
4301            .get(&name)
4302            .expect("every served file has an interned semantic type");
4303        let mut empty = Vec::new();
4304        let mut ancestor = parent;
4305        while let Some(directory) = ancestor {
4306            let partition = serving
4307                .semantic_by_directory
4308                .get_mut(&directory)
4309                .expect("every served file contributes to every ancestor");
4310            unmerge_semantic(&mut partition.all, semantic, attrs);
4311            if !ignored {
4312                unmerge_semantic(&mut partition.unignored, semantic, attrs);
4313            }
4314            if partition.all.is_empty() && partition.unignored.is_empty() {
4315                empty.push(directory);
4316            }
4317            ancestor = retained_parent(arena, directory);
4318        }
4319        for ancestor in empty {
4320            serving.semantic_by_directory.remove(&ancestor);
4321        }
4322        serving.release_semantic(semantic, 1);
4323        if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
4324            let mut exact_empty = Vec::new();
4325            let mut ancestor = parent;
4326            while let Some(directory) = ancestor {
4327                let partition = serving
4328                    .exact_name_by_directory
4329                    .get_mut(&directory)
4330                    .expect("every declared exact-name file contributes to every ancestor");
4331                unmerge_semantic(&mut partition.all, exact_name, attrs);
4332                if !ignored {
4333                    unmerge_semantic(&mut partition.unignored, exact_name, attrs);
4334                }
4335                if partition.all.is_empty() && partition.unignored.is_empty() {
4336                    exact_empty.push(directory);
4337                }
4338                ancestor = retained_parent(arena, directory);
4339            }
4340            for ancestor in exact_empty {
4341                serving.exact_name_by_directory.remove(&ancestor);
4342            }
4343        }
4344    }
4345
4346    fn remove_serving_subtree_semantics(&mut self, root: EntryId, path: &Path) {
4347        if self.serving.is_none() {
4348            return;
4349        }
4350        match self.entry(root).kind {
4351            EntryKind::File => {
4352                let attrs = self.entry(root).attrs;
4353                self.remove_serving_file_semantics(path, root, attrs);
4354            }
4355            EntryKind::Dir => {
4356                let parent = self.entry(root).parent;
4357                let mut stack = vec![root];
4358                let mut directories = Vec::new();
4359                while let Some(id) = stack.pop() {
4360                    let entry = self.entry(id);
4361                    if !entry.kind.is_dir() {
4362                        continue;
4363                    }
4364                    directories.push(id);
4365                    stack.extend(self.child_ids(id));
4366                }
4367                let arena = &self.arena;
4368                let serving = self.serving.as_mut().expect("checked above");
4369                let contribution =
4370                    serving.semantic_by_directory.get(&root).cloned().unwrap_or_default();
4371                let exact_contribution =
4372                    serving.exact_name_by_directory.get(&root).cloned().unwrap_or_default();
4373                let mut empty = Vec::new();
4374                if !contribution.all.is_empty() || !contribution.unignored.is_empty() {
4375                    let mut ancestor = parent;
4376                    while let Some(directory) = ancestor {
4377                        let partition = serving
4378                            .semantic_by_directory
4379                            .get_mut(&directory)
4380                            .expect("a semantic subtree contributes to every ancestor");
4381                        unmerge_semantic_map(&mut partition.all, &contribution.all);
4382                        unmerge_semantic_map(&mut partition.unignored, &contribution.unignored);
4383                        if partition.all.is_empty() && partition.unignored.is_empty() {
4384                            empty.push(directory);
4385                        }
4386                        ancestor = retained_parent(arena, directory);
4387                    }
4388                }
4389                let mut exact_empty = Vec::new();
4390                if !exact_contribution.all.is_empty() || !exact_contribution.unignored.is_empty() {
4391                    let mut ancestor = parent;
4392                    while let Some(directory) = ancestor {
4393                        let partition = serving
4394                            .exact_name_by_directory
4395                            .get_mut(&directory)
4396                            .expect("an exact-name subtree contributes to every ancestor");
4397                        unmerge_semantic_map(&mut partition.all, &exact_contribution.all);
4398                        unmerge_semantic_map(
4399                            &mut partition.unignored,
4400                            &exact_contribution.unignored,
4401                        );
4402                        if partition.all.is_empty() && partition.unignored.is_empty() {
4403                            exact_empty.push(directory);
4404                        }
4405                        ancestor = retained_parent(arena, directory);
4406                    }
4407                }
4408                for ancestor in empty {
4409                    serving.semantic_by_directory.remove(&ancestor);
4410                }
4411                for ancestor in exact_empty {
4412                    serving.exact_name_by_directory.remove(&ancestor);
4413                }
4414                for directory in directories {
4415                    serving.semantic_by_directory.remove(&directory);
4416                    serving.exact_name_by_directory.remove(&directory);
4417                }
4418                for (semantic, tally) in contribution.all {
4419                    serving.release_semantic(semantic, tally.files);
4420                }
4421            }
4422            EntryKind::Symlink | EntryKind::Other => {}
4423        }
4424    }
4425
4426    fn move_serving_file_partition(
4427        &mut self,
4428        path: &Path,
4429        id: EntryId,
4430        previous_ignored: bool,
4431        current_ignored: bool,
4432    ) {
4433        if previous_ignored == current_ignored
4434            || self.serving.is_none()
4435            || self.entry(id).kind != EntryKind::File
4436        {
4437            return;
4438        }
4439        let name = self.classify(path).file_type.as_str().to_string();
4440        let exact_name = path.file_name();
4441        let attrs = self.entry(id).attrs;
4442        let parent = self.entry(id).parent;
4443        let arena = &self.arena;
4444        let serving = self.serving.as_mut().expect("checked above");
4445        let semantic = *serving
4446            .semantic_ids
4447            .get(&name)
4448            .expect("every served file has an interned semantic type");
4449        let mut ancestor = parent;
4450        while let Some(directory) = ancestor {
4451            let partition = serving
4452                .semantic_by_directory
4453                .get_mut(&directory)
4454                .expect("every served file contributes to every ancestor");
4455            if current_ignored {
4456                unmerge_semantic(&mut partition.unignored, semantic, attrs);
4457            } else {
4458                merge_semantic(&mut partition.unignored, semantic, attrs);
4459            }
4460            ancestor = retained_parent(arena, directory);
4461        }
4462        if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
4463            let mut ancestor = parent;
4464            while let Some(directory) = ancestor {
4465                let partition = serving
4466                    .exact_name_by_directory
4467                    .get_mut(&directory)
4468                    .expect("every declared exact-name file contributes to every ancestor");
4469                if current_ignored {
4470                    unmerge_semantic(&mut partition.unignored, exact_name, attrs);
4471                } else {
4472                    merge_semantic(&mut partition.unignored, exact_name, attrs);
4473                }
4474                ancestor = retained_parent(arena, directory);
4475            }
4476        }
4477    }
4478
4479    /// Attributes for any entry, by relative path.
4480    pub fn attrs(&self, path: &Path) -> Option<&Attrs> {
4481        Some(&self.entry(self.lookup(path)?).attrs)
4482    }
4483
4484    /// Kind of an entry, by relative path.
4485    pub fn kind(&self, path: &Path) -> Option<EntryKind> {
4486        Some(self.entry(self.lookup(path)?).kind)
4487    }
4488
4489    /// Effective fixed-control classification for one retained entry.
4490    ///
4491    /// `Ok(Some(ignored))` when a retained entry's governing controls are known;
4492    /// `Ok(None)` for a missing entry or one below a refused control source.
4493    ///
4494    /// # Errors
4495    ///
4496    /// [`crate::Error::ControlStateNotObserved`] when the index was built without
4497    /// observing control state, whatever the path. Every entry of such an index carries
4498    /// "not ignored" only because no rule was read, so that answer would be silently
4499    /// wrong for a tree that has a `.gitignore`.
4500    pub fn is_ignored(&self, path: &Path) -> crate::Result<Option<bool>> {
4501        self.require_observed_controls()?;
4502        Ok(self.ignored_classification(path))
4503    }
4504
4505    /// Known ignore classification of a retained path, or `None` when unavailable.
4506    ///
4507    /// A refusal may hide an ignore or a negation. Its descendants cannot be counted
4508    /// as known members of either population.
4509    pub fn ignored_classification(&self, path: &Path) -> Option<bool> {
4510        let id = self.lookup(path)?;
4511        self.ignored_classification_of(path, id)
4512    }
4513
4514    /// Classification for a retained entry whose handle the caller already has.
4515    pub(crate) fn ignored_classification_of(&self, path: &Path, id: EntryId) -> Option<bool> {
4516        if !self.observes_controls() || !self.control_classification_known(path) {
4517            return None;
4518        }
4519        self.try_entry(id).map(|entry| entry.ignored)
4520    }
4521
4522    /// The entry's own ignore flag, for a caller that already knows its parent's
4523    /// classification is known ([`Self::children_classification_known`]): what
4524    /// [`Self::ignored_classification_of`] answers for it without re-deriving that.
4525    pub(crate) fn entry_ignored(&self, id: EntryId) -> Option<bool> {
4526        self.try_entry(id).map(|entry| entry.ignored)
4527    }
4528
4529    pub(crate) fn control_classification_known(&self, path: &Path) -> bool {
4530        path.parent().is_none_or(|directory| self.controls_known_in(directory))
4531    }
4532
4533    /// Whether the entries directly in `directory` have a known ignore classification, as
4534    /// [`Self::ignored_classification_of`] decides it for each of them: it depends on
4535    /// their parent alone, so it is also what a folded tally of them may state
4536    /// ([`FoldedFiles::ignored`]).
4537    pub(crate) fn children_classification_known(&self, directory: &Path) -> bool {
4538        self.observes_controls() && self.controls_known_in(directory)
4539    }
4540
4541    /// Whether every control file that governs the entries in `directory` was admitted
4542    /// and read: none was refused, and none could not be read.
4543    fn controls_known_in(&self, directory: &Path) -> bool {
4544        self.controls.children_classification_known(directory)
4545            && (self.unreadable_control_paths.is_empty()
4546                || !directory.ancestors().any(|ancestor| {
4547                    self.unreadable_control_paths
4548                        .iter()
4549                        .any(|control| control.parent() == Some(ancestor))
4550                }))
4551    }
4552
4553    /// Whether ignored classification is known throughout this retained subtree.
4554    pub fn ignored_classification_complete_below(&self, path: &Path) -> bool {
4555        self.observes_controls()
4556            && self.controls.classification_known(path)
4557            && !self.unreadable_control_paths.iter().any(|control| {
4558                control.starts_with(path)
4559                    || path
4560                        .parent()
4561                        .into_iter()
4562                        .flat_map(Path::ancestors)
4563                        .any(|directory| control.parent() == Some(directory))
4564            })
4565            && !self.controls.refusals().any(|refusal| {
4566                refusal.path.starts_with(path)
4567                    || path
4568                        .parent()
4569                        .into_iter()
4570                        .flat_map(Path::ancestors)
4571                        .any(|directory| refusal.path.parent() == Some(directory))
4572            })
4573    }
4574
4575    /// Borrow direct children of a directory as `(name, id)` pairs in name order.
4576    ///
4577    /// The iterator borrows this owned index and allocates nothing.
4578    pub fn children(
4579        &self,
4580        path: &Path,
4581    ) -> Option<impl DoubleEndedIterator<Item = (&OsStr, EntryId)> + ExactSizeIterator + '_> {
4582        let id = self.lookup(path)?;
4583        let entry = self.entry(id);
4584        entry.kind.is_dir().then(|| IndexChildren::new(self, entry))
4585    }
4586
4587    /// Borrow direct children of an entry id as `(name, id)` pairs in name order.
4588    ///
4589    /// Returns `None` for a stale handle. A live non-directory returns an empty iterator.
4590    pub fn children_of(
4591        &self,
4592        id: EntryId,
4593    ) -> Option<impl DoubleEndedIterator<Item = (&OsStr, EntryId)> + ExactSizeIterator + '_> {
4594        Some(IndexChildren::new(self, self.try_entry(id)?))
4595    }
4596
4597    /// Reconstruct an entry's path relative to the root by walking parent pointers.
4598    pub fn path_of(&self, id: EntryId) -> Option<PathBuf> {
4599        let mut parts = Vec::new();
4600        let mut current = Some(id);
4601        while let Some(node) = current {
4602            let entry = self.try_entry(node)?;
4603            if entry.parent.is_some() {
4604                parts.push(entry.name.as_os_str());
4605            }
4606            current = entry.parent;
4607        }
4608        parts.reverse();
4609        Some(parts.iter().collect())
4610    }
4611
4612    /// Owned, self-describing roll-up state for an entry id, if it is a directory.
4613    pub fn rollup_of(&self, id: EntryId) -> Option<RollUp> {
4614        let entry = self.try_entry(id)?;
4615        entry.kind.is_dir().then(|| self.named_rollup(&entry.rollup().all))
4616    }
4617
4618    /// Map-free totals of a directory's `all` and `unignored` partitions, in that order,
4619    /// for reporting paths that derive an ignored share.
4620    ///
4621    /// No observation check: in an index that observed no control state the two are equal,
4622    /// so a caller that has not checked [`Self::observes_controls`] derives a zero share
4623    /// rather than an error, and must not present it as one.
4624    pub(crate) fn partition_scalars_of(
4625        &self,
4626        id: EntryId,
4627    ) -> Option<(RollUpScalars, RollUpScalars)> {
4628        let entry = self.try_entry(id)?;
4629        entry.kind.is_dir().then(|| {
4630            let rollup = entry.rollup();
4631            (RollUpScalars::from(&rollup.all), RollUpScalars::from(&rollup.unignored))
4632        })
4633    }
4634
4635    /// Whether this index was built by the transient tree tier and keeps only some of the
4636    /// files it walked ([`Self::folded_children`]).
4637    ///
4638    /// Such an index answers the one tree report its plan made it for, and nothing else:
4639    /// a flat inventory, extension tallies, or a snapshot of it would omit the files it
4640    /// folded. It is unreachable from every route that returns, persists, or mutates an
4641    /// index, which assert as much. Subtree measurements over it (a tree of an incomplete
4642    /// walk takes them) are exact only in which subtrees are complete, the one part a tree
4643    /// reads: completeness is a property of directories, all of which it keeps.
4644    pub(crate) const fn is_folded(&self) -> bool {
4645        self.folded.is_some()
4646    }
4647
4648    /// The files a folded index counted directly in directory `id` without keeping them,
4649    /// or `None` where it kept every one, which is always in an index that keeps every
4650    /// file. The one reader of that tally: a tree states them as rows its share threshold
4651    /// omits.
4652    pub(crate) fn folded_children(&self, id: EntryId) -> Option<FoldedFiles> {
4653        let folded = self.folded.as_ref()?;
4654        self.try_entry(id)?;
4655        folded.get(id.idx()).copied().filter(|folded| folded.files > 0)
4656    }
4657
4658    /// Whether a live directory's in-scope child set is authoritative: the id form of
4659    /// [`Self::directory_complete`], for a reader that already holds the id. `None` for a
4660    /// stale handle or an entry that is not a directory.
4661    pub(crate) fn directory_complete_of(&self, id: EntryId) -> Option<bool> {
4662        let entry = self.try_entry(id)?;
4663        (entry.kind == EntryKind::Dir).then(|| entry.directory().children_complete)
4664    }
4665
4666    /// Attributes for an entry id, or `None` when the handle is stale.
4667    pub fn attrs_of(&self, id: EntryId) -> Option<&Attrs> {
4668        Some(&self.try_entry(id)?.attrs)
4669    }
4670
4671    /// Kind for an entry id, or `None` when the handle is stale.
4672    pub fn kind_of(&self, id: EntryId) -> Option<EntryKind> {
4673        Some(self.try_entry(id)?.kind)
4674    }
4675
4676    /// Name for an entry id. The root's name is empty; stale handles return `None`.
4677    pub fn name_of(&self, id: EntryId) -> Option<&OsStr> {
4678        Some(&self.try_entry(id)?.name)
4679    }
4680
4681    /// Sparse content tier, when analysis has been enabled.
4682    pub fn content(&self) -> Option<&ContentIndex> {
4683        self.content.as_deref()
4684    }
4685
4686    /// Precomputed content rollup for one relative directory.
4687    pub fn content_rollup(&self, path: &Path) -> Option<&ContentRollUp> {
4688        self.content()?.rollup(path)
4689    }
4690
4691    /// The analyzer set this index's content tier holds records for, or
4692    /// [`AnalysisSet::NONE`] when it holds no content tier.
4693    pub fn content_set(&self) -> AnalysisSet {
4694        self.content().and_then(ContentIndex::profile).unwrap_or(AnalysisSet::NONE)
4695    }
4696
4697    /// Whether the retained content tier lacks a record for any admitted regular file.
4698    ///
4699    /// Every requested analyzer records a coverage outcome, including binary, invalid
4700    /// UTF-8, and unsupported files. A count mismatch therefore means analysis is still
4701    /// pending, while deleting a file removes both its entry and its content record.
4702    pub(crate) fn content_has_pending(&self, profile: AnalysisSet) -> bool {
4703        if !profile.is_enabled() {
4704            return false;
4705        }
4706        let wanted = self.content_identity(profile);
4707        let Some(content) = self.content().and_then(|content| content.admit(&wanted)) else {
4708            return true;
4709        };
4710        if self.scope.population == crate::query::IgnoredEntries::Include {
4711            return u64::try_from(content.len()).unwrap_or(u64::MAX)
4712                < self.entry(EntryId::ROOT).rollup().files;
4713        }
4714        // Narrow populations retain control files for reconciliation and unknown
4715        // files until their governing rules can be verified. Neither is an analysis
4716        // candidate, so comparing against all retained regular files would make a
4717        // complete analysis look perpetually partial.
4718        let mut pending = false;
4719        self.for_each_analysis_file(profile, |_, _, attrs, path| {
4720            pending |=
4721                content.file(&path).is_none_or(|record| record.fingerprint != attrs.fingerprint());
4722        });
4723        pending
4724    }
4725
4726    /// The content tier identity this index gives records of `analysis`: its own entry tier,
4727    /// which holds its type rules, the analyzer set, and the analyzers' versions and options.
4728    pub fn content_identity(&self, analysis: AnalysisSet) -> crate::ContentTierIdentity {
4729        crate::ContentTierIdentity::for_request(
4730            crate::EntryTierIdentity::of_scope(self.scope),
4731            analysis,
4732        )
4733    }
4734
4735    /// Prepare the content tier to hold records of `request`'s identity, clearing it when
4736    /// it holds any other.
4737    pub(crate) fn prepare_content_analysis(&mut self, request: crate::content::AnalysisRequest) {
4738        if !request.profile.is_enabled() {
4739            return;
4740        }
4741        let identity = self.content_identity(request.profile);
4742        self.content.get_or_insert_with(|| Box::new(ContentIndex::default())).prepare(identity);
4743    }
4744
4745    pub(crate) fn set_content_tier_state(
4746        &mut self,
4747        source: Source,
4748        freshness: Freshness,
4749        observed_at_ns: Option<i64>,
4750    ) {
4751        if let Some(content) = self.content.as_deref_mut() {
4752            content.set_state(crate::content::ContentTierState {
4753                source,
4754                freshness,
4755                observed_at_ns,
4756            });
4757        }
4758    }
4759
4760    /// Capture every regular-file analysis candidate without retaining a lock or entry
4761    /// borrow across filesystem I/O.
4762    ///
4763    /// Crate-private until the request model (P1.3) decides whether an out-of-crate
4764    /// analyzer is a supported surface (`fdu-5upj`). A caller outside the crate cannot
4765    /// prepare the content tier, so every result it produced would commit as
4766    /// [`AnalysisApplyOutcome::Stale`]; [`analyze_index`] is the entry that works.
4767    ///
4768    /// [`analyze_index`]: crate::content::analyze_index
4769    #[cfg(test)]
4770    pub(crate) fn analysis_candidates(&self, profile: AnalysisSet) -> Vec<AnalysisCandidate> {
4771        let root_files = self.entry(EntryId::ROOT).rollup().files;
4772        let mut candidates = Vec::with_capacity(usize::try_from(root_files).unwrap_or(0));
4773        self.walk_analysis_files(
4774            profile,
4775            &mut AnalysisWalk::start(),
4776            |id, revision, attrs, relative_path| {
4777                candidates.push(self.analysis_candidate(id, revision, attrs, relative_path));
4778                true
4779            },
4780        );
4781        candidates
4782    }
4783
4784    fn analysis_candidate(
4785        &self,
4786        entry_id: EntryId,
4787        revision: u64,
4788        attrs: Attrs,
4789        relative_path: PathBuf,
4790    ) -> AnalysisCandidate {
4791        AnalysisCandidate {
4792            entry_id,
4793            revision,
4794            absolute_path: self.root_path.join(&relative_path),
4795            classification: self.classify(&relative_path),
4796            relative_path,
4797            attrs,
4798        }
4799    }
4800
4801    /// File identities restore matches against sidecar records, without classifying.
4802    ///
4803    /// The `HashMap` is keyed by relative path because load looks up each decoded record
4804    /// that way. Classification is omitted: cache-only restore commits the sidecar's
4805    /// stored classification, and the apply-path self-check cannot change that answer.
4806    pub(crate) fn restore_analysis_candidates(
4807        &self,
4808        profile: AnalysisSet,
4809    ) -> (HashMap<PathBuf, RestoreCandidate>, u64) {
4810        let root_files = self.entry(EntryId::ROOT).rollup().files;
4811        let mut candidates = HashMap::with_capacity(usize::try_from(root_files).unwrap_or(0));
4812        let mut visited = 0_u64;
4813        self.for_each_analysis_file(profile, |id, revision, attrs, relative_path| {
4814            visited = visited.saturating_add(1);
4815            candidates.insert(
4816                relative_path.clone(),
4817                RestoreCandidate { entry_id: id, revision, relative_path, attrs },
4818            );
4819        });
4820        (candidates, visited)
4821    }
4822
4823    fn for_each_analysis_file(
4824        &self,
4825        profile: AnalysisSet,
4826        mut visit: impl FnMut(EntryId, u64, Attrs, PathBuf),
4827    ) {
4828        self.walk_analysis_files(
4829            profile,
4830            &mut AnalysisWalk::start(),
4831            |id, revision, attrs, path| {
4832                visit(id, revision, attrs, path);
4833                true
4834            },
4835        );
4836    }
4837
4838    /// Visit the files an analysis under `profile` may read, from where `walk` left off,
4839    /// until `visit` answers `false`; the walk then resumes at the next file.
4840    ///
4841    /// Depth first, directories in listing order, so every call over one walk visits
4842    /// each file once and in the order [`Self::for_each_analysis_file`] does. The parent
4843    /// path the walk already holds is joined for each file; `path_of` would walk
4844    /// ancestors per file for the same bytes.
4845    fn walk_analysis_files(
4846        &self,
4847        profile: AnalysisSet,
4848        walk: &mut AnalysisWalk,
4849        mut visit: impl FnMut(EntryId, u64, Attrs, PathBuf) -> bool,
4850    ) {
4851        if !profile.is_enabled() {
4852            return;
4853        }
4854        loop {
4855            let (parent, parent_path, resume) = match walk.listing.take() {
4856                Some((parent, parent_path, resume)) => (parent, parent_path, Some(resume)),
4857                None => match walk.pending.pop() {
4858                    Some((parent, parent_path)) => (parent, parent_path, None),
4859                    None => return,
4860                },
4861            };
4862            let visited = resume.as_ref().map_or(0, |resume| resume.position);
4863            let children = self.resumed_children(parent, resume.as_ref());
4864            #[cfg(test)]
4865            let children = children.inspect(|_| {
4866                ANALYSIS_CHILD_STEPS.with(|steps| steps.set(steps.get() + 1));
4867            });
4868            // Each child's position counts it as visited, so a walk that stops at it
4869            // resumes after it.
4870            for (position, (name, id)) in (visited + 1..).zip(children) {
4871                let entry = self.entry(id);
4872                if entry.kind == EntryKind::Dir {
4873                    walk.pending.push((id, parent_path.join(name)));
4874                    continue;
4875                }
4876                if entry.kind != EntryKind::File {
4877                    continue;
4878                }
4879                let relative_path = parent_path.join(name);
4880                if !self.scope.population.admits(entry.ignored)
4881                    || (self.scope.population != crate::query::IgnoredEntries::Include
4882                        && !self.control_classification_known(&relative_path))
4883                {
4884                    continue;
4885                }
4886                if !visit(id, entry.revision, entry.attrs, relative_path) {
4887                    let resume = ListingResume { position, last: name.to_os_string() };
4888                    walk.listing = Some((parent, parent_path, resume));
4889                    return;
4890                }
4891            }
4892        }
4893    }
4894
4895    /// The children of `parent` in name order, from after where `resume` says a walk
4896    /// stopped, or all of them; none for a stale handle or a non-directory, as
4897    /// [`Self::children_of`] gives.
4898    fn resumed_children(
4899        &self,
4900        parent: EntryId,
4901        resume: Option<&ListingResume>,
4902    ) -> ResumedChildren<'_> {
4903        use std::ops::Bound::{Excluded, Unbounded};
4904
4905        let Some(entry) = self.try_entry(parent) else { return ResumedChildren::Empty };
4906        match entry.directory.as_deref().map(|directory| &directory.children) {
4907            None => ResumedChildren::Empty,
4908            Some(DirectoryChildren::Sorted(ids)) => {
4909                let from = resume.map_or(0, |resume| resume.position).min(ids.len());
4910                ResumedChildren::Sorted { index: self, ids: ids[from..].iter() }
4911            }
4912            Some(DirectoryChildren::Mutable(children)) => ResumedChildren::Mutable(match resume {
4913                Some(resume) => {
4914                    children.range::<OsStr, _>((Excluded(resume.last.as_os_str()), Unbounded))
4915                }
4916                None => children.range::<OsStr, _>(..),
4917            }),
4918        }
4919    }
4920
4921    /// The candidates `request` still has to read: every one, unless the content tier
4922    /// holds exactly `request`'s identity, and then those without a record whose
4923    /// fingerprint matches.
4924    ///
4925    /// Every candidate at once. An analysis pass takes them in bounded batches through
4926    /// [`Self::next_analysis_candidates`] instead, so this is for a caller that wants the
4927    /// whole set, such as a test.
4928    #[cfg(test)]
4929    pub(crate) fn pending_analysis_candidates(
4930        &self,
4931        request: crate::content::AnalysisRequest,
4932    ) -> Vec<AnalysisCandidate> {
4933        self.next_analysis_candidates(request, &mut AnalysisWalk::start(), usize::MAX)
4934    }
4935
4936    /// Whether `request` still has to read the file at `relative_path` with `attrs`,
4937    /// given what the content tier holds for `request`'s identity.
4938    fn pending_analysis(
4939        held: Option<crate::stored_state::ContentProjection<'_>>,
4940        relative_path: &Path,
4941        attrs: &Attrs,
4942    ) -> bool {
4943        held.and_then(|content| content.file(relative_path))
4944            .is_none_or(|record| record.fingerprint != attrs.fingerprint() || !record.is_reusable())
4945    }
4946
4947    /// The next batch of at most `limit` candidates `request` still has to read, from
4948    /// where `walk` left off; empty once the walk is over.
4949    ///
4950    /// A candidate holds two paths and a classification, so materializing every one of a
4951    /// million-file tree before the first read cost hundreds of megabytes that the bounded
4952    /// worker channel then drained one at a time (fdu-xjfk). Handing them out in batches
4953    /// keeps the scheduling memory at the batch, and each candidate still carries the
4954    /// revision and fingerprint its result is conditionally applied under.
4955    pub(crate) fn next_analysis_candidates(
4956        &self,
4957        request: crate::content::AnalysisRequest,
4958        walk: &mut AnalysisWalk,
4959        limit: usize,
4960    ) -> Vec<AnalysisCandidate> {
4961        let wanted = self.content_identity(request.profile);
4962        // The tier refuses a record of any identity but its own, so one comparison here
4963        // decides for every record it holds.
4964        let held = self.content().and_then(|content| content.admit(&wanted));
4965        let root_files = usize::try_from(self.entry(EntryId::ROOT).rollup().files).unwrap_or(0);
4966        let mut candidates = Vec::with_capacity(limit.min(root_files));
4967        self.walk_analysis_files(request.profile, walk, |id, revision, attrs, relative_path| {
4968            if Self::pending_analysis(held, &relative_path, &attrs) {
4969                candidates.push(self.analysis_candidate(id, revision, attrs, relative_path));
4970            }
4971            candidates.len() < limit
4972        });
4973        candidates
4974    }
4975
4976    /// How many candidates `request` still has to read: what
4977    /// [`Self::next_analysis_candidates`] hands out over a whole walk, counted without
4978    /// building any of them, so an analysis knows its denominator before its first read.
4979    pub(crate) fn count_pending_analysis_candidates(
4980        &self,
4981        request: crate::content::AnalysisRequest,
4982    ) -> u64 {
4983        let wanted = self.content_identity(request.profile);
4984        let held = self.content().and_then(|content| content.admit(&wanted));
4985        let mut count = 0_u64;
4986        self.walk_analysis_files(
4987            request.profile,
4988            &mut AnalysisWalk::start(),
4989            |_, _, attrs, path| {
4990                count += u64::from(Self::pending_analysis(held, &path, &attrs));
4991                true
4992            },
4993        );
4994        count
4995    }
4996
4997    /// Conditionally commit a worker result if its entry and metadata expectation still
4998    /// match, and the content tier was prepared for the identity the result was produced
4999    /// under.
5000    ///
5001    /// A result of another identity is [`AnalysisApplyOutcome::Stale`]: it answers another
5002    /// request than the one the tier holds, so committing it would mix records of two
5003    /// identities in one tier.
5004    ///
5005    /// Crate-private with [`Index::analysis_candidates`], and for the same reason.
5006    pub(crate) fn apply_analysis(
5007        &mut self,
5008        observation: AnalysisObservation,
5009    ) -> AnalysisApplyOutcome {
5010        self.apply_analysis_record(observation)
5011    }
5012
5013    /// Restore-path apply: insert the record and leave roll-ups for one rebuild.
5014    ///
5015    /// The caller must [`Self::rebuild_content_rollups`] before any query reads a
5016    /// directory total; sidecar load does that after the apply loop.
5017    pub(crate) fn apply_restored_analysis(
5018        &mut self,
5019        candidate: RestoreCandidate,
5020        analysis: crate::stored_state::AdmittedRecord<'_>,
5021    ) -> AnalysisApplyOutcome {
5022        let Some(entry) = self.try_entry(candidate.entry_id) else {
5023            return AnalysisApplyOutcome::Stale;
5024        };
5025        if entry.kind != EntryKind::File
5026            || entry.revision != candidate.revision
5027            || entry.attrs.fingerprint() != candidate.attrs.fingerprint()
5028        {
5029            return AnalysisApplyOutcome::Stale;
5030        }
5031        let Some(content) = self.content.as_mut() else {
5032            return AnalysisApplyOutcome::Stale;
5033        };
5034        if content.commit_without_rollup(candidate.relative_path, analysis) {
5035            AnalysisApplyOutcome::Applied
5036        } else {
5037            AnalysisApplyOutcome::Stale
5038        }
5039    }
5040
5041    pub(crate) fn rebuild_content_rollups(&mut self) {
5042        if let Some(content) = self.content.as_mut() {
5043            content.rebuild_rollups();
5044        }
5045    }
5046
5047    fn apply_analysis_record(&mut self, observation: AnalysisObservation) -> AnalysisApplyOutcome {
5048        let candidate = &observation.candidate;
5049        let Some(entry) = self.try_entry(candidate.entry_id) else {
5050            return AnalysisApplyOutcome::Stale;
5051        };
5052        if entry.kind != EntryKind::File
5053            || entry.revision != candidate.revision
5054            || entry.attrs.fingerprint() != candidate.attrs.fingerprint()
5055            || self.classify(&candidate.relative_path) != candidate.classification
5056        {
5057            return AnalysisApplyOutcome::Stale;
5058        }
5059        let Some(content) = self.content.as_mut() else {
5060            return AnalysisApplyOutcome::Stale;
5061        };
5062        if content.commit(
5063            candidate.relative_path.clone(),
5064            observation.profile,
5065            &observation.provenance,
5066            observation.analysis,
5067        ) {
5068            AnalysisApplyOutcome::Applied
5069        } else {
5070            AnalysisApplyOutcome::Stale
5071        }
5072    }
5073
5074    /// Drop all derived content while preserving metadata and snapshot compatibility.
5075    pub fn clear_content(&mut self) {
5076        self.content = None;
5077    }
5078
5079    // ---- internals ----
5080
5081    fn try_entry(&self, id: EntryId) -> Option<&Entry> {
5082        match self.arena.get(id.idx())? {
5083            Slot::Occupied { generation, entry } if *generation == id.generation => Some(entry),
5084            Slot::Occupied { .. } | Slot::Free { .. } => None,
5085        }
5086    }
5087
5088    fn expectation_matches(&self, op: &Op, expected: PathExpectation) -> bool {
5089        let current = self.path_state(op.path());
5090        // An operation whose target the index already holds changes nothing, whatever
5091        // happened to its baseline: another producer verified the same fact first and
5092        // there is no older state left to overwrite. Refusing it as stale cost the
5093        // observation handoff a full-root walk per convergent refresh, and three in a
5094        // row failed the root, for commits that would have applied as unchanged.
5095        if self.holds_target(op, current) {
5096            return true;
5097        }
5098        if current != expected.state {
5099            return false;
5100        }
5101
5102        let require_structure = match (op, expected.state) {
5103            (Op::Remove { .. }, _) => true,
5104            (Op::Upsert { kind, .. }, PathState::Present { kind: baseline, .. }) => {
5105                *kind != baseline
5106            }
5107            (
5108                Op::Upsert { .. }
5109                | Op::ControlUpsert { .. }
5110                | Op::ControlRemove { .. }
5111                | Op::InvalidateSubtree { .. },
5112                _,
5113            ) => false,
5114        };
5115        if !same_target(self.entry_identity(op.path()), expected.entry(), require_structure) {
5116            return false;
5117        }
5118
5119        match expected.absence_guard() {
5120            Some(expected) => self
5121                .absence_guard_identity(op.path())
5122                .is_some_and(|current| current.same_absence_guard(expected)),
5123            None => true,
5124        }
5125    }
5126
5127    /// Whether the index already holds what `op` would leave behind at `current`, the state
5128    /// of its path.
5129    ///
5130    /// An entry operation's target is a path state. A control operation's is the table:
5131    /// exactly its source retained at its path, or nothing retained there. The walk pushes
5132    /// a control file's entry and rules on one baseline, so both must converge together or
5133    /// the pair is refused for the rules alone. An invalidation always commits a change, so
5134    /// it is arbitrated on its baseline.
5135    fn holds_target(&self, op: &Op, current: PathState) -> bool {
5136        match op {
5137            Op::Upsert { kind, attrs, .. } => {
5138                current == PathState::Present { kind: *kind, attrs: *attrs }
5139            }
5140            Op::Remove { .. } => current == PathState::Absent,
5141            Op::ControlUpsert { path, source } => self.controls.source_is(path, source),
5142            Op::ControlRemove { path } => !self.controls.contains(path),
5143            Op::InvalidateSubtree { .. } => false,
5144        }
5145    }
5146
5147    fn absence_guard_identity(&self, path: &Path) -> Option<EntryIdentity> {
5148        let parts = normalize(path)?;
5149        let (_, ancestors) = parts.split_last()?;
5150        let mut current = EntryId::ROOT;
5151        for part in ancestors {
5152            let Some(child) = self.child(current, part) else {
5153                break;
5154            };
5155            current = child;
5156        }
5157        Some(self.identity(current))
5158    }
5159
5160    /// Prove that every accepted live upsert has a verified parent chain.
5161    ///
5162    /// The overlay follows batch order without touching the real index. That admits
5163    /// parent-first discovery batches and rejects a child whose missing or non-directory
5164    /// ancestry would otherwise be filled with guessed metadata.
5165    fn validate_known_ancestry(
5166        &self,
5167        ops: &[ObservationOp],
5168        accepted: &[bool],
5169    ) -> crate::Result<()> {
5170        if let Some((path, reconcile_from)) =
5171            self.unknown_ancestry(ops, accepted).into_iter().next()
5172        {
5173            return Err(crate::Error::UnknownAncestry { path, reconcile_from });
5174        }
5175        Ok(())
5176    }
5177
5178    fn accepted_operations(&self, ops: &[ObservationOp]) -> Vec<bool> {
5179        ops.iter()
5180            .map(|observed| match observed.expectation {
5181                Expectation::Any => true,
5182                Expectation::State(expected) => self.expectation_matches(&observed.op, expected),
5183            })
5184            .collect()
5185    }
5186
5187    /// Consume a walker-owned batch and prove every parent before mutation begins.
5188    ///
5189    /// Scanner batches contain only unconditional discoveries. The walker publishes a
5190    /// directory before any worker may enumerate it, so almost every parent resolves to
5191    /// an existing id. Serial batches may still contain a parent-first directory and its
5192    /// children together; those children retain the earlier operation index instead.
5193    /// The proof owns no duplicate paths and application performs no second path-tree
5194    /// search.
5195    ///
5196    /// A discovery can also find an entry whose kind the index no longer agrees with: a
5197    /// concurrent refresh may have replaced it after its directory was listed. Replacing a
5198    /// kind drops a subtree, so the parent ids proved here would not survive the batch.
5199    /// That rare batch is prepared for the general lane instead, which proves ancestry in
5200    /// operation order and replaces the entry as it would for any verified observation;
5201    /// the next observation of the path repairs a stale one.
5202    fn prepare_scanner_batch(
5203        &self,
5204        batch: crate::scan::ScannerBatch,
5205    ) -> crate::Result<PreparedObservation> {
5206        let ops = batch.into_ops();
5207        let mut parents = Vec::with_capacity(ops.len());
5208        let mut last_parent: Option<(&Path, ResolvedParent)> = None;
5209        let mut has_batch_parents = false;
5210        let mut path_comparisons = 0_u64;
5211        let mut replaces_kind = false;
5212
5213        for (op_index, observed) in ops.iter().enumerate() {
5214            if !matches!(observed.expectation, Expectation::Any) {
5215                return Err(crate::Error::UnsupportedScanConfig(
5216                    "scanner batches contain unconditional discoveries only",
5217                ));
5218            }
5219            let op = &observed.op;
5220            let path = op.path();
5221            if path.as_os_str().is_empty() {
5222                return Err(crate::Error::UnsupportedScanConfig(
5223                    "scanner batches cannot mutate the index root",
5224                ));
5225            }
5226            for component in path.components() {
5227                match component {
5228                    Component::Normal(_) => {}
5229                    Component::CurDir => {
5230                        return Err(crate::Error::UnsupportedScanConfig(
5231                            "scanner batches require canonical relative paths",
5232                        ));
5233                    }
5234                    Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
5235                        return Err(crate::Error::PathEscapesRoot(path.to_path_buf()));
5236                    }
5237                }
5238            }
5239            match op {
5240                Op::Upsert { .. } => {}
5241                Op::ControlUpsert { .. } | Op::ControlRemove { .. }
5242                    if crate::control::is_control_file(path) => {}
5243                Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {
5244                    return Err(crate::Error::InvalidControlPath(path.to_path_buf()));
5245                }
5246                Op::Remove { .. } | Op::InvalidateSubtree { .. } => {
5247                    return Err(crate::Error::UnsupportedScanConfig(
5248                        "scanner batches contain discoveries only",
5249                    ));
5250                }
5251            }
5252            if replaces_kind {
5253                // The general lane proves every remaining parent. Only the scanner input
5254                // contract above still applies to the rest of the batch.
5255                continue;
5256            }
5257
5258            let parent_path = path.parent().expect("a non-root relative path has a parent");
5259            if last_parent.is_some() {
5260                path_comparisons = path_comparisons.saturating_add(1);
5261            }
5262            let same_parent = last_parent
5263                .filter(|(previous, _)| *previous == parent_path)
5264                .map(|(_, parent)| parent);
5265            let parent = if let Some(parent) = same_parent {
5266                parent
5267            } else {
5268                self.lookup(parent_path)
5269                    .filter(|id| self.entry(*id).kind.is_dir())
5270                    .map(ResolvedParent::Existing)
5271                    .or_else(|| Self::earlier_scanner_parent(&ops, op_index, parent_path))
5272                    .ok_or_else(|| crate::Error::UnknownAncestry {
5273                        path: path.to_path_buf(),
5274                        reconcile_from: PathBuf::new(),
5275                    })?
5276            };
5277            if let (Op::Upsert { kind, .. }, ResolvedParent::Existing(parent)) = (op, parent) {
5278                if path.file_name().is_some_and(|name| {
5279                    self.child(parent, name).is_some_and(|child| self.entry(child).kind != *kind)
5280                }) {
5281                    replaces_kind = true;
5282                    continue;
5283                }
5284            }
5285            has_batch_parents |= matches!(parent, ResolvedParent::Earlier(_));
5286            parents.push(parent);
5287            last_parent = Some((parent_path, parent));
5288        }
5289
5290        if replaces_kind {
5291            return prepare_observation(&Observation::from_ops(ops));
5292        }
5293
5294        crate::counters::bump(|counts| {
5295            counts.ancestry_path_comparisons =
5296                counts.ancestry_path_comparisons.saturating_add(path_comparisons);
5297            counts.ancestry_parent_proofs = counts
5298                .ancestry_parent_proofs
5299                .saturating_add(u64::try_from(ops.len()).unwrap_or(u64::MAX));
5300        });
5301        Ok(PreparedObservation {
5302            ops,
5303            ancestry: PreparedAncestry::Scanner { parents, has_batch_parents },
5304            #[cfg(test)]
5305            reject_before_apply: false,
5306        })
5307    }
5308
5309    /// Resolve a parent produced earlier in the same scanner batch.
5310    fn earlier_scanner_parent(
5311        ops: &[ObservationOp],
5312        before: usize,
5313        parent_path: &Path,
5314    ) -> Option<ResolvedParent> {
5315        let (op_index, op) = ops[..before].iter().enumerate().rev().find(
5316            |(_, observed)| matches!(&observed.op, Op::Upsert { path, .. } if path == parent_path),
5317        )?;
5318        let Op::Upsert { kind, .. } = &op.op else {
5319            unreachable!("the search selected an upsert");
5320        };
5321        kind.is_dir().then_some(ResolvedParent::Earlier(op_index))
5322    }
5323
5324    /// Evaluate the complete resulting control table before any fact or reducer moves.
5325    ///
5326    /// Only a malformed control path fails here; a source the bounds cannot admit is
5327    /// refused inside the projection. Building the whole table first keeps a failing
5328    /// observation fault-atomic even when the same batch also moves ordinary entries.
5329    fn projected_controls(
5330        &self,
5331        ops: &[ObservationOp],
5332        accepted: &[bool],
5333    ) -> crate::Result<Option<crate::control::ControlTable>> {
5334        self.projected_controls_from(
5335            ops.iter()
5336                .zip(accepted)
5337                .filter_map(|(observed, accepted)| accepted.then_some(&observed.op)),
5338        )
5339    }
5340
5341    /// The table this batch would leave behind, or `None` when it leaves the current one.
5342    fn projected_controls_from<'a>(
5343        &self,
5344        ops: impl Iterator<Item = &'a Op> + Clone,
5345    ) -> crate::Result<Option<crate::control::ControlTable>> {
5346        if self.controls_unchanged_by(ops.clone()) {
5347            return Ok(None);
5348        }
5349        let mut projected = self.controls.clone();
5350        #[cfg(test)]
5351        CONTROL_PROJECTION_CLONES.with(|clones| clones.set(clones.get() + 1));
5352        let mut structure = StructuralOverlay::default();
5353        for op in ops {
5354            match op {
5355                Op::Upsert { path, kind, .. } => {
5356                    if crate::control::is_control_file(path) && *kind != EntryKind::File {
5357                        projected.remove(path)?;
5358                    }
5359                    if structure.kind(self, path) == Some(EntryKind::Dir) && !kind.is_dir() {
5360                        projected.remove_subtree(path);
5361                    }
5362                    structure.upsert(self, path, *kind);
5363                }
5364                Op::Remove { path } => {
5365                    if crate::control::is_control_file(path) {
5366                        projected.remove(path)?;
5367                    }
5368                    projected.remove_subtree(path);
5369                    structure.remove(self, path);
5370                }
5371                Op::ControlUpsert { path, source } => {
5372                    projected.upsert(path, source.clone())?;
5373                }
5374                Op::ControlRemove { path } => {
5375                    projected.remove(path)?;
5376                }
5377                Op::InvalidateSubtree { .. } => {}
5378            }
5379        }
5380        Ok(Some(projected))
5381    }
5382
5383    /// Whether no operation in the batch can change the retained control table.
5384    ///
5385    /// Only control ops write the table, and only a structural removal prunes it, so a
5386    /// batch whose control ops are all inert against this table and whose structural ops
5387    /// touch nothing it records leaves it exactly as it is. Each op is decided against the
5388    /// current table rather than against the projection, which is the same thing: an inert
5389    /// op leaves the state the next one is decided against unchanged.
5390    ///
5391    /// This is what keeps a warm revalidate of a tree past its budget from cloning the
5392    /// whole table for every batch of re-read refusals (fdu-hzm5), and a cold scan of a
5393    /// tree with no control files from projecting an empty table onto an empty one
5394    /// (fdu-pro1). A malformed control path is never inert, so the projection still
5395    /// reports it.
5396    fn controls_unchanged_by<'a>(&self, ops: impl Iterator<Item = &'a Op>) -> bool {
5397        // A vacant table decides every op from its kind alone, which is what the cold
5398        // no-controls lane costs per entry: there is nothing for a structural op to drop or
5399        // prune, and no source is inert against it, since `Unchanged` needs a retained
5400        // source and `Refuse` a matching refusal. A removal still asks, so a malformed
5401        // control path stays non-inert and the projection reports it.
5402        if self.controls.is_vacant() {
5403            return ops.into_iter().all(|op| match op {
5404                Op::ControlUpsert { .. } => false,
5405                Op::ControlRemove { path } => self.controls.remove_is_inert(path),
5406                Op::Upsert { .. } | Op::Remove { .. } | Op::InvalidateSubtree { .. } => true,
5407            });
5408        }
5409
5410        ops.into_iter().all(|op| match op {
5411            Op::ControlUpsert { path, source } => self.controls.upsert_is_inert(path, source),
5412            Op::ControlRemove { path } => self.controls.remove_is_inert(path),
5413            Op::Upsert { path, kind, .. } => {
5414                // The kind first: it is one discriminant test, where naming a control file
5415                // parses the path's last component.
5416                let drops_control = *kind != EntryKind::File
5417                    && crate::control::is_control_file(path)
5418                    && self.controls.contains(path);
5419                let prunes_subtree = !kind.is_dir() && self.controls.has_record_at_or_below(path);
5420                !drops_control && !prunes_subtree
5421            }
5422            Op::Remove { path } => {
5423                let drops_control =
5424                    crate::control::is_control_file(path) && self.controls.contains(path);
5425                !drops_control && !self.controls.has_record_at_or_below(path)
5426            }
5427            Op::InvalidateSubtree { .. } => true,
5428        })
5429    }
5430
5431    fn apply_control_transition<C: ConsequenceSink>(
5432        &mut self,
5433        projected: Option<crate::control::ControlTable>,
5434        stats: &mut ApplyStats,
5435        effects: &mut C,
5436    ) {
5437        let Some(projected) = projected else {
5438            return;
5439        };
5440        let changes = projected.changes_from(&self.controls);
5441        let refusals = projected.refusal_changes_from(&self.controls);
5442        if changes.is_empty() && refusals.is_empty() {
5443            return;
5444        }
5445        let affected: Vec<PathBuf> = changes
5446            .iter()
5447            .filter_map(|(path, _, _)| crate::control::ControlTable::affected_subtree(path).ok())
5448            .collect();
5449        self.controls = projected;
5450        stats.controls = u64::try_from(changes.len() + refusals.len()).unwrap_or(u64::MAX);
5451        for (path, previous, current) in changes {
5452            effects.change(|| EffectiveChange::ControlUpdated { path, previous, current });
5453        }
5454        // A refusal changes what classification covers, not what it says, so it moves no
5455        // entry by itself; the source it may have dropped arrived as a change above.
5456        for (path, previous, current) in refusals {
5457            effects.change(|| EffectiveChange::ControlRefusalUpdated { path, previous, current });
5458        }
5459        self.reclassify_controlled_subtrees(&affected, stats, effects);
5460    }
5461
5462    /// Re-evaluate only subtrees governed by changed controls, then rebuild the fixed
5463    /// unignored reducer from the resulting facts.
5464    fn reclassify_controlled_subtrees<C: ConsequenceSink>(
5465        &mut self,
5466        affected: &[PathBuf],
5467        stats: &mut ApplyStats,
5468        effects: &mut C,
5469    ) {
5470        let mut roots: Vec<PathBuf> = affected.to_vec();
5471        roots.sort();
5472        roots.dedup();
5473        let mut collapsed = Vec::new();
5474        for root in roots {
5475            if collapsed.iter().any(|ancestor: &PathBuf| root.starts_with(ancestor)) {
5476                continue;
5477            }
5478            collapsed.push(root);
5479        }
5480
5481        let mut moved = false;
5482        for root in collapsed {
5483            let Some(root_id) = self.lookup(&root) else {
5484                continue;
5485            };
5486            let children: Vec<(PathBuf, EntryId)> = self
5487                .children_of(root_id)
5488                .expect("controlled subtree root is live")
5489                .map(|(name, id)| (root.join(name), id))
5490                .collect();
5491            let mut queue = VecDeque::from(children);
5492            while let Some((path, id)) = queue.pop_front() {
5493                #[cfg(test)]
5494                RECLASSIFY_VISITS.with(|visits| visits.set(visits.get() + 1));
5495                let entry = self.entry(id);
5496                let parent_ignored = entry.parent.is_some_and(|parent| self.entry(parent).ignored);
5497                let current = entry.ignored;
5498                let next = parent_ignored
5499                    || self.controls.matcher_for(&path).is_ignored(entry.kind.is_dir());
5500                let descendants: Vec<(PathBuf, EntryId)> = self
5501                    .children_of(id)
5502                    .expect("controlled subtree entry is live")
5503                    .map(|(name, child)| (path.join(name), child))
5504                    .collect();
5505                if current != next {
5506                    self.move_serving_file_partition(&path, id, current, next);
5507                    self.entry_mut(id).ignored = next;
5508                    stats.reclassified += 1;
5509                    effects.change(|| EffectiveChange::Reclassified {
5510                        path: path.clone(),
5511                        previous_ignored: current,
5512                        current_ignored: next,
5513                    });
5514                    moved = true;
5515                }
5516                queue.extend(descendants);
5517            }
5518        }
5519        if moved {
5520            self.rebuild_unignored_rollups();
5521        }
5522    }
5523
5524    fn rebuild_unignored_rollups(&mut self) {
5525        let mut order = Vec::with_capacity(usize::try_from(self.live).unwrap_or(0));
5526        let mut stack = vec![EntryId::ROOT];
5527        while let Some(id) = stack.pop() {
5528            order.push(id);
5529            if self.entry(id).kind.is_dir() {
5530                stack.extend(self.child_ids(id));
5531            }
5532            if self.entry(id).kind.is_dir() {
5533                self.entry_mut(id).rollup_mut().unignored = InternedRollUp::default();
5534            }
5535        }
5536        for id in order.into_iter().rev() {
5537            let Some(parent) = self.entry(id).parent else {
5538                continue;
5539            };
5540            let contribution = self.contribution(id).unignored;
5541            self.entry_mut(parent).rollup_mut().unignored.merge(&contribution);
5542        }
5543    }
5544
5545    fn unknown_ancestry(
5546        &self,
5547        ops: &[ObservationOp],
5548        accepted: &[bool],
5549    ) -> Vec<(PathBuf, PathBuf)> {
5550        let mut structure = StructuralOverlay::default();
5551        let mut unknown = Vec::new();
5552        let mut overlay_inserts = 0_u64;
5553        let mut path_comparisons = 0_u64;
5554        let mut parent_proofs = 0_u64;
5555        let count_preflight = crate::counters::enabled();
5556        // The last directory this pass proved, with every ancestor of it. A producer
5557        // emits a directory's children together, so consecutive ops overwhelmingly
5558        // share a parent, and re-proving the same chain per op was the largest single
5559        // allocation cost of a cold scan (fdu-pro1): one component vector plus one
5560        // ancestor path rebuilt push-by-push, per entry, for an answer that had not
5561        // changed since the previous entry. The memo is invalidated wherever this loop
5562        // learns something that could change an answer -- a non-directory upsert or a
5563        // removal -- exactly like `ParentMemo` in the apply loop below.
5564        let mut proven_dir: Option<PathBuf> = None;
5565        let mut ancestor = PathBuf::new();
5566        for (observed, accepted) in ops.iter().zip(accepted) {
5567            if !accepted {
5568                continue;
5569            }
5570            match &observed.op {
5571                Op::Upsert { path, .. } | Op::ControlUpsert { path, .. }
5572                    if !path.as_os_str().is_empty() =>
5573                {
5574                    if count_preflight && proven_dir.is_some() {
5575                        path_comparisons = path_comparisons.saturating_add(1);
5576                    }
5577                    let same_proven_parent = matches!(
5578                        (path.parent(), proven_dir.as_deref()),
5579                        (Some(parent), Some(proven)) if parent == proven
5580                    );
5581                    if same_proven_parent {
5582                        if count_preflight {
5583                            parent_proofs = parent_proofs.saturating_add(1);
5584                        }
5585                    } else {
5586                        let mut reconcile_from = PathBuf::new();
5587                        let mut ancestry_known = true;
5588                        let parts = normalize(path).expect("prepared paths are canonical");
5589                        let (_, ancestors) = parts.split_last().expect("non-root path has a name");
5590                        ancestor.clear();
5591                        for part in ancestors {
5592                            ancestor.push(part);
5593                            if structure.kind(self, &ancestor) != Some(EntryKind::Dir) {
5594                                unknown.push((path.clone(), reconcile_from));
5595                                ancestry_known = false;
5596                                break;
5597                            }
5598                            reconcile_from.clone_from(&ancestor);
5599                        }
5600                        if !ancestry_known {
5601                            proven_dir = None;
5602                            continue;
5603                        }
5604                        if count_preflight {
5605                            parent_proofs = parent_proofs.saturating_add(1);
5606                        }
5607                        match &mut proven_dir {
5608                            Some(proven) => {
5609                                proven.clear();
5610                                path.parent().unwrap_or(Path::new("")).clone_into(proven);
5611                            }
5612                            None => {
5613                                proven_dir =
5614                                    Some(path.parent().unwrap_or(Path::new("")).to_path_buf());
5615                            }
5616                        }
5617                    }
5618                    if let Op::Upsert { kind, .. } = &observed.op {
5619                        structure.upsert(self, path, *kind);
5620                        if count_preflight {
5621                            overlay_inserts = overlay_inserts.saturating_add(1);
5622                        }
5623                        if !kind.is_dir() {
5624                            // This path may itself have been somebody's proven ancestor
5625                            // only if it was a directory before; the overlay knows, but
5626                            // the memo does not, so it forgets rather than reasons.
5627                            if proven_dir.as_deref().is_some_and(|proven| proven.starts_with(path))
5628                            {
5629                                proven_dir = None;
5630                            }
5631                        }
5632                    }
5633                }
5634                Op::Remove { path } if !path.as_os_str().is_empty() => {
5635                    structure.remove(self, path);
5636                    if proven_dir.as_deref().is_some_and(|proven| proven.starts_with(path)) {
5637                        proven_dir = None;
5638                    }
5639                }
5640                Op::Upsert { .. }
5641                | Op::Remove { .. }
5642                | Op::ControlUpsert { .. }
5643                | Op::ControlRemove { .. }
5644                | Op::InvalidateSubtree { .. } => {}
5645            }
5646        }
5647        if count_preflight {
5648            crate::counters::bump(|counts| {
5649                counts.ancestry_overlay_inserts =
5650                    counts.ancestry_overlay_inserts.saturating_add(overlay_inserts);
5651                counts.ancestry_path_comparisons =
5652                    counts.ancestry_path_comparisons.saturating_add(path_comparisons);
5653                counts.ancestry_parent_proofs =
5654                    counts.ancestry_parent_proofs.saturating_add(parent_proofs);
5655            });
5656        }
5657        unknown
5658    }
5659
5660    fn entry_identity(&self, path: &Path) -> Option<EntryIdentity> {
5661        Some(self.identity(self.lookup(path)?))
5662    }
5663
5664    fn identity(&self, id: EntryId) -> EntryIdentity {
5665        let entry = self.entry(id);
5666        EntryIdentity::new(
5667            id.slot,
5668            id.generation,
5669            entry.revision,
5670            entry.directory.as_deref().map_or(0, |directory| directory.children_revision),
5671            entry.kind.is_dir(),
5672        )
5673    }
5674
5675    fn bump_revision(entry: &mut Entry) {
5676        entry.revision = entry.revision.checked_add(1).expect("entry revision exhausted");
5677    }
5678
5679    fn bump_children_revision(entry: &mut Entry) {
5680        let directory = entry.directory_mut();
5681        directory.children_revision =
5682            directory.children_revision.checked_add(1).expect("entry children revision exhausted");
5683    }
5684
5685    fn child(&self, parent: EntryId, name: &OsStr) -> Option<EntryId> {
5686        let children = &self.entry(parent).directory.as_deref()?.children;
5687        match children {
5688            DirectoryChildren::Sorted(ids) => ids
5689                .binary_search_by(|id| self.entry(*id).name.as_os_str().cmp(name))
5690                .ok()
5691                .map(|position| ids[position]),
5692            DirectoryChildren::Mutable(children) => children.get(name).copied(),
5693        }
5694    }
5695
5696    fn child_ids(&self, parent: EntryId) -> ChildIds<'_> {
5697        self.entry(parent).directory().children.ids()
5698    }
5699
5700    /// Promote one compact, completed directory when its first mutation arrives.
5701    ///
5702    /// Detached indexes keep each name only on its child entry. Arbitrary public
5703    /// mutation needs keyed insertion and removal, so the touched parent pays the
5704    /// name clones once; untouched one-shot topology stays compact.
5705    fn promote_children(&mut self, parent: EntryId) {
5706        let ids = match &mut self.entry_mut(parent).directory_mut().children {
5707            DirectoryChildren::Sorted(ids) => std::mem::take(ids),
5708            DirectoryChildren::Mutable(_) => return,
5709        };
5710        let expected = ids.len();
5711        let children =
5712            ids.into_iter().map(|id| (self.entry(id).name.clone(), id)).collect::<BTreeMap<_, _>>();
5713        assert_eq!(
5714            children.len(),
5715            expected,
5716            "compact child names must remain unique before promotion"
5717        );
5718        self.entry_mut(parent).directory_mut().children = DirectoryChildren::Mutable(children);
5719    }
5720
5721    fn insert_child(&mut self, parent: EntryId, name: OsString, child: EntryId) {
5722        self.promote_children(parent);
5723        let entry = self.entry_mut(parent);
5724        let DirectoryChildren::Mutable(children) = &mut entry.directory_mut().children else {
5725            unreachable!("child promotion produces mutable storage")
5726        };
5727        children.insert(name, child);
5728        Self::bump_children_revision(entry);
5729    }
5730
5731    fn reserve_detached_children(&mut self, parent: EntryId, additional: usize) {
5732        let entry = self.entry_mut(parent);
5733        let DirectoryChildren::Sorted(children) = &mut entry.directory_mut().children else {
5734            unreachable!("detached directories retain sorted child storage")
5735        };
5736        children.reserve(additional);
5737    }
5738
5739    fn push_detached_child(&mut self, parent: EntryId, child: EntryId) {
5740        let entry = self.entry_mut(parent);
5741        let DirectoryChildren::Sorted(children) = &mut entry.directory_mut().children else {
5742            unreachable!("detached directories retain sorted child storage")
5743        };
5744        children.push(child);
5745        Self::bump_children_revision(entry);
5746    }
5747
5748    /// Put a detached directory's children back in name order after entries were
5749    /// appended to it out of order.
5750    fn sort_detached_children(&mut self, parent: EntryId) {
5751        let DirectoryChildren::Sorted(ids) = &mut self.entry_mut(parent).directory_mut().children
5752        else {
5753            unreachable!("detached directories retain sorted child storage")
5754        };
5755        let mut ids = std::mem::take(ids);
5756        ids.sort_unstable_by(|left, right| self.entry(*left).name.cmp(&self.entry(*right).name));
5757        self.entry_mut(parent).directory_mut().children = DirectoryChildren::Sorted(ids);
5758    }
5759
5760    /// Merge a completed detached directory without cloning its retained roll-up.
5761    fn merge_detached_descendants(&mut self, parent: EntryId, child: EntryId) {
5762        debug_assert!(parent.idx() < child.idx(), "cold parents must precede descendants");
5763        let (parents, children) = self.arena.split_at_mut(child.idx());
5764        let child_rollup = match &children[0] {
5765            Slot::Occupied { generation, entry } if *generation == child.generation => {
5766                entry.rollup()
5767            }
5768            Slot::Occupied { .. } | Slot::Free { .. } => {
5769                panic!("detached child handle must be live: {child:?}")
5770            }
5771        };
5772        let parent_entry = match &mut parents[parent.idx()] {
5773            Slot::Occupied { generation, entry } if *generation == parent.generation => entry,
5774            Slot::Occupied { .. } | Slot::Free { .. } => {
5775                panic!("detached parent handle must be live: {parent:?}")
5776            }
5777        };
5778        parent_entry.rollup_mut().merge(child_rollup);
5779    }
5780
5781    fn remove_child(&mut self, parent: EntryId, name: &OsStr) {
5782        self.promote_children(parent);
5783        let entry = self.entry_mut(parent);
5784        let DirectoryChildren::Mutable(children) = &mut entry.directory_mut().children else {
5785            unreachable!("child promotion produces mutable storage")
5786        };
5787        if children.remove(name).is_some() {
5788            Self::bump_children_revision(entry);
5789        }
5790    }
5791
5792    fn entry(&self, id: EntryId) -> &Entry {
5793        self.try_entry(id).expect("internal entry handle must be live")
5794    }
5795
5796    fn entry_mut(&mut self, id: EntryId) -> &mut Entry {
5797        match self.arena.get_mut(id.idx()) {
5798            Some(Slot::Occupied { generation, entry }) if *generation == id.generation => entry,
5799            Some(Slot::Occupied { .. } | Slot::Free { .. }) | None => {
5800                panic!("internal entry handle must be live: {id:?}")
5801            }
5802        }
5803    }
5804
5805    fn alloc(&mut self, entry: Entry) -> EntryId {
5806        crate::counters::bump(|c| c.entries_allocated += 1);
5807        self.live += 1;
5808        if let Some(free_slot) = self.free_head {
5809            let free_idx = free_slot as usize;
5810            let (generation, next) = match &self.arena[free_idx] {
5811                Slot::Free { generation, next_free } => (*generation, *next_free),
5812                Slot::Occupied { .. } => unreachable!("free list pointed at a live slot"),
5813            };
5814            self.free_head = next;
5815            self.arena[free_idx] = Slot::Occupied { generation, entry };
5816            return EntryId { slot: free_slot, generation };
5817        }
5818        let slot = u32::try_from(self.arena.len()).expect("index arena exceeded u32 capacity");
5819        let id = EntryId { slot, generation: 0 };
5820        self.arena.push(Slot::Occupied { generation: 0, entry });
5821        id
5822    }
5823
5824    fn free(&mut self, id: EntryId) {
5825        let next_generation = match &self.arena[id.idx()] {
5826            Slot::Occupied { generation, .. } if *generation == id.generation => {
5827                generation.checked_add(1).expect("entry generation exhausted")
5828            }
5829            Slot::Occupied { .. } | Slot::Free { .. } => {
5830                panic!("internal entry handle must be live: {id:?}")
5831            }
5832        };
5833        self.arena[id.idx()] =
5834            Slot::Free { generation: next_generation, next_free: self.free_head };
5835        self.free_head = Some(id.slot);
5836        self.live -= 1;
5837    }
5838
5839    /// Wall-clock now, in nanoseconds since the epoch, or zero if the clock is before
5840    /// it. Provenance timestamps are for display, so a nonsensical clock reads as
5841    /// "unknown" rather than propagating an error through every constructor.
5842    fn now_unix_nanos() -> i64 {
5843        std::time::SystemTime::now()
5844            .duration_since(std::time::UNIX_EPOCH)
5845            .ok()
5846            .and_then(|since| i64::try_from(since.as_nanos()).ok())
5847            .unwrap_or(0)
5848    }
5849
5850    /// Provenance of one path: where its value came from, when, and how settled.
5851    ///
5852    /// Built on demand from the entry's stored source and the index's timestamps
5853    /// rather than read from a field, because the timestamps are shared by nearly
5854    /// every entry and storing them per entry would cost far more than the
5855    /// information is worth.
5856    ///
5857    /// # Two limitations, both tracked
5858    ///
5859    /// **This reports the entry's own provenance, not its subtree's.** A directory
5860    /// whose descendants are less trustworthy than itself will still report its own
5861    /// source, so a `Complete`/`Revalidated` directory can contain `Cached` children.
5862    /// Composition belongs in the roll-up, where it costs one merge rather than an
5863    /// O(subtree) walk per query, and it is not implemented yet (`fdu-fka6`,
5864    /// `fdu-b1ts`). Do not read a directory's provenance as a subtree guarantee.
5865    ///
5866    /// A completed reconciliation records one clocked [`StateTransition::Verified`]
5867    /// for its subtree, including when every entry was unchanged. Consumers of exact
5868    /// commits therefore observe the same provenance movement as readers of this view.
5869    pub fn provenance(&self, path: &Path) -> Option<Provenance> {
5870        let id = self.lookup(path)?;
5871        Some(self.provenance_of(id))
5872    }
5873
5874    fn provenance_of(&self, id: EntryId) -> Provenance {
5875        let entry = self.entry(id);
5876        let status = self.status_of(id);
5877        // A completed sweep over an ancestor verified this entry even if no delta ever
5878        // named it, so an interval beats the entry's own stamp.
5879        //
5880        // Only while the index still considers the path fresh, though. An
5881        // `InvalidateSubtree` marks paths `Stale` and a running sweep marks them
5882        // `Reconciling`; in both cases trust has been withdrawn since the interval was
5883        // recorded, and promoting anyway would produce the self-contradicting answer
5884        // "partial, and verified".
5885        //
5886        // This applies to entries a delta *did* name, too, not only the ones it
5887        // skipped. Those were stamped `Revalidated` by the sweep, but their timestamp
5888        // would otherwise come from `observed_at`, which dates `Revalidated` to when
5889        // the index was constructed. One sweep would then report two different "as of"
5890        // times for equally verified paths — the elided siblings dated correctly to the
5891        // sweep, the touched entries dated to construction — and a consumer comparing
5892        // two rows could not tell which discipline it was reading.
5893        //
5894        // `Scanned` is excluded because it is *stronger* than `Revalidated`: a path
5895        // walked fresh this session is not improved by a sweep having covered it, and
5896        // its own scan time is already the right answer.
5897        if entry.source >= Source::Revalidated {
5898            if let Some(path) = self.path_of(id) {
5899                if self.freshness_at(&path) == Freshness::Fresh {
5900                    if let Some(verified_at) = self.verified_at(&path) {
5901                        return Provenance {
5902                            source: Source::Revalidated,
5903                            observed_at_ns: verified_at,
5904                            status,
5905                        };
5906                    }
5907                }
5908            }
5909        }
5910        Provenance { source: entry.source, observed_at_ns: self.observed_at(entry.source), status }
5911    }
5912
5913    /// Whether this path's totals account for everything beneath it.
5914    ///
5915    /// Derived from the freshness marks rather than stored, and answering the coverage
5916    /// question only. `Reconciling` and `Stale` describe values whose *trust* is in
5917    /// doubt while their coverage is not: a cached subtree still accounts for every
5918    /// entry it knows about, and saying otherwise would report a complete cached
5919    /// baseline as if it were half-built. That distinction is [`Source`]'s job, and
5920    /// collapsing the two axes is what let a value that may shrink advertise itself as
5921    /// a lower bound that can only grow.
5922    ///
5923    /// Only [`Freshness::Partial`] — reconciliation errors left some of the subtree
5924    /// unread — is genuinely missing coverage.
5925    fn status_of(&self, id: EntryId) -> Status {
5926        let Some(path) = self.path_of(id) else {
5927            return Status::Complete;
5928        };
5929        match self.freshness_at(&path) {
5930            Freshness::Fresh | Freshness::Reconciling | Freshness::Stale => Status::Complete,
5931            Freshness::Partial => Status::Partial,
5932        }
5933    }
5934
5935    /// When an entry with this source was observed.
5936    const fn observed_at(&self, source: Source) -> i64 {
5937        match source {
5938            Source::Cached | Source::JournalScoped => self.captured_at_ns,
5939            Source::Scanned | Source::Revalidated => self.scanned_at_ns,
5940        }
5941    }
5942
5943    /// The start of the pass a snapshot of this index records as the one that wrote its
5944    /// image.
5945    pub(crate) const fn writing_pass_started_at_ns(&self) -> i64 {
5946        self.writing_pass_started_at_ns
5947    }
5948
5949    /// Record the pass start a loaded snapshot carried for the facts it restored.
5950    pub(crate) fn set_writing_pass_started_at_ns(&mut self, writing_pass_started_at_ns: i64) {
5951        self.writing_pass_started_at_ns = writing_pass_started_at_ns;
5952    }
5953
5954    /// Whether this index holds entry-tier facts no completed metadata write has recorded.
5955    pub(crate) const fn persistence_owed(&self) -> bool {
5956        self.persistence_owed
5957    }
5958
5959    /// Record that a metadata write of this index completed, or that a pass mutated it
5960    /// since the last one did.
5961    pub(crate) fn set_persistence_owed(&mut self, owed: bool) {
5962        self.persistence_owed = owed;
5963    }
5964
5965    /// Stamp deltas applied from here on with `source`, restoring the previous value
5966    /// when the returned guard value is passed back.
5967    ///
5968    /// Used by snapshot loading, which is replaying observations that describe a tree
5969    /// as it was, not as this process has seen it.
5970    pub(crate) fn set_applying_source(&mut self, source: Source, captured_at_ns: i64) -> Source {
5971        let previous = self.applying_source;
5972        self.applying_source = source;
5973        if source == Source::Cached {
5974            self.state.source = Source::Cached;
5975        }
5976        if captured_at_ns != 0 {
5977            self.captured_at_ns = captured_at_ns;
5978        }
5979        previous
5980    }
5981
5982    /// Intern an extension name and retain one file's reference to it.
5983    ///
5984    /// Every call must be matched by a [`Self::release_ext`] when that file leaves the
5985    /// index, which is what keeps the interner proportional to the extensions the tree
5986    /// currently holds rather than to every extension it has ever held.
5987    fn intern_ext(&mut self, name: &str) -> ExtId {
5988        if let Some(&id) = self.ext_ids.get(name) {
5989            let refcount =
5990                self.ext_refcounts.get_mut(id as usize).expect("a live id has a refcount");
5991            *refcount = refcount.checked_add(1).expect("extension refcount exhausted");
5992            return id;
5993        }
5994        let id = if let Some(id) = self.free_ext_ids.pop() {
5995            let slot = id as usize;
5996            self.ext_names[slot] = Some(name.to_string());
5997            self.ext_refcounts[slot] = 1;
5998            id
5999        } else {
6000            let id = ExtId::try_from(self.ext_names.len()).expect("extension interner exhausted");
6001            self.ext_names.push(Some(name.to_string()));
6002            self.ext_refcounts.push(1);
6003            id
6004        };
6005        self.ext_ids.insert(name.to_string(), id);
6006        id
6007    }
6008
6009    /// Drop one file's reference, freeing the id and its name after the last one.
6010    fn release_ext(&mut self, id: ExtId) {
6011        let slot = id as usize;
6012        let refcount = self.ext_refcounts.get_mut(slot).expect("a live id has a refcount");
6013        debug_assert!(*refcount > 0, "extension reference released twice");
6014        *refcount -= 1;
6015        if *refcount != 0 {
6016            return;
6017        }
6018        let name = self.ext_names[slot].take().expect("a live id has a name");
6019        let removed = self.ext_ids.remove(&name);
6020        debug_assert_eq!(removed, Some(id), "the interner's two maps disagreed");
6021        self.free_ext_ids.push(id);
6022    }
6023
6024    /// Resolve hot-path integer keys exactly once at a public query boundary.
6025    fn named_rollup(&self, rollup: &InternedRollUp) -> RollUp {
6026        let by_ext = rollup
6027            .by_ext
6028            .iter()
6029            .map(|(id, tally)| {
6030                let name = self
6031                    .ext_names
6032                    .get(*id as usize)
6033                    .and_then(Option::as_ref)
6034                    .expect("a live roll-up's extension id has a name");
6035                (name.clone(), *tally)
6036            })
6037            .collect();
6038        RollUp {
6039            files: rollup.files,
6040            dirs: rollup.dirs,
6041            bytes: rollup.bytes,
6042            allocated: rollup.allocated,
6043            newest_mtime_ns: rollup.newest_mtime_ns,
6044            by_ext,
6045        }
6046    }
6047
6048    fn named_partitions(&self, rollup: &InternedPartitionRollUp) -> PartitionRollUp {
6049        PartitionRollUp {
6050            all: self.named_rollup(&rollup.all),
6051            unignored: self.named_rollup(&rollup.unignored),
6052        }
6053    }
6054
6055    /// What an entry contributes to each of its ancestors.
6056    fn contribution(&self, id: EntryId) -> InternedPartitionRollUp {
6057        let entry = self.entry(id);
6058        match entry.kind {
6059            EntryKind::Dir => {
6060                let mut all = entry.rollup().all.clone();
6061                all.dirs += 1;
6062                let mut unignored = InternedRollUp::default();
6063                if !entry.ignored {
6064                    unignored = entry.rollup().unignored.clone();
6065                    unignored.dirs += 1;
6066                }
6067                InternedPartitionRollUp { all, unignored }
6068            }
6069            EntryKind::File => Self::file_contribution(&entry.attrs, entry.ext_id, entry.ignored),
6070            EntryKind::Symlink | EntryKind::Other => InternedPartitionRollUp::default(),
6071        }
6072    }
6073
6074    /// What a regular file contributes to each of its ancestors, whether or not the index
6075    /// keeps it as an entry: a folded index counts the files it does not keep through this
6076    /// too, without an extension, since it keeps no extension tallies.
6077    fn file_contribution(
6078        attrs: &Attrs,
6079        ext_id: Option<ExtId>,
6080        ignored: bool,
6081    ) -> InternedPartitionRollUp {
6082        let mut all = InternedRollUp {
6083            files: 1,
6084            dirs: 0,
6085            bytes: attrs.size,
6086            allocated: attrs.allocated,
6087            newest_mtime_ns: attrs.mtime_ns,
6088            by_ext: BTreeMap::new(),
6089        };
6090        if let Some(ext_id) = ext_id {
6091            all.by_ext.insert(
6092                ext_id,
6093                ExtTally { files: 1, bytes: attrs.size, allocated: attrs.allocated },
6094            );
6095        }
6096        let unignored = if ignored { InternedRollUp::default() } else { all.clone() };
6097        InternedPartitionRollUp { all, unignored }
6098    }
6099
6100    fn merge_upward(
6101        &mut self,
6102        from_parent: Option<EntryId>,
6103        contribution: &InternedPartitionRollUp,
6104    ) {
6105        let mut current = from_parent;
6106        while let Some(id) = current {
6107            // Counted per level rather than per call: the O(depth) shape is the thing
6108            // worth seeing, and it is what S4's bottom-up pass would collapse.
6109            crate::counters::bump(|c| c.rollup_merges += 1);
6110            let entry = self.entry_mut(id);
6111            entry.rollup_mut().merge(contribution);
6112            current = entry.parent;
6113        }
6114    }
6115
6116    fn unmerge_upward(
6117        &mut self,
6118        from_parent: Option<EntryId>,
6119        contribution: &InternedPartitionRollUp,
6120    ) {
6121        let mut current = from_parent;
6122        while let Some(id) = current {
6123            let entry = self.entry_mut(id);
6124            entry.rollup_mut().unmerge(contribution);
6125            current = entry.parent;
6126        }
6127    }
6128
6129    /// Rebuild `newest_mtime_ns` from direct children, walking to the root.
6130    ///
6131    /// Every ancestor must be visited even when the nearest directory is already
6132    /// correct. Differential unmerge/re-merge can repair a single-child directory as
6133    /// it goes while leaving an ancestor with other contributors holding the removed
6134    /// maximum. Stopping at the first unchanged directory therefore strands a stale
6135    /// value higher in the tree.
6136    fn recompute_newest_upward(&mut self, from: Option<EntryId>) {
6137        let mut current = from;
6138        while let Some(id) = current {
6139            let mut newest: Option<i64> = None;
6140            for child in self.child_ids(id) {
6141                let child_entry = self.entry(child);
6142                let candidate = match child_entry.kind {
6143                    EntryKind::Dir => (child_entry.rollup().files > 0)
6144                        .then_some(child_entry.rollup().newest_mtime_ns),
6145                    EntryKind::File => Some(child_entry.attrs.mtime_ns),
6146                    EntryKind::Symlink | EntryKind::Other => None,
6147                };
6148                if let Some(candidate) = candidate {
6149                    newest = Some(newest.map_or(candidate, |current| current.max(candidate)));
6150                }
6151            }
6152            let newest = newest.unwrap_or(0);
6153            self.entry_mut(id).rollup_mut().all.newest_mtime_ns = newest;
6154
6155            let mut newest_unignored: Option<i64> = None;
6156            for child in self.child_ids(id) {
6157                let child_entry = self.entry(child);
6158                let candidate = match child_entry.kind {
6159                    EntryKind::Dir => (!child_entry.ignored
6160                        && child_entry.rollup().unignored.files > 0)
6161                        .then_some(child_entry.rollup().unignored.newest_mtime_ns),
6162                    EntryKind::File => (!child_entry.ignored).then_some(child_entry.attrs.mtime_ns),
6163                    EntryKind::Symlink | EntryKind::Other => None,
6164                };
6165                if let Some(candidate) = candidate {
6166                    newest_unignored =
6167                        Some(newest_unignored.map_or(candidate, |current| current.max(candidate)));
6168                }
6169            }
6170            let entry = self.entry_mut(id);
6171            entry.rollup_mut().unignored.newest_mtime_ns = newest_unignored.unwrap_or(0);
6172            current = entry.parent;
6173        }
6174    }
6175
6176    /// Resolve a parent chain already proved by [`Self::validate_known_ancestry`].
6177    fn resolve_dir_chain(&self, parts: &[&OsStr]) -> EntryId {
6178        let mut current = EntryId::ROOT;
6179        for part in parts {
6180            current = self
6181                .child(current, part)
6182                .expect("validated ancestry remains present under the writer lock");
6183            debug_assert!(self.entry(current).kind.is_dir());
6184        }
6185        current
6186    }
6187
6188    fn apply_upsert<C: ConsequenceSink>(
6189        &mut self,
6190        path: &Path,
6191        kind: EntryKind,
6192        attrs: Attrs,
6193        stats: &mut ApplyStats,
6194        effects: &mut C,
6195        parent_memo: &mut ParentMemo,
6196    ) -> bool {
6197        // A walker reports a directory's children consecutively, because that is the
6198        // order one `getdents64` batch hands them over, so the parent resolved for the
6199        // previous entry is almost always the parent of this one. Checking that first
6200        // turns the common case into a single path comparison and skips both the
6201        // component vector and the descent below.
6202        crate::counters::bump(|c| c.upserts += 1);
6203        if let (Some(dir), Some(name)) = (path.parent(), path.file_name()) {
6204            if let Some(parent) = parent_memo.get(dir) {
6205                crate::counters::bump(|c| c.parent_memo_hits += 1);
6206                return self.upsert_beneath(parent, name, path, kind, attrs, stats, effects);
6207            }
6208        }
6209        crate::counters::bump(|c| c.parent_resolutions += 1);
6210
6211        let Some(parts) = normalize(path) else {
6212            return false;
6213        };
6214        let source = self.applying_source;
6215
6216        let Some((name, ancestors)) = parts.split_last() else {
6217            // The root itself: only its own attributes can change. Its source is
6218            // stamped on both paths for the same reason every other entry's is — a
6219            // producer just looked at it — and the root is the entry where getting this
6220            // wrong costs the most, because the whole-tree totals hang off it and a
6221            // consumer reads its provenance to label the headline number.
6222            if self.entry(EntryId::ROOT).attrs == attrs {
6223                self.entry_mut(EntryId::ROOT).source = source;
6224                stats.unchanged += 1;
6225                return false;
6226            }
6227            let root = self.entry_mut(EntryId::ROOT);
6228            let previous = root.attrs;
6229            root.attrs = attrs;
6230            root.source = source;
6231            Self::bump_revision(root);
6232            stats.updated += 1;
6233            effects.change(|| EffectiveChange::Updated {
6234                path: PathBuf::new(),
6235                kind: EntryKind::Dir,
6236                previous,
6237                current: attrs,
6238            });
6239            return true;
6240        };
6241        let parent = self.resolve_dir_chain(ancestors);
6242        if let Some(dir) = path.parent() {
6243            parent_memo.set(dir, parent);
6244        }
6245        self.upsert_beneath(parent, name, path, kind, attrs, stats, effects)
6246    }
6247
6248    /// Apply one upsert beneath a parent whose id is already resolved.
6249    ///
6250    /// This is the whole of [`apply_upsert`] except for finding the parent, split out so
6251    /// that the memoized and the resolved paths share one body rather than two copies of
6252    /// the arbitration rules.  Every guard the delta contract requires still runs here:
6253    /// the caller has supplied a parent, not a decision.
6254    #[allow(clippy::too_many_arguments)]
6255    fn upsert_beneath<C: ConsequenceSink>(
6256        &mut self,
6257        parent: EntryId,
6258        name: &OsStr,
6259        path: &Path,
6260        kind: EntryKind,
6261        attrs: Attrs,
6262        stats: &mut ApplyStats,
6263        effects: &mut C,
6264    ) -> bool {
6265        let source = self.applying_source;
6266        let existing = self.child(parent, name);
6267
6268        if let Some(id) = existing {
6269            let entry = self.entry(id);
6270            if entry.kind == kind {
6271                if entry.attrs == attrs {
6272                    // Nothing about the value changed, but a producer just looked at
6273                    // it, and that is exactly what provenance records. Without this an
6274                    // entry verified by a revalidation sweep keeps reporting the source
6275                    // it was loaded with, and a consumer could never clear a
6276                    // stale-value indicator no matter how much checking happened.
6277                    self.entry_mut(id).source = source;
6278                    stats.unchanged += 1;
6279                    return false;
6280                }
6281                if kind.is_dir() {
6282                    // A directory's own attributes do not reach its ancestors' roll-ups,
6283                    // so there is nothing to re-merge.
6284                    let entry = self.entry_mut(id);
6285                    let previous = entry.attrs;
6286                    entry.attrs = attrs;
6287                    entry.source = source;
6288                    Self::bump_revision(entry);
6289                    stats.updated += 1;
6290                    effects.change(|| EffectiveChange::Updated {
6291                        path: path.to_path_buf(),
6292                        kind,
6293                        previous,
6294                        current: attrs,
6295                    });
6296                    return true;
6297                }
6298                let previous_attrs = entry.attrs;
6299                self.invalidate_content(path);
6300                if kind == EntryKind::File {
6301                    self.remove_serving_file_semantics(path, id, previous_attrs);
6302                }
6303                self.remove_serving_entry(path, kind, previous_attrs, id);
6304                let old = self.contribution(id);
6305                self.unmerge_upward(Some(parent), &old);
6306                let entry = self.entry_mut(id);
6307                let previous = entry.attrs;
6308                entry.attrs = attrs;
6309                entry.source = source;
6310                Self::bump_revision(entry);
6311                let new = self.contribution(id);
6312                self.merge_upward(Some(parent), &new);
6313                self.insert_serving_entry(path, kind, attrs, id);
6314                if new.newest_mtime_ns < old.newest_mtime_ns {
6315                    self.recompute_newest_upward(Some(parent));
6316                }
6317                stats.updated += 1;
6318                effects.change(|| EffectiveChange::Updated {
6319                    path: path.to_path_buf(),
6320                    kind,
6321                    previous,
6322                    current: attrs,
6323                });
6324                return true;
6325            }
6326            // The kind changed (a file became a directory, say). Remove and re-insert
6327            // rather than trying to mutate one shape into the other.
6328            //
6329            // This drops a subtree but cannot invalidate the memo: the memo holds this
6330            // entry's *parent*, and the subtree removed is rooted at the entry itself.
6331            // Clearing here would be untestable defensive code, which reads as a hazard
6332            // that does not exist.
6333            self.remove_entry(id, stats, effects);
6334        }
6335
6336        let ext_id =
6337            (kind == EntryKind::File).then(|| self.intern_ext(&crate::classify::ext_bucket(name)));
6338        let ignored =
6339            self.entry(parent).ignored || self.controls.matcher_for(path).is_ignored(kind.is_dir());
6340        let id = self.alloc(Entry::new(
6341            NewEntry {
6342                parent: Some(parent),
6343                name: name.to_os_string(),
6344                ext_id,
6345                ignored,
6346                source,
6347                kind,
6348                attrs,
6349            },
6350            false,
6351        ));
6352        self.insert_child(parent, name.to_os_string(), id);
6353        let contribution = self.contribution(id);
6354        self.merge_upward(Some(parent), &contribution);
6355        stats.inserted += 1;
6356        effects.change(|| EffectiveChange::Inserted { path: path.to_path_buf(), kind, attrs });
6357        self.insert_serving_entry(path, kind, attrs, id);
6358        true
6359    }
6360
6361    /// Insert one snapshot record beneath a parent whose id the caller already holds.
6362    ///
6363    /// The snapshot loader is not a producer.  It restores state that the delta contract
6364    /// already arbitrated and serialized, in the order it was written, with parents
6365    /// always preceding their children — so every fact [`apply_upsert`] rediscovers by
6366    /// resolving a path is a fact the loader was handed.  Routing it through the
6367    /// observation path made the loader pay, per record, a `PathBuf` join, an
6368    /// `Observation` vector, a `normalize` vector, and a descent from the root through
6369    /// one `BTreeMap` lookup per level, to arrive at a parent it had in a local variable.
6370    /// A callgrind profile of a 450k-entry load put the allocator at about 27% of the
6371    /// work and path-component iteration at about 15%; this removes both.
6372    ///
6373    /// It stays `pub(crate)` and takes an `EntryId` rather than a path precisely so it
6374    /// cannot become a second mutation surface: no external producer can reach it, and
6375    /// the guarantee that a loaded index equals the saved one is enforced by round-trip
6376    /// tests rather than by making deserialization impersonate a producer.
6377    ///
6378    /// Returns `None` when the parent is not a live directory or already holds `name`,
6379    /// which is how a corrupt snapshot fails closed.
6380    pub(crate) fn insert_loaded_child(
6381        &mut self,
6382        parent: EntryId,
6383        name: OsString,
6384        kind: EntryKind,
6385        attrs: Attrs,
6386    ) -> Option<EntryId> {
6387        let parent_entry = self.try_entry(parent)?;
6388        if parent_entry.kind != EntryKind::Dir || self.child(parent, &name).is_some() {
6389            return None;
6390        }
6391        let source = self.applying_source;
6392        let ext_id =
6393            (kind == EntryKind::File).then(|| self.intern_ext(&crate::classify::ext_bucket(&name)));
6394        let id = self.alloc(Entry::new(
6395            NewEntry {
6396                parent: Some(parent),
6397                name: name.clone(),
6398                ext_id,
6399                ignored: false,
6400                source,
6401                kind,
6402                attrs,
6403            },
6404            true,
6405        ));
6406        self.insert_child(parent, name, id);
6407        // Roll-ups stay eager. The same profile put `merge_upward` at about 3.5%, so
6408        // deferring it to a bottom-up pass would buy little and would introduce a window
6409        // in which the index is structurally complete but numerically wrong.
6410        let contribution = self.contribution(id);
6411        self.merge_upward(Some(parent), &contribution);
6412        // One-shot snapshot load constructs the index with serving off.
6413        // `insert_serving_entry` would discard a reconstructed path.
6414        if self.serving.is_some() {
6415            let path = self.path_of(id).expect("a newly loaded entry has a path");
6416            self.insert_serving_entry(&path, kind, attrs, id);
6417        }
6418        Some(id)
6419    }
6420
6421    fn apply_remove<C: ConsequenceSink>(
6422        &mut self,
6423        path: &Path,
6424        stats: &mut ApplyStats,
6425        effects: &mut C,
6426    ) -> bool {
6427        let Some(id) = self.lookup(path) else {
6428            stats.unchanged += 1;
6429            return false;
6430        };
6431        if id == EntryId::ROOT {
6432            stats.unchanged += 1;
6433            return false;
6434        }
6435        self.remove_entry(id, stats, effects);
6436        true
6437    }
6438
6439    fn remove_entry<C: ConsequenceSink>(
6440        &mut self,
6441        id: EntryId,
6442        stats: &mut ApplyStats,
6443        effects: &mut C,
6444    ) {
6445        let removed_root = self.path_of(id).expect("a live entry has a path");
6446        self.invalidate_content(&removed_root);
6447        self.remove_serving_subtree_semantics(id, &removed_root);
6448        let parent = self.entry(id).parent;
6449        let name = self.entry(id).name.clone();
6450        let contribution = self.contribution(id);
6451
6452        self.unmerge_upward(parent, &contribution);
6453        if let Some(parent) = parent {
6454            self.remove_child(parent, &name);
6455        }
6456
6457        // Free the subtree iteratively; a recursive drop would blow the stack on deep
6458        // trees, which is exactly the shape this engine is built for.
6459        let mut queue = VecDeque::from([(id, removed_root)]);
6460        while let Some((node, path)) = queue.pop_front() {
6461            let entry = self.entry(node);
6462            let kind = entry.kind;
6463            let attrs = entry.attrs;
6464            let children: Vec<(OsString, EntryId)> = self
6465                .children_of(node)
6466                .expect("removed subtree entry is live")
6467                .map(|(name, child)| (name.to_os_string(), child))
6468                .collect();
6469            let ext_id = entry.ext_id;
6470            for (name, child) in children {
6471                queue.push_back((child, path.join(name)));
6472            }
6473            self.remove_serving_entry(&path, kind, attrs, node);
6474            effects.change(|| EffectiveChange::Removed { path, kind, attrs });
6475            // Give the extension back before the entry itself goes, so the interner
6476            // holds only what the tree still contains.
6477            if let Some(ext_id) = ext_id {
6478                self.release_ext(ext_id);
6479            }
6480            self.free(node);
6481            stats.removed += 1;
6482        }
6483
6484        // The max may have lived in what was just removed.
6485        self.recompute_newest_upward(parent);
6486    }
6487
6488    fn invalidate_content(&mut self, path: &Path) {
6489        if let Some(content) = self.content.as_mut() {
6490            content.invalidate(path);
6491        }
6492    }
6493}
6494
6495/// Capture every child's expectation directly off its live entry, with no path work.
6496///
6497/// Both reconcile targets use this. The exclusive path once had a twin in `scan.rs`
6498/// that re-derived each expectation by joining a `PathBuf` and descending from the
6499/// root — two full descents and ~13 allocations per child to recover an `EntryId`
6500/// the iterator already held. The equivalence test below is what lets the twin stay
6501/// deleted.
6502pub(crate) fn collect_child_expectations(
6503    index: &Index,
6504    path: &Path,
6505) -> BTreeMap<OsString, PathExpectation> {
6506    index.children(path).map_or_else(BTreeMap::new, |children| {
6507        children
6508            .map(|(name, id)| {
6509                let entry = index.entry(id);
6510                let expectation = PathExpectation::new(
6511                    PathState::Present { kind: entry.kind, attrs: entry.attrs },
6512                    Some(index.identity(id)),
6513                    None,
6514                );
6515                (name.to_os_string(), expectation)
6516            })
6517            .collect()
6518    })
6519}
6520
6521/// Split a relative path into its normal components, rejecting anything that escapes.
6522///
6523/// Returns `None` for paths containing `..`, a root, or a prefix — an index keyed by
6524/// relative path has no way to represent those, and silently normalizing them away would
6525/// let a delta write outside the tree it claims to describe.
6526/// The components are borrowed from `path`, not copied out of it.
6527///
6528/// Owning them cost an allocation per component, and this runs twice for every
6529/// operation in every batch — once to validate the path and once to apply it. On a
6530/// tree averaging eight levels deep that was on the order of eighteen allocations per
6531/// entry, all of them holding bytes that the caller's `PathBuf` already owned and
6532/// outlives. Only the returned `Vec` allocates now, and only where a slice is
6533/// genuinely needed.
6534/// The parent directory resolved for the previous upsert in a batch.
6535///
6536/// A walker reports a directory's children consecutively, so resolving the parent path
6537/// once per directory rather than once per entry removes the dominant cost of applying a
6538/// cold scan: a callgrind profile attributed about 25 path-component comparisons per
6539/// entry to the descent, and the component vector `normalize` builds is an allocation
6540/// per entry on top of that.
6541///
6542/// It is a single slot rather than a map on purpose.  A map would keep entries alive
6543/// across structural changes and turn every miss into a hash, where consecutive runs are
6544/// what the walker actually produces; one slot captures those and costs a path
6545/// comparison when it misses.  The slot holds an id, so it must be cleared whenever a
6546/// removal could unmake it — [`Index::apply_remove`], an invalidation, and the
6547/// kind-change removal inside an upsert all do.
6548#[derive(Default)]
6549struct ParentMemo {
6550    entry: Option<(PathBuf, EntryId)>,
6551}
6552
6553impl ParentMemo {
6554    /// The id remembered for `dir`, if the last resolved parent was that directory.
6555    fn get(&self, dir: &Path) -> Option<EntryId> {
6556        self.entry.as_ref().filter(|(cached, _)| cached == dir).map(|&(_, id)| id)
6557    }
6558
6559    fn set(&mut self, dir: &Path, id: EntryId) {
6560        match &mut self.entry {
6561            // Overwriting in place keeps this to one allocation per directory rather
6562            // than one per run, which matters because a wide tree alternates often.
6563            Some((cached, cached_id)) => {
6564                cached.clear();
6565                cached.push(dir);
6566                *cached_id = id;
6567            }
6568            slot => *slot = Some((dir.to_path_buf(), id)),
6569        }
6570    }
6571
6572    fn clear(&mut self) {
6573        self.entry = None;
6574    }
6575}
6576
6577/// Structural effects of accepted operations evaluated before the real mutation.
6578#[derive(Default)]
6579struct StructuralOverlay {
6580    // Only point lookup and subtree retention use these keys; no iteration order is
6581    // observed. Ordering every path on lookup and insert dominated public preflight.
6582    entries: HashMap<PathBuf, EntryKind>,
6583    removed_roots: Vec<PathBuf>,
6584}
6585
6586impl StructuralOverlay {
6587    fn kind(&self, index: &Index, path: &Path) -> Option<EntryKind> {
6588        self.entries.get(path).copied().or_else(|| {
6589            (!self.removed_roots.iter().any(|removed| path.starts_with(removed)))
6590                .then(|| index.kind(path))
6591                .flatten()
6592        })
6593    }
6594
6595    fn upsert(&mut self, index: &Index, path: &Path, kind: EntryKind) {
6596        if self.kind(index, path).is_some_and(|current| current != kind) {
6597            self.remove(index, path);
6598        }
6599        self.entries.insert(path.to_path_buf(), kind);
6600    }
6601
6602    fn remove(&mut self, index: &Index, path: &Path) {
6603        if self.kind(index, path).is_none() {
6604            return;
6605        }
6606        self.entries.retain(|candidate, _| !candidate.starts_with(path));
6607        self.removed_roots.retain(|candidate| !candidate.starts_with(path));
6608        if !self.removed_roots.iter().any(|removed| path.starts_with(removed)) {
6609            self.removed_roots.push(path.to_path_buf());
6610        }
6611    }
6612}
6613
6614fn normalize(path: &Path) -> Option<Vec<&OsStr>> {
6615    let mut parts = Vec::new();
6616    for component in path.components() {
6617        match component {
6618            Component::Normal(part) => parts.push(part),
6619            Component::CurDir => {}
6620            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
6621        }
6622    }
6623    Some(parts)
6624}
6625
6626fn prepare_observation(observation: &Observation) -> crate::Result<PreparedObservation> {
6627    let mut ops = Vec::with_capacity(observation.len());
6628    for observed in &observation.ops {
6629        let path = canonical_relative_path(observed.op.path())?;
6630        let op = match &observed.op {
6631            Op::Upsert { kind, attrs, .. } => Op::Upsert { path, kind: *kind, attrs: *attrs },
6632            Op::Remove { .. } => Op::Remove { path },
6633            Op::ControlUpsert { source, .. } => {
6634                if !crate::control::is_control_file(&path) {
6635                    return Err(crate::Error::InvalidControlPath(path));
6636                }
6637                Op::ControlUpsert { path, source: source.clone() }
6638            }
6639            Op::ControlRemove { .. } => {
6640                if !crate::control::is_control_file(&path) {
6641                    return Err(crate::Error::InvalidControlPath(path));
6642                }
6643                Op::ControlRemove { path }
6644            }
6645            Op::InvalidateSubtree { reason, .. } => Op::InvalidateSubtree { path, reason: *reason },
6646        };
6647        ops.push(ObservationOp { op, expectation: observed.expectation });
6648    }
6649    Ok(PreparedObservation {
6650        ops,
6651        ancestry: PreparedAncestry::General,
6652        #[cfg(test)]
6653        reject_before_apply: false,
6654    })
6655}
6656
6657fn canonical_relative_path(path: &Path) -> crate::Result<PathBuf> {
6658    let mut canonical = PathBuf::with_capacity(path.as_os_str().as_encoded_bytes().len());
6659    for component in path.components() {
6660        match component {
6661            Component::Normal(part) => canonical.push(part),
6662            Component::CurDir => {}
6663            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
6664                return Err(crate::Error::PathEscapesRoot(path.to_path_buf()));
6665            }
6666        }
6667    }
6668    Ok(canonical)
6669}
6670
6671fn derive_impact(changes: &[EffectiveChange], state: &[StateTransition]) -> Impact {
6672    let mut domains = BTreeSet::new();
6673    let mut paths = BTreeSet::new();
6674    let mut all_dirty = false;
6675    let mut ancestor_visits = 0_u64;
6676    let count_impact = crate::counters::enabled();
6677
6678    for change in changes {
6679        match change {
6680            EffectiveChange::Inserted { .. } | EffectiveChange::Removed { .. } => {
6681                domains.extend([
6682                    ImpactDomain::Topology,
6683                    ImpactDomain::Metadata,
6684                    ImpactDomain::Classification,
6685                    ImpactDomain::Aggregates,
6686                    ImpactDomain::Content,
6687                ]);
6688            }
6689            EffectiveChange::Updated { .. } => {
6690                domains.extend([
6691                    ImpactDomain::Metadata,
6692                    ImpactDomain::Aggregates,
6693                    ImpactDomain::Content,
6694                ]);
6695            }
6696            EffectiveChange::ControlUpdated { .. } | EffectiveChange::Reclassified { .. } => {
6697                domains.extend([ImpactDomain::Classification, ImpactDomain::Aggregates]);
6698            }
6699            EffectiveChange::ControlRefusalUpdated { .. } => {
6700                domains.insert(ImpactDomain::Classification);
6701            }
6702            EffectiveChange::Invalidated { .. } => {
6703                domains.insert(ImpactDomain::State);
6704            }
6705        }
6706        insert_dirty_ancestors(
6707            change.path(),
6708            &mut paths,
6709            &mut all_dirty,
6710            count_impact,
6711            &mut ancestor_visits,
6712        );
6713    }
6714    for transition in state {
6715        domains.insert(ImpactDomain::State);
6716        insert_dirty_ancestors(
6717            transition.path(),
6718            &mut paths,
6719            &mut all_dirty,
6720            count_impact,
6721            &mut ancestor_visits,
6722        );
6723    }
6724
6725    if count_impact {
6726        let candidates =
6727            u64::try_from(changes.len().saturating_add(state.len())).unwrap_or(u64::MAX);
6728        let retained_dirty_paths = u64::try_from(paths.len()).unwrap_or(u64::MAX);
6729        crate::counters::bump(|counts| {
6730            counts.impact_candidates = counts.impact_candidates.saturating_add(candidates);
6731            counts.impact_ancestor_visits =
6732                counts.impact_ancestor_visits.saturating_add(ancestor_visits);
6733            counts.impact_retained_dirty_paths =
6734                counts.impact_retained_dirty_paths.saturating_add(retained_dirty_paths);
6735            counts.impact_all_dirty = counts.impact_all_dirty.saturating_add(u64::from(all_dirty));
6736        });
6737    }
6738
6739    Impact {
6740        domains: domains.into_iter().collect(),
6741        dirty_paths: if all_dirty { Vec::new() } else { paths.into_iter().collect() },
6742        all_dirty,
6743    }
6744}
6745
6746fn commit_work(observations: u64, stats: ApplyStats) -> Work {
6747    Work {
6748        observations,
6749        unchanged: stats.unchanged,
6750        stale: stats.stale,
6751        resource_refused: stats.resource_refused,
6752        ..Work::default()
6753    }
6754}
6755
6756fn insert_dirty_ancestors(
6757    path: &Path,
6758    paths: &mut BTreeSet<PathBuf>,
6759    all_dirty: &mut bool,
6760    count_impact: bool,
6761    ancestor_visits: &mut u64,
6762) {
6763    if *all_dirty {
6764        return;
6765    }
6766    for ancestor in path.ancestors() {
6767        if count_impact {
6768            *ancestor_visits = ancestor_visits.saturating_add(1);
6769        }
6770        paths.insert(ancestor.to_path_buf());
6771        if paths.len() > MAX_DIRTY_PATHS {
6772            paths.clear();
6773            *all_dirty = true;
6774            return;
6775        }
6776    }
6777}
6778
6779fn same_target(
6780    current: Option<EntryIdentity>,
6781    expected: Option<EntryIdentity>,
6782    require_structure: bool,
6783) -> bool {
6784    match (current, expected) {
6785        (Some(current), Some(expected)) => current.same_target(expected, require_structure),
6786        (None, None) => true,
6787        (Some(_), None) | (None, Some(_)) => false,
6788    }
6789}
6790
6791#[cfg(test)]
6792mod tests {
6793    use super::*;
6794    use crate::engine_contract::ObservationOp;
6795    use std::sync::{Arc, Barrier};
6796
6797    #[test]
6798    fn reusable_entry_keeps_directory_state_out_of_line() {
6799        let entry_bytes = std::mem::size_of::<Entry>();
6800        let slot_bytes = std::mem::size_of::<Slot>();
6801
6802        assert!(
6803            entry_bytes <= 136,
6804            "common entry storage must not inline directory-only maps and roll-ups: {entry_bytes} bytes"
6805        );
6806        assert!(
6807            slot_bytes <= entry_bytes + 16,
6808            "the arena slot must not add a second per-entry allocation: entry={entry_bytes}, slot={slot_bytes}"
6809        );
6810    }
6811
6812    #[test]
6813    fn detached_children_store_each_name_once_and_promote_on_mutation() {
6814        let mut builder = DetachedIndexBuilder::new(
6815            "/root",
6816            ScanScope::default(),
6817            crate::classify::TypeRegistry::compiled_shared(),
6818        );
6819        builder
6820            .push_directory(&mut crate::scan::DetachedDirectory {
6821                path: PathBuf::new(),
6822                children: vec![
6823                    crate::scan::DetachedChild {
6824                        name: OsString::from("dir"),
6825                        kind: EntryKind::Dir,
6826                        attrs: Attrs::default(),
6827                        position: 0,
6828                    },
6829                    crate::scan::DetachedChild {
6830                        name: OsString::from("z.txt"),
6831                        kind: EntryKind::File,
6832                        attrs: file_attrs(1, 1),
6833                        position: 1,
6834                    },
6835                ],
6836                control: None,
6837            })
6838            .expect("detached root listing");
6839        builder
6840            .push_directory(&mut crate::scan::DetachedDirectory {
6841                path: PathBuf::from("dir"),
6842                children: vec![
6843                    crate::scan::DetachedChild {
6844                        name: OsString::from("z.txt"),
6845                        kind: EntryKind::File,
6846                        attrs: file_attrs(2, 2),
6847                        position: 0,
6848                    },
6849                    crate::scan::DetachedChild {
6850                        name: OsString::from("a.txt"),
6851                        kind: EntryKind::File,
6852                        attrs: file_attrs(3, 3),
6853                        position: 1,
6854                    },
6855                ],
6856                control: None,
6857            })
6858            .expect("detached child listing");
6859        let mut index = builder.finish();
6860        let directory = index.lookup(Path::new("dir")).expect("detached directory");
6861
6862        assert!(index.entry(EntryId::ROOT).directory().children.is_sorted());
6863        assert!(index.entry(directory).directory().children.is_sorted());
6864        assert_eq!(
6865            index
6866                .children(Path::new("dir"))
6867                .expect("directory children")
6868                .map(|(name, _)| name.to_os_string())
6869                .collect::<Vec<_>>(),
6870            [OsString::from("a.txt"), OsString::from("z.txt")]
6871        );
6872
6873        index.apply_ok(&Observation::new(vec![upsert(
6874            "dir/m.txt",
6875            EntryKind::File,
6876            file_attrs(4, 4),
6877        )]));
6878
6879        assert!(index.entry(EntryId::ROOT).directory().children.is_sorted());
6880        assert!(index.entry(directory).directory().children.is_mutable());
6881        assert_eq!(
6882            index
6883                .children(Path::new("dir"))
6884                .expect("directory children")
6885                .map(|(name, _)| name.to_os_string())
6886                .collect::<Vec<_>>(),
6887            [OsString::from("a.txt"), OsString::from("m.txt"), OsString::from("z.txt")]
6888        );
6889    }
6890
6891    /// A folded builder keeps the largest files as entries, counts every file in the
6892    /// roll-ups, tallies the rest in their directory, keeps the last observation of a
6893    /// repeated name as the full builder does, keeps no extension tallies, and leaves the
6894    /// names of the files it folded at once in the listing, for the walker to free.
6895    #[test]
6896    fn a_folded_builder_keeps_the_largest_files_and_leaves_folded_names_to_the_walker() {
6897        let child = |name: &str, kind, size, position| crate::scan::DetachedChild {
6898            name: OsString::from(name),
6899            kind,
6900            attrs: file_attrs(size, 1),
6901            position,
6902        };
6903        let mut builder = DetachedIndexBuilder::new(
6904            "/root",
6905            ScanScope::default(),
6906            crate::classify::TypeRegistry::compiled_shared(),
6907        )
6908        .folding(crate::execution::TreeRetention {
6909            largest_files: 2,
6910            size: crate::query::SizeMetric::Apparent,
6911        });
6912        let mut root = crate::scan::DetachedDirectory {
6913            path: PathBuf::new(),
6914            children: vec![
6915                child("small.log", EntryKind::File, 1, 0),
6916                child("dir", EntryKind::Dir, 0, 1),
6917                child("big.bin", EntryKind::File, 50, 2),
6918                child("twice.txt", EntryKind::File, 3, 3),
6919                child("twice.txt", EntryKind::File, 40, 4),
6920                child("mid.rs", EntryKind::File, 5, 5),
6921            ],
6922            control: Some(Op::ControlUpsert {
6923                path: PathBuf::from(".gitignore"),
6924                source: b"*.log\n".to_vec(),
6925            }),
6926        };
6927        builder.push_directory(&mut root).expect("root listing");
6928        // `big.bin` and `mid.rs` filled the heap; `small.log` was turned away at once, so
6929        // its name is still in the listing; `twice.txt` then displaced `mid.rs`, whose name
6930        // the heap already held.
6931        let left: Vec<_> =
6932            root.children.iter().filter(|child| !child.name.is_empty()).map(|c| &c.name).collect();
6933        assert_eq!(left, [&OsString::from("small.log")]);
6934
6935        let index = builder.finish();
6936        assert!(index.is_folded());
6937        assert_eq!(index.len(), 4, "the root, the directory, and the two largest files");
6938        assert_eq!(
6939            index
6940                .children(Path::new(""))
6941                .expect("root children")
6942                .map(|(name, _)| name.to_os_string())
6943                .collect::<Vec<_>>(),
6944            [OsString::from("big.bin"), OsString::from("dir"), OsString::from("twice.txt")],
6945            "kept files join their directory in name order"
6946        );
6947        assert_eq!(index.attrs(Path::new("twice.txt")), Some(&file_attrs(40, 1)));
6948        let total = index.total();
6949        assert_eq!((total.files, total.dirs, total.bytes), (4, 1, 96));
6950        assert!(total.by_ext.is_empty(), "a folded index keeps no extension tallies");
6951        assert_eq!(
6952            index.partition_total().expect("observed").unignored.bytes,
6953            95,
6954            "the folded ignored file left the unignored partition"
6955        );
6956        assert_eq!(
6957            index.folded_children(EntryId::ROOT),
6958            Some(FoldedFiles {
6959                files: 2,
6960                bytes: 6,
6961                allocated: 1_024,
6962                ignored: crate::query::IgnoredSize { bytes: 1, allocated: 512 },
6963            })
6964        );
6965        assert!(index.children_classification_known(Path::new("")));
6966        let dir = index.lookup(Path::new("dir")).expect("the directory");
6967        assert_eq!(index.folded_children(dir), None, "nothing was folded there");
6968    }
6969
6970    /// A file the heap displaces folds into its own directory, not into the directory of
6971    /// the file that displaced it, which is listed later and elsewhere.
6972    #[test]
6973    fn a_displaced_file_folds_into_its_own_directory() {
6974        let child = |name: &str, kind, size, position| crate::scan::DetachedChild {
6975            name: OsString::from(name),
6976            kind,
6977            attrs: file_attrs(size, 1),
6978            position,
6979        };
6980        let listing = |path: &str, children| crate::scan::DetachedDirectory {
6981            path: PathBuf::from(path),
6982            children,
6983            control: None,
6984        };
6985        let mut builder = DetachedIndexBuilder::new(
6986            "/root",
6987            ScanScope::default(),
6988            crate::classify::TypeRegistry::compiled_shared(),
6989        )
6990        .folding(crate::execution::TreeRetention {
6991            largest_files: 1,
6992            size: crate::query::SizeMetric::Apparent,
6993        });
6994        builder
6995            .push_directory(&mut listing(
6996                "",
6997                vec![child("first", EntryKind::File, 10, 0), child("dir", EntryKind::Dir, 0, 1)],
6998            ))
6999            .expect("root listing");
7000        builder
7001            .push_directory(&mut listing("dir", vec![child("larger", EntryKind::File, 20, 0)]))
7002            .expect("nested listing");
7003        let index = builder.finish();
7004        assert_eq!(
7005            index.folded_children(EntryId::ROOT),
7006            Some(FoldedFiles { files: 1, bytes: 10, allocated: 512, ..FoldedFiles::default() })
7007        );
7008        let dir = index.lookup(Path::new("dir")).expect("the directory");
7009        assert_eq!(index.folded_children(dir), None);
7010        assert_eq!(index.kind(Path::new("dir/larger")), Some(EntryKind::File));
7011        assert_eq!(index.kind(Path::new("first")), None);
7012        assert_eq!(index.total().bytes, 30, "both files count in the totals");
7013    }
7014
7015    #[test]
7016    fn detached_builder_tolerates_a_duplicate_readdir_name() {
7017        let mut builder = DetachedIndexBuilder::new(
7018            "/root",
7019            ScanScope::default(),
7020            crate::classify::TypeRegistry::compiled_shared(),
7021        );
7022        let twice = |position, mtime_ns| crate::scan::DetachedChild {
7023            name: OsString::from("twice.txt"),
7024            kind: EntryKind::File,
7025            attrs: file_attrs(1, mtime_ns),
7026            position,
7027        };
7028        let result = builder.push_directory(&mut crate::scan::DetachedDirectory {
7029            path: PathBuf::new(),
7030            children: vec![twice(0, 1), twice(1, 2)],
7031            control: None,
7032        });
7033        assert!(result.is_ok(), "a duplicate listing name must not fail the scan: {result:?}");
7034        let detached = builder.finish();
7035        assert_eq!(detached.total().files, 1);
7036        assert_eq!(detached.attrs(Path::new("twice.txt")), Some(&file_attrs(1, 2)));
7037
7038        // The streaming reducer tolerates the same input, and keeps the same observation.
7039        let mut streaming = Index::new("/root");
7040        streaming
7041            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
7042                upsert("twice.txt", EntryKind::File, file_attrs(1, 1)),
7043                upsert("twice.txt", EntryKind::File, file_attrs(1, 2)),
7044            ]))
7045            .expect("streaming tolerates a re-upsert");
7046        assert_eq!(streaming.total(), detached.total());
7047        assert_eq!(streaming.attrs(Path::new("twice.txt")), detached.attrs(Path::new("twice.txt")));
7048    }
7049
7050    #[test]
7051    fn detached_builder_accepts_the_repeated_walk_of_a_duplicated_directory() {
7052        let child = |name: &str, kind, attrs, position| crate::scan::DetachedChild {
7053            name: OsString::from(name),
7054            kind,
7055            attrs,
7056            position,
7057        };
7058        let listing = |path: &str, children| crate::scan::DetachedDirectory {
7059            path: PathBuf::from(path),
7060            children,
7061            control: None,
7062        };
7063        let mut builder = DetachedIndexBuilder::new(
7064            "/root",
7065            ScanScope::default(),
7066            crate::classify::TypeRegistry::compiled_shared(),
7067        );
7068        // The enumerator returned `dir` twice, and `swapped` first as a directory and then
7069        // as the file that replaced it.
7070        builder
7071            .push_directory(&mut listing(
7072                "",
7073                vec![
7074                    child("dir", EntryKind::Dir, file_attrs(0, 1), 0),
7075                    child("swapped", EntryKind::Dir, file_attrs(0, 1), 1),
7076                    child("dir", EntryKind::Dir, file_attrs(0, 2), 2),
7077                    child("swapped", EntryKind::File, file_attrs(5, 2), 3),
7078                ],
7079            ))
7080            .expect("root listing with repeated names");
7081        // The walker lists `dir`, and everything below it, once per observation.
7082        for _ in 0..2 {
7083            builder
7084                .push_directory(&mut listing(
7085                    "dir",
7086                    vec![child("nested", EntryKind::Dir, file_attrs(0, 3), 0)],
7087                ))
7088                .expect("each walk of the repeated directory");
7089            builder
7090                .push_directory(&mut listing(
7091                    "dir/nested",
7092                    vec![child("file.txt", EntryKind::File, file_attrs(4, 4), 0)],
7093                ))
7094                .expect("each walk below the repeated directory");
7095        }
7096        // It also lists the directory observation that the file superseded.
7097        builder
7098            .push_directory(&mut listing(
7099                "swapped",
7100                vec![child("stale.txt", EntryKind::File, file_attrs(6, 5), 0)],
7101            ))
7102            .expect("the superseded directory's walk");
7103        // A listing that no repeated name explains is still an ancestry failure.
7104        let error = builder
7105            .push_directory(&mut listing("elsewhere", Vec::new()))
7106            .expect_err("a listing whose parent was never listed");
7107        assert!(matches!(
7108            error,
7109            crate::Error::UnknownAncestry { path, .. } if path == Path::new("elsewhere")
7110        ));
7111
7112        let index = builder.finish();
7113        assert_eq!(index.attrs(Path::new("dir")), Some(&file_attrs(0, 2)));
7114        assert_eq!(index.kind(Path::new("swapped")), Some(EntryKind::File));
7115        assert!(index.lookup(Path::new("swapped/stale.txt")).is_none());
7116        let total = index.total();
7117        assert_eq!((total.files, total.dirs, total.bytes), (2, 2, 9));
7118    }
7119
7120    #[test]
7121    fn detached_builder_drains_an_applied_listing_for_its_worker() {
7122        // H159: the scan hands each listing back to the worker that allocated it, so the
7123        // builder must leave an applied listing's buffers in place and empty.
7124        let child = |name: &str, kind, position| crate::scan::DetachedChild {
7125            name: OsString::from(name),
7126            kind,
7127            attrs: file_attrs(1, 1),
7128            position,
7129        };
7130        let mut builder = DetachedIndexBuilder::new(
7131            "/root",
7132            ScanScope::default(),
7133            crate::classify::TypeRegistry::compiled_shared(),
7134        );
7135        let mut root = crate::scan::DetachedDirectory {
7136            path: PathBuf::new(),
7137            children: vec![child("z.txt", EntryKind::File, 0), child("dir", EntryKind::Dir, 1)],
7138            control: Some(Op::ControlUpsert {
7139                path: PathBuf::from(".gitignore"),
7140                source: b"*.log\n".to_vec(),
7141            }),
7142        };
7143        let (buffer, capacity) = (root.children.as_ptr(), root.children.capacity());
7144        builder.push_directory(&mut root).expect("root listing");
7145        assert_eq!(root.path, PathBuf::new());
7146        assert!(root.children.is_empty());
7147        assert_eq!((root.children.as_ptr(), root.children.capacity()), (buffer, capacity));
7148        assert!(root.control.is_none(), "the control is applied, not left to be applied twice");
7149
7150        // A listing the builder does not apply keeps its children for the worker to drop.
7151        let mut orphan = crate::scan::DetachedDirectory {
7152            path: PathBuf::from("elsewhere"),
7153            children: vec![child("kept.txt", EntryKind::File, 0)],
7154            control: None,
7155        };
7156        builder.push_directory(&mut orphan).expect_err("a listing whose parent was never listed");
7157        assert_eq!(orphan.children.len(), 1);
7158
7159        let index = builder.finish();
7160        assert_eq!(index.kind(Path::new("dir")), Some(EntryKind::Dir));
7161        assert_eq!(index.kind(Path::new("z.txt")), Some(EntryKind::File));
7162        assert_eq!(index.total().files, 1);
7163    }
7164
7165    fn file_attrs(size: u64, mtime_ns: i64) -> Attrs {
7166        Attrs {
7167            size,
7168            allocated: size.div_ceil(512) * 512,
7169            mtime_ns,
7170            ctime_ns: mtime_ns,
7171            inode: size.wrapping_mul(31).wrapping_add(mtime_ns.unsigned_abs()),
7172            dev: 1,
7173        }
7174    }
7175
7176    fn upsert(path: &str, kind: EntryKind, attrs: Attrs) -> Op {
7177        Op::Upsert { path: PathBuf::from(path), kind, attrs }
7178    }
7179
7180    fn assert_serving_indexes(index: &Index) {
7181        let serving = index.serving.as_ref().expect("test index has serving state");
7182        let mut entries = BTreeMap::new();
7183        let mut children = BTreeMap::<PathBuf, PortableChildren>::new();
7184        let mut recent_files = BTreeSet::new();
7185        let mut semantic_by_directory =
7186            BTreeMap::<EntryId, (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>)>::new();
7187        let declared_exact_names: BTreeSet<_> =
7188            index.types.exact_filenames().map(str::to_ascii_lowercase).collect();
7189        let mut exact_name_by_directory =
7190            BTreeMap::<EntryId, (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>)>::new();
7191        let mut semantic_refcounts = BTreeMap::<String, u64>::new();
7192        let mut pending = vec![(EntryId::ROOT, PathBuf::new(), vec![EntryId::ROOT])];
7193        while let Some((parent_id, parent_path, ancestors)) = pending.pop() {
7194            let facts: Vec<_> = index
7195                .children_of(parent_id)
7196                .expect("live directory")
7197                .map(|(name, id)| (name.to_os_string(), id))
7198                .collect();
7199            for (name, id) in facts {
7200                let path = parent_path.join(&name);
7201                let kind = index.kind_of(id).expect("live child");
7202                let portable = crate::opened::read::portable_path(&path);
7203                entries.insert(portable.clone(), id);
7204                if kind == EntryKind::File {
7205                    recent_files.insert(RecentKey {
7206                        mtime_ns: index.attrs(&path).expect("live child has attributes").mtime_ns,
7207                        portable_path: portable,
7208                        id,
7209                    });
7210                }
7211                if kind == EntryKind::File {
7212                    let semantic = index.classify(&path).file_type.as_str().to_string();
7213                    *semantic_refcounts.entry(semantic.clone()).or_default() += 1;
7214                    let attrs = *index.attrs(&path).expect("live child has attributes");
7215                    let ignored = index.entry(id).ignored;
7216                    for ancestor in &ancestors {
7217                        let partition = semantic_by_directory.entry(*ancestor).or_default();
7218                        let all = partition.0.entry(semantic.clone()).or_default();
7219                        all.files += 1;
7220                        all.bytes += attrs.size;
7221                        all.allocated += attrs.allocated;
7222                        if !ignored {
7223                            let unignored = partition.1.entry(semantic.clone()).or_default();
7224                            unignored.files += 1;
7225                            unignored.bytes += attrs.size;
7226                            unignored.allocated += attrs.allocated;
7227                        }
7228                    }
7229                    if let Some(exact_name) = name
7230                        .to_str()
7231                        .map(str::to_ascii_lowercase)
7232                        .filter(|name| declared_exact_names.contains(name))
7233                    {
7234                        for ancestor in &ancestors {
7235                            let partition = exact_name_by_directory.entry(*ancestor).or_default();
7236                            let all = partition.0.entry(exact_name.clone()).or_default();
7237                            all.files += 1;
7238                            all.bytes += attrs.size;
7239                            all.allocated += attrs.allocated;
7240                            if !ignored {
7241                                let unignored = partition.1.entry(exact_name.clone()).or_default();
7242                                unignored.files += 1;
7243                                unignored.bytes += attrs.size;
7244                                unignored.allocated += attrs.allocated;
7245                            }
7246                        }
7247                    }
7248                }
7249                let partition = children.entry(parent_path.clone()).or_default();
7250                let portable_name = crate::opened::read::portable_component(&name);
7251                if kind.is_dir() {
7252                    partition.directories.insert(portable_name, id);
7253                } else {
7254                    partition.nondirectories.insert(portable_name, id);
7255                }
7256                if kind.is_dir() {
7257                    let mut child_ancestors = ancestors.clone();
7258                    child_ancestors.insert(0, id);
7259                    pending.push((id, path, child_ancestors));
7260                }
7261            }
7262        }
7263
7264        assert_eq!(serving.portable_entries, entries);
7265        assert_eq!(serving.recent_files, recent_files);
7266        let actual_semantics: BTreeMap<_, _> = serving
7267            .semantic_by_directory
7268            .iter()
7269            .map(|(directory, partitions)| {
7270                let named = |source: &BTreeMap<u32, ExtTally>| {
7271                    source
7272                        .iter()
7273                        .map(|(semantic, tally)| {
7274                            let name = serving.semantic_names[*semantic as usize]
7275                                .as_ref()
7276                                .expect("live semantic has a name")
7277                                .clone();
7278                            (name, *tally)
7279                        })
7280                        .collect()
7281                };
7282                (*directory, (named(&partitions.all), named(&partitions.unignored)))
7283            })
7284            .collect();
7285        assert_eq!(actual_semantics, semantic_by_directory);
7286        let actual_exact_names: BTreeMap<_, _> = serving
7287            .exact_name_by_directory
7288            .iter()
7289            .map(|(directory, partitions)| {
7290                let named = |source: &BTreeMap<u32, ExtTally>| {
7291                    source
7292                        .iter()
7293                        .map(|(exact_name, tally)| {
7294                            (serving.exact_names[*exact_name as usize].clone(), *tally)
7295                        })
7296                        .collect()
7297                };
7298                (*directory, (named(&partitions.all), named(&partitions.unignored)))
7299            })
7300            .collect();
7301        assert_eq!(actual_exact_names, exact_name_by_directory);
7302        assert_eq!(
7303            serving.exact_names.iter().cloned().collect::<BTreeSet<_>>(),
7304            declared_exact_names
7305        );
7306        assert_eq!(
7307            serving.exact_name_ids,
7308            serving
7309                .exact_names
7310                .iter()
7311                .enumerate()
7312                .map(|(position, name)| {
7313                    (
7314                        name.clone(),
7315                        u32::try_from(position).expect("the exact-name vocabulary fits u32"),
7316                    )
7317                })
7318                .collect()
7319        );
7320        assert!(serving.exact_name_by_directory.len() <= index.arena.len());
7321        assert!(serving.exact_name_by_directory.values().all(|partitions| {
7322            partitions.all.len() <= serving.exact_names.len()
7323                && partitions.unignored.len() <= serving.exact_names.len()
7324                && partitions
7325                    .all
7326                    .keys()
7327                    .chain(partitions.unignored.keys())
7328                    .all(|name| (*name as usize) < serving.exact_names.len())
7329        }));
7330        let actual_refcounts: BTreeMap<_, _> = serving
7331            .semantic_ids
7332            .iter()
7333            .map(|(name, semantic)| (name.clone(), serving.semantic_refcounts[*semantic as usize]))
7334            .collect();
7335        assert_eq!(actual_refcounts, semantic_refcounts);
7336        assert_eq!(serving.portable_children, children);
7337
7338        // Every retained entry has a portable name, and the names are unique. The second
7339        // half is what the escaping has to earn: `%` is escaped in every name precisely so
7340        // a file called `x%FF` and one whose bytes are `x\xff` cannot collide here.
7341        assert_eq!(
7342            u64::try_from(serving.portable_entries.len()).expect("entry count fits u64"),
7343            index.len().saturating_sub(1),
7344            "every retained non-root entry has exactly one portable name"
7345        );
7346    }
7347
7348    #[test]
7349    fn portable_indexes_conserve_insert_kind_change_and_subtree_removal() {
7350        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
7351            "/root",
7352            ScanScope::default(),
7353            crate::classify::TypeRegistry::compiled_shared(),
7354            DEFAULT_JOURNAL_CAPACITY_BYTES,
7355        );
7356        index.apply_ok(&Observation::new(vec![
7357            upsert("dir", EntryKind::Dir, Attrs::default()),
7358            upsert("dir/a", EntryKind::File, file_attrs(1, 1)),
7359            upsert("replace", EntryKind::File, file_attrs(2, 2)),
7360        ]));
7361        assert_serving_indexes(&index);
7362
7363        index.apply_ok(&Observation::new(vec![
7364            upsert("replace", EntryKind::Dir, Attrs::default()),
7365            upsert("replace/child", EntryKind::File, file_attrs(3, 3)),
7366        ]));
7367        assert_serving_indexes(&index);
7368
7369        index.apply_ok(&Observation::new(vec![upsert("dir/a", EntryKind::File, file_attrs(4, 9))]));
7370        assert_serving_indexes(&index);
7371        assert_eq!(
7372            index
7373                .serving
7374                .as_ref()
7375                .expect("opened test index")
7376                .recent_files
7377                .iter()
7378                .map(|entry| entry.portable_path.as_str())
7379                .collect::<Vec<_>>(),
7380            vec!["dir/a", "replace/child"]
7381        );
7382
7383        // Same-kind attribute updates of entries that hold no semantic tally: a symlink
7384        // re-created in place (`ln -sfn`) and a special entry replaced by another. Every
7385        // file here is extensionless, so each non-file shares its classification with a
7386        // real file, and a non-file update that touched semantics would move that file's
7387        // tally rather than fail loudly.
7388        index.apply_ok(&Observation::new(vec![
7389            upsert("dir/current", EntryKind::Symlink, file_attrs(5, 5)),
7390            upsert("dir/pipe", EntryKind::Other, file_attrs(6, 6)),
7391        ]));
7392        assert_serving_indexes(&index);
7393        index.apply_ok(&Observation::new(vec![
7394            upsert("dir/current", EntryKind::Symlink, file_attrs(7, 7)),
7395            upsert("dir/pipe", EntryKind::Other, file_attrs(8, 8)),
7396        ]));
7397        assert_serving_indexes(&index);
7398
7399        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("replace") }]));
7400        assert_serving_indexes(&index);
7401        assert_eq!(
7402            index.portable_entries().keys().map(crate::PortablePath::as_str).collect::<Vec<_>>(),
7403            vec!["dir", "dir/a", "dir/current", "dir/pipe"]
7404        );
7405    }
7406
7407    /// A symlink or special entry holds no semantic tally, so updating one must neither
7408    /// panic looking for a tally it never had nor subtract from a real file's.
7409    ///
7410    /// Both failures were reachable from ordinary filesystem churn on an opened root. With
7411    /// no file of the same classification, the update panicked inside the commit while the
7412    /// index write guard was held, poisoning the root. With one, it silently subtracted the
7413    /// link's attributes from that file's tally and released the file's interned type, so
7414    /// the file's own later removal panicked instead.
7415    #[test]
7416    fn non_file_attrs_updates_leave_file_semantics_untouched() {
7417        for kind in [EntryKind::Symlink, EntryKind::Other] {
7418            let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
7419                "/root",
7420                ScanScope::default(),
7421                crate::classify::TypeRegistry::compiled_shared(),
7422                DEFAULT_JOURNAL_CAPACITY_BYTES,
7423            );
7424            index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(1, 1))]));
7425            assert_serving_indexes(&index);
7426            index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(1, 2))]));
7427            assert_serving_indexes(&index);
7428
7429            index.apply_ok(&Observation::new(vec![upsert(
7430                "notes",
7431                EntryKind::File,
7432                file_attrs(5, 3),
7433            )]));
7434            assert_serving_indexes(&index);
7435            index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(4, 4))]));
7436            assert_serving_indexes(&index);
7437
7438            index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("notes") }]));
7439            assert_serving_indexes(&index);
7440        }
7441    }
7442
7443    /// Escaping touches exactly two things and leaves everything else byte-identical.
7444    ///
7445    /// The rule is narrow on purpose: a byte that is not valid UTF-8, and `%` itself.
7446    /// Everything else — spaces, non-ASCII scalars, punctuation — passes through, because
7447    /// this produces a JSON string rather than a URL and mangling readable names would be
7448    /// a cost with no benefit.
7449    ///
7450    /// This test used to assert the opposite property, that the derived name could be
7451    /// turned back into a filesystem path with `PathBuf::from`. That held only while the
7452    /// derivation was the identity, and it is now unsound: `100%.txt` derives to
7453    /// `100%25.txt`, which names no file. The conversion was deleted rather than kept
7454    /// working, and callers ask the arena for a native path instead.
7455    #[test]
7456    fn escaping_touches_only_invalid_bytes_and_percent() {
7457        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
7458            "/root",
7459            ScanScope::default(),
7460            crate::classify::TypeRegistry::compiled_shared(),
7461            DEFAULT_JOURNAL_CAPACITY_BYTES,
7462        );
7463        index.apply_ok(&Observation::new(vec![
7464            upsert("dir", EntryKind::Dir, Attrs::default()),
7465            upsert("dir/plain.txt", EntryKind::File, file_attrs(1, 1)),
7466            upsert("café", EntryKind::Dir, Attrs::default()),
7467            upsert("café/naïve.txt", EntryKind::File, file_attrs(2, 2)),
7468            upsert("日本語.md", EntryKind::File, file_attrs(3, 3)),
7469            upsert("a b", EntryKind::Dir, Attrs::default()),
7470            upsert("a b/c d.txt", EntryKind::File, file_attrs(5, 5)),
7471            upsert("100%.txt", EntryKind::File, file_attrs(4, 4)),
7472        ]));
7473
7474        let names: Vec<_> =
7475            index.portable_entries().keys().map(crate::PortablePath::as_str).collect();
7476        assert_eq!(
7477            names,
7478            vec![
7479                "100%25.txt",
7480                "a b",
7481                "a b/c d.txt",
7482                "café",
7483                "café/naïve.txt",
7484                "dir",
7485                "dir/plain.txt",
7486                "日本語.md",
7487            ],
7488            "only the literal percent is rewritten; separators, spaces and non-ASCII are not"
7489        );
7490    }
7491
7492    #[test]
7493    fn declared_exact_names_roll_up_by_ancestor_and_partition() {
7494        let types = Arc::new(
7495            crate::classify::TypeRegistry::from_manifest(
7496                "[[kind]]\nid = \"make\"\nfamily = \"code\"\nfilenames = [\"Makefile\"]\n",
7497            )
7498            .expect("custom registry"),
7499        );
7500        let scope =
7501            ScanScope { type_rules_fingerprint: types.fingerprint(), ..ScanScope::default() };
7502        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
7503            "/root",
7504            scope,
7505            types,
7506            DEFAULT_JOURNAL_CAPACITY_BYTES,
7507        );
7508        index.apply_ok(&Observation::new(vec![
7509            upsert("Makefile", EntryKind::File, file_attrs(2, 1)),
7510            upsert("dir", EntryKind::Dir, Attrs::default()),
7511            upsert("dir/makefile", EntryKind::File, file_attrs(3, 2)),
7512            upsert("dir/notes", EntryKind::File, file_attrs(5, 3)),
7513        ]));
7514
7515        let serving = index.serving.as_ref().expect("opened test index");
7516        let exact_name = serving.exact_name_ids["makefile"];
7517        let root = &serving.exact_name_by_directory[&EntryId::ROOT];
7518        assert_eq!(root.all[&exact_name], ExtTally { files: 2, bytes: 5, allocated: 1_024 });
7519        assert_eq!(root.unignored, root.all);
7520
7521        let directory = index.lookup(Path::new("dir")).expect("directory");
7522        let nested = &serving.exact_name_by_directory[&directory];
7523        assert_eq!(nested.all[&exact_name], ExtTally { files: 1, bytes: 3, allocated: 512 });
7524        assert_eq!(nested.unignored, nested.all);
7525    }
7526
7527    #[test]
7528    #[ignore = "manual opened-root commit-cost evidence"]
7529    fn measure_opened_serving_commit_cost() {
7530        const DIRECTORY_COUNT: usize = 100;
7531        const FILES_PER_DIRECTORY: usize = 100;
7532        const SAMPLE_COUNT: usize = 7;
7533
7534        let mut operations = Vec::with_capacity(
7535            DIRECTORY_COUNT.saturating_mul(FILES_PER_DIRECTORY.saturating_add(1)),
7536        );
7537        for directory in 0..DIRECTORY_COUNT {
7538            let parent = format!("d{directory:03}");
7539            operations.push(upsert(&parent, EntryKind::Dir, Attrs::default()));
7540            for file in 0..FILES_PER_DIRECTORY {
7541                let size = u64::try_from(file).expect("the probe file count fits u64") + 1;
7542                let mtime = i64::try_from(file).expect("the probe file count fits i64");
7543                let name = if file == 0 {
7544                    format!("{parent}/Makefile")
7545                } else {
7546                    format!("{parent}/f{file:03}.rs")
7547                };
7548                operations.push(upsert(&name, EntryKind::File, file_attrs(size, mtime)));
7549            }
7550        }
7551        let observation = Observation::new(operations);
7552        let types = crate::classify::TypeRegistry::compiled_shared();
7553        let scope =
7554            ScanScope { type_rules_fingerprint: types.fingerprint(), ..ScanScope::default() };
7555        let measure = |opened: bool| {
7556            let mut index = if opened {
7557                Index::new_opened_with_scope_types_and_journal_capacity_bytes(
7558                    "/root",
7559                    scope,
7560                    Arc::clone(&types),
7561                    DEFAULT_JOURNAL_CAPACITY_BYTES,
7562                )
7563            } else {
7564                Index::new_with_scope_types_and_journal_capacity_bytes(
7565                    "/root",
7566                    scope,
7567                    Arc::clone(&types),
7568                    DEFAULT_JOURNAL_CAPACITY_BYTES,
7569                )
7570            };
7571            let started = std::time::Instant::now();
7572            index.apply_ok(&observation);
7573            let elapsed = started.elapsed();
7574            std::hint::black_box(index.len());
7575            (elapsed, index)
7576        };
7577
7578        let _ = measure(false);
7579        let _ = measure(true);
7580        let mut detached = Vec::with_capacity(SAMPLE_COUNT);
7581        let mut opened = Vec::with_capacity(SAMPLE_COUNT);
7582        let mut last_opened = None;
7583        for sample in 0..SAMPLE_COUNT {
7584            if sample % 2 == 0 {
7585                detached.push(measure(false).0);
7586                let (duration, index) = measure(true);
7587                opened.push(duration);
7588                last_opened = Some(index);
7589            } else {
7590                let (duration, index) = measure(true);
7591                opened.push(duration);
7592                last_opened = Some(index);
7593                detached.push(measure(false).0);
7594            }
7595        }
7596        detached.sort_unstable();
7597        opened.sort_unstable();
7598        let detached_median = detached[SAMPLE_COUNT / 2];
7599        let opened_median = opened[SAMPLE_COUNT / 2];
7600        let ratio = opened_median.as_secs_f64() / detached_median.as_secs_f64();
7601
7602        let index = last_opened.expect("an opened sample ran");
7603        let serving = index.serving.as_ref().expect("opened sample has serving indexes");
7604        let semantic_rows: usize = serving
7605            .semantic_by_directory
7606            .values()
7607            .map(|partitions| partitions.all.len() + partitions.unignored.len())
7608            .sum();
7609        let exact_name_rows: usize = serving
7610            .exact_name_by_directory
7611            .values()
7612            .map(|partitions| partitions.all.len() + partitions.unignored.len())
7613            .sum();
7614        eprintln!(
7615            "entries={} detached_median_us={} opened_median_us={} ratio={ratio:.3} \
7616             portable_rows={} child_rows={} recent_rows={} semantic_rows={} exact_name_rows={} \
7617             exact_name_vocabulary={}",
7618            index.len(),
7619            detached_median.as_micros(),
7620            opened_median.as_micros(),
7621            serving.portable_entries.len(),
7622            serving
7623                .portable_children
7624                .values()
7625                .map(|children| children.directories.len() + children.nondirectories.len())
7626                .sum::<usize>(),
7627            serving.recent_files.len(),
7628            semantic_rows,
7629            exact_name_rows,
7630            serving.exact_names.len(),
7631        );
7632    }
7633
7634    #[test]
7635    fn detached_indexes_never_allocate_or_populate_serving_state() {
7636        let mut index = Index::new("/root");
7637        index.apply_ok(&Observation::new(vec![
7638            upsert("dir", EntryKind::Dir, Attrs::default()),
7639            upsert("dir/a", EntryKind::File, file_attrs(1, 1)),
7640        ]));
7641
7642        assert!(index.serving.is_none());
7643        assert!(index.portable_children(Path::new("dir")).is_none());
7644    }
7645
7646    #[test]
7647    fn insert_loaded_child_skips_serving_path_when_serving_is_off() {
7648        let mut index = Index::new("/root");
7649        let id = index
7650            .insert_loaded_child(
7651                EntryId::ROOT,
7652                OsString::from("a.rs"),
7653                EntryKind::File,
7654                file_attrs(4, 1),
7655            )
7656            .expect("parent is a live directory");
7657        assert!(!index.serving_indexes_enabled());
7658        assert_eq!(index.path_of(id), Some(PathBuf::from("a.rs")));
7659        assert_eq!(index.lookup(Path::new("a.rs")), Some(id));
7660        assert_eq!(index.total().files, 1);
7661    }
7662
7663    #[test]
7664    fn insert_loaded_child_fills_serving_when_enabled() {
7665        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
7666            "/root",
7667            ScanScope::default(),
7668            crate::classify::TypeRegistry::compiled_shared(),
7669            DEFAULT_JOURNAL_CAPACITY_BYTES,
7670        );
7671        let id = index
7672            .insert_loaded_child(
7673                EntryId::ROOT,
7674                OsString::from("a.rs"),
7675                EntryKind::File,
7676                file_attrs(4, 1),
7677            )
7678            .expect("parent is a live directory");
7679        assert!(index.serving_indexes_enabled());
7680        assert_eq!(index.path_of(id), Some(PathBuf::from("a.rs")));
7681        let serving = index.serving.as_ref().expect("opened test index");
7682        assert!(serving.portable_entries.keys().any(|path| path.as_str() == "a.rs"));
7683    }
7684
7685    #[test]
7686    fn opened_entry_values_project_name_identity_without_retaining_it_on_detached_entries() {
7687        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
7688            "/root",
7689            ScanScope::default(),
7690            crate::classify::TypeRegistry::compiled_shared(),
7691            DEFAULT_JOURNAL_CAPACITY_BYTES,
7692        );
7693        index.apply_ok(&Observation::new(vec![upsert(
7694            "bundle.umd.min.js",
7695            EntryKind::File,
7696            file_attrs(7, 1),
7697        )]));
7698
7699        let row = index.entry_value(Path::new("bundle.umd.min.js")).expect("entry value");
7700        let identity = row.classification.expect("regular files carry name identity");
7701        assert_eq!(identity.logical_extension(), Some(".min.js"));
7702        assert_eq!(identity.canonical_extension(), Some(".js"));
7703        assert_eq!(identity.kind_id(), Some("javascript"));
7704        assert_eq!(identity.content_family(), crate::classify::ContentFamily::Code);
7705    }
7706
7707    #[test]
7708    fn a_shared_snapshot_drops_opened_root_serving_state() {
7709        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
7710            "/root",
7711            ScanScope::default(),
7712            crate::classify::TypeRegistry::compiled_shared(),
7713            DEFAULT_JOURNAL_CAPACITY_BYTES,
7714        );
7715        index.apply_ok(&Observation::new(vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))]));
7716        assert!(index.serving_indexes_enabled());
7717
7718        let snapshot = IndexHandle::new(index).snapshot().expect("detached snapshot");
7719
7720        assert!(!snapshot.serving_indexes_enabled());
7721        assert_eq!(snapshot.total().files, 1);
7722    }
7723
7724    #[test]
7725    #[should_panic(expected = "an index's registry must match its semantic scope")]
7726    fn an_index_cannot_claim_a_registry_different_from_its_scope() {
7727        let types = Arc::new(
7728            crate::classify::TypeRegistry::from_manifest(
7729                "[[kind]]\nid = \"notes\"\nfamily = \"prose\"\nextensions = [\"rs\"]\n",
7730            )
7731            .expect("custom registry"),
7732        );
7733
7734        let _ = Index::new_with_scope_and_types("/root", ScanScope::default(), types);
7735    }
7736
7737    /// The parent memo skips resolving a path when consecutive upserts share a parent,
7738    /// so every test below puts the op that could invalidate it *between* two upserts
7739    /// into the same directory — the arrangement where a stale hit would be believed.
7740    /// A memo that never cleared would still pass an ordinary scan-shaped workload,
7741    /// which is why these are written as batches rather than as separate applies: one
7742    /// `apply_validated` call is the memo's whole lifetime.
7743    #[test]
7744    fn parent_memo_does_not_survive_removing_the_directory_it_remembers() {
7745        let mut index = Index::new(PathBuf::from("/root"));
7746        index.apply_ok(&Observation::new(vec![
7747            upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
7748            upsert("dir/a.txt", EntryKind::File, file_attrs(10, 1)),
7749        ]));
7750
7751        // Rebuild the directory explicitly after the removal. The following child
7752        // must resolve through that new entry rather than a memoized id for the entry
7753        // that was just removed.
7754        index.apply_ok(&Observation::new(vec![
7755            upsert("dir/b.txt", EntryKind::File, file_attrs(20, 1)),
7756            Op::Remove { path: PathBuf::from("dir") },
7757            upsert("dir", EntryKind::Dir, file_attrs(0, 2)),
7758            upsert("dir/c.txt", EntryKind::File, file_attrs(30, 2)),
7759        ]));
7760
7761        let children = index.children(Path::new("dir")).expect("dir survives");
7762        let names: Vec<_> = children.map(|(name, _)| name.to_os_string()).collect();
7763        assert_eq!(names, vec![OsString::from("c.txt")], "only the re-added child remains");
7764        assert_eq!(index.total().bytes, 30, "totals match the surviving child");
7765    }
7766
7767    #[test]
7768    fn a_kind_change_mid_batch_leaves_the_memo_usable_for_the_next_sibling() {
7769        let mut index = Index::new(PathBuf::from("/root"));
7770        index.apply_ok(&Observation::new(vec![
7771            upsert("swap", EntryKind::Dir, file_attrs(0, 1)),
7772            upsert("swap/inner", EntryKind::Dir, file_attrs(0, 1)),
7773            upsert("swap/inner/deep.txt", EntryKind::File, file_attrs(40, 1)),
7774        ]));
7775
7776        // A kind change drops a subtree, which looks like it should invalidate the memo
7777        // and does not: the memo holds the changed entry's parent, and the subtree
7778        // removed is rooted at the entry itself. This pins that reasoning, so that if
7779        // the removal ever widens to touch the parent the failure lands here rather
7780        // than as a dangling id in a scan.
7781        index.apply_ok(&Observation::new(vec![
7782            upsert("swap/inner/other.txt", EntryKind::File, file_attrs(50, 1)),
7783            upsert("swap/inner", EntryKind::File, file_attrs(60, 2)),
7784            upsert("swap/sibling.txt", EntryKind::File, file_attrs(70, 2)),
7785        ]));
7786
7787        let children = index.children(Path::new("swap")).expect("swap survives");
7788        let names: Vec<_> = children.map(|(name, _)| name.to_os_string()).collect();
7789        assert_eq!(names, vec![OsString::from("inner"), OsString::from("sibling.txt")]);
7790        assert_eq!(index.total().files, 2, "inner counts once, as a file");
7791        assert_eq!(index.total().bytes, 130, "the dropped subtree's bytes are gone");
7792    }
7793
7794    #[test]
7795    fn parent_memo_distinguishes_directories_that_share_a_name_prefix() {
7796        let mut index = Index::new(PathBuf::from("/root"));
7797        // `src` and `src2` differ only after the memo's stored bytes end, which is the
7798        // comparison a prefix check rather than an equality check would get wrong.
7799        index.apply_ok(&Observation::new(vec![
7800            upsert("src", EntryKind::Dir, file_attrs(0, 1)),
7801            upsert("src2", EntryKind::Dir, file_attrs(0, 1)),
7802            upsert("src/one.txt", EntryKind::File, file_attrs(11, 1)),
7803            upsert("src2/two.txt", EntryKind::File, file_attrs(22, 1)),
7804            upsert("src/three.txt", EntryKind::File, file_attrs(33, 1)),
7805        ]));
7806
7807        let in_src: Vec<_> = index
7808            .children(Path::new("src"))
7809            .expect("src")
7810            .map(|(name, _)| name.to_os_string())
7811            .collect();
7812        let in_src2: Vec<_> = index
7813            .children(Path::new("src2"))
7814            .expect("src2")
7815            .map(|(name, _)| name.to_os_string())
7816            .collect();
7817        assert_eq!(in_src, vec![OsString::from("one.txt"), OsString::from("three.txt")]);
7818        assert_eq!(in_src2, vec![OsString::from("two.txt")]);
7819    }
7820
7821    #[test]
7822    fn parent_memo_leaves_root_level_entries_alone() {
7823        // A root-level path has `Some("")` as its parent, which must not be confused
7824        // with the root entry itself or with a sibling's empty-parent lookup.
7825        let mut index = Index::new(PathBuf::from("/root"));
7826        index.apply_ok(&Observation::new(vec![
7827            upsert("a.txt", EntryKind::File, file_attrs(5, 1)),
7828            upsert("b.txt", EntryKind::File, file_attrs(6, 1)),
7829            upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
7830            upsert("dir/c.txt", EntryKind::File, file_attrs(7, 1)),
7831            upsert("d.txt", EntryKind::File, file_attrs(8, 1)),
7832        ]));
7833
7834        let top: Vec<_> = index
7835            .children(Path::new(""))
7836            .expect("root children")
7837            .map(|(name, _)| name.to_os_string())
7838            .collect();
7839        assert_eq!(
7840            top,
7841            vec![
7842                OsString::from("a.txt"),
7843                OsString::from("b.txt"),
7844                OsString::from("d.txt"),
7845                OsString::from("dir"),
7846            ]
7847        );
7848        assert_eq!(index.total().files, 4);
7849        assert_eq!(index.total().bytes, 26);
7850    }
7851
7852    #[test]
7853    fn shared_queries_return_owned_values_and_release_the_lock() {
7854        let handle = IndexHandle::new(index_with_sample_tree());
7855        let retained_total = handle.total().expect("total");
7856        let retained_history = handle.since(Clock::ZERO).expect("history");
7857        let retained_children = handle.children(Path::new("src")).expect("children");
7858        let retained_snapshot = handle.snapshot().expect("snapshot");
7859
7860        let writer = handle.clone();
7861        let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
7862        let thread = std::thread::spawn(move || {
7863            let result = writer.apply(&Observation::new(vec![upsert(
7864                "concurrent.txt",
7865                EntryKind::File,
7866                file_attrs(7, 30),
7867            )]));
7868            done_tx.send(result).expect("report writer result");
7869        });
7870
7871        let outcome = done_rx
7872            .recv_timeout(std::time::Duration::from_secs(5))
7873            .expect("owned query results must not retain the read lock")
7874            .expect("writer apply");
7875        thread.join().expect("writer thread");
7876
7877        assert_eq!(outcome.inserted, 1);
7878        assert_eq!(retained_total.files, 3);
7879        assert!(!retained_history.commits.is_empty());
7880        assert_eq!(retained_children.expect("src directory").len(), 2);
7881        assert!(retained_snapshot.lookup(Path::new("concurrent.txt")).is_none());
7882        assert!(handle.kind(Path::new("concurrent.txt")).expect("query").is_some());
7883    }
7884
7885    #[test]
7886    fn cloned_indexes_are_independent_detached_images() {
7887        let original = index_with_sample_tree();
7888        let original_clock = original.clock();
7889        let mut detached = original.clone();
7890
7891        detached.apply_ok(&Observation::new(vec![upsert(
7892            "detached-only.txt",
7893            EntryKind::File,
7894            file_attrs(7, 30),
7895        )]));
7896
7897        assert_eq!(original.clock(), original_clock);
7898        assert!(original.lookup(Path::new("detached-only.txt")).is_none());
7899        assert!(detached.lookup(Path::new("detached-only.txt")).is_some());
7900        assert_eq!(detached.total().files, original.total().files + 1);
7901    }
7902
7903    #[test]
7904    fn captured_child_expectations_match_individual_path_lookups() {
7905        let index = index_with_sample_tree();
7906        let captured = collect_child_expectations(&index, Path::new("src"));
7907
7908        assert!(!captured.is_empty());
7909        for (name, expectation) in captured {
7910            assert_eq!(expectation, index.expectation(&Path::new("src").join(name)));
7911        }
7912    }
7913
7914    #[test]
7915    fn index_and_shared_handle_are_send_and_sync() {
7916        fn assert_send_sync<T: Send + Sync>() {}
7917
7918        assert_send_sync::<Index>();
7919        assert_send_sync::<IndexHandle>();
7920    }
7921
7922    #[test]
7923    fn simultaneous_writers_commit_unique_contiguous_clocks_in_journal_order() {
7924        let writer_count: usize = 8;
7925        let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
7926        let barrier: Arc<Barrier> = Arc::new(Barrier::new(writer_count));
7927        let (result_tx, result_rx) = std::sync::mpsc::sync_channel(writer_count);
7928
7929        std::thread::scope(|scope| {
7930            for worker_id in 0..writer_count {
7931                let worker: IndexHandle = handle.clone();
7932                let start: Arc<Barrier> = Arc::clone(&barrier);
7933                let results = result_tx.clone();
7934                scope.spawn(move || {
7935                    let ordinal: u64 = u64::try_from(worker_id + 1).expect("small worker count");
7936                    let path: String = format!("writer-{ordinal}.txt");
7937                    start.wait();
7938                    let outcome: crate::Result<ApplyOutcome> =
7939                        worker.apply(&Observation::new(vec![upsert(
7940                            &path,
7941                            EntryKind::File,
7942                            file_attrs(ordinal, i64::try_from(ordinal).expect("small ordinal")),
7943                        )]));
7944                    results.send((path, outcome)).expect("report writer result");
7945                });
7946            }
7947        });
7948        drop(result_tx);
7949
7950        let mut committed_clocks: Vec<u64> = Vec::with_capacity(writer_count);
7951        for (path, outcome) in result_rx {
7952            let commit = outcome.expect("writer apply").commit.expect("unique upsert must commit");
7953            committed_clocks.push(commit.clock.0);
7954            assert!(handle.kind(Path::new(&path)).expect("query committed path").is_some());
7955        }
7956        committed_clocks.sort_unstable();
7957
7958        let last_clock: u64 = u64::try_from(writer_count).expect("small writer count");
7959        let expected_clocks: Vec<u64> = (1..=last_clock).collect();
7960        assert_eq!(committed_clocks, expected_clocks);
7961        assert_eq!(handle.clock().expect("clock"), Clock(last_clock));
7962
7963        let journal_clocks: Vec<u64> = handle
7964            .since(Clock::ZERO)
7965            .expect("journal")
7966            .commits
7967            .iter()
7968            .map(|commit| commit.clock.0)
7969            .collect();
7970        assert_eq!(journal_clocks, expected_clocks);
7971    }
7972
7973    #[test]
7974    fn readers_observe_only_complete_states_around_a_large_batch() {
7975        let file_count: u64 = 2_048;
7976        let expected_bytes: u64 = file_count * (file_count + 1) / 2;
7977        let operations: Vec<Op> =
7978            std::iter::once(upsert("batch", EntryKind::Dir, file_attrs(0, 1)))
7979                .chain((1..=file_count).map(|ordinal| {
7980                    upsert(
7981                        &format!("batch/file-{ordinal}.bin"),
7982                        EntryKind::File,
7983                        file_attrs(ordinal, i64::try_from(ordinal).expect("small ordinal")),
7984                    )
7985                }))
7986                .collect();
7987        let observation: Observation = Observation::new(operations);
7988        let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
7989        let before: Index = handle.snapshot().expect("before snapshot");
7990        assert_eq!(before.total().files, 0);
7991
7992        let barrier: Arc<Barrier> = Arc::new(Barrier::new(2));
7993        let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
7994        std::thread::scope(|scope| {
7995            let writer: IndexHandle = handle.clone();
7996            let writer_start: Arc<Barrier> = Arc::clone(&barrier);
7997            scope.spawn(move || {
7998                writer_start.wait();
7999                let result: crate::Result<ApplyOutcome> = writer.apply(&observation);
8000                done_tx.send(result).expect("report batch result");
8001            });
8002
8003            let reader: IndexHandle = handle.clone();
8004            let reader_start: Arc<Barrier> = Arc::clone(&barrier);
8005            scope.spawn(move || {
8006                reader_start.wait();
8007                let deadline: std::time::Instant =
8008                    std::time::Instant::now() + std::time::Duration::from_secs(10);
8009                loop {
8010                    assert!(
8011                        std::time::Instant::now() < deadline,
8012                        "reader did not observe batch completion before the deadline"
8013                    );
8014                    let image: Index = reader.snapshot().expect("coherent reader snapshot");
8015                    let total = image.total();
8016                    match total.files {
8017                        0 => {
8018                            assert_eq!(total.bytes, 0);
8019                            assert_eq!(image.len(), 1);
8020                        }
8021                        count if count == file_count => {
8022                            assert_eq!(total.bytes, expected_bytes);
8023                            assert_eq!(total.dirs, 1);
8024                            assert_eq!(image.len(), file_count + 2);
8025                        }
8026                        partial => panic!("reader observed partial batch with {partial} files"),
8027                    }
8028
8029                    match done_rx.try_recv() {
8030                        Ok(result) => {
8031                            assert_eq!(result.expect("batch apply").inserted, file_count + 1);
8032                            break;
8033                        }
8034                        Err(std::sync::mpsc::TryRecvError::Empty) => {}
8035                        Err(std::sync::mpsc::TryRecvError::Disconnected) => {
8036                            panic!("batch writer disconnected")
8037                        }
8038                    }
8039                }
8040            });
8041        });
8042
8043        let after: Index = handle.snapshot().expect("after snapshot");
8044        assert_eq!(after.total().files, file_count);
8045        assert_eq!(after.total().bytes, expected_bytes);
8046        assert_eq!(after.len(), file_count + 2);
8047    }
8048
8049    #[test]
8050    fn poisoned_shared_lock_returns_typed_errors() {
8051        let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
8052        let poisoner: IndexHandle = handle.clone();
8053        let panic_result: std::thread::Result<()> = std::thread::spawn(move || {
8054            let _poison_guard = poisoner.write_index().expect("initial write lock");
8055            panic!("intentional lock poison");
8056        })
8057        .join();
8058        assert!(panic_result.is_err());
8059
8060        assert!(matches!(handle.total(), Err(crate::Error::IndexLockPoisoned)));
8061        assert!(matches!(
8062            handle.apply(&Observation::new(vec![upsert(
8063                "never-applied.txt",
8064                EntryKind::File,
8065                file_attrs(1, 1),
8066            )])),
8067            Err(crate::Error::IndexLockPoisoned)
8068        ));
8069    }
8070
8071    #[test]
8072    fn clock_exhaustion_rejects_before_any_mutation() {
8073        let mut index = index_with_sample_tree();
8074        index.clock = Clock(u64::MAX);
8075        let before_total = index.total();
8076        let before_len = index.len();
8077
8078        let error = index
8079            .apply(&Observation::new(vec![upsert(
8080                "too-late.txt",
8081                EntryKind::File,
8082                file_attrs(1, 1),
8083            )]))
8084            .expect_err("clock exhaustion must be typed");
8085
8086        assert!(matches!(error, crate::Error::ClockExhausted));
8087        assert_eq!(index.clock(), Clock(u64::MAX));
8088        assert_eq!(index.len(), before_len);
8089        assert_eq!(index.total(), before_total);
8090        assert!(index.lookup(Path::new("too-late.txt")).is_none());
8091    }
8092
8093    #[test]
8094    fn terminal_clock_still_accepts_no_op_and_stale_observations() {
8095        let mut index = index_with_sample_tree();
8096        let current = *index.attrs(Path::new("src/main.rs")).expect("sample attributes");
8097        let stale_baseline = index.expectation(Path::new("src/main.rs"));
8098        index.apply_ok(&Observation::new(vec![upsert(
8099            "src/main.rs",
8100            EntryKind::File,
8101            file_attrs(99, 99),
8102        )]));
8103        index.clock = Clock(u64::MAX);
8104        let before_total = index.total();
8105        let before_len = index.len();
8106        let before_journal = index.journal.clone();
8107
8108        let no_op = index
8109            .apply(&Observation::new(vec![upsert(
8110                "src/main.rs",
8111                EntryKind::File,
8112                file_attrs(99, 99),
8113            )]))
8114            .expect("a no-op needs no new clock");
8115        let stale = index
8116            .apply(&Observation::from_ops(vec![ObservationOp::if_state(
8117                upsert("src/main.rs", EntryKind::File, current),
8118                stale_baseline,
8119            )]))
8120            .expect("a rejected stale observation needs no new clock");
8121
8122        assert_eq!(no_op.unchanged, 1);
8123        assert!(no_op.commit.is_none());
8124        assert_eq!(stale.stale, 1);
8125        assert!(stale.commit.is_none());
8126        assert_eq!(index.clock(), Clock(u64::MAX));
8127        assert_eq!(index.len(), before_len);
8128        assert_eq!(index.total(), before_total);
8129        assert_eq!(index.journal, before_journal);
8130    }
8131
8132    #[test]
8133    fn delayed_conditional_observation_cannot_overwrite_newer_state() {
8134        let mut index = Index::new("/root");
8135        index.apply_ok(&Observation::new(vec![upsert(
8136            "file.txt",
8137            EntryKind::File,
8138            file_attrs(10, 1),
8139        )]));
8140
8141        let baseline = index.expectation(Path::new("file.txt"));
8142        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
8143            upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
8144            baseline,
8145        )]);
8146
8147        index.apply_ok(&Observation::new(vec![upsert(
8148            "file.txt",
8149            EntryKind::File,
8150            file_attrs(30, 3),
8151        )]));
8152        let outcome = index.apply_ok(&delayed);
8153
8154        assert_eq!(outcome.stats.stale, 1);
8155        assert!(outcome.commit.is_none());
8156        assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 30);
8157    }
8158
8159    #[test]
8160    fn delayed_absent_child_cannot_replace_a_newer_parent_file() {
8161        let mut index = Index::new("/root");
8162        index.apply_ok(&Observation::new(vec![upsert("parent", EntryKind::Dir, file_attrs(0, 1))]));
8163        let child_baseline = index.expectation(Path::new("parent/child.txt"));
8164        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
8165            upsert("parent/child.txt", EntryKind::File, file_attrs(10, 2)),
8166            child_baseline,
8167        )]);
8168
8169        index.apply_ok(&Observation::new(vec![upsert(
8170            "parent",
8171            EntryKind::File,
8172            file_attrs(20, 3),
8173        )]));
8174        let outcome = index.apply_ok(&delayed);
8175
8176        assert_eq!(outcome.stats.stale, 1);
8177        assert!(outcome.commit.is_none());
8178        assert_eq!(index.kind(Path::new("parent")), Some(EntryKind::File));
8179        assert!(index.lookup(Path::new("parent/child.txt")).is_none());
8180    }
8181
8182    #[test]
8183    fn conditional_observation_rejects_present_state_aba() {
8184        let mut index = Index::new("/root");
8185        index.apply_ok(&Observation::new(vec![upsert(
8186            "file.txt",
8187            EntryKind::File,
8188            file_attrs(10, 1),
8189        )]));
8190        let baseline = index.expectation(Path::new("file.txt"));
8191        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
8192            upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
8193            baseline,
8194        )]);
8195
8196        index.apply_ok(&Observation::new(vec![upsert(
8197            "file.txt",
8198            EntryKind::File,
8199            file_attrs(30, 3),
8200        )]));
8201        index.apply_ok(&Observation::new(vec![upsert(
8202            "file.txt",
8203            EntryKind::File,
8204            file_attrs(10, 1),
8205        )]));
8206        let outcome = index.apply_ok(&delayed);
8207
8208        assert_eq!(outcome.stats.stale, 1);
8209        assert!(outcome.commit.is_none());
8210        assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 10);
8211    }
8212
8213    #[test]
8214    fn conditional_observation_rejects_absent_state_aba() {
8215        let mut index = Index::new("/root");
8216        let baseline = index.expectation(Path::new("file.txt"));
8217        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
8218            upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
8219            baseline,
8220        )]);
8221
8222        index.apply_ok(&Observation::new(vec![upsert(
8223            "file.txt",
8224            EntryKind::File,
8225            file_attrs(30, 3),
8226        )]));
8227        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("file.txt") }]));
8228        let outcome = index.apply_ok(&delayed);
8229
8230        assert_eq!(outcome.stats.stale, 1);
8231        assert!(outcome.commit.is_none());
8232        assert!(index.lookup(Path::new("file.txt")).is_none());
8233    }
8234
8235    /// A delayed conditional upsert whose baseline moved to exactly its target is no
8236    /// conflict: the other producer verified the same fact first. It applies as unchanged,
8237    /// not stale, so a refresh that converges with the observation handoff does not send
8238    /// the handoff back for another full-root walk.
8239    #[test]
8240    fn convergent_conditional_upsert_applies_as_unchanged_not_stale() {
8241        let mut index = Index::new("/root");
8242        index.apply_ok(&Observation::new(vec![upsert(
8243            "file.txt",
8244            EntryKind::File,
8245            file_attrs(10, 1),
8246        )]));
8247        let baseline = index.expectation(Path::new("file.txt"));
8248        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
8249            upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
8250            baseline,
8251        )]);
8252
8253        index.apply_ok(&Observation::new(vec![upsert(
8254            "file.txt",
8255            EntryKind::File,
8256            file_attrs(20, 2),
8257        )]));
8258        let outcome = index.apply_ok(&delayed);
8259
8260        assert_eq!(outcome.stats.stale, 0);
8261        assert_eq!(outcome.stats.unchanged, 1);
8262        assert!(outcome.commit.is_none());
8263        assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 20);
8264    }
8265
8266    #[test]
8267    fn convergent_conditional_remove_applies_as_unchanged_not_stale() {
8268        let mut index = Index::new("/root");
8269        index.apply_ok(&Observation::new(vec![
8270            upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
8271            upsert("dir/file.txt", EntryKind::File, file_attrs(10, 1)),
8272        ]));
8273        let baseline = index.expectation(Path::new("dir/file.txt"));
8274        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
8275            Op::Remove { path: PathBuf::from("dir/file.txt") },
8276            baseline,
8277        )]);
8278
8279        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("dir/file.txt") }]));
8280        let outcome = index.apply_ok(&delayed);
8281
8282        assert_eq!(outcome.stats.stale, 0);
8283        assert_eq!(outcome.stats.unchanged, 1);
8284        assert!(outcome.commit.is_none());
8285        assert!(index.lookup(Path::new("dir/file.txt")).is_none());
8286    }
8287
8288    /// A control file's entry and rules are pushed on one baseline, so a refresh that
8289    /// verified both first leaves nothing for either to change. Both apply as unchanged; a
8290    /// control op whose rules the table does not hold is still refused on the moved
8291    /// baseline.
8292    #[test]
8293    fn convergent_conditional_control_ops_apply_as_unchanged_not_stale() {
8294        let path = PathBuf::from(".gitignore");
8295        let rules =
8296            |source: &[u8]| Op::ControlUpsert { path: path.clone(), source: source.to_vec() };
8297        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
8298        index.apply_ok(&Observation::new(vec![
8299            upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
8300            rules(b"before"),
8301        ]));
8302        let baseline = index.expectation(&path);
8303        index.apply_ok(&Observation::new(vec![
8304            upsert(".gitignore", EntryKind::File, file_attrs(7, 2)),
8305            rules(b"changed"),
8306        ]));
8307
8308        let outcome = index.apply_ok(&Observation::from_ops(vec![
8309            ObservationOp::if_state(
8310                upsert(".gitignore", EntryKind::File, file_attrs(7, 2)),
8311                baseline,
8312            ),
8313            ObservationOp::if_state(rules(b"changed"), baseline),
8314        ]));
8315        assert_eq!(outcome.stats.stale, 0);
8316        assert!(outcome.commit.is_none());
8317        let diverged = index.apply_ok(&Observation::from_ops(vec![ObservationOp::if_state(
8318            rules(b"other"),
8319            baseline,
8320        )]));
8321        assert_eq!(diverged.stats.stale, 1);
8322        assert!(index.controls().expect("control state observed").source_is(&path, b"changed"));
8323
8324        let baseline = index.expectation(&path);
8325        index.apply_ok(&Observation::new(vec![Op::Remove { path: path.clone() }]));
8326        let outcome = index.apply_ok(&Observation::from_ops(vec![
8327            ObservationOp::if_state(Op::Remove { path: path.clone() }, baseline),
8328            ObservationOp::if_state(Op::ControlRemove { path: path.clone() }, baseline),
8329        ]));
8330        assert_eq!(outcome.stats.stale, 0);
8331        assert!(outcome.commit.is_none());
8332        assert!(!index.controls().expect("control state observed").contains(&path));
8333    }
8334
8335    #[test]
8336    fn unrelated_mutation_does_not_stale_an_absent_path() {
8337        let mut index = Index::new("/root");
8338        index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
8339        let baseline = index.expectation(Path::new("dir/new.txt"));
8340        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
8341            upsert("dir/new.txt", EntryKind::File, file_attrs(20, 2)),
8342            baseline,
8343        )]);
8344
8345        index.apply_ok(&Observation::new(vec![upsert(
8346            "other.txt",
8347            EntryKind::File,
8348            file_attrs(30, 3),
8349        )]));
8350        let outcome = index.apply_ok(&delayed);
8351
8352        assert_eq!(outcome.stats.stale, 0);
8353        assert_eq!(outcome.stats.inserted, 1);
8354        assert_eq!(index.attrs(Path::new("dir/new.txt")).expect("file").size, 20);
8355    }
8356
8357    #[test]
8358    fn directory_metadata_change_does_not_stale_an_absent_child() {
8359        let mut index = Index::new("/root");
8360        index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
8361        let baseline = index.expectation(Path::new("dir/new.txt"));
8362        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
8363            upsert("dir/new.txt", EntryKind::File, file_attrs(20, 2)),
8364            baseline,
8365        )]);
8366
8367        index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 3))]));
8368        let outcome = index.apply_ok(&delayed);
8369
8370        assert_eq!(outcome.stats.stale, 0);
8371        assert_eq!(outcome.stats.inserted, 1);
8372    }
8373
8374    #[test]
8375    fn file_parent_metadata_change_stales_an_absent_child() {
8376        let mut index = Index::new("/root");
8377        index.apply_ok(&Observation::new(vec![upsert(
8378            "parent",
8379            EntryKind::File,
8380            file_attrs(10, 1),
8381        )]));
8382        let baseline = index.expectation(Path::new("parent/child.txt"));
8383        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
8384            upsert("parent/child.txt", EntryKind::File, file_attrs(20, 2)),
8385            baseline,
8386        )]);
8387
8388        index.apply_ok(&Observation::new(vec![upsert(
8389            "parent",
8390            EntryKind::File,
8391            file_attrs(30, 3),
8392        )]));
8393        let outcome = index.apply_ok(&delayed);
8394
8395        assert_eq!(outcome.stats.stale, 1);
8396        assert_eq!(index.kind(Path::new("parent")), Some(EntryKind::File));
8397        assert!(index.lookup(Path::new("parent/child.txt")).is_none());
8398    }
8399
8400    #[test]
8401    fn delayed_directory_remove_cannot_delete_a_newer_child() {
8402        let mut index = Index::new("/root");
8403        index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
8404        let baseline = index.expectation(Path::new("dir"));
8405        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
8406            Op::Remove { path: PathBuf::from("dir") },
8407            baseline,
8408        )]);
8409
8410        index.apply_ok(&Observation::new(vec![upsert(
8411            "dir/new.txt",
8412            EntryKind::File,
8413            file_attrs(20, 2),
8414        )]));
8415        let outcome = index.apply_ok(&delayed);
8416
8417        assert_eq!(outcome.stats.stale, 1);
8418        assert!(index.lookup(Path::new("dir/new.txt")).is_some());
8419    }
8420
8421    #[test]
8422    fn conditional_batch_is_validated_at_one_boundary() {
8423        let mut index = Index::new("/root");
8424        let first = index.expectation(Path::new("first.txt"));
8425        let second = index.expectation(Path::new("second.txt"));
8426
8427        let outcome = index.apply_ok(&Observation::from_ops(vec![
8428            ObservationOp::if_state(upsert("first.txt", EntryKind::File, file_attrs(10, 1)), first),
8429            ObservationOp::if_state(
8430                upsert("second.txt", EntryKind::File, file_attrs(20, 2)),
8431                second,
8432            ),
8433        ]));
8434
8435        assert_eq!(outcome.stats.inserted, 2);
8436        assert_eq!(outcome.stats.stale, 0);
8437    }
8438
8439    #[test]
8440    fn public_observation_outputs_use_canonical_encoded_paths() {
8441        let separator = std::path::MAIN_SEPARATOR;
8442        let dotted = PathBuf::from(format!("dotted{separator}.{separator}file.txt"));
8443        let dotted_canonical = PathBuf::from(format!("dotted{separator}file.txt"));
8444        let repeated = PathBuf::from(format!("repeated{separator}{separator}file.txt"));
8445        let repeated_canonical = PathBuf::from(format!("repeated{separator}file.txt"));
8446        let mut index = Index::new("/root");
8447
8448        let outcome = index.apply_ok(&Observation::new(vec![
8449            upsert("dotted", EntryKind::Dir, file_attrs(0, 1)),
8450            Op::Upsert { path: dotted, kind: EntryKind::File, attrs: file_attrs(10, 2) },
8451            upsert("repeated", EntryKind::Dir, file_attrs(0, 3)),
8452            Op::Upsert { path: repeated, kind: EntryKind::File, attrs: file_attrs(20, 4) },
8453        ]));
8454        let commit = outcome.commit.as_ref().expect("one exact commit");
8455
8456        for (actual, canonical) in [
8457            (commit.changes[1].path(), dotted_canonical.as_path()),
8458            (commit.changes[3].path(), repeated_canonical.as_path()),
8459        ] {
8460            assert_eq!(
8461                actual.as_os_str().as_encoded_bytes(),
8462                canonical.as_os_str().as_encoded_bytes()
8463            );
8464        }
8465
8466        for canonical in [&dotted_canonical, &repeated_canonical] {
8467            let dirty = commit
8468                .impact
8469                .dirty_paths
8470                .iter()
8471                .find(|candidate| candidate.as_path() == canonical)
8472                .expect("changed path is dirty");
8473            assert_eq!(
8474                dirty.as_os_str().as_encoded_bytes(),
8475                canonical.as_os_str().as_encoded_bytes()
8476            );
8477        }
8478    }
8479
8480    /// The trailing spellings `Path::components` hides reach public values canonically.
8481    ///
8482    /// `a/b/` and `a/b/.` compare equal to `a/b` component by component, so lookups never
8483    /// notice a preserved spelling; only a value that carries the bytes out does. The
8484    /// reproduction this pins is an unknown-ancestry error that named `a/b/` (PR #51
8485    /// review COMMIT-4).
8486    #[test]
8487    fn trailing_path_spellings_leave_errors_and_changes_canonical() {
8488        let separator = std::path::MAIN_SEPARATOR;
8489        let canonical = format!("a{separator}b");
8490        for spelling in [format!("a{separator}b{separator}"), format!("a{separator}b{separator}.")]
8491        {
8492            let file = |mtime_ns| Op::Upsert {
8493                path: PathBuf::from(&spelling),
8494                kind: EntryKind::File,
8495                attrs: file_attrs(1, mtime_ns),
8496            };
8497            let mut index = Index::new("/root");
8498
8499            let error = index
8500                .apply(&Observation::new(vec![file(1)]))
8501                .expect_err("a child of an unknown directory is refused");
8502            let crate::Error::UnknownAncestry { path, .. } = error else {
8503                panic!("expected unknown ancestry for {spelling:?}, got {error}");
8504            };
8505            assert_eq!(path.as_os_str().as_encoded_bytes(), canonical.as_bytes(), "{spelling:?}");
8506
8507            let outcome = index.apply_ok(&Observation::new(vec![
8508                upsert("a", EntryKind::Dir, file_attrs(0, 1)),
8509                file(2),
8510            ]));
8511            let commit = outcome.commit.as_ref().expect("one exact commit");
8512            assert_eq!(
8513                commit.changes[1].path().as_os_str().as_encoded_bytes(),
8514                canonical.as_bytes(),
8515                "{spelling:?}"
8516            );
8517        }
8518    }
8519
8520    #[test]
8521    fn malformed_batch_is_rejected_before_any_index_mutation() {
8522        let invalid_paths = [
8523            PathBuf::from("../escape"),
8524            PathBuf::from(format!("{}absolute", std::path::MAIN_SEPARATOR)),
8525        ];
8526
8527        for invalid_path in invalid_paths {
8528            for invalid_first in [false, true] {
8529                let mut index = index_with_sample_tree();
8530                let before_clock = index.clock;
8531                let before_live = index.live;
8532                let before_total = index.total();
8533                let before_journal = index.journal.clone();
8534                let before_journal_cost = index.journal_cost;
8535                let before_journal_floor = index.journal_floor;
8536                let before_invalidations = index.pending_invalidations.clone();
8537                let before_freshness_epoch = index.freshness_epoch;
8538                let before_freshness = index.freshness();
8539                let valid = upsert("new.txt", EntryKind::File, file_attrs(99, 99));
8540                let invalid = Op::InvalidateSubtree {
8541                    path: invalid_path.clone(),
8542                    reason: InvalidateReason::Requested,
8543                };
8544                let ops = if invalid_first { vec![invalid, valid] } else { vec![valid, invalid] };
8545
8546                let error = index.apply(&Observation::new(ops)).expect_err("malformed batch");
8547
8548                assert!(
8549                    matches!(error, crate::Error::PathEscapesRoot(path) if path == invalid_path)
8550                );
8551                assert_eq!(index.clock, before_clock);
8552                assert_eq!(index.live, before_live);
8553                assert_eq!(index.total(), before_total);
8554                assert_eq!(index.journal, before_journal);
8555                assert_eq!(index.journal_cost, before_journal_cost);
8556                assert_eq!(index.journal_floor, before_journal_floor);
8557                assert_eq!(index.pending_invalidations, before_invalidations);
8558                assert_eq!(index.freshness_epoch, before_freshness_epoch);
8559                assert_eq!(index.freshness(), before_freshness);
8560                assert!(index.lookup(Path::new("new.txt")).is_none());
8561            }
8562        }
8563    }
8564
8565    #[cfg(windows)]
8566    #[test]
8567    fn windows_prefix_is_rejected_before_mutation() {
8568        let mut index = index_with_sample_tree();
8569        let before_clock = index.clock();
8570
8571        let error = index
8572            .apply(&Observation::new(vec![Op::Remove { path: PathBuf::from(r"C:\escape") }]))
8573            .expect_err("prefixed path");
8574
8575        assert!(
8576            matches!(error, crate::Error::PathEscapesRoot(path) if path == Path::new(r"C:\escape"))
8577        );
8578        assert_eq!(index.clock(), before_clock);
8579    }
8580
8581    fn index_with_sample_tree() -> Index {
8582        let mut index = Index::new("/root");
8583        index.apply_ok(&Observation::new(vec![
8584            upsert("src", EntryKind::Dir, Attrs::default()),
8585            upsert("src/main.rs", EntryKind::File, file_attrs(100, 10)),
8586            upsert("src/lib.rs", EntryKind::File, file_attrs(200, 20)),
8587            upsert("docs", EntryKind::Dir, Attrs::default()),
8588            upsert("docs/guide.md", EntryKind::File, file_attrs(300, 30)),
8589        ]));
8590        index
8591    }
8592
8593    #[test]
8594    fn the_extension_interner_reclaims_ids_after_churn() {
8595        // The long-lived case: a watched tree that keeps creating and deleting files
8596        // with distinct extensions. Without reclamation both interner maps grow for the
8597        // life of the process, which for `fdu --watch` means forever.
8598        let mut index = Index::new("/root");
8599        for sequence in 0..128 {
8600            let path = PathBuf::from(format!("build.out-{sequence}"));
8601            index.apply_ok(&Observation::new(vec![Op::Upsert {
8602                path: path.clone(),
8603                kind: EntryKind::File,
8604                attrs: file_attrs(1, sequence),
8605            }]));
8606            index.apply_ok(&Observation::new(vec![Op::Remove { path }]));
8607        }
8608
8609        assert!(index.ext_ids.is_empty(), "no extension survives the file that named it");
8610        assert_eq!(index.ext_names.len(), 1, "128 dead extensions reuse one interner slot");
8611        assert!(index.total().by_ext.is_empty());
8612    }
8613
8614    #[test]
8615    fn a_reclaimed_extension_id_does_not_alias_a_live_tally() {
8616        // Reissuing a slot is only safe if nothing still points at it. Keep one file on
8617        // the recycled extension while another one comes and goes.
8618        let mut index = Index::new("/root");
8619        index.apply_ok(&Observation::new(vec![
8620            upsert("keep.rs", EntryKind::File, file_attrs(10, 1)),
8621            upsert("drop.tmp", EntryKind::File, file_attrs(20, 2)),
8622        ]));
8623        let retained = index.total();
8624        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("drop.tmp") }]));
8625        index.apply_ok(&Observation::new(vec![upsert(
8626            "next.bak",
8627            EntryKind::File,
8628            file_attrs(30, 3),
8629        )]));
8630
8631        let tallies = index.total().by_ext;
8632        assert_eq!(tallies[".rs"], ExtTally { files: 1, bytes: 10, allocated: 512 });
8633        assert_eq!(tallies[".bak"], ExtTally { files: 1, bytes: 30, allocated: 512 });
8634        assert!(!tallies.contains_key(".tmp"), "the removed extension is gone");
8635        assert_eq!(
8636            retained.by_ext[".tmp"],
8637            ExtTally { files: 1, bytes: 20, allocated: 512 },
8638            "an owned roll-up stays self-describing after its interner slot is reused"
8639        );
8640        assert!(!retained.by_ext.contains_key(".bak"));
8641    }
8642
8643    #[test]
8644    fn rollups_aggregate_up_the_tree() {
8645        let index = index_with_sample_tree();
8646
8647        let total = index.total();
8648        assert_eq!(total.files, 3);
8649        assert_eq!(total.dirs, 2);
8650        assert_eq!(total.bytes, 600);
8651        assert_eq!(total.newest_mtime_ns, 30);
8652
8653        let src = index.rollup(Path::new("src")).expect("src is a directory");
8654        assert_eq!(src.files, 2);
8655        assert_eq!(src.dirs, 0);
8656        assert_eq!(src.bytes, 300);
8657        assert_eq!(src.newest_mtime_ns, 20);
8658    }
8659
8660    #[test]
8661    fn rollups_conserve_hand_counted_populations_through_updates_and_subtree_replacement() {
8662        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
8663        let attrs =
8664            |size, allocated, inode| Attrs { size, allocated, inode, dev: 1, ..Attrs::default() };
8665        index.apply_ok(&Observation::new(vec![
8666            upsert(".gitignore", EntryKind::File, attrs(6, 512, 1)),
8667            upsert("a", EntryKind::Dir, Attrs::default()),
8668            upsert("a/keep.rs", EntryKind::File, attrs(7, 512, 99)),
8669            upsert("a/drop.log", EntryKind::File, attrs(11, 1_024, 3)),
8670            upsert("z.rs", EntryKind::File, attrs(5, 4_096, 99)),
8671            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
8672        ]));
8673
8674        let total = index.partition_total().expect("control state observed");
8675        assert_eq!(
8676            (total.all.files, total.all.dirs, total.all.bytes, total.all.allocated),
8677            (4, 1, 29, 6_144)
8678        );
8679        assert_eq!(
8680            (total.unignored.files, total.unignored.bytes, total.unignored.allocated),
8681            (3, 18, 5_120)
8682        );
8683        assert_eq!(index.rollup(Path::new("a")).expect("directory").bytes, 18);
8684        assert_eq!(
8685            total.all.by_ext[".rs"].files, 2,
8686            "two paths with one inode each contribute a file"
8687        );
8688
8689        index.apply_ok(&Observation::new(vec![upsert(
8690            "a/drop.log",
8691            EntryKind::File,
8692            attrs(13, 1_536, 3),
8693        )]));
8694        let total = index.partition_total().expect("control state observed");
8695        assert_eq!((total.all.files, total.all.bytes, total.all.allocated), (4, 31, 6_656));
8696        assert_eq!(
8697            (total.unignored.files, total.unignored.bytes, total.unignored.allocated),
8698            (3, 18, 5_120)
8699        );
8700
8701        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("a") }]));
8702        let total = index.partition_total().expect("control state observed");
8703        assert_eq!(
8704            (total.all.files, total.all.dirs, total.all.bytes, total.all.allocated),
8705            (2, 0, 11, 4_608)
8706        );
8707        assert_eq!(total.all, total.unignored);
8708
8709        index.apply_ok(&Observation::new(vec![
8710            upsert("a", EntryKind::Dir, Attrs::default()),
8711            upsert("a/final.log", EntryKind::File, attrs(17, 4_096, 4)),
8712        ]));
8713        let total = index.partition_total().expect("control state observed");
8714        assert_eq!(
8715            (total.all.files, total.all.dirs, total.all.bytes, total.all.allocated),
8716            (3, 1, 28, 8_704)
8717        );
8718        assert_eq!(
8719            (
8720                total.unignored.files,
8721                total.unignored.dirs,
8722                total.unignored.bytes,
8723                total.unignored.allocated
8724            ),
8725            (2, 1, 11, 4_608)
8726        );
8727        assert_eq!(index.rollup(Path::new("a")).expect("directory").bytes, 17);
8728    }
8729
8730    #[test]
8731    fn symlinks_and_special_nodes_do_not_contribute_regular_file_tallies() {
8732        let mut index = Index::new("/root");
8733        index.apply_ok(&Observation::new(vec![
8734            upsert("regular.txt", EntryKind::File, file_attrs(10, 10)),
8735            upsert("link.rs", EntryKind::Symlink, file_attrs(99, 99)),
8736            upsert("socket.md", EntryKind::Other, file_attrs(88, 88)),
8737        ]));
8738
8739        let total = index.total();
8740        assert_eq!(total.files, 1);
8741        assert_eq!(total.bytes, 10);
8742        assert_eq!(total.allocated, 512);
8743        assert_eq!(total.newest_mtime_ns, 10);
8744        assert_eq!(total.by_ext[".txt"], ExtTally { files: 1, bytes: 10, allocated: 512 });
8745        assert!(!total.by_ext.contains_key(".rs"));
8746        assert!(!total.by_ext.contains_key(".md"));
8747
8748        index.apply_ok(&Observation::new(vec![upsert(
8749            "link.rs",
8750            EntryKind::File,
8751            file_attrs(99, 99),
8752        )]));
8753        assert_eq!(index.total().files, 2);
8754        assert_eq!(index.total().bytes, 109);
8755
8756        index.apply_ok(&Observation::new(vec![upsert(
8757            "link.rs",
8758            EntryKind::Symlink,
8759            file_attrs(99, 99),
8760        )]));
8761        assert_eq!(index.total().files, 1);
8762        assert_eq!(index.total().bytes, 10);
8763    }
8764
8765    #[test]
8766    fn per_extension_tallies_roll_up_hierarchically() {
8767        let index = index_with_sample_tree();
8768
8769        let total = index.total();
8770        assert_eq!(total.by_ext[".rs"], ExtTally { files: 2, bytes: 300, allocated: 1024 });
8771        assert_eq!(total.by_ext[".md"], ExtTally { files: 1, bytes: 300, allocated: 512 });
8772
8773        // Per-directory breakdown, which no surveyed tool provides.
8774        let src = index.rollup(Path::new("src")).expect("src is a directory");
8775        assert_eq!(src.by_ext[".rs"], ExtTally { files: 2, bytes: 300, allocated: 1024 });
8776        assert!(!src.by_ext.contains_key(".md"));
8777    }
8778
8779    #[test]
8780    fn per_extension_allocated_tracks_apparent_bytes_separately() {
8781        // Both size metrics ride in the same tally, so a report asked for allocated bytes
8782        // keeps its per-type breakdown instead of silently answering in apparent bytes.
8783        let mut index = Index::new("/root");
8784        index.apply_ok(&Observation::new(vec![
8785            // Two small files: apparent bytes are tiny, allocation rounds each to a block.
8786            upsert("a.rs", EntryKind::File, file_attrs(1, 10)),
8787            upsert("b.rs", EntryKind::File, file_attrs(2, 20)),
8788        ]));
8789
8790        let rs = index.total().by_ext[".rs"];
8791        assert_eq!((rs.files, rs.bytes), (2, 3));
8792        assert_eq!(rs.allocated, 1024, "each file occupies one 512-byte block");
8793
8794        // Removing one file withdraws its allocation from the tally, not just its bytes.
8795        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("b.rs") }]));
8796        let rs = index.total().by_ext[".rs"];
8797        assert_eq!((rs.files, rs.bytes, rs.allocated), (1, 1, 512));
8798    }
8799
8800    #[test]
8801    fn upsert_with_matching_fingerprint_is_a_no_op() {
8802        let mut index = index_with_sample_tree();
8803        let before = index.total();
8804        let mark = index.clock();
8805
8806        let stats = index.apply_ok(&Observation::new(vec![upsert(
8807            "src/main.rs",
8808            EntryKind::File,
8809            file_attrs(100, 10),
8810        )]));
8811
8812        assert_eq!(stats.unchanged, 1);
8813        assert_eq!(stats.updated, 0);
8814        assert_eq!(index.total(), before);
8815        assert_eq!(index.clock(), mark);
8816        assert!(index.since(mark).commits.is_empty());
8817    }
8818
8819    #[test]
8820    fn commit_contains_only_effective_mutations() {
8821        let mut index = index_with_sample_tree();
8822        let outcome = index.apply_ok(&Observation::new(vec![
8823            upsert("src/main.rs", EntryKind::File, file_attrs(100, 10)),
8824            upsert("new.txt", EntryKind::File, file_attrs(4, 4)),
8825            Op::Remove { path: PathBuf::from("missing.txt") },
8826        ]));
8827
8828        assert_eq!(outcome.stats.unchanged, 2);
8829        let commit = outcome.commit.expect("one effective insert");
8830        assert_eq!(commit.changes.len(), 1);
8831        assert_eq!(commit.changes[0].path(), Path::new("new.txt"));
8832    }
8833
8834    #[test]
8835    fn mutation_counters_match_exact_batch_and_consequence_totals() {
8836        struct DisableCounters;
8837
8838        impl Drop for DisableCounters {
8839            fn drop(&mut self) {
8840                crate::counters::enable(false);
8841                crate::counters::test_thread_reset();
8842            }
8843        }
8844
8845        let _serial = crate::counters::test_serial();
8846        crate::counters::enable(true);
8847        let _disable = DisableCounters;
8848        let observation = Observation::new(vec![
8849            upsert("a", EntryKind::Dir, Attrs::default()),
8850            upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
8851            upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
8852        ]);
8853
8854        crate::counters::test_thread_reset();
8855        let mut baseline = Index::new("/root");
8856        baseline.apply_baseline_ok(&observation);
8857        let counts = crate::counters::test_thread_snapshot();
8858        assert_eq!(counts.baseline_batches, 1);
8859        assert_eq!(counts.baseline_accepted_ops, 3);
8860        assert_eq!(counts.opened_batches, 0);
8861        assert_eq!(counts.public_batches, 0);
8862        assert_eq!(counts.ancestry_overlay_inserts, 3);
8863        assert_eq!(counts.ancestry_path_comparisons, 2);
8864        assert_eq!(counts.ancestry_parent_proofs, 3);
8865        assert_eq!(counts.effect_paths, 0);
8866        assert_eq!(counts.effect_path_bytes, 0);
8867        assert_eq!(counts.impact_candidates, 0);
8868        assert_eq!(counts.impact_ancestor_visits, 0);
8869        assert_eq!(counts.impact_retained_dirty_paths, 0);
8870        assert_eq!(counts.impact_all_dirty, 0);
8871        assert_eq!(counts.journal_cloned_commits, 0);
8872        assert_eq!(counts.journal_retained_commits, 0);
8873
8874        crate::counters::test_thread_reset();
8875        let mut public = Index::new("/root");
8876        public.apply_ok(&observation);
8877        let counts = crate::counters::test_thread_snapshot();
8878        assert_eq!(counts.baseline_batches, 0);
8879        assert_eq!(counts.opened_batches, 0);
8880        assert_eq!(counts.public_batches, 1);
8881        assert_eq!(counts.public_accepted_ops, 3);
8882        assert_eq!(counts.effect_paths, 3);
8883        assert_eq!(counts.journal_cloned_commits, 1);
8884        assert_eq!(counts.journal_retained_commits, 1);
8885        assert_eq!(counts.journal_oversized_commits, 0);
8886        assert_eq!(counts.journal_dropped_commits, 0);
8887
8888        crate::counters::test_thread_reset();
8889        let opened = IndexHandle::new(Index::new("/root"));
8890        opened
8891            .apply_discovery(&observation, DiscoveryCommit::default())
8892            .expect("opened discovery batch");
8893        let counts = crate::counters::test_thread_snapshot();
8894        assert_eq!(counts.baseline_batches, 0);
8895        assert_eq!(counts.opened_batches, 1);
8896        assert_eq!(counts.opened_accepted_ops, 3);
8897        assert_eq!(counts.public_batches, 0);
8898
8899        crate::counters::test_thread_reset();
8900        let mut scanner = Index::new("/root");
8901        scanner
8902            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
8903                upsert("a", EntryKind::Dir, Attrs::default()),
8904                upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
8905                upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
8906            ]))
8907            .expect("private scanner batch");
8908        let counts = crate::counters::test_thread_snapshot();
8909        assert_eq!(counts.baseline_batches, 1);
8910        assert_eq!(counts.baseline_accepted_ops, 3);
8911        assert_eq!(counts.ancestry_overlay_inserts, 0);
8912        assert_eq!(counts.ancestry_path_comparisons, 2);
8913        assert_eq!(counts.ancestry_parent_proofs, 3);
8914        assert_eq!(counts.parent_resolutions, 0);
8915        assert_eq!(counts.parent_memo_hits, 0);
8916        assert_eq!(scanner.total().dirs, 1);
8917        assert_eq!(scanner.total().files, 2);
8918
8919        crate::counters::test_thread_reset();
8920        let opened_scanner = IndexHandle::new(Index::new("/root"));
8921        opened_scanner
8922            .apply_scanner_discovery_bounded(
8923                crate::scan::ScannerBatch::from_ops(vec![
8924                    upsert("a", EntryKind::Dir, Attrs::default()),
8925                    upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
8926                    upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
8927                ]),
8928                DiscoveryCommit::default(),
8929                None,
8930            )
8931            .expect("opened scanner batch");
8932        let counts = crate::counters::test_thread_snapshot();
8933        assert_eq!(counts.opened_batches, 1);
8934        assert_eq!(counts.opened_accepted_ops, 3);
8935        assert_eq!(counts.ancestry_overlay_inserts, 0);
8936        assert_eq!(counts.effect_paths, 3);
8937        assert_eq!(counts.journal_retained_commits, 1);
8938
8939        // Room for exactly two one-file commits; measured before the counters reset so the
8940        // probe's own journal work is not counted.
8941        let two_commits = 2 * commit_cost(vec![upsert("one", EntryKind::File, file_attrs(1, 1))]);
8942        crate::counters::test_thread_reset();
8943        let mut bounded = Index::new("/root");
8944        bounded.journal_capacity_bytes = two_commits;
8945        bounded.apply_ok(&Observation::new(vec![upsert("one", EntryKind::File, file_attrs(1, 1))]));
8946        bounded.apply_ok(&Observation::new(vec![upsert("two", EntryKind::File, file_attrs(2, 2))]));
8947        bounded.journal_capacity_bytes = 1;
8948        bounded.apply_ok(&Observation::new(vec![upsert(
8949            "three",
8950            EntryKind::File,
8951            file_attrs(3, 3),
8952        )]));
8953        let counts = crate::counters::test_thread_snapshot();
8954        assert_eq!(counts.journal_cloned_commits, 2);
8955        assert_eq!(counts.journal_retained_commits, 2);
8956        assert_eq!(counts.journal_oversized_commits, 1);
8957        assert_eq!(counts.journal_dropped_commits, 2);
8958    }
8959
8960    #[test]
8961    fn detached_and_exact_reducers_produce_the_same_facts_and_stats() {
8962        fn fact_image(index: &Index) -> Vec<(PathBuf, EntryKind, Attrs, bool, Source, bool)> {
8963            let mut image = Vec::new();
8964            let mut frontier = VecDeque::from([EntryId::ROOT]);
8965            while let Some(id) = frontier.pop_front() {
8966                let entry = index.entry(id);
8967                if entry.kind.is_dir() {
8968                    frontier.extend(index.child_ids(id));
8969                }
8970                image.push((
8971                    index.path_of(id).expect("live entry path"),
8972                    entry.kind,
8973                    entry.attrs,
8974                    entry.ignored,
8975                    entry.source,
8976                    entry.directory.as_deref().is_none_or(|directory| directory.children_complete),
8977                ));
8978            }
8979            image.sort_by(|left, right| left.0.cmp(&right.0));
8980            image
8981        }
8982
8983        fn assert_same_facts(detached: &Index, exact: &Index) {
8984            assert_eq!(fact_image(detached), fact_image(exact));
8985            assert_eq!(detached.total(), exact.total());
8986            let partitions =
8987                |index: &Index| index.named_partitions(index.entry(EntryId::ROOT).rollup());
8988            assert_eq!(partitions(detached), partitions(exact));
8989            let controls = |index: &Index| {
8990                index
8991                    .controls
8992                    .sources()
8993                    .map(|(path, source)| (path, source.to_vec()))
8994                    .collect::<Vec<_>>()
8995            };
8996            assert_eq!(controls(detached), controls(exact));
8997            assert_eq!(detached.state, exact.state);
8998            assert_eq!(detached.issues, exact.issues);
8999            let freshness = |index: &Index| {
9000                index
9001                    .freshness_marks
9002                    .iter()
9003                    .map(|(path, mark)| (path.clone(), mark.state, mark.epoch))
9004                    .collect::<Vec<_>>()
9005            };
9006            assert_eq!(freshness(detached), freshness(exact));
9007            assert_eq!(detached.verified, exact.verified);
9008            assert_eq!(detached.pending_invalidations, exact.pending_invalidations);
9009        }
9010
9011        let mut detached = Index::new("/root");
9012        let mut exact = detached.clone();
9013        let batches = [
9014            Observation::new(vec![
9015                upsert("a", EntryKind::Dir, file_attrs(0, 1)),
9016                upsert("a/one.rs", EntryKind::File, file_attrs(10, 2)),
9017                upsert("a/two.txt", EntryKind::File, file_attrs(20, 3)),
9018            ]),
9019            Observation::new(vec![
9020                upsert("a/one.rs", EntryKind::File, file_attrs(30, 4)),
9021                Op::Remove { path: PathBuf::from("a/two.txt") },
9022                Op::InvalidateSubtree {
9023                    path: PathBuf::from("a"),
9024                    reason: InvalidateReason::VerificationFailed,
9025                },
9026            ]),
9027        ];
9028
9029        for batch in batches {
9030            let detached_stats = detached.apply_baseline(&batch).expect("detached batch");
9031            let exact_outcome = exact.apply(&batch).expect("exact batch");
9032            assert_eq!(detached_stats, exact_outcome.stats);
9033            exact.establish_baseline();
9034            assert_same_facts(&detached, &exact);
9035        }
9036    }
9037
9038    #[test]
9039    fn exact_commit_records_verified_ancestry_and_kind_replacement() {
9040        let mut index = Index::new("/root");
9041        let inserted = index.apply_ok(&Observation::new(vec![
9042            upsert("unknown", EntryKind::Dir, file_attrs(0, 1)),
9043            upsert("unknown/deep", EntryKind::Dir, file_attrs(0, 2)),
9044            upsert("unknown/deep/file.txt", EntryKind::File, file_attrs(10, 3)),
9045        ]));
9046        let inserted = inserted.commit.expect("ancestry commit");
9047        assert_eq!(
9048            inserted.changes.iter().map(EffectiveChange::path).collect::<Vec<_>>(),
9049            [Path::new("unknown"), Path::new("unknown/deep"), Path::new("unknown/deep/file.txt")]
9050        );
9051        assert!(
9052            inserted
9053                .changes
9054                .iter()
9055                .all(|change| matches!(change, EffectiveChange::Inserted { .. }))
9056        );
9057
9058        let replaced = index.apply_ok(&Observation::new(vec![upsert(
9059            "unknown",
9060            EntryKind::File,
9061            file_attrs(20, 2),
9062        )]));
9063        let replaced = replaced.commit.expect("replacement commit");
9064        assert_eq!(
9065            replaced.changes,
9066            vec![
9067                EffectiveChange::Removed {
9068                    path: "unknown".into(),
9069                    kind: EntryKind::Dir,
9070                    attrs: file_attrs(0, 1),
9071                },
9072                EffectiveChange::Removed {
9073                    path: "unknown/deep".into(),
9074                    kind: EntryKind::Dir,
9075                    attrs: file_attrs(0, 2),
9076                },
9077                EffectiveChange::Removed {
9078                    path: "unknown/deep/file.txt".into(),
9079                    kind: EntryKind::File,
9080                    attrs: file_attrs(10, 3),
9081                },
9082                EffectiveChange::Inserted {
9083                    path: "unknown".into(),
9084                    kind: EntryKind::File,
9085                    attrs: file_attrs(20, 2),
9086                },
9087            ]
9088        );
9089        assert_eq!(
9090            replaced.impact.domains,
9091            vec![
9092                ImpactDomain::Topology,
9093                ImpactDomain::Metadata,
9094                ImpactDomain::Classification,
9095                ImpactDomain::Aggregates,
9096                ImpactDomain::Content,
9097            ]
9098        );
9099        assert_eq!(
9100            replaced.impact.dirty_paths,
9101            vec![
9102                PathBuf::new(),
9103                "unknown".into(),
9104                "unknown/deep".into(),
9105                "unknown/deep/file.txt".into(),
9106            ]
9107        );
9108    }
9109
9110    #[test]
9111    fn rejected_prepared_commit_is_fault_atomic() {
9112        let mut index = index_with_sample_tree();
9113        let before_clock = index.clock();
9114        let before_total = index.total();
9115        let before_len = index.len();
9116        let before_history = index.since(Clock::ZERO);
9117        let mut prepared = prepare_observation(&Observation::new(vec![upsert(
9118            "new/deep.txt",
9119            EntryKind::File,
9120            file_attrs(99, 99),
9121        )]))
9122        .expect("valid preparation");
9123        prepared.reject_before_apply = true;
9124
9125        let error = index.commit_prepared(prepared, true).expect_err("injected preflight");
9126
9127        assert!(matches!(error, crate::Error::CommitRejected("injected reducer preflight")));
9128        assert_eq!(index.clock(), before_clock);
9129        assert_eq!(index.total(), before_total);
9130        assert_eq!(index.len(), before_len);
9131        assert_eq!(index.since(Clock::ZERO), before_history);
9132        assert!(index.lookup(Path::new("new")).is_none());
9133    }
9134
9135    #[test]
9136    fn reconciliation_state_moves_through_exact_commits() {
9137        let mut index = Index::new("/root");
9138        let (started, start) = index.begin_reconcile(Path::new("src")).expect("begin");
9139        let start = start.expect("start commit");
9140        assert!(start.changes.is_empty());
9141        assert_eq!(
9142            start.state,
9143            vec![
9144                StateTransition::Freshness {
9145                    path: "src".into(),
9146                    previous: Freshness::Fresh,
9147                    current: Freshness::Reconciling,
9148                },
9149                StateTransition::IndexState {
9150                    previous: IndexState::default(),
9151                    current: IndexState {
9152                        freshness: Freshness::Reconciling,
9153                        ..IndexState::default()
9154                    },
9155                },
9156            ]
9157        );
9158
9159        let finish = index
9160            .finish_reconcile(
9161                Path::new("src"),
9162                started,
9163                true,
9164                &[],
9165                &[],
9166                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9167            )
9168            .expect("finish")
9169            .commit
9170            .expect("finish commit");
9171        assert!(finish.changes.is_empty());
9172        assert_eq!(
9173            finish.state,
9174            vec![
9175                StateTransition::Verified { path: "src".into() },
9176                StateTransition::Freshness {
9177                    path: "src".into(),
9178                    previous: Freshness::Reconciling,
9179                    current: Freshness::Fresh,
9180                },
9181                StateTransition::IndexState {
9182                    previous: IndexState {
9183                        freshness: Freshness::Reconciling,
9184                        ..IndexState::default()
9185                    },
9186                    current: IndexState::default(),
9187                },
9188            ]
9189        );
9190    }
9191
9192    #[cfg(feature = "watch")]
9193    #[test]
9194    fn observation_failure_cannot_overwrite_a_terminal_resource_stop() {
9195        let handle = IndexHandle::new(Index::new("/root"));
9196        handle
9197            .transition_discovery(DiscoveryTransition::BudgetRefused(Issue::resource_budget(0)))
9198            .expect("stop for budget");
9199        let stopped = handle.state().expect("stopped state");
9200        let clock = handle.clock().expect("stopped clock");
9201
9202        let outcome = handle
9203            .transition_observation(ObservationTransition::Failed(Issue::from_error(
9204                &crate::Error::WatchStopped,
9205            )))
9206            .expect("late observer failure is ignored");
9207
9208        assert_eq!(outcome.commit, None);
9209        assert_eq!(handle.state().expect("terminal state"), stopped);
9210        assert_eq!(handle.clock().expect("terminal clock"), clock);
9211    }
9212
9213    /// Once a root has stopped or failed, discovery can neither expand it nor reopen it.
9214    #[test]
9215    fn a_terminal_root_refuses_every_discovery_commit() {
9216        let terminals = [
9217            DiscoveryTransition::BudgetRefused(Issue::resource_budget(1)),
9218            DiscoveryTransition::Failed(Issue::from_error(&crate::Error::OpenedIndexClosed)),
9219        ];
9220        for terminal in terminals {
9221            let handle = IndexHandle::new(Index::new("/root"));
9222            handle.transition_discovery(DiscoveryTransition::Begin).expect("begin");
9223            handle
9224                .apply_discovery(
9225                    &Observation::new(vec![upsert("dir", EntryKind::Dir, Attrs::default())]),
9226                    DiscoveryCommit::default(),
9227                )
9228                .expect("listing before the stop");
9229            handle.transition_discovery(terminal.clone()).expect("terminal transition");
9230            let state = handle.state().expect("terminal state");
9231            let clock = handle.clock().expect("terminal clock");
9232
9233            let late = [
9234                (
9235                    vec![upsert("dir/late.txt", EntryKind::File, file_attrs(1, 1))],
9236                    DiscoveryCommit {
9237                        directory_complete: Some(PathBuf::from("dir")),
9238                        transition: None,
9239                    },
9240                ),
9241                (
9242                    Vec::new(),
9243                    DiscoveryCommit {
9244                        directory_complete: None,
9245                        transition: Some(DiscoveryTransition::Finish),
9246                    },
9247                ),
9248                (
9249                    Vec::new(),
9250                    DiscoveryCommit {
9251                        directory_complete: None,
9252                        transition: Some(DiscoveryTransition::Inaccessible {
9253                            issues: vec![Issue::resource_budget(2)],
9254                            omitted: 0,
9255                        }),
9256                    },
9257                ),
9258            ];
9259            for (ops, discovery) in late {
9260                assert!(
9261                    matches!(
9262                        handle.apply_discovery(&Observation::new(ops), discovery.clone()),
9263                        Err(crate::Error::OpenedIndexStopped)
9264                    ),
9265                    "after {terminal:?}, {discovery:?} was accepted"
9266                );
9267            }
9268            assert_eq!(handle.state().expect("state"), state, "after {terminal:?}");
9269            assert_eq!(handle.clock().expect("clock"), clock, "after {terminal:?}");
9270            assert_eq!(handle.kind(Path::new("dir/late.txt")).expect("lookup"), None);
9271        }
9272    }
9273
9274    #[test]
9275    fn replaying_the_same_delta_twice_changes_nothing() {
9276        let mut index = Index::new("/root");
9277        let delta = Observation::new(vec![
9278            upsert("a", EntryKind::Dir, Attrs::default()),
9279            upsert("a/f.txt", EntryKind::File, file_attrs(10, 1)),
9280        ]);
9281        index.apply_ok(&delta);
9282        let after_first = index.total();
9283        let stats = index.apply_ok(&delta);
9284
9285        assert_eq!(stats.unchanged, 2);
9286        assert_eq!(index.total(), after_first);
9287        assert_eq!(index.len(), 3);
9288    }
9289
9290    #[test]
9291    fn changed_size_updates_every_ancestor() {
9292        let mut index = index_with_sample_tree();
9293        index.apply_ok(&Observation::new(vec![upsert(
9294            "src/main.rs",
9295            EntryKind::File,
9296            file_attrs(150, 11),
9297        )]));
9298
9299        assert_eq!(index.rollup(Path::new("src")).expect("dir").bytes, 350);
9300        assert_eq!(index.total().bytes, 650);
9301        assert_eq!(index.total().by_ext[".rs"], ExtTally { files: 2, bytes: 350, allocated: 1024 });
9302    }
9303
9304    #[test]
9305    fn allocated_size_change_updates_rollups_even_when_fingerprint_matches() {
9306        let mut index = Index::new("/root");
9307        let original = Attrs { allocated: 512, ..file_attrs(100, 10) };
9308        index.apply_ok(&Observation::new(vec![upsert("file.bin", EntryKind::File, original)]));
9309
9310        let repacked = Attrs { allocated: 4096, ..original };
9311        let outcome =
9312            index.apply_ok(&Observation::new(vec![upsert("file.bin", EntryKind::File, repacked)]));
9313
9314        assert_eq!(outcome.stats.updated, 1);
9315        assert_eq!(outcome.stats.unchanged, 0);
9316        assert_eq!(index.total().allocated, 4096);
9317        assert_eq!(index.attrs(Path::new("file.bin")), Some(&repacked));
9318    }
9319
9320    #[test]
9321    fn newest_mtime_preserves_pre_epoch_values_through_updates_and_removals() {
9322        let mut index = Index::new("/root");
9323        index.apply_ok(&Observation::new(vec![
9324            upsert("newer.txt", EntryKind::File, file_attrs(10, -10)),
9325            upsert("older.txt", EntryKind::File, file_attrs(20, -20)),
9326        ]));
9327
9328        assert_eq!(index.total().newest_mtime_ns, -10);
9329
9330        index.apply_ok(&Observation::new(vec![upsert(
9331            "newer.txt",
9332            EntryKind::File,
9333            file_attrs(10, -30),
9334        )]));
9335        assert_eq!(index.total().newest_mtime_ns, -20);
9336
9337        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("older.txt") }]));
9338        assert_eq!(index.total().newest_mtime_ns, -30);
9339    }
9340
9341    #[test]
9342    fn removing_a_file_corrects_sums_and_rebuilds_the_max() {
9343        let mut index = index_with_sample_tree();
9344        // guide.md holds the newest mtime for the whole tree.
9345        let stats = index
9346            .apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("docs/guide.md") }]));
9347
9348        assert_eq!(stats.removed, 1);
9349        let total = index.total();
9350        assert_eq!(total.files, 2);
9351        assert_eq!(total.bytes, 300);
9352        assert_eq!(total.newest_mtime_ns, 20, "max must fall back to src/lib.rs");
9353        assert!(!total.by_ext.contains_key(".md"), "emptied tallies are dropped");
9354    }
9355
9356    #[test]
9357    fn removing_a_directory_cascades_to_descendants() {
9358        let mut index = index_with_sample_tree();
9359        let stats = index
9360            .apply(&Observation::new(vec![Op::Remove { path: PathBuf::from("src") }]))
9361            .expect("valid observation");
9362
9363        assert_eq!(stats.removed, 3, "the directory and both files");
9364        let total = index.total();
9365        assert_eq!(total.files, 1);
9366        assert_eq!(total.dirs, 1);
9367        assert_eq!(total.bytes, 300);
9368        assert!(index.lookup(Path::new("src/main.rs")).is_none());
9369        assert!(!total.by_ext.contains_key(".rs"));
9370    }
9371
9372    #[test]
9373    fn freed_slots_are_reused() {
9374        let mut index = index_with_sample_tree();
9375        let before = index.len();
9376        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("src") }]));
9377        index.apply_ok(&Observation::new(vec![
9378            upsert("other", EntryKind::Dir, Attrs::default()),
9379            upsert("other/x.rs", EntryKind::File, file_attrs(1, 1)),
9380            upsert("other/y.rs", EntryKind::File, file_attrs(1, 1)),
9381        ]));
9382        assert_eq!(index.len(), before, "three freed slots, three new entries");
9383    }
9384
9385    #[test]
9386    fn stale_entry_handle_does_not_alias_a_reused_slot() {
9387        let mut index = Index::new("/root");
9388        index.apply_ok(&Observation::new(vec![upsert(
9389            "first.txt",
9390            EntryKind::File,
9391            file_attrs(1, 1),
9392        )]));
9393        let stale = index.lookup(Path::new("first.txt")).expect("first id");
9394        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("first.txt") }]));
9395        index.apply_ok(&Observation::new(vec![upsert(
9396            "second.txt",
9397            EntryKind::File,
9398            file_attrs(2, 2),
9399        )]));
9400        let current = index.lookup(Path::new("second.txt")).expect("second id");
9401
9402        assert_ne!(stale, current, "generation participates in handle identity");
9403        assert!(index.attrs_of(stale).is_none());
9404        assert!(index.kind_of(stale).is_none());
9405        assert!(index.name_of(stale).is_none());
9406        assert!(index.path_of(stale).is_none());
9407        assert!(index.children_of(stale).is_none());
9408        assert!(index.rollup_of(stale).is_none());
9409        assert_eq!(index.attrs_of(current).map(|attrs| attrs.size), Some(2));
9410    }
9411
9412    #[test]
9413    fn parent_first_batch_establishes_exact_ancestry() {
9414        let mut index = Index::new("/root");
9415        let deep = file_attrs(0, 1);
9416        let nested = file_attrs(0, 2);
9417        let tree = file_attrs(0, 3);
9418        index.apply_ok(&Observation::new(vec![
9419            upsert("deep", EntryKind::Dir, deep),
9420            upsert("deep/nested", EntryKind::Dir, nested),
9421            upsert("deep/nested/tree", EntryKind::Dir, tree),
9422            upsert("deep/nested/tree/file.txt", EntryKind::File, file_attrs(42, 7)),
9423        ]));
9424
9425        assert_eq!(index.total().files, 1);
9426        assert_eq!(index.total().dirs, 3);
9427        assert_eq!(index.total().bytes, 42);
9428        assert_eq!(index.rollup(Path::new("deep/nested")).expect("created").files, 1);
9429        assert_eq!(index.attrs(Path::new("deep")), Some(&deep));
9430        assert_eq!(index.attrs(Path::new("deep/nested")), Some(&nested));
9431        assert_eq!(index.attrs(Path::new("deep/nested/tree")), Some(&tree));
9432    }
9433
9434    #[test]
9435    fn scanner_parent_proof_matches_public_parent_first_application() {
9436        let ops = vec![
9437            upsert("deep", EntryKind::Dir, file_attrs(0, 1)),
9438            upsert("deep/nested", EntryKind::Dir, file_attrs(0, 2)),
9439            upsert("deep/nested/file.txt", EntryKind::File, file_attrs(42, 3)),
9440        ];
9441        let mut scanner = Index::new("/root");
9442        let scanner_stats = scanner
9443            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(ops.clone()))
9444            .expect("scanner proof");
9445        let mut public = Index::new("/root");
9446        let public_stats = public.apply(&Observation::new(ops)).expect("public proof").stats;
9447
9448        assert_eq!(scanner_stats, public_stats);
9449        assert_eq!(scanner.total(), public.total());
9450        assert_eq!(scanner.len(), public.len());
9451        for path in ["deep", "deep/nested", "deep/nested/file.txt"] {
9452            assert_eq!(scanner.kind(Path::new(path)), public.kind(Path::new(path)));
9453            assert_eq!(scanner.attrs(Path::new(path)), public.attrs(Path::new(path)));
9454        }
9455    }
9456
9457    #[test]
9458    fn scanner_parent_proof_rejects_unknown_ancestry_before_mutation() {
9459        let mut index = Index::new("/root");
9460        let before = index.total();
9461        let error = index
9462            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![upsert(
9463                "missing/child.txt",
9464                EntryKind::File,
9465                file_attrs(1, 1),
9466            )]))
9467            .expect_err("scanner proof must reject an unknown parent");
9468
9469        assert!(matches!(
9470            error,
9471            crate::Error::UnknownAncestry { path, .. }
9472                if path == Path::new("missing/child.txt")
9473        ));
9474        assert_eq!(index.total(), before);
9475        assert_eq!(index.len(), 1);
9476        assert_eq!(index.clock(), Clock::ZERO);
9477    }
9478
9479    #[test]
9480    fn scanner_kind_replacement_is_proved_by_the_general_lane() {
9481        let mut index = Index::new("/root");
9482        index.apply_ok(&Observation::new(vec![
9483            upsert("a", EntryKind::Dir, file_attrs(0, 1)),
9484            upsert("a/old.txt", EntryKind::File, file_attrs(7, 1)),
9485        ]));
9486        let before = index.total();
9487        let before_clock = index.clock();
9488
9489        // Once `a` is a file nothing can attach below it, and the batch is refused before
9490        // any fact moves, exactly as a public observation would be.
9491        let error = index
9492            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
9493                upsert("a", EntryKind::File, file_attrs(2, 2)),
9494                upsert("a/new.txt", EntryKind::File, file_attrs(3, 2)),
9495            ]))
9496            .expect_err("a child cannot attach after its parent became a file");
9497
9498        assert!(matches!(
9499            error,
9500            crate::Error::UnknownAncestry { path, .. } if path == Path::new("a/new.txt")
9501        ));
9502        assert_eq!(index.total(), before);
9503        assert_eq!(index.clock(), before_clock);
9504        assert_eq!(index.kind(Path::new("a")), Some(EntryKind::Dir));
9505        assert!(index.lookup(Path::new("a/old.txt")).is_some());
9506        assert!(index.lookup(Path::new("a/new.txt")).is_none());
9507
9508        // The replacement on its own is an ordinary verified observation.
9509        index
9510            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![upsert(
9511                "a",
9512                EntryKind::File,
9513                file_attrs(2, 2),
9514            )]))
9515            .expect("a scanner batch can replace an entry's kind");
9516        assert_eq!(index.kind(Path::new("a")), Some(EntryKind::File));
9517        assert!(index.lookup(Path::new("a/old.txt")).is_none());
9518        let total = index.total();
9519        assert_eq!((total.files, total.dirs, total.bytes), (1, 0, 2));
9520    }
9521
9522    #[test]
9523    fn scanner_discovery_survives_a_kind_changed_by_a_concurrent_refresh() {
9524        let handle = IndexHandle::new(Index::new("/root"));
9525        handle
9526            .apply_scanner_discovery_bounded(
9527                crate::scan::ScannerBatch::from_ops(vec![upsert(
9528                    "p",
9529                    EntryKind::Dir,
9530                    Attrs::default(),
9531                )]),
9532                DiscoveryCommit::default(),
9533                None,
9534            )
9535            .expect("root listing");
9536        // A refresh, on the general lane, saw that p/d is now a file on disk.
9537        handle
9538            .apply(&Observation::new(vec![upsert("p/d", EntryKind::File, file_attrs(1, 1))]))
9539            .expect("refresh insert");
9540        // Discovery's pending batch still carries the directory observation it listed.
9541        let outcome = handle.apply_scanner_discovery_bounded(
9542            crate::scan::ScannerBatch::from_ops(vec![upsert(
9543                "p/d",
9544                EntryKind::Dir,
9545                Attrs::default(),
9546            )]),
9547            DiscoveryCommit { directory_complete: Some(PathBuf::from("p")), transition: None },
9548            None,
9549        );
9550        assert!(outcome.is_ok(), "discovery must not die on a kind race: {outcome:?}");
9551        // The listed observation replaces the entry, as any verified observation does, and
9552        // the directory it came from is complete.
9553        assert_eq!(handle.kind(Path::new("p/d")).expect("kind read"), Some(EntryKind::Dir));
9554        assert_eq!(handle.directory_complete(Path::new("p")).expect("read"), Some(true));
9555    }
9556
9557    #[test]
9558    fn live_upsert_refuses_unknown_ancestry_without_mutation() {
9559        let mut index = Index::new("/root");
9560        let before = index.clock();
9561
9562        let error = index
9563            .apply(&Observation::new(vec![upsert(
9564                "unknown/deep/file.txt",
9565                EntryKind::File,
9566                file_attrs(10, 1),
9567            )]))
9568            .expect_err("live input must not invent parent metadata");
9569
9570        assert!(matches!(
9571            error,
9572            crate::Error::UnknownAncestry { path, reconcile_from }
9573                if path == Path::new("unknown/deep/file.txt")
9574                    && reconcile_from.as_os_str().is_empty()
9575        ));
9576        assert_eq!(index.clock(), before);
9577        assert_eq!(index.len(), 1);
9578        assert!(index.since(before).commits.is_empty());
9579    }
9580
9581    #[test]
9582    fn explicit_kind_replacement_precedes_attaching_a_child() {
9583        let mut index = Index::new("/root");
9584        index.apply_ok(&Observation::new(vec![upsert(
9585            "conflict",
9586            EntryKind::File,
9587            file_attrs(9, 1),
9588        )]));
9589
9590        let outcome = index.apply_ok(&Observation::new(vec![
9591            upsert("conflict", EntryKind::Dir, file_attrs(0, 2)),
9592            upsert("conflict/child.txt", EntryKind::File, file_attrs(4, 2)),
9593        ]));
9594
9595        assert_eq!(index.kind(Path::new("conflict")), Some(EntryKind::Dir));
9596        assert!(index.lookup(Path::new("conflict/child.txt")).is_some());
9597        assert_eq!(index.total().files, 1);
9598        assert_eq!(index.total().dirs, 1);
9599        assert_eq!(index.total().bytes, 4);
9600        assert_eq!(outcome.removed, 1);
9601    }
9602
9603    #[test]
9604    fn kind_change_replaces_the_entry() {
9605        let mut index = Index::new("/root");
9606        index.apply_ok(&Observation::new(vec![upsert(
9607            "thing",
9608            EntryKind::File,
9609            file_attrs(50, 5),
9610        )]));
9611        assert_eq!(index.total().files, 1);
9612
9613        index.apply_ok(&Observation::new(vec![upsert("thing", EntryKind::Dir, Attrs::default())]));
9614        let total = index.total();
9615        assert_eq!(total.files, 0);
9616        assert_eq!(total.dirs, 1);
9617        assert_eq!(total.bytes, 0);
9618    }
9619
9620    #[test]
9621    fn paths_are_reconstructed_from_parent_pointers() {
9622        let index = index_with_sample_tree();
9623        let id = index.lookup(Path::new("src/main.rs")).expect("present");
9624        assert_eq!(index.path_of(id), Some(PathBuf::from("src/main.rs")));
9625        assert_eq!(index.path_of(EntryId::ROOT), Some(PathBuf::new()));
9626    }
9627
9628    #[test]
9629    fn since_returns_commits_after_a_clock() {
9630        let mut index = Index::new("/root");
9631        index.apply_ok(&Observation::new(vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))]));
9632        let mark = index.clock();
9633        index.apply_ok(&Observation::new(vec![upsert("b.txt", EntryKind::File, file_attrs(2, 2))]));
9634
9635        let since = index.since(mark);
9636        assert!(!since.truncated);
9637        assert_eq!(since.commits.len(), 1);
9638        assert_eq!(since.commits[0].changes[0].path(), Path::new("b.txt"));
9639
9640        assert_eq!(index.since(index.clock()).commits.len(), 0);
9641    }
9642
9643    /// The bytes one batch is charged when it commits against an empty tree.
9644    fn commit_cost(ops: Vec<Op>) -> usize {
9645        let mut probe = Index::new("/root");
9646        probe.apply_ok(&Observation::new(ops)).commit.expect("effective commit").retained_cost()
9647    }
9648
9649    #[test]
9650    fn oversized_single_batch_is_not_retained() {
9651        let batch = || {
9652            vec![
9653                upsert("a.txt", EntryKind::File, file_attrs(1, 1)),
9654                upsert("b.txt", EntryKind::File, file_attrs(2, 2)),
9655                upsert("c.txt", EntryKind::File, file_attrs(3, 3)),
9656            ]
9657        };
9658        let mut index = Index::with_journal_capacity_bytes("/root", commit_cost(batch()) - 1);
9659        let outcome = index.apply_ok(&Observation::new(batch()));
9660
9661        assert_eq!(outcome.commit.as_ref().expect("committed").changes.len(), 3);
9662        let since = index.since(Clock::ZERO);
9663        assert!(since.truncated);
9664        assert!(since.commits.is_empty());
9665    }
9666
9667    #[test]
9668    fn journal_eviction_charges_the_complete_retained_payload() {
9669        let first = || {
9670            vec![
9671                upsert("a.txt", EntryKind::File, file_attrs(1, 1)),
9672                upsert("b.txt", EntryKind::File, file_attrs(2, 2)),
9673            ]
9674        };
9675        let second = || {
9676            vec![
9677                upsert("c.txt", EntryKind::File, file_attrs(3, 3)),
9678                upsert("d.txt", EntryKind::File, file_attrs(4, 4)),
9679            ]
9680        };
9681        // Room for the second commit and all but one byte of the first.
9682        let capacity = commit_cost(first()) + commit_cost(second()) - 1;
9683        let mut index = Index::with_journal_capacity_bytes("/root", capacity);
9684        index.apply_ok(&Observation::new(first()));
9685        index.apply_ok(&Observation::new(second()));
9686
9687        let since = index.since(Clock::ZERO);
9688        assert!(since.truncated);
9689        assert_eq!(since.commits.len(), 1);
9690        assert_eq!(since.commits[0].changes.len(), 2);
9691        assert_eq!(since.commits[0].changes[0].path(), Path::new("c.txt"));
9692    }
9693
9694    /// Two commits of one inserted file each: the same item count, but the second names a
9695    /// path whose bytes alone dwarf the first commit. A budget counted in items held both
9696    /// and let a long-path tree retain tens of mebibytes under a 64 Ki budget; a budget in
9697    /// bytes evicts the first.
9698    #[test]
9699    fn journal_eviction_is_charged_in_path_bytes() {
9700        let long_name = format!("{}.txt", "n".repeat(4096));
9701        let short = || vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))];
9702        let long = || vec![upsert(&long_name, EntryKind::File, file_attrs(2, 2))];
9703        let short_cost = commit_cost(short());
9704        let long_cost = commit_cost(long());
9705        assert!(
9706            long_cost > short_cost + 4096,
9707            "path bytes must be charged: short {short_cost}, long {long_cost}"
9708        );
9709
9710        let mut index = Index::with_journal_capacity_bytes("/root", short_cost + long_cost - 1);
9711        index.apply_ok(&Observation::new(short()));
9712        index.apply_ok(&Observation::new(long()));
9713
9714        let since = index.since(Clock::ZERO);
9715        assert!(since.truncated, "an item budget kept both commits; a byte budget cannot");
9716        assert_eq!(since.commits.len(), 1);
9717        assert_eq!(since.commits[0].changes[0].path(), Path::new(&long_name));
9718    }
9719
9720    #[test]
9721    fn impact_drops_an_overflowing_path_set_instead_of_truncating_it() {
9722        let mut index = Index::new("/root");
9723        let ops = (0..=MAX_DIRTY_PATHS)
9724            .map(|which| {
9725                upsert(
9726                    &format!("file-{which}.txt"),
9727                    EntryKind::File,
9728                    file_attrs(u64::try_from(which).expect("bounded"), 1),
9729                )
9730            })
9731            .collect();
9732
9733        let commit =
9734            index.apply_ok(&Observation::new(ops)).commit.expect("overflowing impact commit");
9735
9736        assert!(commit.impact.all_dirty);
9737        assert!(commit.impact.dirty_paths.is_empty(), "a partial path list must not escape");
9738        assert_eq!(commit.changes.len(), MAX_DIRTY_PATHS + 1);
9739    }
9740
9741    #[test]
9742    fn invalidations_are_queued_for_the_scan_layer() {
9743        let mut index = Index::new("/root");
9744        let stats = index.apply_ok(&Observation::new(vec![Op::InvalidateSubtree {
9745            path: PathBuf::from("src"),
9746            reason: InvalidateReason::WatchOverflow,
9747        }]));
9748
9749        assert_eq!(stats.invalidated, 1);
9750        let pending = index.take_pending_invalidations();
9751        assert_eq!(pending.len(), 1);
9752        assert_eq!(pending[0].0, PathBuf::from("src"));
9753        assert_eq!(pending[0].1, InvalidateReason::WatchOverflow);
9754        assert!(index.take_pending_invalidations().is_empty(), "drained once");
9755    }
9756
9757    #[test]
9758    fn paths_escaping_the_root_are_rejected() {
9759        assert!(normalize(Path::new("../escape")).is_none());
9760        assert!(normalize(Path::new("/absolute")).is_none());
9761        assert_eq!(
9762            normalize(Path::new("./a/b")).expect("relative"),
9763            vec![OsString::from("a"), OsString::from("b")]
9764        );
9765
9766        let mut index = Index::new("/root");
9767        let upsert_error = index
9768            .apply(&Observation::new(vec![upsert("../escape", EntryKind::File, file_attrs(1, 1))]))
9769            .expect_err("escaping upsert");
9770        assert!(matches!(upsert_error, crate::Error::PathEscapesRoot(_)));
9771        assert_eq!(index.total().files, 0);
9772
9773        let invalidation_error = index
9774            .apply(&Observation::new(vec![Op::InvalidateSubtree {
9775                path: PathBuf::from("../outside"),
9776                reason: InvalidateReason::Requested,
9777            }]))
9778            .expect_err("escaping invalidation");
9779        assert!(matches!(invalidation_error, crate::Error::PathEscapesRoot(_)));
9780        assert!(index.take_pending_invalidations().is_empty());
9781        assert_eq!(index.freshness(), Freshness::Fresh);
9782    }
9783
9784    #[cfg(unix)]
9785    #[test]
9786    fn distinct_non_utf8_names_have_distinct_identity() {
9787        use std::ffi::OsString;
9788        use std::os::unix::ffi::OsStringExt;
9789
9790        let first = PathBuf::from(OsString::from_vec(vec![b'n', 0x80]));
9791        let second = PathBuf::from(OsString::from_vec(vec![b'n', 0x81]));
9792        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
9793            "/root",
9794            ScanScope::default(),
9795            crate::classify::TypeRegistry::compiled_shared(),
9796            DEFAULT_JOURNAL_CAPACITY_BYTES,
9797        );
9798        index.apply_ok(&Observation::new(vec![
9799            Op::Upsert { path: first.clone(), kind: EntryKind::File, attrs: file_attrs(10, 1) },
9800            Op::Upsert { path: second.clone(), kind: EntryKind::File, attrs: file_attrs(20, 2) },
9801        ]));
9802
9803        assert_eq!(index.total().files, 2);
9804        assert_eq!(index.total().bytes, 30);
9805        assert!(index.lookup(&first).is_some());
9806        assert!(index.lookup(&second).is_some());
9807        assert_serving_indexes(&index);
9808    }
9809
9810    #[cfg(unix)]
9811    #[test]
9812    fn a_non_utf8_parent_still_lists_its_children() {
9813        use std::ffi::OsString;
9814        use std::os::unix::ffi::OsStringExt;
9815
9816        let directory = PathBuf::from(OsString::from_vec(vec![b'd', 0x80]));
9817        let child = directory.join("child");
9818        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
9819            "/root",
9820            ScanScope::default(),
9821            crate::classify::TypeRegistry::compiled_shared(),
9822            DEFAULT_JOURNAL_CAPACITY_BYTES,
9823        );
9824        index.apply_ok(&Observation::new(vec![
9825            Op::Upsert { path: directory.clone(), kind: EntryKind::Dir, attrs: Attrs::default() },
9826            Op::Upsert { path: child, kind: EntryKind::File, attrs: file_attrs(1, 1) },
9827        ]));
9828
9829        assert_serving_indexes(&index);
9830        // The directory's own name escapes to `d%80`, and its child is reachable beneath
9831        // it. While the encoding was partial this directory had no portable name, so its
9832        // whole subtree was unlistable and the assertion here counted the loss instead.
9833        assert_eq!(
9834            index.portable_children(&directory).map(|children| children.nondirectories.len()),
9835            Some(1)
9836        );
9837        assert!(
9838            index.portable_entries().keys().any(|portable| portable.as_str() == "d%80/child"),
9839            "a child under a non-utf8 directory is listed at its escaped path"
9840        );
9841    }
9842
9843    #[cfg(unix)]
9844    #[test]
9845    fn non_utf8_stem_keeps_ascii_extension_tally() {
9846        use std::ffi::OsString;
9847        use std::os::unix::ffi::OsStringExt;
9848
9849        let path = PathBuf::from(OsString::from_vec(vec![b'n', 0x80, b'.', b'R', b'S']));
9850        let mut index = Index::new("/root");
9851        index.apply_ok(&Observation::new(vec![Op::Upsert {
9852            path,
9853            kind: EntryKind::File,
9854            attrs: file_attrs(10, 1),
9855        }]));
9856
9857        let tallies = index.total().by_ext;
9858        assert_eq!(
9859            tallies.get(".rs"),
9860            Some(&ExtTally { files: 1, bytes: 10, allocated: file_attrs(10, 1).allocated })
9861        );
9862    }
9863
9864    #[test]
9865    fn restore_candidates_count_visited_files_not_pathbuf_aliases() {
9866        use crate::content::AnalysisSet;
9867
9868        let mut index = Index::new("/root");
9869        let attrs = file_attrs(10, 1);
9870        assert!(
9871            index
9872                .insert_loaded_child(EntryId::ROOT, OsString::from("a"), EntryKind::File, attrs)
9873                .is_some()
9874        );
9875        assert!(
9876            index
9877                .insert_loaded_child(EntryId::ROOT, OsString::from("a/"), EntryKind::File, attrs)
9878                .is_some()
9879        );
9880        let (candidates, visited) =
9881            index.restore_analysis_candidates(AnalysisSet::NONE.with_lines());
9882        assert_eq!(visited, 2, "each visited regular file is a completeness slot");
9883        assert_eq!(candidates.len(), 1, "PathBuf keys merge trailing-separator aliases");
9884    }
9885
9886    #[test]
9887    fn restore_candidates_match_analysis_file_identities() {
9888        use crate::content::AnalysisSet;
9889
9890        let mut index = Index::new("/root");
9891        index.apply_ok(&Observation::new(vec![
9892            upsert("src", EntryKind::Dir, file_attrs(0, 1)),
9893            upsert("src/nested", EntryKind::Dir, file_attrs(0, 2)),
9894            upsert("src/nested/lib.rs", EntryKind::File, file_attrs(10, 1)),
9895            upsert("README.md", EntryKind::File, file_attrs(20, 2)),
9896        ]));
9897        let profile = AnalysisSet::NONE.with_lines();
9898        let live = index.analysis_candidates(profile);
9899        let (restore, visited) = index.restore_analysis_candidates(profile);
9900        assert_eq!(restore.len(), live.len());
9901        assert_eq!(visited, u64::try_from(live.len()).expect("candidate count fits u64"));
9902        assert_eq!(restore.len(), 2);
9903        for candidate in &live {
9904            assert_eq!(
9905                index.path_of(candidate.entry_id).as_deref(),
9906                Some(candidate.relative_path.as_path())
9907            );
9908            let restored = restore.get(&candidate.relative_path).expect("same relative path");
9909            assert_eq!(restored.entry_id, candidate.entry_id);
9910            assert_eq!(restored.revision, candidate.revision);
9911            assert_eq!(restored.attrs.fingerprint(), candidate.attrs.fingerprint());
9912        }
9913    }
9914
9915    #[test]
9916    fn content_results_commit_conditionally_and_metadata_changes_invalidate_them() {
9917        use crate::content::{
9918            AnalysisApplyOutcome, AnalysisRequest, AnalysisSet, AnalyzerOutcome, BasicMetrics,
9919            ContentProvenance, FileAnalysis,
9920        };
9921
9922        let mut index = Index::new("/root");
9923        index.apply_ok(&Observation::new(vec![
9924            upsert("src", EntryKind::Dir, file_attrs(0, 1)),
9925            upsert("src/lib.rs", EntryKind::File, file_attrs(10, 1)),
9926        ]));
9927        let profile = AnalysisSet::NONE.with_lines();
9928        let candidate =
9929            index.analysis_candidates(profile).into_iter().next().expect("file candidate");
9930        let analysis = FileAnalysis {
9931            fingerprint: candidate.attrs.fingerprint(),
9932            bytes: candidate.attrs.size,
9933            detection: candidate.classification.clone().into(),
9934            lines: AnalyzerOutcome::analyzed(BasicMetrics {
9935                physical_lines: 2,
9936                nonblank_lines: 2,
9937                ..BasicMetrics::default()
9938            }),
9939            code: None,
9940            words: None,
9941            error: None,
9942        };
9943        let provenance = ContentProvenance::for_request(
9944            AnalysisRequest { profile, ..AnalysisRequest::default() },
9945            crate::classify::type_rule_fingerprint(),
9946        );
9947        let observation = AnalysisObservation {
9948            candidate: candidate.clone(),
9949            profile,
9950            provenance: provenance.clone(),
9951            analysis: analysis.clone(),
9952        };
9953        assert_eq!(
9954            index.apply_analysis(observation.clone()),
9955            AnalysisApplyOutcome::Stale,
9956            "an index prepared for no content identity holds no record"
9957        );
9958        assert!(index.content().is_none());
9959
9960        index.prepare_content_analysis(AnalysisRequest { profile, ..AnalysisRequest::default() });
9961        assert_eq!(index.content_set(), profile);
9962        assert_eq!(index.apply_analysis(observation), AnalysisApplyOutcome::Applied);
9963        assert_eq!(
9964            index
9965                .content_rollup(Path::new(""))
9966                .expect("content root")
9967                .total
9968                .lines
9969                .metrics
9970                .physical_lines,
9971            2
9972        );
9973
9974        index.apply_ok(&Observation::new(vec![upsert(
9975            "src/lib.rs",
9976            EntryKind::File,
9977            file_attrs(20, 2),
9978        )]));
9979        assert!(index.content_rollup(Path::new("")).is_none());
9980        assert_eq!(
9981            index.apply_analysis(AnalysisObservation { candidate, profile, provenance, analysis }),
9982            AnalysisApplyOutcome::Stale
9983        );
9984    }
9985
9986    #[test]
9987    fn operational_content_failures_are_retained_but_retried_until_recovery() {
9988        use crate::content::{
9989            AnalysisApplyOutcome, AnalysisRequest, AnalysisSet, AnalyzerOutcome, BasicMetrics,
9990            ContentProvenance, CoverageReason, FileAnalysis,
9991        };
9992
9993        let mut index = Index::new("/root");
9994        index.apply_ok(&Observation::new(vec![
9995            upsert("src", EntryKind::Dir, file_attrs(0, 1)),
9996            upsert("src/lib.rs", EntryKind::File, file_attrs(10, 1)),
9997        ]));
9998        let profile = AnalysisSet::LINES_ONLY;
9999        let request = AnalysisRequest { profile, ..AnalysisRequest::default() };
10000        index.prepare_content_analysis(request);
10001        let candidate = index.pending_analysis_candidates(request).pop().expect("candidate");
10002        let provenance =
10003            ContentProvenance::for_request(request, crate::classify::type_rule_fingerprint());
10004        let record = |reason, error: &str| FileAnalysis {
10005            fingerprint: candidate.attrs.fingerprint(),
10006            bytes: candidate.attrs.size,
10007            detection: candidate.classification.clone().into(),
10008            lines: AnalyzerOutcome::unavailable(reason),
10009            code: None,
10010            words: None,
10011            error: Some(error.to_owned()),
10012        };
10013
10014        for (reason, error) in [
10015            (CoverageReason::IoError, "read failed"),
10016            (CoverageReason::ChangedDuringRead, "changed during read"),
10017        ] {
10018            assert_eq!(
10019                index.apply_analysis(AnalysisObservation {
10020                    candidate: candidate.clone(),
10021                    profile,
10022                    provenance: provenance.clone(),
10023                    analysis: record(reason, error),
10024                }),
10025                AnalysisApplyOutcome::Applied
10026            );
10027            let retained = index.content().expect("content").file(Path::new("src/lib.rs"));
10028            assert_eq!(retained.and_then(FileAnalysis::operational_failure), Some(reason));
10029            assert_eq!(
10030                index.pending_analysis_candidates(request).len(),
10031                1,
10032                "an operational failure must remain pending"
10033            );
10034        }
10035
10036        let recovered = FileAnalysis {
10037            fingerprint: candidate.attrs.fingerprint(),
10038            bytes: candidate.attrs.size,
10039            detection: candidate.classification.clone().into(),
10040            lines: AnalyzerOutcome::analyzed(BasicMetrics {
10041                physical_lines: 1,
10042                nonblank_lines: 1,
10043                raw_words: 1,
10044                ..BasicMetrics::default()
10045            }),
10046            code: None,
10047            words: None,
10048            error: None,
10049        };
10050        assert_eq!(
10051            index.apply_analysis(AnalysisObservation {
10052                candidate,
10053                profile,
10054                provenance,
10055                analysis: recovered,
10056            }),
10057            AnalysisApplyOutcome::Applied
10058        );
10059        assert!(index.pending_analysis_candidates(request).is_empty());
10060        assert_eq!(
10061            index
10062                .content()
10063                .expect("content")
10064                .file(Path::new("src/lib.rs"))
10065                .and_then(FileAnalysis::operational_failure),
10066            None
10067        );
10068    }
10069
10070    /// An index that read no control file cannot say what is ignored, so it says that,
10071    /// rather than calling every entry unignored.
10072    #[test]
10073    fn an_index_that_did_not_observe_controls_refuses_ignore_questions() {
10074        let mut index =
10075            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
10076        assert!(!index.observes_controls());
10077        index.apply_ok(&Observation::new(vec![upsert(
10078            "debug.log",
10079            EntryKind::File,
10080            file_attrs(10, 1),
10081        )]));
10082        for path in ["debug.log", "absent.log"] {
10083            assert!(
10084                matches!(
10085                    index.is_ignored(Path::new(path)),
10086                    Err(crate::Error::ControlStateNotObserved)
10087                ),
10088                "{path}"
10089            );
10090        }
10091        assert!(matches!(index.controls(), Err(crate::Error::ControlStateNotObserved)));
10092
10093        let mut observed =
10094            Index::new_with_scope("/root", crate::test_support::observing_controls());
10095        assert!(observed.observes_controls());
10096        observed.apply_ok(&Observation::new(vec![upsert(
10097            "debug.log",
10098            EntryKind::File,
10099            file_attrs(10, 1),
10100        )]));
10101        assert_eq!(observed.is_ignored(Path::new("debug.log")).ok(), Some(Some(false)));
10102        assert_eq!(observed.is_ignored(Path::new("absent.log")).ok(), Some(None));
10103        assert!(observed.controls().is_ok_and(crate::control::ControlTable::is_empty));
10104    }
10105
10106    /// Control input to an index that observes no control state is refused, typed, and
10107    /// changes nothing. Accepted, it installed a table and reclassified entries under a
10108    /// scope that says no rule was read, so `is_ignored` refused over classification the
10109    /// index held and a snapshot saved from it loaded into an open that turned observation
10110    /// off as an exact match (`fdu-agb6`). A stale conditional control op is refused as
10111    /// well: the refusal is about the index's scope, not its state.
10112    #[test]
10113    fn an_index_that_does_not_observe_controls_refuses_control_input() {
10114        let mut index =
10115            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
10116        index.apply_ok(&Observation::new(vec![
10117            upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
10118            upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
10119        ]));
10120        let stale_baseline = index.expectation(Path::new(".gitignore"));
10121        index.apply_ok(&Observation::new(vec![upsert(
10122            ".gitignore",
10123            EntryKind::File,
10124            file_attrs(7, 3),
10125        )]));
10126        let clock = index.clock();
10127        let total = index.total();
10128        let controls = || {
10129            [
10130                Op::ControlUpsert {
10131                    path: PathBuf::from(".gitignore"),
10132                    source: b"*.log\n".to_vec(),
10133                },
10134                Op::ControlRemove { path: PathBuf::from(".gitignore") },
10135            ]
10136        };
10137
10138        for control in controls() {
10139            let batch = Observation::new(vec![
10140                upsert("new.txt", EntryKind::File, file_attrs(1, 4)),
10141                control.clone(),
10142            ]);
10143            assert!(
10144                matches!(index.apply(&batch), Err(crate::Error::ControlStateNotObserved)),
10145                "{control:?}"
10146            );
10147            assert!(
10148                matches!(index.apply_baseline(&batch), Err(crate::Error::ControlStateNotObserved)),
10149                "{control:?}"
10150            );
10151            let stale = Observation::from_ops(vec![ObservationOp::if_state(
10152                control.clone(),
10153                stale_baseline,
10154            )]);
10155            assert!(
10156                matches!(index.apply(&stale), Err(crate::Error::ControlStateNotObserved)),
10157                "stale {control:?}"
10158            );
10159        }
10160        assert_eq!(index.clock(), clock, "a refused batch commits nothing");
10161        assert_eq!(index.total(), total);
10162        assert!(index.lookup(Path::new("new.txt")).is_none());
10163        assert!(index.control_table().is_empty());
10164
10165        let mut table = crate::control::ControlTable::default();
10166        table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control source");
10167        assert!(matches!(
10168            index.install_controls(table),
10169            Err(crate::Error::ControlStateNotObserved)
10170        ));
10171        assert!(index.control_table().is_empty());
10172
10173        let mut observed =
10174            Index::new_with_scope("/root", crate::test_support::observing_controls());
10175        for control in controls() {
10176            observed
10177                .apply(&Observation::new(vec![
10178                    upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
10179                    control,
10180                ]))
10181                .expect("an observing index accepts control input");
10182        }
10183    }
10184
10185    /// The unignored partition and a shared child's ignore bit are ignore facts too. On an
10186    /// index that read no rule the partition equals `all` and every bit reads "not
10187    /// ignored" only because nobody looked, so they refuse the way `is_ignored` does,
10188    /// while the `all` roll-up and the children themselves still answer (`fdu-agb6`).
10189    #[test]
10190    fn an_index_that_did_not_observe_controls_states_no_partition_or_child_ignore_fact() {
10191        let tree = Observation::new(vec![
10192            upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
10193            upsert("dir/debug.log", EntryKind::File, file_attrs(10, 2)),
10194        ]);
10195        let mut unobserved =
10196            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
10197        unobserved.apply_ok(&tree);
10198        assert!(matches!(unobserved.partition_total(), Err(crate::Error::ControlStateNotObserved)));
10199        for path in ["", "dir", "dir/debug.log", "absent"] {
10200            assert!(
10201                matches!(
10202                    unobserved.partition_rollup(Path::new(path)),
10203                    Err(crate::Error::ControlStateNotObserved)
10204                ),
10205                "{path}"
10206            );
10207            assert!(
10208                matches!(
10209                    unobserved.partition_rollup_summary(Path::new(path)),
10210                    Err(crate::Error::ControlStateNotObserved)
10211                ),
10212                "{path}"
10213            );
10214        }
10215        assert_eq!(unobserved.total().files, 1, "the all partition still answers");
10216        let children = IndexHandle::new(unobserved)
10217            .children(Path::new(""))
10218            .expect("children read")
10219            .expect("root directory");
10220        assert_eq!(children.len(), 1);
10221        assert_eq!(children[0].ignored, None);
10222        assert_eq!(children[0].partitions, None);
10223        assert_eq!(children[0].rollup.as_ref().map(|rollup| rollup.files), Some(1));
10224
10225        let mut observed =
10226            Index::new_with_scope("/root", crate::test_support::observing_controls());
10227        observed.apply_ok(&tree);
10228        assert_eq!(observed.partition_total().expect("control state observed").unignored.files, 1);
10229        assert!(
10230            observed.partition_rollup(Path::new("dir")).expect("control state observed").is_some()
10231        );
10232        assert_eq!(
10233            observed
10234                .partition_rollup_summary(Path::new("dir/debug.log"))
10235                .expect("control state observed"),
10236            None,
10237            "a file has no partitions"
10238        );
10239        let children = IndexHandle::new(observed)
10240            .children(Path::new(""))
10241            .expect("children read")
10242            .expect("root directory");
10243        assert_eq!(children[0].ignored, Some(false));
10244        assert_eq!(
10245            children[0].partitions.as_ref().map(|partitions| partitions.unignored.files),
10246            Some(1)
10247        );
10248    }
10249
10250    #[test]
10251    fn control_changes_atomically_move_fixed_partitions_without_changing_all() {
10252        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
10253        index.apply_ok(&Observation::new(vec![
10254            upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
10255            upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
10256            upsert("keep.rs", EntryKind::File, file_attrs(20, 3)),
10257            upsert("docs", EntryKind::Dir, file_attrs(0, 4)),
10258            upsert("docs/other.log", EntryKind::File, file_attrs(30, 5)),
10259            upsert("docs/keep.log", EntryKind::File, file_attrs(40, 6)),
10260        ]));
10261        let before = index.partition_total().expect("control state observed");
10262
10263        let outcome = index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
10264            path: PathBuf::from(".gitignore"),
10265            source: b"*.log\n".to_vec(),
10266        }]));
10267        let partitions = index.partition_total().expect("control state observed");
10268
10269        assert_eq!(partitions.all, before.all, "classification never changes all facts");
10270        assert_eq!(partitions.all.files, 5);
10271        assert_eq!(partitions.unignored.files, 2);
10272        assert_eq!(partitions.unignored.bytes, 26);
10273        assert_eq!(outcome.controls, 1);
10274        assert_eq!(outcome.reclassified, 3);
10275        assert_eq!(
10276            index.is_ignored(Path::new("debug.log")).expect("control state observed"),
10277            Some(true)
10278        );
10279        assert_eq!(
10280            index.is_ignored(Path::new("keep.rs")).expect("control state observed"),
10281            Some(false)
10282        );
10283
10284        let commit = outcome.commit.expect("control and classification commit together");
10285        assert!(matches!(
10286            commit.changes.first(),
10287            Some(EffectiveChange::ControlUpdated { path, previous: None, current: Some(_) })
10288                if path == Path::new(".gitignore")
10289        ));
10290        assert_eq!(
10291            commit
10292                .changes
10293                .iter()
10294                .filter(|change| matches!(change, EffectiveChange::Reclassified { .. }))
10295                .count(),
10296            3
10297        );
10298    }
10299
10300    #[test]
10301    fn serving_semantics_follow_ignore_reclassification_exactly() {
10302        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
10303            "/root",
10304            crate::test_support::observing_controls(),
10305            crate::classify::TypeRegistry::compiled_shared(),
10306            DEFAULT_JOURNAL_CAPACITY_BYTES,
10307        );
10308        index.apply_ok(&Observation::new(vec![
10309            upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
10310            upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
10311            upsert("keep.rs", EntryKind::File, file_attrs(20, 3)),
10312            upsert("Makefile", EntryKind::File, file_attrs(30, 4)),
10313        ]));
10314        assert_serving_indexes(&index);
10315
10316        index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
10317            path: PathBuf::from(".gitignore"),
10318            source: b"*.log\nMakefile\n".to_vec(),
10319        }]));
10320        assert_serving_indexes(&index);
10321
10322        index.apply_ok(&Observation::new(vec![Op::ControlRemove {
10323            path: PathBuf::from(".gitignore"),
10324        }]));
10325        assert_serving_indexes(&index);
10326    }
10327
10328    #[test]
10329    fn nested_negation_edit_and_last_control_deletion_reclassify_exactly() {
10330        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
10331        index.apply_ok(&Observation::new(vec![
10332            upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
10333            upsert("docs", EntryKind::Dir, file_attrs(0, 2)),
10334            upsert("docs/.gitignore", EntryKind::File, file_attrs(10, 3)),
10335            upsert("docs/keep.log", EntryKind::File, file_attrs(40, 4)),
10336            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
10337            Op::ControlUpsert {
10338                path: PathBuf::from("docs/.gitignore"),
10339                source: b"!keep.log\n".to_vec(),
10340            },
10341        ]));
10342        assert_eq!(
10343            index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
10344            Some(false)
10345        );
10346
10347        let edited = index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
10348            path: PathBuf::from("docs/.gitignore"),
10349            source: b"# no exception\n".to_vec(),
10350        }]));
10351        assert_eq!(edited.reclassified, 1);
10352        assert_eq!(
10353            index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
10354            Some(true)
10355        );
10356
10357        let removed = index
10358            .apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from(".gitignore") }]));
10359        assert_eq!(removed.controls, 1, "removing the retained row removes its control state");
10360        assert_eq!(removed.reclassified, 1);
10361        assert_eq!(
10362            index.controls().expect("control state observed").len(),
10363            1,
10364            "the nested control remains"
10365        );
10366        assert_eq!(
10367            index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
10368            Some(false)
10369        );
10370
10371        index.apply_ok(&Observation::new(vec![Op::Remove {
10372            path: PathBuf::from("docs/.gitignore"),
10373        }]));
10374        assert!(index.controls().expect("control state observed").is_empty());
10375        assert_eq!(
10376            index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
10377            Some(false)
10378        );
10379    }
10380
10381    #[test]
10382    fn replacing_batch_ancestors_prunes_retained_and_transient_controls() {
10383        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
10384        index
10385            .apply(&Observation::new(vec![
10386                upsert("docs", EntryKind::Dir, file_attrs(0, 1)),
10387                upsert("docs/.gitignore", EntryKind::File, file_attrs(6, 2)),
10388                Op::ControlUpsert { path: "docs/.gitignore".into(), source: b"*.log\n".to_vec() },
10389            ]))
10390            .expect("retained control");
10391
10392        let outcome = index
10393            .apply(&Observation::new(vec![
10394                upsert("scratch", EntryKind::Dir, file_attrs(0, 3)),
10395                upsert("scratch/.gitignore", EntryKind::File, file_attrs(6, 4)),
10396                Op::ControlUpsert {
10397                    path: "scratch/.gitignore".into(),
10398                    source: b"*.log\n".to_vec(),
10399                },
10400                upsert("scratch", EntryKind::File, file_attrs(1, 5)),
10401                upsert("scratch", EntryKind::Dir, file_attrs(0, 6)),
10402                upsert("scratch/new.log", EntryKind::File, file_attrs(7, 7)),
10403                Op::Remove { path: "docs".into() },
10404                upsert("docs", EntryKind::Dir, file_attrs(0, 8)),
10405                upsert("docs/new.log", EntryKind::File, file_attrs(9, 9)),
10406            ]))
10407            .expect("mixed control and structural batch");
10408
10409        assert!(index.controls().expect("control state observed").is_empty());
10410        assert_eq!(
10411            index.is_ignored(Path::new("scratch/new.log")).expect("control state observed"),
10412            Some(false)
10413        );
10414        assert_eq!(
10415            index.is_ignored(Path::new("docs/new.log")).expect("control state observed"),
10416            Some(false)
10417        );
10418        assert_eq!(outcome.stats.controls, 1, "only the retained control has a net change");
10419        let controls = outcome
10420            .commit
10421            .as_ref()
10422            .expect("one exact commit")
10423            .changes
10424            .iter()
10425            .filter(|change| matches!(change, EffectiveChange::ControlUpdated { .. }))
10426            .collect::<Vec<_>>();
10427        assert!(matches!(
10428            controls.as_slice(),
10429            [EffectiveChange::ControlUpdated { path, previous: Some(_), current: None }]
10430                if path == Path::new("docs/.gitignore")
10431        ));
10432    }
10433
10434    /// A control the budget cannot admit is refused inside the commit that carried it: the
10435    /// batch's ordinary entries land, the refusal is a change of its own, and a source it
10436    /// replaces is dropped and its entries reclassified.
10437    #[test]
10438    fn an_over_budget_control_is_refused_while_its_batch_commits() {
10439        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
10440            "/root",
10441            crate::test_support::observing_controls(),
10442            crate::classify::TypeRegistry::compiled_shared(),
10443            DEFAULT_JOURNAL_CAPACITY_BYTES,
10444        );
10445        index.apply_ok(&Observation::new(vec![
10446            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
10447            upsert("debug.log", EntryKind::File, file_attrs(10, 1)),
10448        ]));
10449        assert_eq!(index.is_ignored(Path::new("debug.log")).expect("observed"), Some(true));
10450        let mut oversized = crate::control::source_at_test_limit();
10451        oversized.push(b'a');
10452
10453        let outcome = index.apply_ok(&Observation::new(vec![
10454            upsert("ordinary.txt", EntryKind::File, file_attrs(1, 2)),
10455            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: oversized },
10456        ]));
10457
10458        assert!(index.lookup(Path::new("ordinary.txt")).is_some(), "ordinary work commits");
10459        assert_eq!(index.total().files, 2);
10460        assert_eq!(
10461            index.is_ignored(Path::new("debug.log")).expect("observed"),
10462            None,
10463            "the refused replacement leaves classification unknown"
10464        );
10465        let changes = &outcome.commit.as_ref().expect("one commit").changes;
10466        let refused = Some(crate::control::ControlRefusalReason::Budget);
10467        assert!(changes.iter().any(|change| matches!(
10468            change,
10469            EffectiveChange::ControlUpdated { previous: Some(_), current: None, .. }
10470        )));
10471        assert!(changes.iter().any(|change| matches!(
10472            change,
10473            EffectiveChange::ControlRefusalUpdated { previous: None, current, .. }
10474                if *current == refused
10475        )));
10476        let crate::control::ControlCoverage::Observed(coverage) = index.control_coverage() else {
10477            panic!("an observing index reports observed coverage");
10478        };
10479        assert_eq!((coverage.applied, coverage.refused), (0, 1));
10480        assert_eq!(coverage.refusals[0].path, Path::new(".gitignore"));
10481
10482        // Removing the refused file lifts the refusal in a commit of its own.
10483        let lifted = index.apply_ok(&Observation::new(vec![Op::ControlRemove {
10484            path: PathBuf::from(".gitignore"),
10485        }]));
10486        assert!(matches!(
10487            lifted.commit.as_ref().expect("lifting a refusal commits").changes.as_slice(),
10488            [EffectiveChange::ControlRefusalUpdated { previous, current: None, .. }]
10489                if *previous == refused
10490        ));
10491        assert_eq!(
10492            index.control_coverage(),
10493            crate::control::ControlCoverage::Observed(crate::control::ControlObservation {
10494                limits: crate::control::ControlLimits::default(),
10495                applied: 0,
10496                rules: 0,
10497                refused: 0,
10498                refusals: Vec::new(),
10499            })
10500        );
10501    }
10502
10503    /// A batch that cannot change the control table does not copy it.
10504    ///
10505    /// Every warm revalidate re-reads each refused `.gitignore`, because nothing is
10506    /// retained where one was refused, and re-upserts it; projecting each such batch copied
10507    /// the whole table, once per batch, to arrive at the table it started from (fdu-hzm5).
10508    /// A batch that does change it still projects.
10509    #[test]
10510    fn a_batch_that_cannot_change_the_control_table_does_not_copy_it() {
10511        let projection_clones = |index: &mut Index, ops: Vec<Op>| {
10512            CONTROL_PROJECTION_CLONES.with(|clones| clones.set(0));
10513            let outcome = index.apply_ok(&Observation::new(ops));
10514            (CONTROL_PROJECTION_CLONES.with(std::cell::Cell::get), outcome)
10515        };
10516        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
10517
10518        // The cold lane first: against a table that records nothing, a batch of ordinary
10519        // entries has nothing to drop or prune, so it never projects (fdu-pro1).
10520        let (clones, _) = projection_clones(
10521            &mut index,
10522            vec![
10523                upsert("cold", EntryKind::Dir, file_attrs(0, 1)),
10524                upsert("cold/file.txt", EntryKind::File, file_attrs(1, 1)),
10525            ],
10526        );
10527        assert_eq!(clones, 0, "an empty table has nothing a structural batch can change");
10528
10529        let mut over_budget = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
10530        over_budget.push(b'\n');
10531        index.apply_ok(&Observation::new(vec![
10532            upsert("keep", EntryKind::Dir, file_attrs(0, 1)),
10533            upsert("keep/file.txt", EntryKind::File, file_attrs(3, 1)),
10534            upsert("vendor", EntryKind::Dir, file_attrs(0, 1)),
10535            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
10536            Op::ControlUpsert {
10537                path: PathBuf::from("vendor/.gitignore"),
10538                source: over_budget.clone(),
10539            },
10540        ]));
10541        let coverage = index.control_coverage();
10542
10543        // A warm revalidate's shape: the refused source re-read, the retained one re-read
10544        // unchanged, and ordinary entries beside them.
10545        let (clones, outcome) = projection_clones(
10546            &mut index,
10547            vec![
10548                upsert("keep/file.txt", EntryKind::File, file_attrs(4, 1)),
10549                Op::ControlUpsert {
10550                    path: PathBuf::from(".gitignore"),
10551                    source: b"*.log\n".to_vec(),
10552                },
10553                Op::ControlUpsert { path: PathBuf::from("vendor/.gitignore"), source: over_budget },
10554            ],
10555        );
10556        assert_eq!(clones, 0, "no control op changes anything");
10557        assert_eq!(index.control_coverage(), coverage);
10558        assert!(!outcome.commit.expect("the file's size changed").changes.iter().any(
10559            |change| matches!(
10560                change,
10561                EffectiveChange::ControlUpdated { .. }
10562                    | EffectiveChange::ControlRefusalUpdated { .. }
10563            )
10564        ));
10565
10566        // Removing the refused file is a change, so this batch projects.
10567        let (clones, _) = projection_clones(
10568            &mut index,
10569            vec![Op::ControlRemove { path: PathBuf::from("vendor/.gitignore") }],
10570        );
10571        assert_eq!(clones, 1);
10572        assert_eq!(index.controls().expect("observed").refused_len(), 0);
10573    }
10574
10575    /// A structural removal takes the refusals under it along, even when no rule is retained.
10576    #[test]
10577    fn removing_a_subtree_lifts_the_refusals_beneath_it() {
10578        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
10579        let mut line = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
10580        line.push(b'\n');
10581        index.apply_ok(&Observation::new(vec![
10582            upsert("vendor", EntryKind::Dir, file_attrs(0, 1)),
10583            upsert("vendor/.gitignore", EntryKind::File, file_attrs(16_386, 1)),
10584            Op::ControlUpsert { path: PathBuf::from("vendor/.gitignore"), source: line },
10585        ]));
10586        assert_eq!(index.controls().expect("observed").refused_len(), 1);
10587
10588        let outcome =
10589            index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("vendor") }]));
10590
10591        assert_eq!(index.controls().expect("observed").refused_len(), 0);
10592        assert!(outcome.commit.expect("commit").changes.iter().any(|change| matches!(
10593            change,
10594            EffectiveChange::ControlRefusalUpdated { current: None, .. }
10595        )));
10596    }
10597
10598    #[test]
10599    fn one_sweep_reports_one_as_of_time_for_everything_it_verified() {
10600        // Found by reviewing the PR #6 provenance work against the composable-CLI
10601        // merge. A revalidation sweep elides entries whose attributes did not change,
10602        // so within one pass some paths are named by a delta and some are not. Both
10603        // were verified at the same moment and must say so identically: if the
10604        // delta-touched entry dates itself to index construction while its untouched
10605        // sibling dates itself to the sweep, a consumer sorting rows by age is
10606        // comparing two different clocks and cannot tell.
10607        let mut index = Index::new("/root");
10608        index.set_applying_source(Source::Cached, 1_000);
10609        index.apply_ok(&Observation::new(vec![
10610            Op::Upsert { path: "a".into(), kind: EntryKind::Dir, attrs: file_attrs(0, 1) },
10611            Op::Upsert {
10612                path: "a/kept.txt".into(),
10613                kind: EntryKind::File,
10614                attrs: file_attrs(1, 1),
10615            },
10616            Op::Upsert {
10617                path: "a/changed.txt".into(),
10618                kind: EntryKind::File,
10619                attrs: file_attrs(2, 2),
10620            },
10621        ]));
10622
10623        // A sweep re-observes both: one is unchanged and elided, one is updated.
10624        index.set_applying_source(Source::Revalidated, 2_000);
10625        index.begin_reconcile(Path::new("")).expect("begin reconciliation");
10626        index.apply_ok(&Observation::new(vec![
10627            Op::Upsert {
10628                path: "a/kept.txt".into(),
10629                kind: EntryKind::File,
10630                attrs: file_attrs(1, 1),
10631            },
10632            Op::Upsert {
10633                path: "a/changed.txt".into(),
10634                kind: EntryKind::File,
10635                attrs: file_attrs(9, 2),
10636            },
10637        ]));
10638        index
10639            .finish_reconcile(
10640                Path::new(""),
10641                0,
10642                true,
10643                &[],
10644                &[],
10645                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10646            )
10647            .expect("finish reconciliation");
10648
10649        let kept = index.provenance(Path::new("a/kept.txt")).expect("present");
10650        let changed = index.provenance(Path::new("a/changed.txt")).expect("present");
10651        assert!(kept.is_verified() && changed.is_verified(), "the sweep covered both");
10652        assert_eq!(
10653            kept.observed_at_ns, changed.observed_at_ns,
10654            "one sweep, one as-of time: {kept:?} vs {changed:?}"
10655        );
10656    }
10657
10658    #[test]
10659    fn withdrawn_trust_beats_a_verification_interval() {
10660        // A verification interval records that a sweep once covered a path. If the
10661        // index has since withdrawn trust — an InvalidateSubtree marking it Stale, or
10662        // a sweep in progress marking it Reconciling — the interval must not promote
10663        // it, or provenance answers "partial, and verified" in one breath.
10664        let mut index = Index::new("/root");
10665        // The entry arrives the way a snapshot load delivers it: unverified.
10666        index.set_applying_source(Source::Cached, 1_000);
10667        index.apply_baseline_ok(&Observation::new(vec![
10668            Op::Upsert { path: PathBuf::from("a"), kind: EntryKind::Dir, attrs: Attrs::default() },
10669            Op::Upsert {
10670                path: PathBuf::from("a/file.txt"),
10671                kind: EntryKind::File,
10672                attrs: Attrs { size: 1, ..Attrs::default() },
10673            },
10674        ]));
10675        assert_eq!(
10676            index.provenance(Path::new("a/file.txt")).expect("present").source,
10677            Source::Cached,
10678            "nothing has checked it yet"
10679        );
10680        // A completed sweep then covers the whole tree.
10681        index
10682            .finish_reconcile(
10683                Path::new(""),
10684                0,
10685                true,
10686                &[],
10687                &[],
10688                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10689            )
10690            .expect("finish reconciliation");
10691        let path = Path::new("a/file.txt");
10692        assert_eq!(
10693            index.provenance(path).expect("present").source,
10694            Source::Revalidated,
10695            "a completed sweep covers this path"
10696        );
10697
10698        // Now withdraw trust over the subtree.
10699        index.mark_unfresh(Path::new("a"), Freshness::Stale);
10700        let provenance = index.provenance(path).expect("present");
10701        assert!(
10702            !provenance.is_verified(),
10703            "an invalidated path must not read as verified: {provenance:?}"
10704        );
10705        assert_eq!(
10706            provenance.status,
10707            Status::Complete,
10708            "withdrawing trust changes how far to believe the value, not how much of \
10709             the subtree it covers: the cached total still accounts for every entry \
10710             beneath this path, and reporting it as Partial would tell a consumer the \
10711             number is still being built when it is merely unverified"
10712        );
10713    }
10714
10715    #[test]
10716    fn verification_intervals_stay_bounded() {
10717        // Repeated scoped sweeps of sibling subtrees must not grow without bound;
10718        // dropping the oldest only ever under-claims trust.
10719        let mut index = Index::new("/root");
10720        for which in 0..(MAX_VERIFIED_INTERVALS * 2) {
10721            index
10722                .finish_reconcile(
10723                    &PathBuf::from(format!("dir-{which}")),
10724                    0,
10725                    true,
10726                    &[],
10727                    &[],
10728                    ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10729                )
10730                .expect("finish reconciliation");
10731        }
10732        assert!(
10733            index.verified.len() <= MAX_VERIFIED_INTERVALS,
10734            "interval list grew to {}",
10735            index.verified.len()
10736        );
10737    }
10738
10739    #[test]
10740    fn retained_walk_issues_are_the_same_first_paths_for_every_arrival_order() {
10741        let make = |order: Vec<usize>| {
10742            order
10743                .into_iter()
10744                .map(|number| {
10745                    crate::Error::io(
10746                        PathBuf::from(format!("/root/file-{number:02}")),
10747                        std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
10748                    )
10749                })
10750                .collect::<Vec<_>>()
10751        };
10752        let mut reverse_order: Vec<_> = (0..66).rev().collect();
10753        reverse_order.push(65);
10754        let mut shuffled_order: Vec<_> = (0..66).step_by(2).collect();
10755        shuffled_order.extend((0..66).skip(1).step_by(2));
10756        shuffled_order.push(65);
10757
10758        let mut reverse = Index::new("/root");
10759        let mut reverse_errors = make(reverse_order);
10760        reverse.record_walk_errors(&mut reverse_errors);
10761        let mut shuffled = Index::new("/root");
10762        let mut shuffled_errors = make(shuffled_order);
10763        shuffled.record_walk_errors(&mut shuffled_errors);
10764
10765        let reverse_paths: Vec<_> =
10766            reverse.issues().iter().map(|issue| issue.path.clone().expect("path")).collect();
10767        let shuffled_paths: Vec<_> =
10768            shuffled.issues().iter().map(|issue| issue.path.clone().expect("path")).collect();
10769        assert_eq!(reverse_paths, shuffled_paths);
10770        assert_eq!(reverse_paths.len(), MAX_RETAINED_ISSUES);
10771        assert_eq!(reverse_paths.first().map(PathBuf::as_path), Some(Path::new("file-00")));
10772        assert_eq!(reverse_paths.last().map(PathBuf::as_path), Some(Path::new("file-63")));
10773        assert_eq!(reverse.state().issues.omitted, 2);
10774        assert_eq!(shuffled.state().issues.omitted, 2);
10775        assert_eq!(reverse.issue_epochs.len(), reverse.issues.len());
10776        assert_eq!(shuffled.issue_epochs.len(), shuffled.issues.len());
10777    }
10778
10779    #[test]
10780    fn repeated_partial_root_pass_replaces_bounded_issues_and_omitted_count() {
10781        let root = Path::new("/root");
10782        let mut index = Index::new(root);
10783        let run = |index: &mut Index, range: std::ops::Range<usize>| {
10784            let errors: Vec<_> = range
10785                .clone()
10786                .map(|number| {
10787                    crate::Error::io(
10788                        root.join(format!("file-{number:02}")),
10789                        std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
10790                    )
10791                })
10792                .collect();
10793            let failed: Vec<_> =
10794                range.map(|number| PathBuf::from(format!("file-{number:02}"))).collect();
10795            let (started, _) = index.begin_reconcile(Path::new("")).expect("begin");
10796            index
10797                .finish_reconcile(
10798                    Path::new(""),
10799                    started,
10800                    false,
10801                    &[],
10802                    &failed,
10803                    ReconcileErrors { errors: &errors, terminal: None, disproves_old: true },
10804                )
10805                .expect("finish");
10806        };
10807
10808        run(&mut index, 0..66);
10809        assert_eq!(index.state().issues.omitted, 2);
10810        run(&mut index, 10..76);
10811
10812        assert_eq!(index.state().issues.omitted, 2, "a retry replaces the old omission count");
10813        assert_eq!(index.omitted_issue_epochs.len(), 1, "sequential failures use one bucket");
10814        assert_eq!(index.issues().len(), crate::MAX_RETAINED_ISSUES);
10815        assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("file-10")));
10816        assert_eq!(index.issues()[63].path.as_deref(), Some(Path::new("file-73")));
10817    }
10818
10819    #[test]
10820    fn partial_pass_preserves_an_issue_published_after_it_began() {
10821        let root = Path::new("/root");
10822        let mut index = Index::new(root);
10823        let (started, _) = index.begin_reconcile(Path::new("")).expect("begin");
10824        index.mark_unfresh(Path::new("concurrent"), Freshness::Stale);
10825        index.retain_issue(Issue::from_error_under(
10826            root,
10827            &crate::Error::io(
10828                root.join("concurrent"),
10829                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "concurrent"),
10830            ),
10831        ));
10832        let pass_error = crate::Error::io(
10833            root.join("pass"),
10834            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "pass"),
10835        );
10836
10837        index
10838            .finish_reconcile(
10839                Path::new(""),
10840                started,
10841                false,
10842                &[],
10843                &[PathBuf::from("pass")],
10844                ReconcileErrors { errors: &[pass_error], terminal: None, disproves_old: true },
10845            )
10846            .expect("finish");
10847
10848        let paths: Vec<_> =
10849            index.issues().iter().filter_map(|issue| issue.path.as_deref()).collect();
10850        assert_eq!(paths, [Path::new("concurrent"), Path::new("pass")]);
10851    }
10852
10853    #[test]
10854    fn older_root_closer_preserves_newer_pass_omissions_and_partial_coverage() {
10855        let root = Path::new("/root");
10856        let mut index = Index::new(root);
10857        for number in 0..66 {
10858            let path = PathBuf::from(format!("a-{number:02}"));
10859            index.retain_issue(Issue::observation_gap(
10860                &path,
10861                crate::InvalidateReason::WatchOverflow,
10862            ));
10863        }
10864        index.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
10865        assert_eq!(index.state.issues.omitted, 2);
10866
10867        let (older, _) = index.begin_reconcile(Path::new("")).expect("begin older pass");
10868        let (newer, _) = index.begin_reconcile(Path::new("")).expect("begin newer pass");
10869        let newer_errors = ["z-one", "z-two"].map(|path| {
10870            crate::Error::io(
10871                root.join(path),
10872                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
10873            )
10874        });
10875        index
10876            .finish_reconcile(
10877                Path::new(""),
10878                newer,
10879                false,
10880                &[],
10881                &[PathBuf::from("z-one"), PathBuf::from("z-two")],
10882                ReconcileErrors { errors: &newer_errors, terminal: None, disproves_old: true },
10883            )
10884            .expect("finish newer pass");
10885        assert_eq!(index.state.issues.omitted, 2);
10886
10887        index
10888            .finish_reconcile(
10889                Path::new(""),
10890                older,
10891                true,
10892                &[],
10893                &[],
10894                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10895            )
10896            .expect("finish older pass");
10897
10898        assert_eq!(index.state.issues.omitted, 2, "the older closer cannot erase newer omissions");
10899        assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10900    }
10901
10902    #[test]
10903    fn an_unvisited_aborted_scope_does_not_disprove_its_old_issue() {
10904        let root = Path::new("/root");
10905        let mut index = Index::new(root);
10906        index.retain_issue(Issue::from_error_under(
10907            root,
10908            &crate::Error::io(
10909                root.join("later/blocked"),
10910                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "old failure"),
10911            ),
10912        ));
10913        let (started, _) = index.begin_reconcile(Path::new("later")).expect("begin later scope");
10914
10915        index
10916            .finish_reconcile(
10917                Path::new("later"),
10918                started,
10919                false,
10920                &[],
10921                &[],
10922                ReconcileErrors { errors: &[], terminal: None, disproves_old: false },
10923            )
10924            .expect("close skipped scope");
10925
10926        assert_eq!(index.issues().len(), 1);
10927        assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("later/blocked")));
10928    }
10929    #[test]
10930    fn older_pass_cannot_publish_errors_after_newer_clean_verification() {
10931        let root = Path::new("/root");
10932        let mut index = Index::new(root);
10933        let (older, _) = index.begin_reconcile(Path::new("")).expect("begin older pass");
10934        let (newer, _) = index.begin_reconcile(Path::new("")).expect("begin newer pass");
10935        index
10936            .finish_reconcile(
10937                Path::new(""),
10938                newer,
10939                true,
10940                &[],
10941                &[],
10942                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10943            )
10944            .expect("finish newer pass");
10945        let stale_error = crate::Error::io(
10946            root.join("stale"),
10947            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "older failure"),
10948        );
10949
10950        index
10951            .finish_reconcile(
10952                Path::new(""),
10953                older,
10954                false,
10955                &[],
10956                &[PathBuf::from("stale")],
10957                ReconcileErrors { errors: &[stale_error], terminal: None, disproves_old: true },
10958            )
10959            .expect("finish superseded older pass");
10960
10961        assert!(index.issues().is_empty());
10962        assert_eq!(index.state.coverage, Coverage::Complete);
10963        assert_eq!(index.state.freshness, Freshness::Fresh);
10964    }
10965
10966    #[test]
10967    fn newer_unchanged_verification_refuses_an_older_conditional_fact() {
10968        let mut index = Index::new("/root");
10969        index
10970            .apply(&Observation::new(vec![Op::Upsert {
10971                path: PathBuf::from("same"),
10972                kind: EntryKind::File,
10973                attrs: file_attrs(1, 1),
10974            }]))
10975            .expect("fixture");
10976        let handle = IndexHandle::new(index);
10977        let baseline = handle.expectation(Path::new("same")).expect("baseline");
10978        let (older, _) = handle.begin_reconcile(Path::new("")).expect("begin older pass");
10979        let (newer, _) = handle.begin_reconcile(Path::new("")).expect("begin newer pass");
10980        handle
10981            .finish_reconcile(
10982                Path::new(""),
10983                newer,
10984                true,
10985                &[],
10986                &[],
10987                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10988            )
10989            .expect("finish unchanged newer pass");
10990
10991        let stale = handle
10992            .apply_reconcile(
10993                older,
10994                &Observation::from_ops(vec![ObservationOp::if_state(
10995                    Op::Remove { path: PathBuf::from("same") },
10996                    baseline,
10997                )]),
10998            )
10999            .expect("arbitrate older fact");
11000
11001        assert_eq!(stale.stats.stale, 1);
11002        assert!(stale.commit.is_none());
11003        assert!(handle.attrs(Path::new("same")).expect("attrs").is_some());
11004    }
11005
11006    #[test]
11007    fn newer_disjoint_verification_does_not_refuse_an_older_fact() {
11008        let mut index = Index::new("/root");
11009        index
11010            .apply(&Observation::new(vec![
11011                Op::Upsert {
11012                    path: PathBuf::from("a"),
11013                    kind: EntryKind::File,
11014                    attrs: file_attrs(1, 1),
11015                },
11016                Op::Upsert {
11017                    path: PathBuf::from("b"),
11018                    kind: EntryKind::File,
11019                    attrs: file_attrs(1, 1),
11020                },
11021            ]))
11022            .expect("fixture");
11023        let handle = IndexHandle::new(index);
11024        let baseline = handle.expectation(Path::new("a")).expect("baseline");
11025        let (older, _) = handle.begin_reconcile(Path::new("a")).expect("begin a");
11026        let (newer, _) = handle.begin_reconcile(Path::new("b")).expect("begin b");
11027        handle
11028            .finish_reconcile(
11029                Path::new("b"),
11030                newer,
11031                true,
11032                &[],
11033                &[],
11034                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
11035            )
11036            .expect("finish b");
11037
11038        let applied = handle
11039            .apply_reconcile(
11040                older,
11041                &Observation::from_ops(vec![ObservationOp::if_state(
11042                    Op::Remove { path: PathBuf::from("a") },
11043                    baseline,
11044                )]),
11045            )
11046            .expect("apply disjoint a fact");
11047
11048        assert_eq!(applied.stats.removed, 1);
11049        assert_eq!(applied.stats.stale, 0);
11050        assert!(handle.attrs(Path::new("a")).expect("attrs").is_none());
11051    }
11052    #[test]
11053    fn newer_child_verification_preserves_older_sibling_failure() {
11054        let root = Path::new("/root");
11055        let mut index = Index::new(root);
11056        index.apply_ok(&Observation::new(
11057            ["a", "a/old", "b", "b/blocked", "healthy"]
11058                .map(|path| Op::Upsert {
11059                    path: PathBuf::from(path),
11060                    kind: EntryKind::Dir,
11061                    attrs: Attrs::default(),
11062                })
11063                .to_vec(),
11064        ));
11065        index.set_initial_scan_freshness(&[]);
11066        let (older, _) = index.begin_reconcile(Path::new("")).expect("older root");
11067        let (newer, _) = index.begin_reconcile(Path::new("a")).expect("newer child");
11068        index
11069            .finish_reconcile(
11070                Path::new("a"),
11071                newer,
11072                true,
11073                &[],
11074                &[],
11075                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
11076            )
11077            .expect("verify a");
11078        let errors = ["a/old", "b/blocked"].map(|path| {
11079            crate::Error::io(
11080                root.join(path),
11081                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed read"),
11082            )
11083        });
11084        index
11085            .finish_reconcile(
11086                Path::new(""),
11087                older,
11088                false,
11089                &[],
11090                &[PathBuf::from("a/old"), PathBuf::from("b/blocked")],
11091                ReconcileErrors { errors: &errors, terminal: None, disproves_old: true },
11092            )
11093            .expect("finish older root");
11094        for path in ["", "a", "a/old", "b", "healthy"] {
11095            assert_eq!(index.directory_complete(Path::new(path)), Some(true), "{path}");
11096        }
11097        assert_eq!(index.directory_complete(Path::new("b/blocked")), Some(false));
11098        assert_eq!(index.issues().len(), 1);
11099        assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("b/blocked")));
11100        assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
11101        assert_eq!(index.freshness_at(Path::new("a")), Freshness::Fresh);
11102        assert_eq!(index.freshness_at(Path::new("b/blocked")), Freshness::Partial);
11103    }
11104    #[test]
11105    fn failure_published_after_a_newer_pass_began_keeps_its_issue_with_its_mark() {
11106        // A root pass begins, a child pass begins under it, and the root pass fails on
11107        // that child and closes first. Its `Partial` mark is minted after the child pass
11108        // began, so the child's clean finish leaves the mark in place; the issue that
11109        // explains the mark must follow the same rule, or the root reports a partial with
11110        // no explanation until another root pass runs.
11111        let root = Path::new("/root");
11112        let mut index = Index::new(root);
11113        index.apply_ok(&Observation::new(
11114            ["x", "x/deep", "healthy"]
11115                .map(|path| Op::Upsert {
11116                    path: PathBuf::from(path),
11117                    kind: EntryKind::Dir,
11118                    attrs: Attrs::default(),
11119                })
11120                .to_vec(),
11121        ));
11122        index.set_initial_scan_freshness(&[]);
11123        let (older_root, _) = index.begin_reconcile(Path::new("")).expect("older root");
11124        let (newer_child, _) = index.begin_reconcile(Path::new("x")).expect("newer child");
11125        let error = crate::Error::io(
11126            root.join("x"),
11127            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed read"),
11128        );
11129        index
11130            .finish_reconcile(
11131                Path::new(""),
11132                older_root,
11133                false,
11134                &[],
11135                &[PathBuf::from("x")],
11136                ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
11137            )
11138            .expect("older root fails on x");
11139        assert_eq!(index.freshness_at(Path::new("x")), Freshness::Partial);
11140        assert_eq!(index.issues().len(), 1);
11141
11142        index
11143            .finish_reconcile(
11144                Path::new("x"),
11145                newer_child,
11146                true,
11147                &[],
11148                &[],
11149                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
11150            )
11151            .expect("newer child verifies clean");
11152
11153        let explained = !index.issues().is_empty();
11154        let partial = index.freshness_at(Path::new("x")) == Freshness::Partial;
11155        assert_eq!(
11156            partial,
11157            explained,
11158            "a surviving partial mark and its issue must be kept or dropped together: \
11159             partial={partial}, issues={:?}",
11160            index.issues()
11161        );
11162        assert!(partial, "the mark minted after the child pass began is the newer claim");
11163        assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("x")));
11164        assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
11165        assert_eq!(index.state.freshness, Freshness::Partial);
11166        let request = crate::query::Request::new(
11167            crate::query::Basis::held_by(&index),
11168            crate::query::Query::default(),
11169            std::time::UNIX_EPOCH,
11170        );
11171        let status = crate::query::TreeStatus::of(&index, &request);
11172        assert!(!status.complete);
11173        assert_eq!(status.coverage, Coverage::Partial(CoverageReason::Inaccessible));
11174        assert_eq!(status.errors.len(), 1, "an incomplete status names its cause");
11175    }
11176    #[test]
11177    fn cold_scan_failure_does_not_verify_unknown_descendants_or_unscoped_work() {
11178        let mut index = Index::new("/root");
11179        index.apply_ok(&Observation::new(
11180            ["blocked", "blocked/nested", "healthy"]
11181                .map(|path| Op::Upsert {
11182                    path: PathBuf::from(path),
11183                    kind: EntryKind::Dir,
11184                    attrs: Attrs::default(),
11185                })
11186                .to_vec(),
11187        ));
11188        let error = crate::Error::io(
11189            PathBuf::from("/root/blocked"),
11190            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed listing"),
11191        );
11192        index.set_initial_scan_freshness(&[error]);
11193        assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
11194        assert_eq!(index.directory_complete(Path::new("")), Some(true));
11195        assert_eq!(index.freshness_at(Path::new("")), Freshness::Partial);
11196        for path in ["blocked", "blocked/nested"] {
11197            assert_eq!(index.directory_complete(Path::new(path)), Some(false), "{path}");
11198            assert_eq!(index.freshness_at(Path::new(path)), Freshness::Partial, "{path}");
11199        }
11200        index.set_initial_scan_freshness(&[crate::Error::Snapshot("unscoped failure".into())]);
11201        assert_eq!(index.directory_complete(Path::new("healthy")), Some(false));
11202        assert_eq!(index.freshness_at(Path::new("healthy")), Freshness::Partial);
11203    }
11204
11205    #[test]
11206    fn unscoped_failure_publishes_listing_withdrawal_when_root_state_is_unchanged() {
11207        let mut index = Index::new("/root");
11208        index.apply_ok(&Observation::new(vec![Op::Upsert {
11209            path: PathBuf::from("healthy"),
11210            kind: EntryKind::Dir,
11211            attrs: Attrs::default(),
11212        }]));
11213        index.set_initial_scan_freshness(&[]);
11214        index.mark_unfresh(Path::new("elsewhere"), Freshness::Partial);
11215        index.state.freshness = Freshness::Partial;
11216        index.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
11217        let error = crate::Error::Snapshot("unscoped failure".into());
11218        index.retain_issue(Issue::from_error_under(&index.root_path, &error));
11219        let progress = index.state.progress;
11220        let (epoch, _) = index.begin_reconcile(Path::new("")).expect("begin partial root");
11221        let before = index.state;
11222        let clock = index.clock;
11223        let finished = index
11224            .finish_reconcile(
11225                Path::new(""),
11226                epoch,
11227                false,
11228                &[],
11229                &[],
11230                ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
11231            )
11232            .expect("finish failure");
11233        assert_eq!(index.state, before, "aggregate root state remains identical");
11234        assert_eq!(index.state.progress, progress, "discovery progress is cumulative");
11235        assert_eq!(index.directory_complete(Path::new("healthy")), Some(false));
11236        let commit = finished.commit.expect("withdrawal must publish even without another effect");
11237        assert!(index.clock > clock);
11238        assert_eq!(commit.clock, index.clock);
11239        assert!(
11240            commit
11241                .state
11242                .iter()
11243                .all(|effect| matches!(effect, StateTransition::DirectoryIncomplete { .. }))
11244        );
11245        assert!(
11246            commit
11247                .state
11248                .contains(&StateTransition::DirectoryIncomplete { path: PathBuf::from("healthy") })
11249        );
11250        assert!(commit.impact.dirty_paths.contains(&PathBuf::from("healthy")));
11251    }
11252
11253    #[test]
11254    fn omitted_failed_entry_withdraws_parent_listing_without_tainting_siblings() {
11255        let mut index = Index::new("/root");
11256        index.apply_ok(&Observation::new(vec![Op::Upsert {
11257            path: PathBuf::from("healthy"),
11258            kind: EntryKind::Dir,
11259            attrs: Attrs::default(),
11260        }]));
11261        let error = crate::Error::io(
11262            PathBuf::from("/root/missing"),
11263            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "metadata failed"),
11264        );
11265        index.set_initial_scan_freshness(&[error]);
11266        assert_eq!(index.directory_complete(Path::new("")), Some(false));
11267        assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
11268        index.set_initial_scan_freshness(&[]);
11269        let (epoch, _) = index.begin_reconcile(Path::new("")).expect("begin root");
11270        let error = crate::Error::io(
11271            PathBuf::from("/root/missing"),
11272            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "metadata failed"),
11273        );
11274        index
11275            .finish_reconcile(
11276                Path::new(""),
11277                epoch,
11278                false,
11279                &[],
11280                &[PathBuf::from("missing")],
11281                ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
11282            )
11283            .expect("partial root");
11284        assert_eq!(index.directory_complete(Path::new("")), Some(false));
11285        assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
11286    }
11287
11288    #[test]
11289    fn complete_older_root_does_not_verify_a_newer_failed_child() {
11290        let root = Path::new("/root");
11291        let mut index = Index::new(root);
11292        let (older, _) = index.begin_reconcile(Path::new("")).expect("older root");
11293        let (newer, _) = index.begin_reconcile(Path::new("child")).expect("newer child");
11294        let error = crate::Error::io(
11295            root.join("child"),
11296            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "new failure"),
11297        );
11298        index
11299            .finish_reconcile(
11300                Path::new("child"),
11301                newer,
11302                false,
11303                &[],
11304                &[PathBuf::from("child")],
11305                ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
11306            )
11307            .expect("failed child");
11308        let finish = index
11309            .finish_reconcile(
11310                Path::new(""),
11311                older,
11312                true,
11313                &[],
11314                &[],
11315                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
11316            )
11317            .expect("complete older walk");
11318        assert_eq!(index.issues().len(), 1);
11319        assert_eq!(index.freshness_at(Path::new("child")), Freshness::Partial);
11320        assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
11321        assert!(!finish.commit.iter().flat_map(|commit| commit.state.iter()).any(|state| {
11322            matches!(state, StateTransition::Verified { path } if path.as_os_str().is_empty())
11323        }));
11324    }
11325
11326    #[test]
11327    fn reconciliation_scope_budget_preserves_issues_and_newer_facts() {
11328        let mut index = Index::new("/root");
11329        index.apply_ok(&Observation::new(vec![Op::Upsert {
11330            path: PathBuf::from("kept"),
11331            kind: EntryKind::File,
11332            attrs: file_attrs(1, 1),
11333        }]));
11334        index.retain_issue(Issue::provider_failure(
11335            Some(Path::new("unvisited")),
11336            "earlier failure".into(),
11337        ));
11338        let (older, _) = index.begin_reconcile(Path::new("")).expect("older pass");
11339        let budget = index.active_reconciles[&older].scope_budget;
11340        assert_eq!(budget, 2, "root and kept file define the evidence budget");
11341        for number in 0..100 {
11342            let path = PathBuf::from(format!("missing-{number}"));
11343            let (newer, _) = index.begin_reconcile(&path).expect("newer pass");
11344            index
11345                .finish_reconcile(
11346                    &path,
11347                    newer,
11348                    true,
11349                    &[],
11350                    &[],
11351                    ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
11352                )
11353                .expect("newer verification");
11354            if let ReconcileEvidence::Scopes(scopes) = &index.active_reconciles[&older].evidence {
11355                assert!(scopes.len() <= budget);
11356            }
11357        }
11358        assert!(matches!(index.active_reconciles[&older].evidence, ReconcileEvidence::Retry));
11359        index.apply_ok(&Observation::new(vec![Op::Upsert {
11360            path: PathBuf::from("kept"),
11361            kind: EntryKind::File,
11362            attrs: file_attrs(9, 2),
11363        }]));
11364        let finished = index
11365            .finish_reconcile(
11366                Path::new(""),
11367                older,
11368                true,
11369                &[],
11370                &[],
11371                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
11372            )
11373            .expect("close interrupted pass");
11374        assert!(finished.retry);
11375        assert!(finished.commit.is_some());
11376        assert_eq!(index.total_scalars().bytes, 9);
11377        assert!(
11378            index
11379                .issues()
11380                .iter()
11381                .any(|issue| issue.path.as_deref() == Some(Path::new("unvisited")))
11382        );
11383        assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
11384        assert!(index.active_reconciles.is_empty(), "closed passes retain no shadow history");
11385    }
11386
11387    // A whole-tree total no u64 can hold is refused before any mutation (fdu-sqyk).
11388
11389    fn sized(size: u64) -> Attrs {
11390        Attrs { size, allocated: size, mtime_ns: 1, ctime_ns: 1, inode: size ^ 7, dev: 1 }
11391    }
11392
11393    fn assert_unrepresentable(error: &crate::Error, path: &str, counter: &str) {
11394        match error {
11395            crate::Error::UnrepresentableTotal { path: at, counter: which } => {
11396                assert_eq!(at, Path::new(path));
11397                assert_eq!(*which, counter);
11398            }
11399            other => panic!("expected an unrepresentable-total refusal, got {other:?}"),
11400        }
11401    }
11402
11403    /// Everything a refused batch must leave alone.
11404    fn observable_state(index: &Index) -> (u64, Clock, RollUp, Vec<PathBuf>, usize) {
11405        let mut paths: Vec<PathBuf> = Vec::new();
11406        let mut pending = vec![EntryId::ROOT];
11407        while let Some(id) = pending.pop() {
11408            paths.push(index.path_of(id).expect("live entry"));
11409            if index.entry(id).kind.is_dir() {
11410                pending.extend(index.child_ids(id));
11411            }
11412        }
11413        paths.sort();
11414        (index.len(), index.clock(), index.total(), paths, index.journal.len())
11415    }
11416
11417    #[test]
11418    fn a_batch_whose_byte_total_would_exceed_u64_is_refused_before_any_mutation() {
11419        let mut index = Index::new("/root");
11420        let before = observable_state(&index);
11421        let error = index
11422            .apply(&Observation::new(vec![
11423                upsert("a", EntryKind::File, sized(u64::MAX)),
11424                upsert("b", EntryKind::File, sized(1)),
11425            ]))
11426            .expect_err("two files summing past u64::MAX cannot be represented");
11427        assert_unrepresentable(&error, "b", "bytes");
11428        assert_eq!(observable_state(&index), before, "a refused batch changes nothing");
11429
11430        // The first file alone fits exactly; the next byte does not, and the directory that
11431        // would fit on its own is refused with it, because the batch is one commit.
11432        index.apply_ok(&Observation::new(vec![upsert("a", EntryKind::File, sized(u64::MAX))]));
11433        let before = observable_state(&index);
11434        assert_eq!(before.2.bytes, u64::MAX);
11435        let error = index
11436            .apply(&Observation::new(vec![
11437                upsert("dir", EntryKind::Dir, Attrs::default()),
11438                upsert("dir/b", EntryKind::File, sized(1)),
11439            ]))
11440            .expect_err("one more byte is unrepresentable");
11441        assert_unrepresentable(&error, "dir/b", "bytes");
11442        assert_eq!(observable_state(&index), before);
11443        assert!(index.lookup(Path::new("dir")).is_none(), "the batch is fault-atomic");
11444
11445        // Allocated bytes are checked as their own total.
11446        let error = index
11447            .apply(&Observation::new(vec![upsert(
11448                "c",
11449                EntryKind::File,
11450                Attrs { size: 0, allocated: 1, ..sized(0) },
11451            )]))
11452            .expect_err("allocated bytes overflow on their own");
11453        assert_unrepresentable(&error, "c", "allocated bytes");
11454        assert_eq!(observable_state(&index), before);
11455    }
11456
11457    #[test]
11458    fn exact_fit_replacement_removal_and_kind_change_batches_are_accepted() {
11459        // Replacing the largest file and adding another lands exactly on u64::MAX.
11460        let mut index = Index::new("/root");
11461        index.apply_ok(&Observation::new(vec![upsert("a", EntryKind::File, sized(u64::MAX))]));
11462        index.apply_ok(&Observation::new(vec![
11463            upsert("a", EntryKind::File, sized(1)),
11464            upsert("b", EntryKind::File, sized(u64::MAX - 1)),
11465        ]));
11466        assert_eq!(index.total().bytes, u64::MAX);
11467        assert_eq!(index.total().files, 2);
11468
11469        // A removal first makes room for an insertion in the same batch; the batch is
11470        // applied in order, so the reverse order is refused: the index would have to hold
11471        // u64::MAX + 5 between the two operations.
11472        let before = observable_state(&index);
11473        let error = index
11474            .apply(&Observation::new(vec![
11475                upsert("c", EntryKind::File, sized(5)),
11476                Op::Remove { path: PathBuf::from("b") },
11477            ]))
11478            .expect_err("an insertion before the removal that makes room for it");
11479        assert_unrepresentable(&error, "c", "bytes");
11480        assert_eq!(observable_state(&index), before);
11481        index.apply_ok(&Observation::new(vec![
11482            Op::Remove { path: PathBuf::from("b") },
11483            upsert("c", EntryKind::File, sized(u64::MAX - 1)),
11484        ]));
11485        assert_eq!(index.total().bytes, u64::MAX);
11486
11487        // A kind change drops the subtree it replaces, and that room counts.
11488        let mut index = Index::new("/root");
11489        index.apply_ok(&Observation::new(vec![
11490            upsert("d", EntryKind::Dir, Attrs::default()),
11491            upsert("d/f", EntryKind::File, sized(u64::MAX)),
11492        ]));
11493        index.apply_ok(&Observation::new(vec![
11494            upsert("d", EntryKind::File, sized(5)),
11495            upsert("e", EntryKind::File, sized(u64::MAX - 5)),
11496        ]));
11497        assert_eq!(index.total().bytes, u64::MAX);
11498        assert_eq!((index.total().files, index.total().dirs), (2, 0));
11499
11500        // A later operation on the same path supersedes an earlier one in the batch.
11501        let mut index = Index::new("/root");
11502        index.apply_ok(&Observation::new(vec![
11503            upsert("x", EntryKind::File, sized(u64::MAX)),
11504            upsert("x", EntryKind::File, sized(1)),
11505            upsert("y", EntryKind::File, sized(u64::MAX - 1)),
11506        ]));
11507        assert_eq!(index.total().bytes, u64::MAX);
11508
11509        // Removing a subtree that an earlier operation in the batch grew.
11510        let mut index = Index::new("/root");
11511        index.apply_ok(&Observation::new(vec![
11512            upsert("d", EntryKind::Dir, Attrs::default()),
11513            upsert("d/f", EntryKind::File, sized(u64::MAX - 10)),
11514        ]));
11515        index.apply_ok(&Observation::new(vec![
11516            upsert("d/g", EntryKind::File, sized(10)),
11517            Op::Remove { path: PathBuf::from("d") },
11518            upsert("z", EntryKind::File, sized(u64::MAX)),
11519        ]));
11520        assert_eq!(index.total().bytes, u64::MAX);
11521        assert_eq!((index.total().files, index.total().dirs), (1, 0));
11522    }
11523
11524    #[test]
11525    fn a_stale_conditional_upsert_does_not_count_toward_the_projected_total() {
11526        let mut index = Index::new("/root");
11527        index.apply_ok(&Observation::new(vec![upsert("a", EntryKind::File, sized(5))]));
11528        let stale = index.expectation(Path::new("b"));
11529        index.apply_ok(&Observation::new(vec![upsert("b", EntryKind::File, sized(1))]));
11530        let outcome = index.apply_ok(&Observation::from_ops(vec![
11531            ObservationOp::if_state(upsert("b", EntryKind::File, sized(u64::MAX)), stale),
11532            ObservationOp::unconditional(upsert("c", EntryKind::File, sized(u64::MAX - 6))),
11533        ]));
11534        assert_eq!(outcome.stats.stale, 1);
11535        assert_eq!(index.total().bytes, u64::MAX);
11536    }
11537
11538    #[test]
11539    fn the_baseline_and_scanner_lanes_refuse_unrepresentable_totals() {
11540        let mut index = Index::new("/root");
11541        let before = observable_state(&index);
11542        let error = index
11543            .apply_baseline(&Observation::new(vec![
11544                upsert("a", EntryKind::File, sized(u64::MAX)),
11545                upsert("b", EntryKind::File, sized(1)),
11546            ]))
11547            .expect_err("the baseline lane refuses too");
11548        assert_unrepresentable(&error, "b", "bytes");
11549        assert_eq!(observable_state(&index), before);
11550
11551        let error = index
11552            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
11553                upsert("dir", EntryKind::Dir, Attrs::default()),
11554                upsert("dir/a", EntryKind::File, sized(u64::MAX)),
11555                upsert("dir/b", EntryKind::File, sized(1)),
11556            ]))
11557            .expect_err("the scanner lane refuses too");
11558        assert_unrepresentable(&error, "dir/b", "bytes");
11559        assert_eq!(observable_state(&index), before);
11560
11561        // The scanner lane's exact projection accepts what fits.
11562        index
11563            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
11564                upsert("dir", EntryKind::Dir, Attrs::default()),
11565                upsert("dir/a", EntryKind::File, sized(u64::MAX - 1)),
11566                upsert("dir/b", EntryKind::File, sized(1)),
11567            ]))
11568            .expect("an exact fit");
11569        assert_eq!(index.total().bytes, u64::MAX);
11570    }
11571
11572    // Analysis candidates are handed out in bounded batches over one resumable walk
11573    // (fdu-xjfk), in the order a whole walk gives them.
11574
11575    fn tree_for_analysis() -> Index {
11576        let mut index = Index::new("/root");
11577        let mut ops = vec![upsert("z.txt", EntryKind::File, file_attrs(3, 1))];
11578        for directory in ["a", "b", "c"] {
11579            ops.push(upsert(directory, EntryKind::Dir, Attrs::default()));
11580            ops.push(upsert(&format!("{directory}/deep"), EntryKind::Dir, Attrs::default()));
11581            for file in 0..4 {
11582                ops.push(upsert(
11583                    &format!("{directory}/f{file}.rs"),
11584                    EntryKind::File,
11585                    file_attrs(10 + file, 1),
11586                ));
11587            }
11588            ops.push(upsert(&format!("{directory}/deep/d.md"), EntryKind::File, file_attrs(7, 2)));
11589            ops.push(upsert(&format!("{directory}/link"), EntryKind::Symlink, Attrs::default()));
11590        }
11591        index.apply_ok(&Observation::new(ops));
11592        index
11593    }
11594
11595    #[test]
11596    fn analysis_candidates_come_in_bounded_batches_in_walk_order() {
11597        let index = tree_for_analysis();
11598        let request = crate::content::AnalysisRequest {
11599            profile: AnalysisSet::NONE.with_lines(),
11600            ..crate::content::AnalysisRequest::default()
11601        };
11602        let whole: Vec<PathBuf> = index
11603            .pending_analysis_candidates(request)
11604            .into_iter()
11605            .map(|candidate| candidate.relative_path)
11606            .collect();
11607        assert_eq!(whole.len(), 16, "one root file and five files under each directory");
11608        assert_eq!(index.count_pending_analysis_candidates(request), 16);
11609
11610        for limit in [1, 3, 5, 16, 17, usize::MAX] {
11611            let mut walk = AnalysisWalk::start();
11612            let mut collected = Vec::new();
11613            let mut batch_count = 0;
11614            loop {
11615                let batch = index.next_analysis_candidates(request, &mut walk, limit);
11616                if batch.is_empty() {
11617                    break;
11618                }
11619                assert!(batch.len() <= limit, "a batch is bounded by its limit");
11620                batch_count += 1;
11621                collected.extend(batch.into_iter().map(|candidate| candidate.relative_path));
11622            }
11623            assert_eq!(
11624                collected, whole,
11625                "batches of {limit} walk the same files in the same order"
11626            );
11627            assert_eq!(batch_count, whole.len().div_ceil(limit.min(whole.len())));
11628            assert!(
11629                index.next_analysis_candidates(request, &mut walk, limit).is_empty(),
11630                "a finished walk stays finished"
11631            );
11632        }
11633    }
11634
11635    /// A directory of more than two batches is walked once, in order: each batch resumes
11636    /// the listing where the last one stopped rather than stepping over what it already
11637    /// walked (R164-6), whether the directory keeps its children sorted, as a cold scan
11638    /// builds it, or in a map, as an applied batch leaves it.
11639    #[test]
11640    fn a_long_listing_is_resumed_where_the_last_batch_stopped() {
11641        let names: Vec<String> = (0..11).map(|file| format!("f{file:02}.rs")).collect();
11642        let request = crate::content::AnalysisRequest {
11643            profile: AnalysisSet::NONE.with_lines(),
11644            ..crate::content::AnalysisRequest::default()
11645        };
11646        let sorted = {
11647            let mut builder = DetachedIndexBuilder::new(
11648                "/root",
11649                ScanScope::default(),
11650                crate::classify::TypeRegistry::compiled_shared(),
11651            );
11652            let mut listing = crate::scan::DetachedDirectory {
11653                path: PathBuf::new(),
11654                children: (0_u32..)
11655                    .zip(&names)
11656                    .map(|(position, name)| crate::scan::DetachedChild {
11657                        name: OsString::from(name),
11658                        kind: EntryKind::File,
11659                        attrs: file_attrs(10, 1),
11660                        position,
11661                    })
11662                    .collect(),
11663                control: None,
11664            };
11665            builder.push_directory(&mut listing).expect("listing");
11666            builder.finish()
11667        };
11668        let mut mapped = Index::new("/root");
11669        mapped.apply_ok(&Observation::new(
11670            names.iter().map(|name| upsert(name, EntryKind::File, file_attrs(10, 1))).collect(),
11671        ));
11672        for (storage, index) in [("sorted", &sorted), ("mapped", &mapped)] {
11673            ANALYSIS_CHILD_STEPS.with(|steps| steps.set(0));
11674            let mut walk = AnalysisWalk::start();
11675            let mut walked = Vec::new();
11676            let mut batches = 0;
11677            loop {
11678                let batch = index.next_analysis_candidates(request, &mut walk, 3);
11679                if batch.is_empty() {
11680                    break;
11681                }
11682                batches += 1;
11683                walked.extend(batch.into_iter().map(|candidate| candidate.relative_path));
11684            }
11685            assert_eq!(batches, 4, "{storage}: eleven files in batches of three");
11686            assert_eq!(
11687                walked,
11688                names.iter().map(PathBuf::from).collect::<Vec<_>>(),
11689                "{storage}: every file once, in listing order"
11690            );
11691            assert_eq!(
11692                ANALYSIS_CHILD_STEPS.with(std::cell::Cell::get),
11693                11,
11694                "{storage}: each child is stepped over once across all four batches"
11695            );
11696        }
11697    }
11698
11699    #[test]
11700    fn batches_skip_what_the_content_tier_already_holds() {
11701        let root = tempfile::tempdir().expect("root");
11702        for name in ["one.rs", "two.rs", "three.rs"] {
11703            std::fs::write(root.path().join(name), b"fn main() {}\n").expect("file");
11704        }
11705        let (mut index, _) =
11706            crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
11707        let request =
11708            crate::content::AnalysisRequest { profile: AnalysisSet::NONE.with_lines(), workers: 1 };
11709        assert_eq!(index.count_pending_analysis_candidates(request), 3);
11710        crate::content::analyze_index(&mut index, request);
11711        assert_eq!(index.count_pending_analysis_candidates(request), 0);
11712        assert!(index.next_analysis_candidates(request, &mut AnalysisWalk::start(), 2).is_empty());
11713
11714        std::fs::write(root.path().join("two.rs"), b"fn main() { changed(); }\n").expect("grow");
11715        let (rescanned, _) =
11716            crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default())
11717                .expect("rescan");
11718        index
11719            .apply(&Observation::new(vec![upsert(
11720                "two.rs",
11721                EntryKind::File,
11722                *rescanned.attrs(Path::new("two.rs")).expect("attrs"),
11723            )]))
11724            .expect("apply");
11725        assert_eq!(index.count_pending_analysis_candidates(request), 1);
11726        let batch = index.next_analysis_candidates(request, &mut AnalysisWalk::start(), 2);
11727        assert_eq!(
11728            batch.iter().map(|candidate| candidate.relative_path.clone()).collect::<Vec<_>>(),
11729            [PathBuf::from("two.rs")]
11730        );
11731    }
11732}