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
95/// Approximate bytes the exact commit history used by [`Index::since`] may retain.
96///
97/// Bounded on purpose: an unbounded journal is a memory leak in a long-lived server. A
98/// consumer that falls further behind than this is told so ([`Since::truncated`]) and is
99/// expected to re-read state rather than silently miss changes. The bound is stated in
100/// bytes, as [`Commit::retained_cost`] estimates them, because the question it answers is
101/// how much memory history may hold, and a budget counted in items would let long paths
102/// hold many times as much. An opened root lifts it through `journal_capacity_bytes`;
103/// there is no unbounded setting, since truncation is always announced and a journal that
104/// never truncates would grow for the life of the session.
105pub const DEFAULT_JOURNAL_CAPACITY_BYTES: usize = 8 * 1024 * 1024;
106
107/// Identifier for an entry within an [`Index`] arena.
108#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
109pub struct EntryId {
110    slot: u32,
111    generation: u64,
112}
113
114impl EntryId {
115    /// The root entry. Always present, never removed.
116    pub const ROOT: EntryId = EntryId { slot: 0, generation: 0 };
117
118    #[inline]
119    const fn idx(self) -> usize {
120        self.slot as usize
121    }
122}
123
124/// Index-private extension identity.
125type ExtId = u32;
126
127/// Per-extension tally within a roll-up.
128#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
129pub struct ExtTally {
130    /// Files with this extension.
131    pub files: u64,
132    /// Apparent bytes across those files.
133    pub bytes: u64,
134    /// Allocated bytes across those files.
135    ///
136    /// Carried alongside `bytes` so a per-type report can answer in either metric. A
137    /// tally that tracked only apparent size would force a report asked for allocated
138    /// bytes to either switch metrics silently or drop the breakdown.
139    pub allocated: u64,
140}
141
142/// Pre-computed aggregate state for one directory's entire subtree.
143///
144/// # What is counted
145///
146/// `bytes` and `allocated` sum **files only**. Directories contribute their own subtree
147/// plus one to `dirs`, but their own inode block usage is not added — unlike `du`, which
148/// counts directory blocks. The difference is small and constant per directory, and
149/// making it configurable is deferred rather than guessed at.
150///
151/// `newest_mtime_ns` is the newest modification time among descendant **files**.
152/// Directory mtimes are excluded because they change on every child add or remove, which
153/// makes "what changed recently" answer with directories instead of the edits a user
154/// actually made.
155#[derive(Clone, PartialEq, Eq, Debug, Default)]
156pub struct RollUp {
157    /// Descendant files.
158    pub files: u64,
159    /// Descendant directories, not counting the directory that owns this roll-up.
160    pub dirs: u64,
161    /// Apparent bytes across descendant files.
162    pub bytes: u64,
163    /// Allocated bytes across descendant files.
164    pub allocated: u64,
165    /// Newest mtime among descendant files, or 0 when there are none.
166    pub newest_mtime_ns: i64,
167    /// Per-extension file and byte tallies across the subtree.
168    pub by_ext: BTreeMap<String, ExtTally>,
169}
170
171/// The two fixed aggregate partitions maintained for inventory reads.
172#[derive(Clone, PartialEq, Eq, Debug, Default)]
173pub struct PartitionRollUp {
174    /// Every retained descendant.
175    pub all: RollUp,
176    /// Retained descendants outside the effective ignored partition.
177    pub unignored: RollUp,
178}
179
180/// Constant-size directory totals suitable for bounded interactive rows.
181#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
182pub struct RollUpSummary {
183    /// Descendant regular files.
184    pub files: u64,
185    /// Descendant directories, excluding the directory that owns this summary.
186    pub dirs: u64,
187    /// Apparent bytes across descendant regular files.
188    pub bytes: u64,
189    /// Allocated bytes across descendant regular files.
190    pub allocated: u64,
191    /// Newest descendant-file modification time, or `None` for an empty subtree.
192    pub newest_mtime_ns: Option<i64>,
193}
194
195/// Constant-size totals for the fixed all and unignored partitions.
196#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
197pub struct PartitionRollUpSummary {
198    /// Every retained descendant.
199    pub all: RollUpSummary,
200    /// Retained descendants outside the effective ignored partition.
201    pub unignored: RollUpSummary,
202}
203
204/// Hot-path aggregate state owned by one index.
205///
206/// Integer extension keys make ancestor merges cheap, but they are meaningful only
207/// while held by the index that issued them. Public query methods convert this into a
208/// self-describing [`RollUp`] so a retained result cannot be relabelled when an interner
209/// slot is reused.
210#[derive(Clone, PartialEq, Eq, Debug, Default)]
211struct InternedRollUp {
212    files: u64,
213    dirs: u64,
214    bytes: u64,
215    allocated: u64,
216    newest_mtime_ns: i64,
217    by_ext: BTreeMap<ExtId, ExtTally>,
218}
219
220/// Hot-path form of the fixed `all` and `unignored` partitions.
221///
222/// Dereferencing yields `all`, keeping existing unrestricted query code direct while
223/// mutation helpers update both partitions explicitly.
224#[derive(Clone, PartialEq, Eq, Debug, Default)]
225struct InternedPartitionRollUp {
226    all: InternedRollUp,
227    unignored: InternedRollUp,
228}
229
230impl std::ops::Deref for InternedPartitionRollUp {
231    type Target = InternedRollUp;
232
233    fn deref(&self) -> &Self::Target {
234        &self.all
235    }
236}
237
238impl std::ops::DerefMut for InternedPartitionRollUp {
239    fn deref_mut(&mut self) -> &mut Self::Target {
240        &mut self.all
241    }
242}
243
244impl InternedPartitionRollUp {
245    fn merge(&mut self, other: &Self) {
246        self.all.merge(&other.all);
247        self.unignored.merge(&other.unignored);
248    }
249
250    fn unmerge(&mut self, other: &Self) {
251        self.all.unmerge(&other.all);
252        self.unignored.unmerge(&other.unignored);
253    }
254}
255
256fn rollup_summary(rollup: &InternedRollUp) -> RollUpSummary {
257    RollUpSummary {
258        files: rollup.files,
259        dirs: rollup.dirs,
260        bytes: rollup.bytes,
261        allocated: rollup.allocated,
262        newest_mtime_ns: (rollup.files > 0).then_some(rollup.newest_mtime_ns),
263    }
264}
265
266fn partition_summary(rollup: &InternedPartitionRollUp) -> PartitionRollUpSummary {
267    PartitionRollUpSummary {
268        all: rollup_summary(&rollup.all),
269        unignored: rollup_summary(&rollup.unignored),
270    }
271}
272
273/// Map-free roll-up fields for internal reports that do not need extension names.
274///
275/// Keeping this view separate avoids cloning every extension string for summary and
276/// tree queries while the public [`RollUp`] remains safe to retain independently.
277#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
278pub(crate) struct RollUpScalars {
279    pub(crate) files: u64,
280    pub(crate) dirs: u64,
281    pub(crate) bytes: u64,
282    pub(crate) allocated: u64,
283    pub(crate) newest_mtime_ns: i64,
284}
285
286impl From<&InternedRollUp> for RollUpScalars {
287    fn from(rollup: &InternedRollUp) -> Self {
288        Self {
289            files: rollup.files,
290            dirs: rollup.dirs,
291            bytes: rollup.bytes,
292            allocated: rollup.allocated,
293            newest_mtime_ns: rollup.newest_mtime_ns,
294        }
295    }
296}
297
298impl InternedRollUp {
299    /// Fold another roll-up into this one. Commutative and associative, which is what
300    /// lets the walk merge subtrees in whatever order threads finish them.
301    fn merge(&mut self, other: &InternedRollUp) {
302        let had_files = self.files > 0;
303        self.files += other.files;
304        self.dirs += other.dirs;
305        self.bytes += other.bytes;
306        self.allocated += other.allocated;
307        if other.files > 0 {
308            self.newest_mtime_ns = if had_files {
309                self.newest_mtime_ns.max(other.newest_mtime_ns)
310            } else {
311                other.newest_mtime_ns
312            };
313        }
314        for (ext, tally) in &other.by_ext {
315            let slot = self.by_ext.entry(*ext).or_default();
316            slot.files += tally.files;
317            slot.bytes += tally.bytes;
318            slot.allocated += tally.allocated;
319        }
320    }
321
322    /// Remove another roll-up's contribution from this one.
323    ///
324    /// Only the invertible reducers are corrected here. `newest_mtime_ns` is left stale
325    /// on purpose and repaired by [`Index::recompute_newest_upward`], because a max
326    /// cannot be un-merged without knowing what else contributed it.
327    fn unmerge(&mut self, other: &InternedRollUp) {
328        self.files = self.files.saturating_sub(other.files);
329        self.dirs = self.dirs.saturating_sub(other.dirs);
330        self.bytes = self.bytes.saturating_sub(other.bytes);
331        self.allocated = self.allocated.saturating_sub(other.allocated);
332        for (ext, tally) in &other.by_ext {
333            if let Some(slot) = self.by_ext.get_mut(ext) {
334                slot.files = slot.files.saturating_sub(tally.files);
335                slot.bytes = slot.bytes.saturating_sub(tally.bytes);
336                slot.allocated = slot.allocated.saturating_sub(tally.allocated);
337                if slot.files == 0 && slot.bytes == 0 && slot.allocated == 0 {
338                    self.by_ext.remove(ext);
339                }
340            }
341        }
342    }
343}
344
345#[derive(Clone, Debug)]
346enum DirectoryChildren {
347    /// Name order without a second copy of each retained name.
348    Sorted(Vec<EntryId>),
349    /// Incrementally mutable topology for opened and arbitrary public indexes.
350    Mutable(BTreeMap<OsString, EntryId>),
351}
352
353impl DirectoryChildren {
354    #[cfg(test)]
355    fn is_sorted(&self) -> bool {
356        matches!(self, Self::Sorted(_))
357    }
358
359    #[cfg(test)]
360    fn is_mutable(&self) -> bool {
361        matches!(self, Self::Mutable(_))
362    }
363
364    fn ids(&self) -> ChildIds<'_> {
365        match self {
366            Self::Sorted(ids) => ChildIds::Sorted(ids.iter()),
367            Self::Mutable(children) => ChildIds::Mutable(children.values()),
368        }
369    }
370}
371
372#[derive(Clone, Debug)]
373struct DirectoryEntry {
374    children: DirectoryChildren,
375    rollup: InternedPartitionRollUp,
376    children_revision: u64,
377    children_complete: bool,
378}
379
380impl DirectoryEntry {
381    fn new(children_complete: bool) -> Self {
382        Self {
383            children: DirectoryChildren::Mutable(BTreeMap::new()),
384            rollup: InternedPartitionRollUp::default(),
385            children_revision: 0,
386            children_complete,
387        }
388    }
389}
390
391#[derive(Clone, Debug)]
392struct Entry {
393    parent: Option<EntryId>,
394    name: OsString,
395    /// Interned extension, computed once at insert. Files only; `None` elsewhere and
396    /// for files without an extension. Precomputing it here is what lets
397    /// `contribution` run without a string allocation or an interner borrow.
398    ext_id: Option<ExtId>,
399    /// Effective fixed-control classification, including an ignored ancestor.
400    ignored: bool,
401    /// Where this entry's metadata came from.
402    ///
403    /// One byte, not a `Provenance` struct: the timestamps that complete the picture
404    /// are shared by nearly every entry in a tree, so they live once on the index
405    /// while only the source genuinely varies per entry. See `Index::provenance`.
406    source: Source,
407    kind: EntryKind,
408    attrs: Attrs,
409    /// Changes on direct metadata updates. Together with the arena generation this
410    /// detects present-state ABA races.
411    revision: u64,
412    /// Child topology, subtree roll-ups, and discovery state exist only for directories.
413    /// Keeping them behind one pointer prevents every file from paying for two roll-up
414    /// planes and an empty child map.
415    directory: Option<Box<DirectoryEntry>>,
416}
417
418struct NewEntry {
419    parent: Option<EntryId>,
420    name: OsString,
421    ext_id: Option<ExtId>,
422    ignored: bool,
423    source: Source,
424    kind: EntryKind,
425    attrs: Attrs,
426}
427
428impl Entry {
429    fn new(entry: NewEntry, children_complete: bool) -> Self {
430        let NewEntry { parent, name, ext_id, ignored, source, kind, attrs } = entry;
431        Self {
432            parent,
433            name,
434            ext_id,
435            ignored,
436            source,
437            kind,
438            attrs,
439            revision: 0,
440            directory: kind.is_dir().then(|| Box::new(DirectoryEntry::new(children_complete))),
441        }
442    }
443
444    fn new_detached(new_entry: NewEntry, children_complete: bool) -> Self {
445        let mut entry = Self::new(new_entry, children_complete);
446        if let Some(directory) = entry.directory.as_deref_mut() {
447            directory.children = DirectoryChildren::Sorted(Vec::new());
448        }
449        entry
450    }
451
452    fn directory(&self) -> &DirectoryEntry {
453        self.directory.as_deref().expect("directory entry must retain directory state")
454    }
455
456    fn directory_mut(&mut self) -> &mut DirectoryEntry {
457        self.directory.as_deref_mut().expect("directory entry must retain directory state")
458    }
459
460    fn rollup(&self) -> &InternedPartitionRollUp {
461        &self.directory().rollup
462    }
463
464    fn rollup_mut(&mut self) -> &mut InternedPartitionRollUp {
465        &mut self.directory_mut().rollup
466    }
467}
468
469/// Portable direct children retained in the order interactive tree pages emit them.
470#[derive(Clone, PartialEq, Eq, Debug, Default)]
471pub(crate) struct PortableChildren {
472    pub(crate) directories: BTreeMap<String, EntryId>,
473    pub(crate) nondirectories: BTreeMap<String, EntryId>,
474}
475
476/// Commit-maintained orders and diagnostics used only while serving an opened root.
477///
478/// A detached [`Index`] is the storage and one-shot execution shape used by the CLI,
479/// snapshots, and ordinary library callers. Keeping these maps behind one optional
480/// allocation makes interactive reads additive without charging those paths one copied
481/// portable string and child-map node per entry.
482#[derive(Clone, PartialEq, Eq, Debug, Default)]
483struct ServingIndexes {
484    portable_children: BTreeMap<PathBuf, PortableChildren>,
485    portable_entries: BTreeMap<crate::PortablePath, EntryId>,
486    recent_files: BTreeSet<RecentKey>,
487    semantic_names: Vec<Option<String>>,
488    semantic_ids: BTreeMap<String, u32>,
489    semantic_refcounts: Vec<u64>,
490    free_semantic_ids: Vec<u32>,
491    semantic_by_directory: BTreeMap<EntryId, InternedSemanticPartitions>,
492    exact_name_ids: BTreeMap<String, u32>,
493    exact_names: Vec<String>,
494    exact_name_by_directory: BTreeMap<EntryId, InternedSemanticPartitions>,
495}
496
497#[derive(Clone, PartialEq, Eq, Debug, Default)]
498struct InternedSemanticPartitions {
499    all: BTreeMap<u32, ExtTally>,
500    unignored: BTreeMap<u32, ExtTally>,
501}
502
503/// One regular file in global newest-first order.
504#[derive(Clone, PartialEq, Eq, Debug)]
505struct RecentKey {
506    mtime_ns: i64,
507    portable_path: crate::PortablePath,
508    id: EntryId,
509}
510
511impl PartialOrd for RecentKey {
512    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
513        Some(self.cmp(other))
514    }
515}
516
517impl Ord for RecentKey {
518    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
519        other
520            .mtime_ns
521            .cmp(&self.mtime_ns)
522            .then_with(|| self.portable_path.cmp(&other.portable_path))
523            .then_with(|| self.id.cmp(&other.id))
524    }
525}
526
527impl ServingIndexes {
528    fn for_types(types: &crate::classify::TypeRegistry) -> Self {
529        let exact_names: Vec<_> = types
530            .exact_filenames()
531            .map(str::to_ascii_lowercase)
532            .collect::<BTreeSet<_>>()
533            .into_iter()
534            .collect();
535        let exact_name_ids = exact_names
536            .iter()
537            .enumerate()
538            .map(|(index, name)| {
539                let id = u32::try_from(index)
540                    .expect("a registry declares fewer than four billion exact filenames");
541                (name.clone(), id)
542            })
543            .collect();
544        Self { exact_name_ids, exact_names, ..Self::default() }
545    }
546
547    fn exact_name_id(&self, name: &OsStr) -> Option<u32> {
548        let name = name.to_str()?;
549        if let Some(id) = self.exact_name_ids.get(name) {
550            return Some(*id);
551        }
552        name.bytes()
553            .any(|byte| byte.is_ascii_uppercase())
554            .then(|| name.to_ascii_lowercase())
555            .and_then(|name| self.exact_name_ids.get(&name).copied())
556    }
557
558    fn intern_semantic(&mut self, name: &str) -> u32 {
559        if let Some(id) = self.semantic_ids.get(name).copied() {
560            let refcount = self
561                .semantic_refcounts
562                .get_mut(id as usize)
563                .expect("a live semantic id has a refcount");
564            *refcount = refcount.checked_add(1).expect("semantic refcount exhausted");
565            return id;
566        }
567        let id = if let Some(id) = self.free_semantic_ids.pop() {
568            self.semantic_names[id as usize] = Some(name.to_string());
569            self.semantic_refcounts[id as usize] = 1;
570            id
571        } else {
572            let id = u32::try_from(self.semantic_names.len())
573                .expect("fewer than four billion semantic types are live");
574            self.semantic_names.push(Some(name.to_string()));
575            self.semantic_refcounts.push(1);
576            id
577        };
578        self.semantic_ids.insert(name.to_string(), id);
579        id
580    }
581
582    fn release_semantic(&mut self, id: u32, count: u64) {
583        let slot = self
584            .semantic_refcounts
585            .get_mut(id as usize)
586            .expect("a live semantic id has a refcount");
587        *slot = slot.checked_sub(count).expect("semantic reference released twice");
588        if *slot != 0 {
589            return;
590        }
591        let name =
592            self.semantic_names[id as usize].take().expect("a referenced semantic id has a name");
593        let removed = self.semantic_ids.remove(&name);
594        debug_assert_eq!(removed, Some(id), "the semantic interner's two maps disagreed");
595        self.free_semantic_ids.push(id);
596    }
597}
598
599fn merge_semantic(map: &mut BTreeMap<u32, ExtTally>, id: u32, attrs: Attrs) {
600    let tally = map.entry(id).or_default();
601    tally.files = tally.files.saturating_add(1);
602    tally.bytes = tally.bytes.saturating_add(attrs.size);
603    tally.allocated = tally.allocated.saturating_add(attrs.allocated);
604}
605
606fn unmerge_semantic(map: &mut BTreeMap<u32, ExtTally>, id: u32, attrs: Attrs) {
607    let tally = map.get_mut(&id).expect("a semantic contribution must exist before removal");
608    tally.files = tally.files.saturating_sub(1);
609    tally.bytes = tally.bytes.saturating_sub(attrs.size);
610    tally.allocated = tally.allocated.saturating_sub(attrs.allocated);
611    if tally.files == 0 && tally.bytes == 0 && tally.allocated == 0 {
612        map.remove(&id);
613    }
614}
615
616fn unmerge_semantic_map(
617    destination: &mut BTreeMap<u32, ExtTally>,
618    contribution: &BTreeMap<u32, ExtTally>,
619) {
620    for (id, removed) in contribution {
621        let tally = destination
622            .get_mut(id)
623            .expect("a semantic subtree contribution must exist before removal");
624        tally.files = tally.files.saturating_sub(removed.files);
625        tally.bytes = tally.bytes.saturating_sub(removed.bytes);
626        tally.allocated = tally.allocated.saturating_sub(removed.allocated);
627        if tally.files == 0 && tally.bytes == 0 && tally.allocated == 0 {
628            destination.remove(id);
629        }
630    }
631}
632
633#[derive(Clone, Debug)]
634enum Slot {
635    Occupied { generation: u64, entry: Entry },
636    Free { generation: u64, next_free: Option<u32> },
637}
638
639fn retained_parent(arena: &[Slot], id: EntryId) -> Option<EntryId> {
640    match arena.get(id.idx()) {
641        Some(Slot::Occupied { generation, entry }) if *generation == id.generation => entry.parent,
642        Some(Slot::Occupied { .. } | Slot::Free { .. }) | None => {
643            panic!("internal entry handle must be live: {id:?}")
644        }
645    }
646}
647
648/// Result of [`Index::since`].
649#[derive(Clone, PartialEq, Eq, Debug, Default)]
650#[must_use]
651pub struct Since {
652    /// Exact commits applied strictly after the requested clock, oldest first.
653    pub commits: Vec<Commit>,
654    /// Terminal clock captured under the same read boundary as `commits`.
655    pub clock: Clock,
656    /// Complete public state at `clock`.
657    pub state: IndexState,
658    /// True when the requested clock is older than the retained journal, meaning the
659    /// caller has missed commits and must re-read state rather than trust either view.
660    pub truncated: bool,
661}
662
663/// Summary of what one [`Index::apply`] call did.
664#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
665pub struct ApplyStats {
666    /// Entries created.
667    pub inserted: u64,
668    /// Entries whose attributes changed.
669    pub updated: u64,
670    /// Entries removed, including cascaded descendants.
671    pub removed: u64,
672    /// Operations whose complete observed state already matched, so nothing changed.
673    pub unchanged: u64,
674    /// Subtrees escalated for re-scan.
675    pub invalidated: u64,
676    /// Exact control sources inserted, replaced, or removed.
677    pub controls: u64,
678    /// Retained entries moved between ignored and unignored partitions.
679    pub reclassified: u64,
680    /// Conditional observations rejected because the indexed state changed after the
681    /// producer captured its baseline.
682    pub stale: u64,
683    /// File upserts refused because their exact effect would exceed an opened-root
684    /// resource budget.
685    pub resource_refused: u64,
686}
687
688impl ApplyStats {
689    /// True when any operation changed indexed state.
690    pub const fn mutated(&self) -> bool {
691        self.inserted > 0
692            || self.updated > 0
693            || self.removed > 0
694            || self.invalidated > 0
695            || self.controls > 0
696            || self.reclassified > 0
697    }
698}
699
700/// Result of arbitrating and applying one producer observation.
701#[derive(Clone, PartialEq, Eq, Debug, Default)]
702pub struct ApplyOutcome {
703    /// Per-operation arbitration and mutation counts.
704    pub stats: ApplyStats,
705    /// Present only when at least one exact fact or state transition was committed.
706    pub commit: Option<Commit>,
707}
708
709/// One direct child captured from a shared index at a single read boundary.
710///
711/// Every field is owned so retaining this value never retains an index lock. The
712/// optional roll-up is present for directories; non-directories carry only `attrs`.
713#[derive(Clone, PartialEq, Eq, Debug)]
714pub struct ChildSnapshot {
715    /// Generation-safe arena identity at the capture boundary.
716    pub id: EntryId,
717    /// Entry name relative to its direct parent.
718    pub name: OsString,
719    /// Filesystem entry kind.
720    pub kind: EntryKind,
721    /// Last observed metadata.
722    pub attrs: Attrs,
723    /// Effective fixed-control classification, or `None` when the index did not observe
724    /// control state ([`Index::observes_controls`]).
725    ///
726    /// Such an index read no rule, so `Some(false)` would claim the child is not ignored
727    /// when nobody looked.
728    pub ignored: Option<bool>,
729    /// Pre-computed subtree totals for a directory.
730    pub rollup: Option<RollUp>,
731    /// Both maintained aggregate partitions for a directory, or `None` for a
732    /// non-directory and for any child of an index that did not observe control state,
733    /// whose unignored partition would only repeat `rollup` as if no rule applied.
734    pub partitions: Option<PartitionRollUp>,
735}
736
737impl std::ops::Deref for ApplyOutcome {
738    type Target = ApplyStats;
739
740    fn deref(&self) -> &Self::Target {
741        &self.stats
742    }
743}
744
745impl ApplyOutcome {
746    fn from_commit(stats: ApplyStats, commit: Option<Commit>) -> Self {
747        Self { stats, commit }
748    }
749}
750
751#[derive(Clone, Copy)]
752enum BatchProvenance {
753    Baseline,
754    Opened,
755    Public,
756}
757
758fn record_batch(provenance: BatchProvenance, observed: usize, stats: ApplyStats) {
759    let observed = u64::try_from(observed).unwrap_or(u64::MAX);
760    let accepted = observed.saturating_sub(stats.stale);
761    crate::counters::bump(|counts| match provenance {
762        BatchProvenance::Baseline => {
763            counts.baseline_batches = counts.baseline_batches.saturating_add(1);
764            counts.baseline_accepted_ops = counts.baseline_accepted_ops.saturating_add(accepted);
765        }
766        BatchProvenance::Opened => {
767            counts.opened_batches = counts.opened_batches.saturating_add(1);
768            counts.opened_accepted_ops = counts.opened_accepted_ops.saturating_add(accepted);
769        }
770        BatchProvenance::Public => {
771            counts.public_batches = counts.public_batches.saturating_add(1);
772            counts.public_accepted_ops = counts.public_accepted_ops.saturating_add(accepted);
773        }
774    });
775}
776
777fn elapsed_micros(started: std::time::Instant) -> u64 {
778    u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX)
779}
780
781/// Validated, canonical producer input ready for arbitration under the write guard.
782#[derive(Clone, Debug)]
783struct PreparedObservation {
784    ops: Vec<ObservationOp>,
785    ancestry: PreparedAncestry,
786    #[cfg(test)]
787    reject_before_apply: bool,
788}
789
790#[derive(Clone, Debug)]
791enum PreparedAncestry {
792    General,
793    Scanner { parents: Vec<ResolvedParent>, has_batch_parents: bool },
794}
795
796/// Parent identity proved for one operation in a private scanner batch.
797#[derive(Clone, Copy, Debug)]
798enum ResolvedParent {
799    /// The parent is already live in the index at the preparation boundary.
800    Existing(EntryId),
801    /// The parent is the directory produced by this earlier operation in the batch.
802    Earlier(usize),
803}
804
805#[derive(Default)]
806struct ExactConsequences {
807    changes: Vec<EffectiveChange>,
808    state: Vec<StateTransition>,
809}
810
811impl ExactConsequences {
812    fn is_empty(&self) -> bool {
813        self.changes.is_empty() && self.state.is_empty()
814    }
815}
816
817#[derive(Default)]
818struct NoConsequences;
819
820/// Batch-selected destination for facts that escape the shared reducer.
821///
822/// The closure is intentional: `NoConsequences` never evaluates it, so path copies and
823/// effect construction compile out of detached baseline application rather than hiding
824/// behind a branch in the per-entry loop.
825trait ConsequenceSink {
826    fn change(&mut self, change: impl FnOnce() -> EffectiveChange);
827    fn state(&mut self, transition: impl FnOnce() -> StateTransition);
828}
829
830impl ConsequenceSink for ExactConsequences {
831    #[inline]
832    fn change(&mut self, change: impl FnOnce() -> EffectiveChange) {
833        self.changes.push(change());
834    }
835
836    #[inline]
837    fn state(&mut self, transition: impl FnOnce() -> StateTransition) {
838        self.state.push(transition());
839    }
840}
841
842impl ConsequenceSink for NoConsequences {
843    #[inline]
844    fn change(&mut self, _change: impl FnOnce() -> EffectiveChange) {}
845
846    #[inline]
847    fn state(&mut self, _transition: impl FnOnce() -> StateTransition) {}
848}
849
850/// The in-memory hierarchical index.
851#[derive(Clone, Debug)]
852pub struct Index {
853    root_path: PathBuf,
854    scope: ScanScope,
855    arena: Vec<Slot>,
856    free_head: Option<u32>,
857    live: u64,
858    clock: Clock,
859    journal: VecDeque<Commit>,
860    journal_cost: usize,
861    journal_capacity_bytes: usize,
862    /// Oldest clock still represented in `journal`.
863    journal_floor: Clock,
864    pending_invalidations: Vec<(PathBuf, InvalidateReason)>,
865    /// Source recorded on entries that incoming deltas create or update.
866    ///
867    /// Producers do not carry provenance in the delta itself — an observation says
868    /// what it saw, not how much to trust it — so the consumer stamps it, and a
869    /// caller loading a snapshot sets this to `Cached` for the duration.
870    applying_source: Source,
871    /// When this session observed the filesystem, in nanoseconds since the epoch.
872    scanned_at_ns: i64,
873    /// When the snapshot this index was loaded from captured the tree. Zero when the
874    /// index was never loaded from one.
875    captured_at_ns: i64,
876    /// The start of the pass a snapshot of this index records as the one that last wrote
877    /// its image: construction for an index built by a walk, which precedes the walk, and
878    /// the stamp a loaded snapshot carried for one loaded from a snapshot.
879    ///
880    /// A lower bound on when the facts were last verified, never later than the truth. A
881    /// later pass that verifies the same facts keeps the image on disk and its stamp, and a
882    /// reconciliation that verifies a loaded index again leaves this at the snapshot's
883    /// stamp, so the value can predate many verifying passes until P1.4.4 stamps completed
884    /// passes.
885    writing_pass_started_at_ns: i64,
886    /// Whether this index holds facts no completed metadata write has recorded.
887    ///
888    /// True from construction, because a walked index has been written nowhere; cleared
889    /// when the index is loaded from a snapshot or a metadata write of it completes; set
890    /// again by a pass that mutated the entry tier. A partial pass mutates without being
891    /// writable, so this is what carries its verified facts to the next complete write
892    /// rather than keying that write to the one pass that happened to change nothing.
893    persistence_owed: bool,
894    /// Wall-clock starts of in-flight full-root passes, keyed by their freshness epoch.
895    active_root_reconciles: BTreeMap<u64, i64>,
896    /// Scopes and newer verification evidence for filesystem passes still in flight.
897    active_reconciles: BTreeMap<u64, ActiveReconcile>,
898    /// Subtrees a completed reconciliation has verified, with when it finished.
899    ///
900    /// Kept as intervals rather than per-entry flags because a sweep verifies
901    /// everything beneath a path at once, including entries the producer elided as
902    /// no-ops, and because one record per sweep costs nothing against millions of
903    /// entries. Nested and repeated sweeps collapse: a new record replaces any it
904    /// covers.
905    verified: Vec<(PathBuf, i64)>,
906    /// Interner storage: id → live name. Ids are indexes into this vector, and a
907    /// vacant slot holds `None` until it is reissued.
908    ext_names: Vec<Option<String>>,
909    /// Interner lookup: name → id.
910    ext_ids: BTreeMap<String, ExtId>,
911    /// Live file entries holding each extension id, parallel to `ext_names`.
912    ///
913    /// Interning without a matching release is a leak in the case this engine is built
914    /// for: a watched tree that churns through editor temporaries, build outputs, and
915    /// content-hashed asset names keeps minting extensions the tree no longer contains,
916    /// and both maps grow for the life of the process.
917    ext_refcounts: Vec<u64>,
918    /// Slots whose last referencing file went away, available for reissue.
919    free_ext_ids: Vec<ExtId>,
920    /// Sparse derived-data tier, allocated only after analysis is enabled.
921    content: Option<Box<ContentIndex>>,
922    /// File-type rules this index classifies against.
923    ///
924    /// Held rather than reached for globally, because a caller may run two indexes under
925    /// different taxonomies in one process. It must agree with `scope`'s type-rule
926    /// fingerprint: an index that classified under one set of rules while claiming
927    /// another would serve a snapshot that is wrong in a way nothing checks.
928    types: std::sync::Arc<crate::classify::TypeRegistry>,
929    /// Exact fixed control sources and their derived matchers.
930    controls: crate::control::ControlTable,
931    /// Control files whose text could not be read in the latest owning pass.
932    /// Their governing descendants have unknown ignore classification.
933    unreadable_control_paths: BTreeSet<PathBuf>,
934    freshness_epoch: u64,
935    freshness_marks: BTreeMap<PathBuf, FreshnessMark>,
936    /// Coherent opened-root state. Detached indexes retain the settled default and do
937    /// not acquire live identity or worker ownership by carrying this value.
938    state: IndexState,
939    /// Bounded diagnostic details summarized by `state.issues`.
940    issues: Vec<Issue>,
941    /// The freshness epoch at which each retained issue was last observed, in step with
942    /// `issues`. A reconciliation only disproves an issue observed before it began.
943    issue_epochs: Vec<u64>,
944    /// Omitted issue counts grouped by the active reconciliation boundary that owns them.
945    /// At most one group exists before/between each active boundary, so this is bounded by
946    /// concurrent passes rather than by the number of failures.
947    omitted_issue_epochs: BTreeMap<u64, u64>,
948    /// Optional commit-maintained state for interactive opened-root projections.
949    ///
950    /// Detached indexes deliberately carry `None`, including the standalone CLI's
951    /// one-shot scan. Only [`crate::OpenedIndex`] enables this allocation.
952    serving: Option<Box<ServingIndexes>>,
953}
954
955#[derive(Clone, Copy)]
956pub(crate) struct ReconcileErrors<'a> {
957    pub(crate) errors: &'a [crate::Error],
958    pub(crate) terminal: Option<&'a crate::Error>,
959    pub(crate) disproves_old: bool,
960}
961
962#[derive(Clone, Debug)]
963struct ActiveReconcile {
964    path: PathBuf,
965    // One scope per entry present when this pass began. This bounds concurrent
966    // verification history by retained state, including on a root-only index.
967    scope_budget: usize,
968    evidence: ReconcileEvidence,
969}
970
971#[derive(Clone, Debug)]
972enum ReconcileEvidence {
973    Scopes(BTreeSet<PathBuf>),
974    Retry,
975}
976
977impl ActiveReconcile {
978    fn supersede(&mut self, path: &Path) {
979        if self.path.starts_with(path) {
980            self.evidence = ReconcileEvidence::Scopes(BTreeSet::from([path.to_path_buf()]));
981            return;
982        }
983        let ReconcileEvidence::Scopes(scopes) = &mut self.evidence else {
984            return;
985        };
986        if scopes.iter().any(|newer| path.starts_with(newer)) {
987            return;
988        }
989        scopes.retain(|newer| !newer.starts_with(path));
990        if scopes.len() == self.scope_budget {
991            // Discard proof, never widen it: the caller must retry this pass.
992            self.evidence = ReconcileEvidence::Retry;
993        } else {
994            scopes.insert(path.to_path_buf());
995        }
996    }
997
998    fn refuses(&self, observation: &Observation, index: &Index) -> bool {
999        match &self.evidence {
1000            ReconcileEvidence::Retry => true,
1001            ReconcileEvidence::Scopes(scopes) => observation.ops.iter().any(|op| {
1002                scopes
1003                    .iter()
1004                    .any(|path| op.op.path().starts_with(path) || path.starts_with(op.op.path()))
1005                    && !index.holds_target(&op.op, index.path_state(op.op.path()))
1006            }),
1007        }
1008    }
1009}
1010
1011pub(crate) struct ReconcileFinish {
1012    pub(crate) commit: Option<Commit>,
1013    pub(crate) retry: bool,
1014}
1015
1016enum ChildIds<'a> {
1017    Sorted(std::slice::Iter<'a, EntryId>),
1018    Mutable(std::collections::btree_map::Values<'a, OsString, EntryId>),
1019}
1020
1021impl Iterator for ChildIds<'_> {
1022    type Item = EntryId;
1023
1024    fn next(&mut self) -> Option<Self::Item> {
1025        match self {
1026            Self::Sorted(ids) => ids.next().copied(),
1027            Self::Mutable(ids) => ids.next().copied(),
1028        }
1029    }
1030
1031    fn size_hint(&self) -> (usize, Option<usize>) {
1032        let len = self.len();
1033        (len, Some(len))
1034    }
1035}
1036
1037impl DoubleEndedIterator for ChildIds<'_> {
1038    fn next_back(&mut self) -> Option<Self::Item> {
1039        match self {
1040            Self::Sorted(ids) => ids.next_back().copied(),
1041            Self::Mutable(ids) => ids.next_back().copied(),
1042        }
1043    }
1044}
1045
1046impl ExactSizeIterator for ChildIds<'_> {
1047    fn len(&self) -> usize {
1048        match self {
1049            Self::Sorted(ids) => ids.len(),
1050            Self::Mutable(ids) => ids.len(),
1051        }
1052    }
1053}
1054
1055enum IndexChildren<'a> {
1056    Empty,
1057    Sorted { index: &'a Index, ids: std::slice::Iter<'a, EntryId> },
1058    Mutable(std::collections::btree_map::Iter<'a, OsString, EntryId>),
1059}
1060
1061impl<'a> IndexChildren<'a> {
1062    fn new(index: &'a Index, entry: &'a Entry) -> Self {
1063        match entry.directory.as_deref().map(|directory| &directory.children) {
1064            None => Self::Empty,
1065            Some(DirectoryChildren::Sorted(ids)) => Self::Sorted { index, ids: ids.iter() },
1066            Some(DirectoryChildren::Mutable(children)) => Self::Mutable(children.iter()),
1067        }
1068    }
1069}
1070
1071impl<'a> Iterator for IndexChildren<'a> {
1072    type Item = (&'a OsStr, EntryId);
1073
1074    fn next(&mut self) -> Option<Self::Item> {
1075        match self {
1076            Self::Empty => None,
1077            Self::Sorted { index, ids } => {
1078                let id = *ids.next()?;
1079                Some((index.entry(id).name.as_os_str(), id))
1080            }
1081            Self::Mutable(children) => children.next().map(|(name, id)| (name.as_os_str(), *id)),
1082        }
1083    }
1084
1085    fn size_hint(&self) -> (usize, Option<usize>) {
1086        let len = self.len();
1087        (len, Some(len))
1088    }
1089}
1090
1091impl DoubleEndedIterator for IndexChildren<'_> {
1092    fn next_back(&mut self) -> Option<Self::Item> {
1093        match self {
1094            Self::Empty => None,
1095            Self::Sorted { index, ids } => {
1096                let id = *ids.next_back()?;
1097                Some((index.entry(id).name.as_os_str(), id))
1098            }
1099            Self::Mutable(children) => {
1100                children.next_back().map(|(name, id)| (name.as_os_str(), *id))
1101            }
1102        }
1103    }
1104}
1105
1106impl ExactSizeIterator for IndexChildren<'_> {
1107    fn len(&self) -> usize {
1108        match self {
1109            Self::Empty => 0,
1110            Self::Sorted { ids, .. } => ids.len(),
1111            Self::Mutable(children) => children.len(),
1112        }
1113    }
1114}
1115
1116#[derive(Clone, Copy, Debug)]
1117struct FreshnessMark {
1118    state: Freshness,
1119    epoch: u64,
1120}
1121
1122/// Shareable owner for serving readers while reconciliation applies short writes.
1123#[derive(Clone, Debug)]
1124pub struct IndexHandle {
1125    inner: Arc<RwLock<Index>>,
1126}
1127
1128/// Index-owned part of one progressive discovery commit.
1129///
1130/// The producer may combine one of these with entry observations; the opened commit
1131/// policy updates exact file progress and publishes one atomic fact-and-state commit.
1132#[derive(Clone, Debug, Default)]
1133pub(crate) struct DiscoveryCommit {
1134    pub(crate) directory_complete: Option<PathBuf>,
1135    pub(crate) transition: Option<DiscoveryTransition>,
1136}
1137
1138#[derive(Clone, Debug)]
1139pub(crate) enum DiscoveryTransition {
1140    Begin,
1141    Finish,
1142    BudgetRefused(Issue),
1143    Inaccessible { issues: Vec<Issue>, omitted: u64 },
1144    Cancelled,
1145    Failed(Issue),
1146}
1147
1148/// Index-owned lifecycle transitions for the optional observation producer.
1149#[derive(Clone, Debug)]
1150#[cfg_attr(not(feature = "watch"), allow(dead_code))]
1151pub(crate) enum ObservationTransition {
1152    /// Baseline discovery finished and the observer is closing its registration gap.
1153    Reconciling,
1154    /// The observer is active and its baseline handoff has been verified.
1155    ///
1156    /// Persistent inaccessible boundaries do not prevent observation of the readable
1157    /// scope, but they keep coverage partial and their causes remain inspectable.
1158    Watching { issues: Vec<Issue>, omitted: u64 },
1159    /// A reconciliation while watching could not read part of the scope.
1160    ///
1161    /// The subtree it covered is already partial and is not retried on every later event,
1162    /// so its causes are retained here, where partial freshness can be explained. Both
1163    /// watch drivers publish it: the opened root's observer and `Watcher::apply_next`.
1164    Unreadable { issues: Vec<Issue>, omitted: u64 },
1165    /// Observation could not establish or retain a trustworthy live boundary.
1166    Failed(Issue),
1167}
1168
1169impl IndexHandle {
1170    /// Wrap an owned index in the shared single-writer owner.
1171    pub fn new(index: Index) -> Self {
1172        Self { inner: Arc::new(RwLock::new(index)) }
1173    }
1174
1175    fn read_index(&self) -> crate::Result<std::sync::RwLockReadGuard<'_, Index>> {
1176        self.inner.read().map_err(|_| crate::Error::IndexLockPoisoned)
1177    }
1178
1179    fn write_index(&self) -> crate::Result<std::sync::RwLockWriteGuard<'_, Index>> {
1180        self.inner.write().map_err(|_| crate::Error::IndexLockPoisoned)
1181    }
1182
1183    /// Evaluate one owned result while holding exactly one coherent read boundary.
1184    pub(crate) fn read_with<T>(&self, read: impl FnOnce(&Index) -> T) -> crate::Result<T> {
1185        let index = self.read_index()?;
1186        Ok(read(&index))
1187    }
1188
1189    #[cfg(test)]
1190    pub(crate) fn poison_for_test(&self) {
1191        let handle = self.clone();
1192        std::thread::spawn(move || handle.panic_holding_the_write_lock_for_test())
1193            .join()
1194            .expect_err("injected index panic");
1195    }
1196
1197    /// Panic on this thread while holding the write lock, as a commit that panics does,
1198    /// leaving the lock poisoned.
1199    #[cfg(test)]
1200    pub(crate) fn panic_holding_the_write_lock_for_test(&self) -> ! {
1201        let _guard = self.inner.write().expect("test index write lock");
1202        panic!("inject index poison");
1203    }
1204
1205    /// Arbitrate and apply one observation under the single-writer lock.
1206    pub fn apply(&self, observation: &Observation) -> crate::Result<ApplyOutcome> {
1207        let prepared = prepare_observation(observation)?;
1208        let outcome = self.write_index()?.commit_prepared(prepared, true)?;
1209        record_batch(BatchProvenance::Public, observation.len(), outcome.stats);
1210        Ok(outcome)
1211    }
1212
1213    pub(crate) fn apply_discovery(
1214        &self,
1215        observation: &Observation,
1216        discovery: DiscoveryCommit,
1217    ) -> crate::Result<ApplyOutcome> {
1218        let prepared = prepare_observation(observation)?;
1219        let outcome = self.write_index()?.commit_prepared_with(
1220            prepared,
1221            true,
1222            Some(discovery),
1223            None,
1224            None,
1225            true,
1226        )?;
1227        record_batch(BatchProvenance::Opened, observation.len(), outcome.stats);
1228        Ok(outcome)
1229    }
1230
1231    #[cfg(feature = "watch")]
1232    pub(crate) fn apply_opened(
1233        &self,
1234        observation: &Observation,
1235        max_files: Option<u64>,
1236    ) -> crate::Result<ApplyOutcome> {
1237        let prepared = prepare_observation(observation)?;
1238        let outcome = self
1239            .write_index()?
1240            .commit_prepared_with(prepared, true, None, None, max_files, true)?;
1241        record_batch(BatchProvenance::Opened, observation.len(), outcome.stats);
1242        Ok(outcome)
1243    }
1244
1245    pub(crate) fn apply_scanner_discovery_bounded(
1246        &self,
1247        batch: crate::scan::ScannerBatch,
1248        discovery: DiscoveryCommit,
1249        max_files: Option<u64>,
1250    ) -> crate::Result<ApplyOutcome> {
1251        let observed = batch.len();
1252        let mut index = self.write_index()?;
1253        let prepared = index.prepare_scanner_batch(batch)?;
1254        let outcome =
1255            index.commit_prepared_with(prepared, true, Some(discovery), None, max_files, true)?;
1256        record_batch(BatchProvenance::Opened, observed, outcome.stats);
1257        Ok(outcome)
1258    }
1259
1260    pub(crate) fn transition_discovery(
1261        &self,
1262        transition: DiscoveryTransition,
1263    ) -> crate::Result<ApplyOutcome> {
1264        self.apply_discovery(
1265            &Observation::new(Vec::new()),
1266            DiscoveryCommit { directory_complete: None, transition: Some(transition) },
1267        )
1268    }
1269
1270    #[cfg(feature = "watch")]
1271    pub(crate) fn transition_observation(
1272        &self,
1273        transition: ObservationTransition,
1274    ) -> crate::Result<ApplyOutcome> {
1275        let prepared = prepare_observation(&Observation::default())?;
1276        let outcome = self.write_index()?.commit_prepared_with(
1277            prepared,
1278            true,
1279            None,
1280            Some(transition),
1281            None,
1282            true,
1283        )?;
1284        record_batch(BatchProvenance::Opened, 0, outcome.stats);
1285        Ok(outcome)
1286    }
1287
1288    /// Absolute filesystem root, copied without retaining the read lock.
1289    pub fn root_path(&self) -> crate::Result<PathBuf> {
1290        Ok(self.read_index()?.root_path().to_path_buf())
1291    }
1292
1293    /// Semantic scan scope represented by the shared index.
1294    pub fn scope(&self) -> crate::Result<ScanScope> {
1295        Ok(self.read_index()?.scope())
1296    }
1297
1298    /// Trust state for the whole index.
1299    pub fn freshness(&self) -> crate::Result<Freshness> {
1300        Ok(self.read_index()?.freshness())
1301    }
1302
1303    /// Trust state for one subtree.
1304    pub fn freshness_at(&self, path: &Path) -> crate::Result<Freshness> {
1305        Ok(self.read_index()?.freshness_at(path))
1306    }
1307
1308    /// Clock of the most recently committed delta.
1309    pub fn clock(&self) -> crate::Result<Clock> {
1310        Ok(self.read_index()?.clock())
1311    }
1312
1313    /// Number of live entries, including the root.
1314    pub fn len(&self) -> crate::Result<u64> {
1315        Ok(self.read_index()?.len())
1316    }
1317
1318    /// Whether the index contains only its root.
1319    pub fn is_empty(&self) -> crate::Result<bool> {
1320        Ok(self.read_index()?.is_empty())
1321    }
1322
1323    /// Owned roll-up totals for the whole tree.
1324    pub fn total(&self) -> crate::Result<RollUp> {
1325        Ok(self.read_index()?.total())
1326    }
1327
1328    /// Coherent opened-root state at the returned clock.
1329    pub(crate) fn state(&self) -> crate::Result<IndexState> {
1330        Ok(self.read_index()?.state())
1331    }
1332
1333    #[allow(dead_code)] // Consumed by the opened-root coherent read checkpoint.
1334    pub(crate) fn issues(&self) -> crate::Result<Vec<Issue>> {
1335        Ok(self.read_index()?.issues().to_vec())
1336    }
1337
1338    /// Whether a known directory has an authoritative in-scope child set.
1339    #[allow(dead_code)] // Consumed by the opened-root coherent read checkpoint.
1340    pub(crate) fn directory_complete(&self, path: &Path) -> crate::Result<Option<bool>> {
1341        Ok(self.read_index()?.directory_complete(path))
1342    }
1343
1344    /// Owned roll-up state for a relative directory path.
1345    pub fn rollup(&self, path: &Path) -> crate::Result<Option<RollUp>> {
1346        Ok(self.read_index()?.rollup(path))
1347    }
1348
1349    /// Owned metadata for a relative path.
1350    pub fn attrs(&self, path: &Path) -> crate::Result<Option<Attrs>> {
1351        Ok(self.read_index()?.attrs(path).copied())
1352    }
1353
1354    /// Entry kind for a relative path.
1355    pub fn kind(&self, path: &Path) -> crate::Result<Option<EntryKind>> {
1356        Ok(self.read_index()?.kind(path))
1357    }
1358
1359    /// Current visible state for a relative path.
1360    pub fn path_state(&self, path: &Path) -> crate::Result<PathState> {
1361        Ok(self.read_index()?.path_state(path))
1362    }
1363
1364    /// Conditional baseline for a producer operating on a shared index.
1365    pub fn expectation(&self, path: &Path) -> crate::Result<PathExpectation> {
1366        Ok(self.read_index()?.expectation(path))
1367    }
1368
1369    /// Owned exact commits after `clock`.
1370    pub fn since(&self, clock: Clock) -> crate::Result<Since> {
1371        Ok(self.read_index()?.since(clock))
1372    }
1373
1374    /// Direct children captured coherently at one read boundary.
1375    ///
1376    /// On an index that did not observe control state each child's
1377    /// [`ChildSnapshot::ignored`] and [`ChildSnapshot::partitions`] are `None`, the way
1378    /// [`Index::is_ignored`] refuses; the names, metadata, and roll-ups still answer.
1379    pub fn children(&self, path: &Path) -> crate::Result<Option<Vec<ChildSnapshot>>> {
1380        let index = self.read_index()?;
1381        let Some(children) = index.children(path) else {
1382            return Ok(None);
1383        };
1384        let observed = index.observes_controls();
1385        Ok(Some(
1386            children
1387                .map(|(name, id)| {
1388                    let entry = index.entry(id);
1389                    ChildSnapshot {
1390                        id,
1391                        name: name.to_os_string(),
1392                        kind: entry.kind,
1393                        attrs: entry.attrs,
1394                        ignored: observed.then_some(entry.ignored),
1395                        rollup: entry
1396                            .kind
1397                            .is_dir()
1398                            .then(|| index.named_rollup(&entry.rollup().all)),
1399                        partitions: (observed && entry.kind.is_dir())
1400                            .then(|| index.named_partitions(entry.rollup())),
1401                    }
1402                })
1403                .collect(),
1404        ))
1405    }
1406
1407    /// Capture one coherent owned index image, releasing the lock before callers do
1408    /// serialization, filesystem I/O, conversion, or other potentially blocking work.
1409    pub fn snapshot(&self) -> crate::Result<Index> {
1410        let mut snapshot = self.read_index()?.clone();
1411        snapshot.serving = None;
1412        Ok(snapshot)
1413    }
1414
1415    pub(crate) fn child_states(
1416        &self,
1417        path: &Path,
1418    ) -> crate::Result<BTreeMap<OsString, PathExpectation>> {
1419        let index = self.read_index()?;
1420        Ok(collect_child_expectations(&index, path))
1421    }
1422
1423    /// Child baselines for one opened-root listing, with whether the index does not yet
1424    /// hold that directory's child set as complete, read at one boundary.
1425    pub(crate) fn listing_baseline(
1426        &self,
1427        path: &Path,
1428    ) -> crate::Result<(BTreeMap<OsString, PathExpectation>, bool)> {
1429        let index = self.read_index()?;
1430        Ok((collect_child_expectations(&index, path), index.directory_complete(path) != Some(true)))
1431    }
1432
1433    pub(crate) fn has_control(&self, path: &Path) -> crate::Result<bool> {
1434        Ok(self.read_index()?.control_table().contains(path))
1435    }
1436
1437    pub(crate) fn take_pending_invalidations(
1438        &self,
1439    ) -> crate::Result<Vec<(PathBuf, InvalidateReason)>> {
1440        Ok(self.write_index()?.take_pending_invalidations())
1441    }
1442
1443    pub(crate) fn restore_pending_invalidations(
1444        &self,
1445        invalidations: Vec<(PathBuf, InvalidateReason)>,
1446    ) -> crate::Result<()> {
1447        self.write_index()?.restore_pending_invalidations(invalidations);
1448        Ok(())
1449    }
1450
1451    pub(crate) fn begin_reconcile(&self, path: &Path) -> crate::Result<(u64, Option<Commit>)> {
1452        self.write_index()?.begin_reconcile(path)
1453    }
1454
1455    pub(crate) fn finish_reconcile(
1456        &self,
1457        path: &Path,
1458        started_at: u64,
1459        complete: bool,
1460        listed_incomplete: &[PathBuf],
1461        failed_paths: &[PathBuf],
1462        errors: ReconcileErrors<'_>,
1463    ) -> crate::Result<ReconcileFinish> {
1464        self.write_index()?.finish_reconcile(
1465            path,
1466            started_at,
1467            complete,
1468            listed_incomplete,
1469            failed_paths,
1470            errors,
1471        )
1472    }
1473
1474    pub(crate) fn apply_reconcile(
1475        &self,
1476        started_at: u64,
1477        observation: &Observation,
1478    ) -> crate::Result<ApplyOutcome> {
1479        self.apply_reconcile_with(started_at, observation, None, BatchProvenance::Public)
1480    }
1481
1482    pub(crate) fn apply_opened_reconcile(
1483        &self,
1484        started_at: u64,
1485        observation: &Observation,
1486        max_files: Option<u64>,
1487    ) -> crate::Result<ApplyOutcome> {
1488        self.apply_reconcile_with(started_at, observation, max_files, BatchProvenance::Opened)
1489    }
1490
1491    fn apply_reconcile_with(
1492        &self,
1493        started_at: u64,
1494        observation: &Observation,
1495        max_files: Option<u64>,
1496        provenance: BatchProvenance,
1497    ) -> crate::Result<ApplyOutcome> {
1498        let prepared = prepare_observation(observation)?;
1499        let mut index = self.write_index()?;
1500        if index
1501            .active_reconciles
1502            .get(&started_at)
1503            .is_some_and(|active| active.refuses(observation, &index))
1504        {
1505            let stats = ApplyStats {
1506                stale: u64::try_from(observation.len()).unwrap_or(u64::MAX),
1507                ..ApplyStats::default()
1508            };
1509            record_batch(provenance, observation.len(), stats);
1510            return Ok(ApplyOutcome { stats, commit: None });
1511        }
1512        let outcome = index.commit_prepared_with(
1513            prepared,
1514            true,
1515            None,
1516            None,
1517            max_files,
1518            matches!(provenance, BatchProvenance::Opened),
1519        )?;
1520        record_batch(provenance, observation.len(), outcome.stats);
1521        Ok(outcome)
1522    }
1523
1524    #[cfg(feature = "watch")]
1525    pub(crate) fn apply_if_clock(
1526        &self,
1527        clock: Clock,
1528        observation: &Observation,
1529    ) -> crate::Result<Option<ApplyOutcome>> {
1530        let prepared = prepare_observation(observation)?;
1531        let mut index = self.write_index()?;
1532        if index.clock() != clock {
1533            return Ok(None);
1534        }
1535        index.commit_prepared(prepared, true).map(Some)
1536    }
1537
1538    #[cfg(feature = "watch")]
1539    pub(crate) fn apply_opened_if_clock(
1540        &self,
1541        clock: Clock,
1542        observation: &Observation,
1543        max_files: Option<u64>,
1544    ) -> crate::Result<Option<ApplyOutcome>> {
1545        let prepared = prepare_observation(observation)?;
1546        let mut index = self.write_index()?;
1547        if index.clock() != clock {
1548            return Ok(None);
1549        }
1550        index.commit_prepared_with(prepared, true, None, None, max_files, true).map(Some)
1551    }
1552
1553    #[cfg(feature = "watch")]
1554    pub(crate) fn unknown_ancestry(
1555        &self,
1556        observation: &Observation,
1557    ) -> crate::Result<Vec<(PathBuf, PathBuf)>> {
1558        let prepared = prepare_observation(observation)?;
1559        let index = self.read_index()?;
1560        let accepted = index.accepted_operations(&prepared.ops);
1561        Ok(index.unknown_ancestry(&prepared.ops, &accepted))
1562    }
1563
1564    #[cfg(feature = "watch")]
1565    pub(crate) fn watch_boundary(&self) -> crate::Result<(PathBuf, ScanScope, Clock)> {
1566        let index = self.read_index()?;
1567        Ok((index.root_path().to_path_buf(), index.scope(), index.clock()))
1568    }
1569
1570    #[cfg(feature = "watch")]
1571    pub(crate) fn invalidate_root(&self, reason: InvalidateReason) -> crate::Result<ApplyOutcome> {
1572        self.apply(&Observation::new(vec![Op::InvalidateSubtree { path: PathBuf::new(), reason }]))
1573    }
1574}
1575
1576/// Private parent-first builder for a cold index that is not yet externally visible.
1577///
1578/// Directory groups arrive while filesystem workers are still running. Applying each
1579/// group here overlaps structural construction with the walk without turning the cold
1580/// path back into public observations or manufacturing one full path per file.
1581pub(crate) struct DetachedIndexBuilder {
1582    index: Index,
1583    directory_ids: HashMap<PathBuf, EntryId>,
1584    /// Directories whose name one listing repeated. The walker lists each of them, and
1585    /// everything below it, once per observation.
1586    repeated_directories: Vec<PathBuf>,
1587    inserted: u64,
1588}
1589
1590impl DetachedIndexBuilder {
1591    pub(crate) fn new(
1592        root_path: impl Into<PathBuf>,
1593        scope: ScanScope,
1594        types: std::sync::Arc<crate::classify::TypeRegistry>,
1595    ) -> Self {
1596        let mut index = Index::new_with_scope_and_types(root_path, scope, types);
1597        index.entry_mut(EntryId::ROOT).directory_mut().children =
1598            DirectoryChildren::Sorted(Vec::new());
1599        Self {
1600            index,
1601            directory_ids: HashMap::from([(PathBuf::new(), EntryId::ROOT)]),
1602            repeated_directories: Vec::new(),
1603            inserted: 0,
1604        }
1605    }
1606
1607    /// Refuse control sources past either of `limits` while building.
1608    pub(crate) fn with_control_limits(mut self, limits: crate::control::ControlLimits) -> Self {
1609        self.index.set_control_limits(limits);
1610        self
1611    }
1612
1613    /// Consume one listing after its parent listing has already been consumed.
1614    ///
1615    /// An enumerator can repeat a name while its directory is modified, which the
1616    /// streaming reducer absorbs as a re-upsert. Here a listing keeps the last observation
1617    /// of each name. A directory observed twice is also listed twice, and so is everything
1618    /// below it: the first listing to arrive for each such directory builds it, and a
1619    /// repeat is accepted without being applied again. A filesystem race must not fail
1620    /// the scan.
1621    pub(crate) fn push_directory(
1622        &mut self,
1623        directory: crate::scan::DetachedDirectory,
1624    ) -> crate::Result<()> {
1625        let crate::scan::DetachedDirectory { path, mut children, control } = directory;
1626        // No descendant can become claimable until its parent's listing has been sent,
1627        // so the first listing of a directory finds its lookup entry. Retire the entry
1628        // now instead of retaining every walked directory path until the end of the scan.
1629        let Some(parent) = self.directory_ids.remove(&path) else {
1630            if self.repeated_directories.iter().any(|repeated| path.starts_with(repeated)) {
1631                return Ok(());
1632            }
1633            return Err(crate::Error::UnknownAncestry { path, reconcile_from: PathBuf::new() });
1634        };
1635
1636        // The worker publishes this listing before descendants become claimable. Apply
1637        // its complete fixed-control state before classifying any sibling, and every
1638        // later child listing will therefore inherit all governing controls without a
1639        // post-build subtree reclassification pass.
1640        if let Some(control) = control {
1641            match control {
1642                Op::ControlUpsert { path, source } => {
1643                    self.index.controls.upsert(&path, source)?;
1644                }
1645                Op::ControlRemove { path } => {
1646                    self.index.controls.remove(&path)?;
1647                }
1648                _ => unreachable!("detached directory retains only fixed-control operations"),
1649            }
1650            self.inserted = self.inserted.saturating_add(1);
1651        }
1652
1653        // Allocate in name order, the order the directory retains its children in, keeping
1654        // the last observation of a repeated name. The sort is unstable so that it needs
1655        // no scratch allocation; the enumeration position is what keeps "last" exact.
1656        children.sort_unstable_by(|left, right| {
1657            left.name.cmp(&right.name).then(left.position.cmp(&right.position))
1658        });
1659        let repeated_directories = &mut self.repeated_directories;
1660        children.dedup_by(|later, kept| {
1661            if later.name != kept.name {
1662                return false;
1663            }
1664            if later.kind.is_dir() || kept.kind.is_dir() {
1665                let repeated = path.join(&kept.name);
1666                if repeated_directories.last() != Some(&repeated) {
1667                    repeated_directories.push(repeated);
1668                }
1669            }
1670            std::mem::swap(later, kept);
1671            true
1672        });
1673
1674        let parent_ignored = self.index.entry(parent).ignored;
1675        let mut match_path =
1676            (!parent_ignored && !self.index.controls.is_empty()).then(|| path.clone());
1677        self.index.reserve_detached_children(parent, children.len());
1678        for child in children {
1679            let crate::scan::DetachedChild { name, kind, attrs, .. } = child;
1680            crate::counters::bump(|counts| counts.upserts += 1);
1681            let ext_id = (kind == EntryKind::File)
1682                .then(|| self.index.intern_ext(&crate::classify::ext_bucket(&name)));
1683            let (ignored, child_path) = if let Some(scratch) = &mut match_path {
1684                scratch.push(&name);
1685                let ignored = self.index.controls.matcher_for(scratch).is_ignored(kind.is_dir());
1686                let child_path = kind.is_dir().then(|| scratch.clone());
1687                let popped = scratch.pop();
1688                debug_assert!(popped);
1689                (ignored, child_path)
1690            } else {
1691                (parent_ignored, kind.is_dir().then(|| path.join(&name)))
1692            };
1693            let child_id = self.index.alloc(Entry::new_detached(
1694                NewEntry {
1695                    parent: Some(parent),
1696                    name,
1697                    ext_id,
1698                    ignored,
1699                    source: Source::Scanned,
1700                    kind,
1701                    attrs,
1702                },
1703                false,
1704            ));
1705            self.index.push_detached_child(parent, child_id);
1706            // Fold the child's own direct contribution while filesystem work is still
1707            // in flight. Files are now complete; directories will add only their
1708            // descendant roll-up in the short bottom-up finish pass.
1709            let direct = self.index.contribution(child_id);
1710            crate::counters::bump(|counts| counts.rollup_merges += 1);
1711            self.index.entry_mut(parent).rollup_mut().merge(&direct);
1712            if let Some(child_path) = child_path {
1713                self.directory_ids.insert(child_path, child_id);
1714            }
1715            self.inserted = self.inserted.saturating_add(1);
1716        }
1717        Ok(())
1718    }
1719
1720    /// Complete the private baseline after every directory listing has arrived.
1721    pub(crate) fn finish(mut self) -> Index {
1722        // Parents are allocated before descendants, so reverse arena order is a valid
1723        // bottom-up traversal. Direct contributions were merged during the pipelined
1724        // build; only completed directory descendants remain to propagate here.
1725        for slot in (1..self.index.arena.len()).rev() {
1726            let id = EntryId {
1727                slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
1728                generation: 0,
1729            };
1730            if !self.index.entry(id).kind.is_dir() {
1731                continue;
1732            }
1733            let parent = self.index.entry(id).parent.expect("every non-root entry has a parent");
1734            // The directory's own count was merged when its parent listing arrived.
1735            crate::counters::bump(|counts| counts.rollup_merges += 1);
1736            self.index.merge_detached_descendants(parent, id);
1737        }
1738
1739        crate::counters::bump(|counts| {
1740            counts.baseline_batches = counts.baseline_batches.saturating_add(1);
1741            counts.baseline_accepted_ops =
1742                counts.baseline_accepted_ops.saturating_add(self.inserted);
1743        });
1744        self.index.establish_baseline();
1745        self.index
1746    }
1747}
1748
1749impl Index {
1750    /// Create an empty index rooted at `root_path`, under [`ScanScope::default`].
1751    ///
1752    /// That is the scope of [`ScanConfig::default`](crate::ScanConfig), which observes
1753    /// control state, so this index answers [`Self::is_ignored`], [`Self::controls`], and
1754    /// the partition accessors ([`Self::partition_total`], [`Self::partition_rollup`], and
1755    /// [`Self::partition_rollup_summary`]), and it accepts control input. Build any other
1756    /// scope, including one that turns control observation off, with
1757    /// [`Self::new_with_scope`].
1758    pub fn new(root_path: impl Into<PathBuf>) -> Self {
1759        Self::new_with_scope(root_path, ScanScope::default())
1760    }
1761
1762    /// Create an empty index with an explicit semantic scan scope.
1763    ///
1764    /// Its control table applies the default
1765    /// [`ControlLimits`](crate::control::ControlLimits), whatever limits the scope was
1766    /// taken under, so a snapshot of it saves only when those agree. Build an index for any
1767    /// other limits with [`Self::new_with_config`].
1768    pub fn new_with_scope(root_path: impl Into<PathBuf>, scope: ScanScope) -> Self {
1769        Self::new_with_scope_and_types(
1770            root_path,
1771            scope,
1772            crate::classify::TypeRegistry::compiled_shared(),
1773        )
1774    }
1775
1776    /// Create an empty index with the scope, file-type rules, and control limits of
1777    /// `config`, as the scans behind [`crate::open`] and [`crate::OpenedIndex`] do.
1778    ///
1779    /// The scope and the control table come from one configuration, so the table enforces
1780    /// exactly the limits the scope's ignore-rules identity claims.
1781    pub fn new_with_config(root_path: impl Into<PathBuf>, config: &crate::ScanConfig) -> Self {
1782        let mut index =
1783            Self::new_with_scope_and_types(root_path, config.scope(), config.types_shared());
1784        index.set_control_limits(config.control_limits);
1785        index
1786    }
1787
1788    /// Create an index whose registry is part of its validated semantic scope.
1789    pub(crate) fn new_with_scope_and_types(
1790        root_path: impl Into<PathBuf>,
1791        scope: ScanScope,
1792        types: std::sync::Arc<crate::classify::TypeRegistry>,
1793    ) -> Self {
1794        Self::new_with_scope_types_and_journal_capacity_bytes(
1795            root_path,
1796            scope,
1797            types,
1798            DEFAULT_JOURNAL_CAPACITY_BYTES,
1799        )
1800    }
1801
1802    pub(crate) fn new_with_scope_types_and_journal_capacity_bytes(
1803        root_path: impl Into<PathBuf>,
1804        scope: ScanScope,
1805        types: std::sync::Arc<crate::classify::TypeRegistry>,
1806        journal_capacity_bytes: usize,
1807    ) -> Self {
1808        assert_eq!(
1809            scope.type_rules_fingerprint,
1810            types.fingerprint(),
1811            "an index's registry must match its semantic scope"
1812        );
1813        Self::new_with_journal_capacity_bytes(root_path, scope, journal_capacity_bytes, types, None)
1814    }
1815
1816    /// Create the retained index behind an opened root, including its serving orders.
1817    pub(crate) fn new_opened_with_scope_types_and_journal_capacity_bytes(
1818        root_path: impl Into<PathBuf>,
1819        scope: ScanScope,
1820        types: std::sync::Arc<crate::classify::TypeRegistry>,
1821        journal_capacity_bytes: usize,
1822    ) -> Self {
1823        assert_eq!(
1824            scope.type_rules_fingerprint,
1825            types.fingerprint(),
1826            "an index's registry must match its semantic scope"
1827        );
1828        let serving = ServingIndexes::for_types(&types);
1829        Self::new_with_journal_capacity_bytes(
1830            root_path,
1831            scope,
1832            journal_capacity_bytes,
1833            types,
1834            Some(Box::new(serving)),
1835        )
1836    }
1837
1838    fn new_with_journal_capacity_bytes(
1839        root_path: impl Into<PathBuf>,
1840        scope: ScanScope,
1841        journal_capacity_bytes: usize,
1842        types: std::sync::Arc<crate::classify::TypeRegistry>,
1843        serving: Option<Box<ServingIndexes>>,
1844    ) -> Self {
1845        let root = Entry::new(
1846            NewEntry {
1847                parent: None,
1848                name: OsString::new(),
1849                ext_id: None,
1850                ignored: false,
1851                source: Source::Scanned,
1852                kind: EntryKind::Dir,
1853                attrs: Attrs::default(),
1854            },
1855            true,
1856        );
1857        let constructed_at_ns = Self::now_unix_nanos();
1858        Self {
1859            root_path: root_path.into(),
1860            scope,
1861            arena: vec![Slot::Occupied { generation: 0, entry: root }],
1862            free_head: None,
1863            live: 1,
1864            clock: Clock::ZERO,
1865            journal: VecDeque::new(),
1866            journal_cost: 0,
1867            journal_capacity_bytes,
1868            journal_floor: Clock::ZERO,
1869            pending_invalidations: Vec::new(),
1870            freshness_epoch: 0,
1871            freshness_marks: BTreeMap::new(),
1872            state: IndexState::default(),
1873            issues: Vec::new(),
1874            issue_epochs: Vec::new(),
1875            omitted_issue_epochs: BTreeMap::new(),
1876            serving,
1877            applying_source: Source::Scanned,
1878            scanned_at_ns: constructed_at_ns,
1879            captured_at_ns: 0,
1880            writing_pass_started_at_ns: constructed_at_ns,
1881            persistence_owed: true,
1882            active_root_reconciles: BTreeMap::new(),
1883            active_reconciles: BTreeMap::new(),
1884            verified: Vec::new(),
1885            ext_names: Vec::new(),
1886            ext_ids: BTreeMap::new(),
1887            ext_refcounts: Vec::new(),
1888            free_ext_ids: Vec::new(),
1889            content: None,
1890            types,
1891            controls: crate::control::ControlTable::default(),
1892            unreadable_control_paths: BTreeSet::new(),
1893        }
1894    }
1895
1896    /// The file-type rules this index classifies against.
1897    pub fn types(&self) -> &crate::classify::TypeRegistry {
1898        self.types.as_ref()
1899    }
1900
1901    /// Exact fixed control state retained by this detached index.
1902    ///
1903    /// # Errors
1904    ///
1905    /// [`crate::Error::ControlStateNotObserved`] when the index was built without
1906    /// observing control state ([`ScanScope::observes_controls`]). Its table is empty
1907    /// because nothing was read, and returning it would claim the tree has no control
1908    /// files.
1909    pub fn controls(&self) -> crate::Result<&crate::control::ControlTable> {
1910        self.require_observed_controls()?;
1911        Ok(&self.controls)
1912    }
1913
1914    /// Whether this index observed `.gitignore` control state, and so can answer
1915    /// [`Self::is_ignored`] and [`Self::controls`].
1916    pub const fn observes_controls(&self) -> bool {
1917        self.scope.observes_controls()
1918    }
1919
1920    /// Whether this index observed `.gitignore` control state, and under which limits.
1921    pub fn control_identity(&self) -> crate::ControlTierIdentity {
1922        if self.observes_controls() {
1923            crate::ControlTierIdentity::Observed { limits: self.controls.limits() }
1924        } else {
1925            crate::ControlTierIdentity::NotObserved
1926        }
1927    }
1928
1929    /// The identity of every tier a snapshot of this index holds.
1930    pub fn snapshot_identity(&self) -> crate::SnapshotIdentity {
1931        crate::SnapshotIdentity {
1932            entries: crate::EntryTierIdentity::of_scope(self.scope),
1933            controls: self.control_identity(),
1934        }
1935    }
1936
1937    fn require_observed_controls(&self) -> crate::Result<()> {
1938        if self.observes_controls() { Ok(()) } else { Err(crate::Error::ControlStateNotObserved) }
1939    }
1940
1941    /// Whether a batch carries control input this index must refuse because its scope
1942    /// observes no control state.
1943    ///
1944    /// Accepted, such input installed a table and reclassified entries under a scope that
1945    /// says no rule was read: `is_ignored` refused over classification the index held, and
1946    /// a snapshot saved from it loaded into an open that turned observation off as an exact
1947    /// scope match (`fdu-agb6`). Every operation counts, accepted or stale, so the refusal
1948    /// does not depend on the index's state.
1949    fn carries_unobserved_control_input(&self, ops: &[ObservationOp]) -> bool {
1950        !self.observes_controls()
1951            && ops.iter().any(|observed| {
1952                matches!(observed.op, Op::ControlUpsert { .. } | Op::ControlRemove { .. })
1953            })
1954    }
1955
1956    /// The retained control table whatever the scope: empty when nothing was observed.
1957    ///
1958    /// For the engine's own maintenance, which compares what it retains against what it
1959    /// reads and so needs no claim about coverage.
1960    pub(crate) fn control_table(&self) -> &crate::control::ControlTable {
1961        &self.controls
1962    }
1963
1964    /// Whether this index's ignore classification applies every control file in scope.
1965    ///
1966    /// [`crate::control::ControlCoverage::NotObserved`] when the index read no control
1967    /// file. Otherwise the limits, the applied and refused counts, and the first refused
1968    /// files. Sizes and counts are exact either way; only the ignored and unignored split
1969    /// below a refused file is not.
1970    pub fn control_coverage(&self) -> crate::control::ControlCoverage {
1971        if self.observes_controls() {
1972            crate::control::ControlCoverage::Observed(self.controls.observation())
1973        } else {
1974            crate::control::ControlCoverage::NotObserved
1975        }
1976    }
1977
1978    /// Refuse control sources past either of `limits`, as the scan configuration that
1979    /// builds this index asks. Set once, before any control input arrives: a table's
1980    /// refusals are only meaningful under the limits that made them.
1981    pub(crate) fn set_control_limits(&mut self, limits: crate::control::ControlLimits) {
1982        debug_assert!(self.controls.is_vacant(), "the control limits are set before any control");
1983        self.controls = crate::control::ControlTable::with_limits(limits);
1984    }
1985
1986    /// Refuse `limits` unless they are the ones this index's scope was taken under.
1987    ///
1988    /// A scope that observes control state names its limits in its ignore-rules identity,
1989    /// and an index's scope and its table must never disagree: a table refusing under other
1990    /// limits would be served, and saved, as if it applied the scope's. A scope that
1991    /// observes nothing retains no table, so its limits decide nothing.
1992    ///
1993    /// The guard is for [`crate::snapshot::save`], whose index may have been built with a
1994    /// scope and a table set apart (as [`Self::new_with_scope`] does), and for callers of
1995    /// [`Self::install_controls`] other than the loader. On the load path it cannot fail,
1996    /// because the loader builds the scope and the table from the same header limits.
1997    pub(crate) fn require_control_limits_in_scope(
1998        &self,
1999        limits: crate::control::ControlLimits,
2000    ) -> crate::Result<()> {
2001        if self.scope.observes_controls()
2002            && (crate::ControlTierIdentity::Observed { limits }).ignore_rules_fingerprint()
2003                != self.scope.ignore_rules_fingerprint
2004        {
2005            return Err(crate::Error::ControlLimitsOutsideScope { limits });
2006        }
2007        Ok(())
2008    }
2009
2010    /// Install a complete control table while restoring a detached snapshot.
2011    pub(crate) fn install_controls(
2012        &mut self,
2013        controls: crate::control::ControlTable,
2014    ) -> crate::Result<()> {
2015        self.require_control_limits_in_scope(controls.limits())?;
2016        // Every source a table retains was admitted under its own budget, and the charge
2017        // does not depend on admission order, so a larger total was not written by one.
2018        if controls.limits().budget.is_some_and(|budget| controls.retained_cost() > budget) {
2019            return Err(crate::Error::Snapshot(
2020                "a snapshot's control table exceeds its own control budget".into(),
2021            ));
2022        }
2023        // A scope that observed no control state retains no table and refuses nothing; a
2024        // snapshot carrying either under such a scope was not written by a scan that
2025        // honoured it.
2026        if !controls.is_vacant() {
2027            self.require_observed_controls()?;
2028        }
2029        // Every entry's ignored bit agrees with the table it replaces, so when neither table
2030        // governs anything no bit can move. Walking the tree to confirm it allocated a path
2031        // per entry on every snapshot load, including the common load with no controls.
2032        let unchanged = controls.is_empty() && self.controls.is_empty();
2033        self.controls = controls;
2034        if unchanged {
2035            return Ok(());
2036        }
2037        let mut stats = ApplyStats::default();
2038        let mut effects = NoConsequences;
2039        self.reclassify_controlled_subtrees(&[PathBuf::new()], &mut stats, &mut effects);
2040        Ok(())
2041    }
2042
2043    /// Share the registry with background analysis workers.
2044    pub(crate) fn types_shared(&self) -> std::sync::Arc<crate::classify::TypeRegistry> {
2045        std::sync::Arc::clone(&self.types)
2046    }
2047
2048    /// Classify one relative path under this index's rules, without opening the file.
2049    pub fn classify(&self, relative_path: &Path) -> crate::classify::Classification {
2050        crate::classify::classify_with(&self.types, relative_path, None)
2051    }
2052
2053    #[cfg(test)]
2054    fn with_journal_capacity_bytes(
2055        root_path: impl Into<PathBuf>,
2056        journal_capacity_bytes: usize,
2057    ) -> Self {
2058        Self::new_with_journal_capacity_bytes(
2059            root_path,
2060            ScanScope::default(),
2061            journal_capacity_bytes,
2062            crate::classify::TypeRegistry::compiled_shared(),
2063            None,
2064        )
2065    }
2066
2067    /// The absolute path this index is rooted at.
2068    pub fn root_path(&self) -> &Path {
2069        &self.root_path
2070    }
2071
2072    /// Semantic scope represented by this index and any snapshot written from it.
2073    pub const fn scope(&self) -> ScanScope {
2074        self.scope
2075    }
2076
2077    /// Trust state for the whole index.
2078    pub fn freshness(&self) -> Freshness {
2079        self.freshness_at(Path::new(""))
2080    }
2081
2082    /// The freshness the coherent [`IndexState`] publishes for the root.
2083    ///
2084    /// Subtree marks decide it, with one exception: while the observation handoff owns the
2085    /// root -- the `Reconciling` phase -- the root does not become `Fresh` until `Watching`
2086    /// says the handoff verified it. The handoff's own full pass clears the root's mark
2087    /// before the hints captured behind it are drained, and `Fresh` beside `Reconciling`
2088    /// promised a verified root the handoff had not delivered yet. Stale and partial marks
2089    /// still show through, since they say something the handoff has not yet disproved.
2090    fn published_freshness(&self) -> Freshness {
2091        let derived = self.freshness();
2092        if self.state.phase == LifecyclePhase::Reconciling && derived == Freshness::Fresh {
2093            Freshness::Reconciling
2094        } else {
2095            derived
2096        }
2097    }
2098
2099    /// Coherent state at the current clock.
2100    pub(crate) const fn state(&self) -> IndexState {
2101        self.state
2102    }
2103
2104    #[allow(dead_code)] // Consumed through `IndexHandle` by the next vertical slice.
2105    pub(crate) fn issues(&self) -> &[Issue] {
2106        &self.issues
2107    }
2108
2109    /// Whether the complete in-scope child set of a known directory is authoritative.
2110    #[allow(dead_code)] // Consumed through `IndexHandle` by the next vertical slice.
2111    pub(crate) fn directory_complete(&self, path: &Path) -> Option<bool> {
2112        let id = self.lookup(path)?;
2113        let entry = self.entry(id);
2114        (entry.kind == EntryKind::Dir).then(|| entry.directory().children_complete)
2115    }
2116
2117    /// Trust state for one subtree, including any stale descendant it contains.
2118    pub fn freshness_at(&self, path: &Path) -> Freshness {
2119        self.freshness_marks
2120            .iter()
2121            .filter(|(marked, _)| path.starts_with(marked) || marked.starts_with(path))
2122            .map(|(_, mark)| mark.state)
2123            .max_by_key(|state| state.rank())
2124            .unwrap_or(Freshness::Fresh)
2125    }
2126
2127    /// The clock of the most recently applied commit.
2128    pub fn clock(&self) -> Clock {
2129        self.clock
2130    }
2131
2132    /// Number of live entries, including the root.
2133    pub fn len(&self) -> u64 {
2134        self.live
2135    }
2136
2137    /// True when the index holds nothing but its root.
2138    pub fn is_empty(&self) -> bool {
2139        self.live <= 1
2140    }
2141
2142    /// Owned, self-describing roll-up state for the whole tree.
2143    pub fn total(&self) -> RollUp {
2144        self.named_rollup(&self.entry(EntryId::ROOT).rollup().all)
2145    }
2146
2147    /// Both fixed aggregate partitions for the complete tree.
2148    ///
2149    /// # Errors
2150    ///
2151    /// [`crate::Error::ControlStateNotObserved`] when the index did not observe control
2152    /// state: its unignored partition equals `all` only because no rule was read.
2153    /// [`Self::total`] answers the `all` partition for any index.
2154    pub fn partition_total(&self) -> crate::Result<PartitionRollUp> {
2155        self.require_observed_controls()?;
2156        Ok(self.named_partitions(self.entry(EntryId::ROOT).rollup()))
2157    }
2158
2159    /// Map-free whole-tree totals for in-crate reporting paths.
2160    pub(crate) fn total_scalars(&self) -> RollUpScalars {
2161        RollUpScalars::from(&self.entry(EntryId::ROOT).rollup().all)
2162    }
2163
2164    /// Arbitrate a producer observation and commit its effective mutations.
2165    ///
2166    /// Conditional operations are accepted only while their baseline still matches.
2167    /// No-ops and stale operations do not advance the clock or enter the journal.
2168    ///
2169    /// A control operation on an index that does not observe control state
2170    /// ([`Self::observes_controls`]) fails the whole batch with
2171    /// [`crate::Error::ControlStateNotObserved`], whatever its baseline.
2172    pub fn apply(&mut self, observation: &Observation) -> crate::Result<ApplyOutcome> {
2173        let prepared = prepare_observation(observation)?;
2174        let outcome = self.commit_prepared(prepared, true)?;
2175        record_batch(BatchProvenance::Public, observation.len(), outcome.stats);
2176        Ok(outcome)
2177    }
2178
2179    /// Arbitrate and atomically apply normalized producer input.
2180    fn commit_prepared(
2181        &mut self,
2182        prepared: PreparedObservation,
2183        journal: bool,
2184    ) -> crate::Result<ApplyOutcome> {
2185        self.commit_prepared_with(prepared, journal, None, None, None, false)
2186    }
2187
2188    fn commit_prepared_with(
2189        &mut self,
2190        prepared: PreparedObservation,
2191        journal: bool,
2192        discovery: Option<DiscoveryCommit>,
2193        observation: Option<ObservationTransition>,
2194        max_files: Option<u64>,
2195        track_file_progress: bool,
2196    ) -> crate::Result<ApplyOutcome> {
2197        if prepared.ops.is_empty()
2198            && discovery.as_ref().is_none_or(|discovery| {
2199                discovery.directory_complete.is_none() && discovery.transition.is_none()
2200            })
2201            && observation.is_none()
2202        {
2203            return Ok(ApplyOutcome::default());
2204        }
2205
2206        // A stopped or failed root is terminal for discovery. A listing that lands after
2207        // the stop -- a refresh can trip the shared budget while discovery is mid-walk --
2208        // may neither expand the retained set nor carry a transition that reopens the
2209        // phase: `Finish` would declare the root `Ready`, and an inaccessible boundary
2210        // would relabel why its coverage is partial.
2211        if discovery.is_some()
2212            && matches!(self.state.phase, LifecyclePhase::Stopped | LifecyclePhase::Failed)
2213        {
2214            return Err(crate::Error::OpenedIndexStopped);
2215        }
2216
2217        let mut discovery = discovery;
2218        if let Some(path) =
2219            discovery.as_mut().and_then(|discovery| discovery.directory_complete.as_mut())
2220        {
2221            // The transition this commit publishes carries the canonical relative path,
2222            // not the producer's spelling: a `DirectoryComplete` was only ever canonical
2223            // because discovery happened to build it that way.
2224            let canonical = canonical_relative_path(path)?;
2225            let Some(id) = self.lookup(&canonical) else {
2226                return Err(crate::Error::InvalidDirectoryCompletion(canonical));
2227            };
2228            if self.entry(id).kind != EntryKind::Dir {
2229                return Err(crate::Error::InvalidDirectoryCompletion(canonical));
2230            }
2231            *path = canonical;
2232        }
2233
2234        #[cfg(test)]
2235        if prepared.reject_before_apply {
2236            return Err(crate::Error::CommitRejected("injected reducer preflight"));
2237        }
2238
2239        let Some(next_clock) = self.clock.checked_next() else {
2240            // At the terminal clock, an all-no-op or all-stale batch is still a valid
2241            // observation. Probe on a detached clone to distinguish it from a real
2242            // change without touching the original index.
2243            let mut probe = self.clone();
2244            probe.clock = Clock(self.clock.0 - 1);
2245            let outcome = probe.commit_prepared_with(
2246                prepared,
2247                false,
2248                discovery,
2249                observation,
2250                max_files,
2251                track_file_progress,
2252            )?;
2253            return if outcome.commit.is_some() {
2254                Err(crate::Error::ClockExhausted)
2255            } else {
2256                Ok(outcome)
2257            };
2258        };
2259
2260        let observed = u64::try_from(prepared.ops.len()).unwrap_or(u64::MAX);
2261        let mut effects = ExactConsequences::default();
2262        let stats = self.reduce_prepared(
2263            &prepared,
2264            discovery,
2265            observation,
2266            max_files,
2267            track_file_progress,
2268            &mut effects,
2269        )?;
2270
2271        if effects.is_empty() {
2272            return Ok(ApplyOutcome::from_commit(stats, None));
2273        }
2274
2275        let commit =
2276            self.publish_effects(next_clock, effects, commit_work(observed, stats), journal);
2277        Ok(ApplyOutcome::from_commit(stats, Some(commit)))
2278    }
2279
2280    /// Apply one prepared batch through the shared fact and roll-up reducer.
2281    ///
2282    /// `C` is selected once by the caller. The exact instantiation retains closures as
2283    /// commits; the detached instantiation erases them, including their path copies.
2284    fn reduce_prepared<C: ConsequenceSink>(
2285        &mut self,
2286        prepared: &PreparedObservation,
2287        discovery: Option<DiscoveryCommit>,
2288        observation: Option<ObservationTransition>,
2289        max_files: Option<u64>,
2290        track_file_progress: bool,
2291        effects: &mut C,
2292    ) -> crate::Result<ApplyStats> {
2293        if self.carries_unobserved_control_input(&prepared.ops) {
2294            return Err(crate::Error::ControlStateNotObserved);
2295        }
2296        if matches!(prepared.ancestry, PreparedAncestry::Scanner { .. }) {
2297            debug_assert!(observation.is_none());
2298            return self.reduce_scanner_prepared(
2299                prepared,
2300                discovery,
2301                max_files,
2302                track_file_progress,
2303                effects,
2304            );
2305        }
2306        let mut stats = ApplyStats::default();
2307        let mut parent_memo = ParentMemo::default();
2308        let accepted = self.accepted_operations(&prepared.ops);
2309        stats.stale = u64::try_from(accepted.iter().filter(|accepted| !**accepted).count())
2310            .unwrap_or(u64::MAX);
2311        self.validate_known_ancestry(&prepared.ops, &accepted)?;
2312        let projected_controls = self.projected_controls(&prepared.ops, &accepted)?;
2313
2314        for (observed, accepted) in prepared.ops.iter().zip(accepted) {
2315            if !accepted {
2316                continue;
2317            }
2318            let op = &observed.op;
2319            if let (Some(max_files), Op::Upsert { path, kind, .. }) = (max_files, op) {
2320                if self.files_after_upsert(path, *kind) > max_files {
2321                    stats.resource_refused = stats.resource_refused.saturating_add(1);
2322                    continue;
2323                }
2324            }
2325            match op {
2326                Op::Upsert { path, kind, attrs } => {
2327                    self.apply_upsert(path, *kind, *attrs, &mut stats, effects, &mut parent_memo);
2328                }
2329                Op::Remove { path } => {
2330                    // A removal takes a subtree with it, so a remembered id inside that
2331                    // subtree would dangle. Both of the non-upsert arms drop the memo
2332                    // rather than reason about whether this particular path could be an
2333                    // ancestor of it: the memo is refilled by the next upsert, so the
2334                    // cost of being conservative is one path resolution.
2335                    parent_memo.clear();
2336                    self.apply_remove(path, &mut stats, effects);
2337                }
2338                Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {
2339                    // The complete table was already prepared above. It is installed
2340                    // once, after ordinary structural mutations, so classification and
2341                    // both reducer partitions become visible atomically.
2342                    parent_memo.clear();
2343                }
2344                Op::InvalidateSubtree { path, reason } => {
2345                    parent_memo.clear();
2346                    let previous_index_state = self.state;
2347                    let previous = self.freshness_at(path);
2348                    self.pending_invalidations.push((path.clone(), *reason));
2349                    self.mark_unfresh(path, Freshness::Stale);
2350                    let current = self.freshness_at(path);
2351                    self.state.freshness = self.published_freshness();
2352                    if matches!(
2353                        reason,
2354                        InvalidateReason::WatchOverflow
2355                            | InvalidateReason::UnpairedRename
2356                            | InvalidateReason::WatchSetupRace
2357                            | InvalidateReason::VerificationFailed
2358                            | InvalidateReason::UnknownAncestry
2359                            | InvalidateReason::WatchContention
2360                    ) {
2361                        self.retain_issue(Issue::observation_gap(path, *reason));
2362                    }
2363                    stats.invalidated += 1;
2364                    effects.change(|| EffectiveChange::Invalidated {
2365                        path: path.clone(),
2366                        reason: *reason,
2367                    });
2368                    if previous != current {
2369                        effects.state(|| StateTransition::Freshness {
2370                            path: path.clone(),
2371                            previous,
2372                            current,
2373                        });
2374                    }
2375                    if previous_index_state != self.state {
2376                        effects.state(|| StateTransition::IndexState {
2377                            previous: previous_index_state,
2378                            current: self.state,
2379                        });
2380                    }
2381                }
2382            }
2383        }
2384
2385        self.finish_reduction(
2386            projected_controls,
2387            &mut stats,
2388            discovery,
2389            observation,
2390            max_files,
2391            track_file_progress,
2392            effects,
2393        );
2394
2395        Ok(stats)
2396    }
2397
2398    /// Apply one scanner batch using only the parent identities proved above.
2399    fn reduce_scanner_prepared<C: ConsequenceSink>(
2400        &mut self,
2401        prepared: &PreparedObservation,
2402        discovery: Option<DiscoveryCommit>,
2403        max_files: Option<u64>,
2404        track_file_progress: bool,
2405        effects: &mut C,
2406    ) -> crate::Result<ApplyStats> {
2407        let PreparedAncestry::Scanner { parents, has_batch_parents } = &prepared.ancestry else {
2408            unreachable!("scanner reduction requires scanner ancestry");
2409        };
2410        debug_assert_eq!(prepared.ops.len(), parents.len());
2411        let projection_started = crate::counters::enabled().then(std::time::Instant::now);
2412        let projected_controls =
2413            self.projected_controls_from(prepared.ops.iter().map(|observed| &observed.op))?;
2414        if let Some(started) = projection_started {
2415            let elapsed = elapsed_micros(started);
2416            crate::counters::bump(|counts| {
2417                counts.scanner_control_projection_us =
2418                    counts.scanner_control_projection_us.saturating_add(elapsed);
2419            });
2420        }
2421        let mut stats = ApplyStats::default();
2422        let mut applied_ids = has_batch_parents.then(|| vec![None; prepared.ops.len()]);
2423
2424        for (op_index, (observed, parent)) in prepared.ops.iter().zip(parents.iter()).enumerate() {
2425            match &observed.op {
2426                Op::Upsert { path, kind, attrs } => {
2427                    if let Some(max_files) = max_files {
2428                        if self.files_after_upsert(path, *kind) > max_files {
2429                            stats.resource_refused = stats.resource_refused.saturating_add(1);
2430                            continue;
2431                        }
2432                    }
2433                    let parent = match parent {
2434                        ResolvedParent::Existing(parent) => *parent,
2435                        ResolvedParent::Earlier(parent_op) => applied_ids
2436                            .as_ref()
2437                            .and_then(|ids| ids.get(*parent_op))
2438                            .copied()
2439                            .flatten()
2440                            .expect("a proved parent directory was applied earlier"),
2441                    };
2442                    let name = path.file_name().expect("scanner upserts are not root mutations");
2443                    crate::counters::bump(|counts| counts.upserts += 1);
2444                    self.upsert_beneath(parent, name, path, *kind, *attrs, &mut stats, effects);
2445                    if kind.is_dir() {
2446                        if let Some(ids) = &mut applied_ids {
2447                            ids[op_index] = self.child(parent, name);
2448                        }
2449                    }
2450                }
2451                Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {}
2452                Op::Remove { .. } | Op::InvalidateSubtree { .. } => {
2453                    unreachable!("scanner preparation rejects non-discovery operations");
2454                }
2455            }
2456        }
2457
2458        self.finish_reduction(
2459            projected_controls,
2460            &mut stats,
2461            discovery,
2462            None,
2463            max_files,
2464            track_file_progress,
2465            effects,
2466        );
2467
2468        Ok(stats)
2469    }
2470
2471    #[allow(clippy::too_many_arguments)] // One shared tail keeps both reducer lanes identical.
2472    fn finish_reduction<C: ConsequenceSink>(
2473        &mut self,
2474        projected_controls: Option<crate::control::ControlTable>,
2475        stats: &mut ApplyStats,
2476        discovery: Option<DiscoveryCommit>,
2477        observation: Option<ObservationTransition>,
2478        max_files: Option<u64>,
2479        track_file_progress: bool,
2480        effects: &mut C,
2481    ) {
2482        self.apply_control_transition(projected_controls, stats, effects);
2483        let mut discovery = discovery;
2484        if stats.resource_refused > 0 {
2485            let max_files = max_files.expect("resource refusal requires a file limit");
2486            let discovery = discovery.get_or_insert_with(DiscoveryCommit::default);
2487            discovery.directory_complete = None;
2488            discovery.transition =
2489                Some(DiscoveryTransition::BudgetRefused(Issue::resource_budget(max_files)));
2490        }
2491        self.apply_opened_state(discovery, observation, track_file_progress, effects);
2492    }
2493
2494    fn apply_opened_state<C: ConsequenceSink>(
2495        &mut self,
2496        discovery: Option<DiscoveryCommit>,
2497        observation: Option<ObservationTransition>,
2498        track_file_progress: bool,
2499        effects: &mut C,
2500    ) {
2501        if discovery.is_none() && observation.is_none() && !track_file_progress {
2502            return;
2503        }
2504        let previous = self.state;
2505        if track_file_progress {
2506            self.state.progress.files_retained = self.total_scalars().files;
2507        }
2508
2509        if let Some(discovery) = discovery {
2510            if let Some(path) = discovery.directory_complete {
2511                let id = self.lookup(&path).expect("discovery completion was preflighted");
2512                if !self.entry(id).directory().children_complete {
2513                    self.entry_mut(id).directory_mut().children_complete = true;
2514                    self.state.progress.directories_complete =
2515                        self.state.progress.directories_complete.saturating_add(1);
2516                    effects.state(|| StateTransition::DirectoryComplete { path });
2517                }
2518            }
2519
2520            if let Some(transition) = discovery.transition {
2521                match transition {
2522                    DiscoveryTransition::Begin => {
2523                        for slot in &mut self.arena {
2524                            if let Slot::Occupied { entry, .. } = slot {
2525                                if entry.kind == EntryKind::Dir {
2526                                    entry.directory_mut().children_complete = false;
2527                                }
2528                            }
2529                        }
2530                        self.state = IndexState {
2531                            phase: LifecyclePhase::Discovering,
2532                            coverage: Coverage::Partial(CoverageReason::Building),
2533                            freshness: Freshness::Fresh,
2534                            source: Source::Scanned,
2535                            progress: DiscoveryProgress::default(),
2536                            issues: crate::IssueSummary::default(),
2537                        };
2538                        self.issues.clear();
2539                        self.issue_epochs.clear();
2540                        self.omitted_issue_epochs.clear();
2541                    }
2542                    DiscoveryTransition::Finish => {
2543                        self.state.phase = LifecyclePhase::Ready;
2544                        if self.state.coverage == Coverage::Partial(CoverageReason::Building) {
2545                            self.state.coverage = Coverage::Complete;
2546                        }
2547                        self.state.freshness = if self.state.coverage == Coverage::Complete {
2548                            Freshness::Fresh
2549                        } else {
2550                            Freshness::Partial
2551                        };
2552                    }
2553                    DiscoveryTransition::BudgetRefused(issue) => {
2554                        let already_stopped_for_budget = self.state.phase
2555                            == LifecyclePhase::Stopped
2556                            && self.state.coverage == Coverage::Partial(CoverageReason::Budget);
2557                        self.state.phase = LifecyclePhase::Stopped;
2558                        self.state.coverage = Coverage::Partial(CoverageReason::Budget);
2559                        self.state.freshness = Freshness::Fresh;
2560                        if !already_stopped_for_budget {
2561                            self.retain_issue(issue);
2562                        }
2563                    }
2564                    DiscoveryTransition::Inaccessible { issues, omitted } => {
2565                        if self.state.coverage != Coverage::Partial(CoverageReason::Budget) {
2566                            self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
2567                            self.state.freshness = Freshness::Partial;
2568                        }
2569                        for issue in issues {
2570                            self.retain_issue(issue);
2571                        }
2572                        self.retain_omitted(omitted);
2573                    }
2574                    DiscoveryTransition::Cancelled => {
2575                        self.state.phase = LifecyclePhase::Stopped;
2576                        if self.state.coverage != Coverage::Complete {
2577                            self.state.coverage = Coverage::Partial(CoverageReason::Cancelled);
2578                        }
2579                    }
2580                    DiscoveryTransition::Failed(issue) => {
2581                        self.state.phase = LifecyclePhase::Failed;
2582                        self.state.coverage = Coverage::Partial(CoverageReason::Failed);
2583                        self.state.freshness = Freshness::Partial;
2584                        self.retain_issue(issue);
2585                    }
2586                }
2587            }
2588        }
2589
2590        if let Some(observation) = observation {
2591            match observation {
2592                ObservationTransition::Reconciling => {
2593                    if self.state.phase == LifecyclePhase::Ready {
2594                        self.state.phase = LifecyclePhase::Reconciling;
2595                        self.state.freshness = Freshness::Reconciling;
2596                    }
2597                }
2598                ObservationTransition::Watching { issues, omitted } => {
2599                    if self.state.phase == LifecyclePhase::Reconciling {
2600                        self.state.phase = LifecyclePhase::Watching;
2601                        if !issues.is_empty() || omitted > 0 {
2602                            self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
2603                            for issue in issues {
2604                                self.retain_issue(issue);
2605                            }
2606                            self.retain_omitted(omitted);
2607                        } else if self.state.coverage
2608                            == Coverage::Partial(CoverageReason::Inaccessible)
2609                        {
2610                            // The handoff has just read the whole root without one error, so
2611                            // a boundary discovery could not read no longer exists. Coverage
2612                            // says what can be known now. That complete pass has already
2613                            // dropped the issues it disproved and recorded completeness for
2614                            // every directory it listed, so nothing below contradicts it.
2615                            self.state.coverage = Coverage::Complete;
2616                        }
2617                        self.state.freshness = self.freshness();
2618                        if self.state.coverage != Coverage::Complete {
2619                            self.state.freshness = Freshness::Partial;
2620                        }
2621                    }
2622                }
2623                ObservationTransition::Unreadable { issues, omitted } => {
2624                    // `Ready` is a shared index watched without an opened-root lifecycle,
2625                    // which never leaves that phase; a stopped or failed root keeps nothing.
2626                    if matches!(self.state.phase, LifecyclePhase::Watching | LifecyclePhase::Ready)
2627                    {
2628                        for issue in issues {
2629                            self.retain_issue(issue);
2630                        }
2631                        self.retain_omitted(omitted);
2632                    }
2633                }
2634                ObservationTransition::Failed(issue) => {
2635                    if self.state.phase != LifecyclePhase::Stopped {
2636                        self.state.phase = LifecyclePhase::Failed;
2637                        self.state.freshness = Freshness::Partial;
2638                        self.retain_issue(issue);
2639                    }
2640                }
2641            }
2642        }
2643
2644        if previous != self.state {
2645            effects.state(|| StateTransition::IndexState { previous, current: self.state });
2646        }
2647    }
2648
2649    /// Exact regular-file total that would remain after one upsert at the current
2650    /// commit boundary.
2651    fn files_after_upsert(&self, path: &Path, kind: EntryKind) -> u64 {
2652        let current_total = self.total_scalars().files;
2653        let Some(id) = self.lookup(path) else {
2654            return current_total.saturating_add(u64::from(kind == EntryKind::File));
2655        };
2656        let current = self.entry(id);
2657        if current.kind == kind {
2658            return current_total;
2659        }
2660        let removed = match current.kind {
2661            EntryKind::File => 1,
2662            EntryKind::Dir => current.rollup().all.files,
2663            _ => 0,
2664        };
2665        current_total.saturating_sub(removed).saturating_add(u64::from(kind == EntryKind::File))
2666    }
2667
2668    /// Retain one issue, once per cause.
2669    ///
2670    /// A cause is its kind and path: a boundary a producer meets again on every re-walk --
2671    /// an unreadable directory, a gap the observer keeps reporting at one place -- is one
2672    /// issue. Without a key, routine repeats filled the bounded list and every later
2673    /// distinct issue was omitted with no text. A repeat only records that the cause was
2674    /// seen again, which a later reconciliation needs; the retained text stays as it was,
2675    /// since changing what a read returns without a commit would let one version answer two
2676    /// ways. An issue without a path has nothing to key on, so only an identical one counts
2677    /// as a repeat.
2678    fn retain_issue(&mut self, issue: Issue) {
2679        self.retain_issue_at(issue, self.freshness_epoch);
2680    }
2681
2682    fn retain_issue_at(&mut self, issue: Issue, epoch: u64) {
2683        let repeat = self.issues.iter().position(|retained| same_issue_cause(retained, &issue));
2684        if let Some(position) = repeat {
2685            self.issue_epochs[position] = epoch;
2686        } else {
2687            let position = self
2688                .issues
2689                .binary_search_by(|retained| compare_issues(retained, &issue))
2690                .unwrap_or_else(|position| position);
2691            if position < MAX_RETAINED_ISSUES {
2692                self.issues.insert(position, issue);
2693                self.issue_epochs.insert(position, epoch);
2694                if self.issues.len() > MAX_RETAINED_ISSUES {
2695                    self.issues.pop();
2696                    let omitted_epoch =
2697                        self.issue_epochs.pop().expect("issue epochs stay parallel");
2698                    self.retain_omitted_at(1, omitted_epoch);
2699                }
2700            } else {
2701                self.retain_omitted_at(1, epoch);
2702            }
2703            self.state.issues.retained = u64::try_from(self.issues.len()).unwrap_or(u64::MAX);
2704        }
2705    }
2706
2707    fn retain_omitted(&mut self, count: u64) {
2708        if count == 0 {
2709            return;
2710        }
2711        let epoch =
2712            self.active_reconciles.keys().next_back().copied().unwrap_or(self.freshness_epoch);
2713        self.retain_omitted_at(count, epoch);
2714    }
2715
2716    fn retain_omitted_at(&mut self, count: u64, epoch: u64) {
2717        let retained = self.omitted_issue_epochs.entry(epoch).or_default();
2718        *retained = retained.saturating_add(count);
2719        self.state.issues.omitted = self.state.issues.omitted.saturating_add(count);
2720    }
2721
2722    fn drop_disproven_omitted(&mut self, started_at: u64) {
2723        self.omitted_issue_epochs.retain(|epoch, _| *epoch >= started_at);
2724        self.state.issues.omitted =
2725            self.omitted_issue_epochs.values().fold(0_u64, |sum, count| sum.saturating_add(*count));
2726    }
2727
2728    fn compact_omitted_epochs(&mut self) {
2729        let mut compact = BTreeMap::new();
2730        for (epoch, count) in std::mem::take(&mut self.omitted_issue_epochs) {
2731            let owner =
2732                self.active_reconciles.range(..=epoch).next_back().map_or(0, |(epoch, _)| *epoch);
2733            let retained = compact.entry(owner).or_insert(0_u64);
2734            *retained = retained.saturating_add(count);
2735        }
2736        self.omitted_issue_epochs = compact;
2737    }
2738
2739    /// Drop retained issues a reconciliation that visited `path` has disproved.
2740    ///
2741    /// An issue about a path at or below `path`, published before the pass began, described
2742    /// something the pass has just read without an error: a directory that could not be
2743    /// listed, an entry whose metadata could not be read, a control the index refused. It is
2744    /// no longer true, and keeping it would explain a state the root is not in. Three kinds
2745    /// of issue survive. An observation gap records that the observer lost precision and had
2746    /// to recover, which the recovery does not undo. An issue without a path cannot be placed
2747    /// under the pass. And an issue published at or after `started_at` came from a pass
2748    /// that closed while this one ran, whose `Partial` mark this pass leaves in place: the
2749    /// issue is stamped with the same epoch as that mark so the two survive together. The
2750    /// omitted count stays: what it counted was never retained.
2751    fn drop_disproven_issues(&mut self, path: &Path, started_at: u64) {
2752        let mut position = 0;
2753        while position < self.issues.len() {
2754            let issue = &self.issues[position];
2755            let disproven = issue.kind != crate::IssueKind::ObservationGap
2756                && issue.path.as_deref().is_some_and(|issue_path| issue_path.starts_with(path))
2757                && self.issue_epochs[position] < started_at;
2758            if disproven {
2759                self.issues.remove(position);
2760                self.issue_epochs.remove(position);
2761            } else {
2762                position += 1;
2763            }
2764        }
2765        self.state.issues.retained = u64::try_from(self.issues.len()).unwrap_or(u64::MAX);
2766    }
2767
2768    /// Mint and optionally retain one fully evaluated transition.
2769    ///
2770    /// Every fact-only, state-only, or combined mutation reaches this function after
2771    /// its fallible validation and preflight work is complete.
2772    fn publish_effects(
2773        &mut self,
2774        next_clock: Clock,
2775        effects: ExactConsequences,
2776        work: Work,
2777        journal: bool,
2778    ) -> Commit {
2779        debug_assert!(!effects.is_empty());
2780        if crate::counters::enabled() {
2781            let effect_paths = u64::try_from(effects.changes.len()).unwrap_or(u64::MAX);
2782            let effect_path_bytes = effects.changes.iter().fold(0_u64, |total, change| {
2783                total.saturating_add(
2784                    u64::try_from(change.path().as_os_str().as_encoded_bytes().len())
2785                        .unwrap_or(u64::MAX),
2786                )
2787            });
2788            crate::counters::bump(|counts| {
2789                counts.effect_paths = counts.effect_paths.saturating_add(effect_paths);
2790                counts.effect_path_bytes =
2791                    counts.effect_path_bytes.saturating_add(effect_path_bytes);
2792            });
2793        }
2794        let commit = Commit {
2795            clock: next_clock,
2796            impact: derive_impact(&effects.changes, &effects.state),
2797            changes: effects.changes,
2798            state: effects.state,
2799            work,
2800        };
2801        self.clock = next_clock;
2802        if journal {
2803            self.retain_commit(&commit);
2804        }
2805        commit
2806    }
2807
2808    fn retain_commit(&mut self, commit: &Commit) {
2809        let cost = commit.retained_cost();
2810        if cost > self.journal_capacity_bytes {
2811            let dropped = u64::try_from(self.journal.len()).unwrap_or(u64::MAX);
2812            crate::counters::bump(|counts| {
2813                counts.journal_oversized_commits =
2814                    counts.journal_oversized_commits.saturating_add(1);
2815                counts.journal_dropped_commits =
2816                    counts.journal_dropped_commits.saturating_add(dropped);
2817            });
2818            self.journal.clear();
2819            self.journal_cost = 0;
2820            self.journal_floor = commit.clock;
2821            return;
2822        }
2823
2824        while self.journal_cost + cost > self.journal_capacity_bytes {
2825            if let Some(dropped) = self.journal.pop_front() {
2826                crate::counters::bump(|counts| {
2827                    counts.journal_dropped_commits =
2828                        counts.journal_dropped_commits.saturating_add(1);
2829                });
2830                self.journal_cost -= dropped.retained_cost();
2831                self.journal_floor = dropped.clock;
2832            }
2833        }
2834        self.journal_cost += cost;
2835        self.journal.push_back(commit.clone());
2836        crate::counters::bump(|counts| {
2837            counts.journal_cloned_commits = counts.journal_cloned_commits.saturating_add(1);
2838            counts.journal_retained_commits = counts.journal_retained_commits.saturating_add(1);
2839        });
2840    }
2841
2842    /// Apply trusted bootstrap data without exposing it as live change history.
2843    pub(crate) fn apply_baseline(
2844        &mut self,
2845        observation: &Observation,
2846    ) -> crate::Result<ApplyStats> {
2847        let prepared = prepare_observation(observation)?;
2848        #[cfg(test)]
2849        if prepared.reject_before_apply {
2850            return Err(crate::Error::CommitRejected("injected reducer preflight"));
2851        }
2852        let mut effects = NoConsequences;
2853        let stats = self.reduce_prepared(&prepared, None, None, None, false, &mut effects)?;
2854        record_batch(BatchProvenance::Baseline, observation.len(), stats);
2855        self.establish_baseline();
2856        Ok(stats)
2857    }
2858
2859    /// Apply one owned filesystem-walker batch without constructing public history.
2860    pub(crate) fn apply_scanner_baseline(
2861        &mut self,
2862        batch: crate::scan::ScannerBatch,
2863    ) -> crate::Result<ApplyStats> {
2864        let observed = batch.len();
2865        let prepare_started = crate::counters::enabled().then(std::time::Instant::now);
2866        let prepared = self.prepare_scanner_batch(batch)?;
2867        if let Some(started) = prepare_started {
2868            let elapsed = elapsed_micros(started);
2869            crate::counters::bump(|counts| {
2870                counts.scanner_prepare_us = counts.scanner_prepare_us.saturating_add(elapsed);
2871            });
2872        }
2873        let mut effects = NoConsequences;
2874        let reduce_started = crate::counters::enabled().then(std::time::Instant::now);
2875        // Dispatch on the prepared lane: a batch that replaces a kind is general.
2876        let stats = self.reduce_prepared(&prepared, None, None, None, false, &mut effects)?;
2877        if let Some(started) = reduce_started {
2878            let elapsed = elapsed_micros(started);
2879            crate::counters::bump(|counts| {
2880                counts.scanner_reduce_us = counts.scanner_reduce_us.saturating_add(elapsed);
2881            });
2882        }
2883        record_batch(BatchProvenance::Baseline, observed, stats);
2884        self.establish_baseline();
2885        Ok(stats)
2886    }
2887
2888    #[cfg(test)]
2889    pub(crate) fn apply_ok(&mut self, observation: &Observation) -> ApplyOutcome {
2890        self.apply(observation).expect("test observation must be valid")
2891    }
2892
2893    #[cfg(test)]
2894    pub(crate) fn apply_baseline_ok(&mut self, observation: &Observation) -> ApplyStats {
2895        self.apply_baseline(observation).expect("test baseline must be valid")
2896    }
2897
2898    /// Mark the current tree as the process baseline.
2899    pub(crate) fn establish_baseline(&mut self) {
2900        self.clock = Clock::ZERO;
2901        self.journal.clear();
2902        self.journal_cost = 0;
2903        self.journal_floor = Clock::ZERO;
2904        self.pending_invalidations.clear();
2905    }
2906
2907    /// Mark the whole index as not verified against the filesystem.
2908    ///
2909    /// Used by the cache-only open path: a snapshot records the freshness it had when it
2910    /// was written, and replaying that verbatim would let an unverified answer claim
2911    /// currency it has not earned.
2912    pub(crate) fn mark_unverified(&mut self) {
2913        self.freshness_marks.clear();
2914        self.mark_unfresh(Path::new(""), Freshness::Stale);
2915        self.state.source = Source::Cached;
2916        self.state.freshness = Freshness::Stale;
2917    }
2918
2919    pub(crate) fn set_initial_freshness(&mut self, complete: bool) {
2920        self.freshness_marks.clear();
2921        if complete {
2922            for slot in &mut self.arena {
2923                if let Slot::Occupied { entry, .. } = slot {
2924                    if entry.kind == EntryKind::Dir {
2925                        entry.directory_mut().children_complete = true;
2926                    }
2927                }
2928            }
2929            self.state.phase = LifecyclePhase::Ready;
2930            self.state.coverage = Coverage::Complete;
2931            self.state.freshness = Freshness::Fresh;
2932        } else {
2933            self.mark_unfresh(Path::new(""), Freshness::Partial);
2934            self.state.phase = LifecyclePhase::Ready;
2935            self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
2936            self.state.freshness = Freshness::Partial;
2937        }
2938    }
2939
2940    /// Finish a cold walk using all of its failures, before diagnostic retention bounds
2941    /// discard any paths. A scoped failure withdraws its unverified listing boundary;
2942    /// ancestors still have their own listings, and readers fold eligible descendants.
2943    /// An unscoped failure cannot establish completeness anywhere in the walk.
2944    pub(crate) fn set_initial_scan_freshness(&mut self, errors: &[crate::Error]) {
2945        self.set_initial_freshness(errors.is_empty());
2946        if errors.is_empty() {
2947            return;
2948        }
2949        for slot in &mut self.arena {
2950            if let Slot::Occupied { entry, .. } = slot {
2951                if entry.kind.is_dir() {
2952                    entry.directory_mut().children_complete = false;
2953                }
2954            }
2955        }
2956        let mut failed = Vec::with_capacity(errors.len());
2957        for error in errors {
2958            let Some(path) = Issue::from_error_under(&self.root_path, error).path else {
2959                return;
2960            };
2961            if path.is_absolute() || path.as_os_str().is_empty() {
2962                return;
2963            }
2964            failed.push(path);
2965        }
2966        failed.sort();
2967        failed.dedup();
2968        let listings: Vec<_> =
2969            failed.iter().map(|path| self.failed_listing_boundary(path)).collect();
2970        self.freshness_marks.clear();
2971        for path in &failed {
2972            self.mark_unfresh(path, Freshness::Partial);
2973        }
2974        // The walk has terminated and each failure is scoped above. Every retained
2975        // directory outside those boundaries therefore has its complete in-scope
2976        // listing. In particular, never promote descendants of a failed listing.
2977        for slot in 0..self.arena.len() {
2978            let Slot::Occupied { generation, entry } = &self.arena[slot] else {
2979                continue;
2980            };
2981            if !entry.kind.is_dir() {
2982                continue;
2983            }
2984            let id = EntryId {
2985                slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
2986                generation: *generation,
2987            };
2988            let Some(path) = self.path_of(id) else {
2989                continue;
2990            };
2991            self.entry_mut(id).directory_mut().children_complete =
2992                !listings.iter().zip(&failed).any(|(boundary, failure)| {
2993                    path == *boundary || (boundary == failure && path.starts_with(boundary))
2994                });
2995        }
2996    }
2997
2998    /// A retained failed directory has an unknown child set. If the failed entry was
2999    /// omitted (for example after a metadata failure), its nearest retained directory
3000    /// cannot claim a complete listing either.
3001    fn failed_listing_boundary(&self, failed: &Path) -> PathBuf {
3002        let mut boundary = failed.to_path_buf();
3003        loop {
3004            if self.lookup(&boundary).is_some_and(|id| self.entry(id).kind.is_dir()) {
3005                return boundary;
3006            }
3007            if !boundary.pop() {
3008                return PathBuf::new();
3009            }
3010        }
3011    }
3012
3013    pub(crate) fn record_walk_errors(&mut self, errors: &mut Vec<crate::Error>) {
3014        self.issues.clear();
3015        self.issue_epochs.clear();
3016        self.omitted_issue_epochs.clear();
3017        self.state.issues = crate::IssueSummary::default();
3018        let root = self.root_path.clone();
3019        crate::scan::normalize_walk_errors(&root, errors);
3020        self.unreadable_control_paths.clear();
3021        for error in errors {
3022            if let crate::Error::Io { .. } = error {
3023                if let Some(path) = Issue::from_error_under(&root, error).path {
3024                    if crate::control::is_control_file(&path) {
3025                        self.unreadable_control_paths.insert(path);
3026                    }
3027                }
3028            }
3029            self.retain_issue(Issue::from_error_under(&root, error));
3030        }
3031    }
3032
3033    pub(crate) fn begin_reconcile(&mut self, path: &Path) -> crate::Result<(u64, Option<Commit>)> {
3034        let path = canonical_relative_path(path)?;
3035        let next_clock = self.clock.checked_next().ok_or(crate::Error::ClockExhausted)?;
3036        let previous_index_state = self.state;
3037        let previous = self.freshness_at(&path);
3038        let epoch = self.mark_unfresh(&path, Freshness::Reconciling);
3039        self.active_reconciles.insert(
3040            epoch,
3041            ActiveReconcile {
3042                path: path.clone(),
3043                scope_budget: usize::try_from(self.len()).unwrap_or(usize::MAX),
3044                evidence: ReconcileEvidence::Scopes(BTreeSet::new()),
3045            },
3046        );
3047        if path.as_os_str().is_empty() {
3048            self.active_root_reconciles.insert(epoch, Self::now_unix_nanos());
3049        }
3050        let current = self.freshness_at(&path);
3051        self.state.freshness = self.published_freshness();
3052        let commit = if previous == current && previous_index_state == self.state {
3053            None
3054        } else {
3055            let mut state = Vec::new();
3056            if previous != current {
3057                state.push(StateTransition::Freshness { path, previous, current });
3058            }
3059            if previous_index_state != self.state {
3060                state.push(StateTransition::IndexState {
3061                    previous: previous_index_state,
3062                    current: self.state,
3063                });
3064            }
3065            let effects = ExactConsequences { state, ..ExactConsequences::default() };
3066            Some(self.publish_effects(next_clock, effects, Work::default(), true))
3067        };
3068        Ok((epoch, commit))
3069    }
3070
3071    /// Close one reconciliation opened by [`Self::begin_reconcile`].
3072    ///
3073    /// `listed_incomplete` names the directories the pass listed in full, with no error
3074    /// inside them, that the index did not hold as complete when it listed them. Each is
3075    /// recorded as complete in this commit whether or not the whole pass completed, exactly
3076    /// as discovery's listing commit records the directories it lists, unless a producer
3077    /// invalidated or began verifying it after this pass started: that producer's own pass
3078    /// owns its listing now. A caller passes none when a conditional commit lost a race.
3079    pub(crate) fn finish_reconcile(
3080        &mut self,
3081        path: &Path,
3082        started_at: u64,
3083        complete: bool,
3084        listed_incomplete: &[PathBuf],
3085        failed_paths: &[PathBuf],
3086        errors: ReconcileErrors<'_>,
3087    ) -> crate::Result<ReconcileFinish> {
3088        let path = canonical_relative_path(path)?;
3089        let next_clock = self.clock.checked_next().ok_or(crate::Error::ClockExhausted)?;
3090        let previous_index_state = self.state;
3091        let previous = self.freshness_at(&path);
3092        // Retired evidence is needed only until every older overlapping pass closes.
3093        let evidence = self.active_reconciles.get(&started_at).map_or_else(
3094            || ReconcileEvidence::Scopes(BTreeSet::new()),
3095            |active| active.evidence.clone(),
3096        );
3097        let superseded = match evidence {
3098            ReconcileEvidence::Scopes(scopes) => scopes,
3099            ReconcileEvidence::Retry => {
3100                self.active_root_reconciles.remove(&started_at);
3101                self.active_reconciles.remove(&started_at);
3102                self.compact_omitted_epochs();
3103                self.mark_unfresh(&path, Freshness::Partial);
3104                if self.state.coverage == Coverage::Complete {
3105                    self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
3106                }
3107                self.state.freshness = self.published_freshness();
3108                self.retain_issue(Issue::provider_failure(
3109                    Some(&path),
3110                    "reconciliation interrupted by newer verification; retry this scope"
3111                        .to_string(),
3112                ));
3113                let current = self.freshness_at(&path);
3114                let mut state = Vec::new();
3115                if previous != current {
3116                    state.push(StateTransition::Freshness { path, previous, current });
3117                }
3118                if previous_index_state != self.state {
3119                    state.push(StateTransition::IndexState {
3120                        previous: previous_index_state,
3121                        current: self.state,
3122                    });
3123                }
3124                let commit = if state.is_empty() {
3125                    None
3126                } else {
3127                    let effects = ExactConsequences { state, ..ExactConsequences::default() };
3128                    Some(self.publish_effects(next_clock, effects, Work::default(), true))
3129                };
3130                return Ok(ReconcileFinish { commit, retry: true });
3131            }
3132        };
3133        let fully_superseded = superseded.iter().any(|newer| path.starts_with(newer));
3134        if errors.disproves_old && !fully_superseded {
3135            for (epoch, active) in &mut self.active_reconciles {
3136                if *epoch < started_at
3137                    && (active.path.starts_with(&path) || path.starts_with(&active.path))
3138                {
3139                    active.supersede(&path);
3140                }
3141            }
3142        }
3143        self.freshness_marks
3144            .retain(|marked, mark| !marked.starts_with(&path) || mark.epoch > started_at);
3145        if fully_superseded {
3146            self.active_root_reconciles.remove(&started_at);
3147            self.active_reconciles.remove(&started_at);
3148            self.compact_omitted_epochs();
3149            let current = self.freshness_at(&path);
3150            self.state.freshness = self.published_freshness();
3151            if self.state.coverage == Coverage::Partial(CoverageReason::Inaccessible) {
3152                self.state.freshness = Freshness::Partial;
3153            }
3154            let mut state = Vec::new();
3155            if previous != current {
3156                state.push(StateTransition::Freshness { path, previous, current });
3157            }
3158            if previous_index_state != self.state {
3159                state.push(StateTransition::IndexState {
3160                    previous: previous_index_state,
3161                    current: self.state,
3162                });
3163            }
3164            if state.is_empty() {
3165                return Ok(ReconcileFinish { commit: None, retry: false });
3166            }
3167            let effects = ExactConsequences { state, ..ExactConsequences::default() };
3168            return Ok(ReconcileFinish {
3169                commit: Some(self.publish_effects(next_clock, effects, Work::default(), true)),
3170                retry: false,
3171            });
3172        }
3173        let still_owned =
3174            |candidate: &Path| !superseded.iter().any(|newer| candidate.starts_with(newer));
3175        if errors.disproves_old {
3176            self.unreadable_control_paths
3177                .retain(|control| !control.starts_with(&path) || !still_owned(control));
3178        }
3179        for error in errors.errors.iter().chain(errors.terminal) {
3180            if let crate::Error::Io { .. } = error {
3181                if let Some(control) = Issue::from_error_under(&self.root_path, error).path {
3182                    if control.starts_with(&path)
3183                        && still_owned(&control)
3184                        && crate::control::is_control_file(&control)
3185                    {
3186                        self.unreadable_control_paths.insert(control);
3187                    }
3188                }
3189            }
3190        }
3191        // Each listed directory is recorded on its own listing, complete pass or not: the
3192        // walk names only those it listed in full with no error inside them, as discovery
3193        // decides per directory, and the caller passes none when a commit lost a race. A
3194        // directory another producer invalidated or began verifying after this pass
3195        // started is left to that producer's pass, so decide here, before this pass's own
3196        // partial mark below would read as such a newer claim.
3197        let recordable: Vec<&PathBuf> = listed_incomplete
3198            .iter()
3199            .filter(|directory| {
3200                directory.starts_with(&path)
3201                    && still_owned(directory)
3202                    && !self.freshness_marks.iter().any(|(marked, mark)| {
3203                        mark.epoch > started_at && directory.starts_with(marked)
3204                    })
3205            })
3206            .collect();
3207        let scoped_failures: Vec<&PathBuf> = failed_paths
3208            .iter()
3209            .filter(|failed| failed.starts_with(&path) && still_owned(failed))
3210            .collect();
3211        // If every failure in this scope was subsequently verified, the older
3212        // pass's remaining evidence is complete. Arbitration/resource refusals do
3213        // not qualify: their caller cannot disprove prior state.
3214        let complete = complete
3215            || (errors.disproves_old
3216                && failed_paths.iter().any(|failed| failed.starts_with(&path))
3217                && scoped_failures.is_empty()
3218                && errors.errors.iter().chain(errors.terminal).all(|error| {
3219                    Issue::from_error_under(&self.root_path, error)
3220                        .path
3221                        .is_some_and(|failed| !failed.starts_with(&path) || !still_owned(&failed))
3222                }));
3223        if errors.disproves_old && self.state.phase != LifecyclePhase::Failed {
3224            self.drop_disproven_issues(&path, started_at);
3225        }
3226        if errors.disproves_old
3227            && self.state.phase != LifecyclePhase::Failed
3228            && path.as_os_str().is_empty()
3229        {
3230            self.drop_disproven_omitted(started_at);
3231        }
3232        let mut state = Vec::new();
3233        // Completeness describes this directory's own listing, not its descendants.
3234        // Withdraw old listing evidence at failures before publishing the partial pass.
3235        // Do not touch successful ancestors: readers compose only eligible descendants,
3236        // and an excluded failed child must not poison an otherwise complete subtree.
3237        if !complete && (!errors.errors.is_empty() || errors.terminal.is_some()) {
3238            let boundaries: Vec<_> = if scoped_failures.is_empty() {
3239                vec![(path.clone(), true)]
3240            } else {
3241                scoped_failures
3242                    .iter()
3243                    .map(|failed| {
3244                        let boundary = self.failed_listing_boundary(failed);
3245                        let subtree = boundary == **failed;
3246                        (boundary, subtree)
3247                    })
3248                    .collect()
3249            };
3250            for slot in 0..self.arena.len() {
3251                let Slot::Occupied { generation, entry } = &self.arena[slot] else {
3252                    continue;
3253                };
3254                if !entry.kind.is_dir() || !entry.directory().children_complete {
3255                    continue;
3256                }
3257                let id = EntryId {
3258                    slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
3259                    generation: *generation,
3260                };
3261                let Some(directory) = self.path_of(id) else {
3262                    continue;
3263                };
3264                if boundaries.iter().any(|(boundary, subtree)| {
3265                    directory == *boundary || (*subtree && directory.starts_with(boundary))
3266                }) && still_owned(&directory)
3267                    && !self.freshness_marks.iter().any(|(marked, mark)| {
3268                        mark.epoch > started_at && directory.starts_with(marked)
3269                    })
3270                {
3271                    self.entry_mut(id).directory_mut().children_complete = false;
3272                    state.push(StateTransition::DirectoryIncomplete { path: directory });
3273                }
3274            }
3275        }
3276        // A complete older walk plus successful newer child verification still proves
3277        // the entire scope. A newer failed child must keep its own evidence and mark.
3278        let verified_scope = superseded.is_empty()
3279            || (complete
3280                && superseded.iter().all(|newer| self.freshness_at(newer) == Freshness::Fresh));
3281        if verified_scope && (complete || !scoped_failures.is_empty()) {
3282            // A sweep stat'd every entry beneath `path` except the precise failure paths,
3283            // which carry stronger `Partial` marks below. Record the successful interval
3284            // once rather than manufacturing millions of unchanged entry updates.
3285            let now = Self::now_unix_nanos();
3286            self.verified.retain(|(verified_path, _)| !verified_path.starts_with(&path));
3287            self.verified.push((path.clone(), now));
3288            if self.verified.len() > MAX_VERIFIED_INTERVALS {
3289                let excess = self.verified.len() - MAX_VERIFIED_INTERVALS;
3290                self.verified.sort_by_key(|(_, at)| *at);
3291                self.verified.drain(..excess);
3292            }
3293            state.push(StateTransition::Verified { path: path.clone() });
3294        }
3295        if complete {
3296            if path.as_os_str().is_empty() {
3297                if let Some(started) = self.active_root_reconciles.remove(&started_at) {
3298                    self.writing_pass_started_at_ns = started;
3299                }
3300                self.state.source = self.applying_source;
3301            }
3302        } else {
3303            if scoped_failures.is_empty() {
3304                self.mark_unfresh(&path, Freshness::Partial);
3305            } else {
3306                for failed in scoped_failures {
3307                    self.mark_unfresh(failed, Freshness::Partial);
3308                }
3309            }
3310            if !errors.errors.is_empty() || errors.terminal.is_some() {
3311                self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
3312            }
3313            if path.as_os_str().is_empty() {
3314                if let Some(started) = self.active_root_reconciles.remove(&started_at) {
3315                    self.writing_pass_started_at_ns = started;
3316                }
3317                self.state.source = self.applying_source;
3318            }
3319        }
3320        // Retain this pass's failures at the epoch its `Partial` marks were just minted at,
3321        // not at `started_at`: a pass that began after this one and closes clean later
3322        // keeps the marks (minted after it began) and must keep the issues that explain
3323        // them, or a partial subtree would have no cause until the next root pass.
3324        // `started_at` is only this pass's own disproof threshold, applied above.
3325        for error in errors.errors.iter().chain(errors.terminal) {
3326            let issue = Issue::from_error_under(&self.root_path, error);
3327            if issue
3328                .path
3329                .as_deref()
3330                .is_none_or(|issue_path| issue_path.starts_with(&path) && still_owned(issue_path))
3331            {
3332                self.retain_issue(issue);
3333            }
3334        }
3335        for directory in recordable {
3336            let Some(id) = self.lookup(directory) else {
3337                continue;
3338            };
3339            let entry = self.entry_mut(id);
3340            if entry.kind != EntryKind::Dir || entry.directory().children_complete {
3341                continue;
3342            }
3343            entry.directory_mut().children_complete = true;
3344            self.state.progress.directories_complete =
3345                self.state.progress.directories_complete.saturating_add(1);
3346            state.push(StateTransition::DirectoryComplete { path: directory.clone() });
3347        }
3348
3349        if complete
3350            && self.state.coverage == Coverage::Partial(CoverageReason::Inaccessible)
3351            && !self.issues.iter().any(|issue| issue.kind != crate::IssueKind::ObservationGap)
3352            && self.state.issues.omitted == 0
3353            && self.arena.iter().all(|slot| {
3354                let Slot::Occupied { entry, .. } = slot else {
3355                    return true;
3356                };
3357                entry.kind != EntryKind::Dir || entry.directory().children_complete
3358            })
3359        {
3360            self.state.coverage = Coverage::Complete;
3361        }
3362
3363        let current = self.freshness_at(&path);
3364        self.state.freshness = self.published_freshness();
3365        if self.state.coverage == Coverage::Partial(CoverageReason::Inaccessible) {
3366            self.state.freshness = Freshness::Partial;
3367        }
3368        self.active_reconciles.remove(&started_at);
3369        self.compact_omitted_epochs();
3370        if previous != current {
3371            state.push(StateTransition::Freshness { path: path.clone(), previous, current });
3372        }
3373        if previous_index_state != self.state {
3374            state.push(StateTransition::IndexState {
3375                previous: previous_index_state,
3376                current: self.state,
3377            });
3378        }
3379        if state.is_empty() {
3380            return Ok(ReconcileFinish { commit: None, retry: false });
3381        }
3382        let effects = ExactConsequences { state, ..ExactConsequences::default() };
3383        Ok(ReconcileFinish {
3384            commit: Some(self.publish_effects(next_clock, effects, Work::default(), true)),
3385            retry: false,
3386        })
3387    }
3388
3389    /// When a completed reconciliation last covered this path, if one did.
3390    fn verified_at(&self, path: &Path) -> Option<i64> {
3391        self.verified
3392            .iter()
3393            .filter(|(covered, _)| path.starts_with(covered))
3394            .map(|(_, at)| *at)
3395            .max()
3396    }
3397
3398    fn mark_unfresh(&mut self, path: &Path, state: Freshness) -> u64 {
3399        self.freshness_epoch =
3400            self.freshness_epoch.checked_add(1).expect("freshness epoch exhausted");
3401        let epoch = self.freshness_epoch;
3402        self.freshness_marks.insert(path.to_path_buf(), FreshnessMark { state, epoch });
3403        epoch
3404    }
3405
3406    /// Current user-visible state for one path.
3407    ///
3408    /// Conditional producers should capture [`Self::expectation`] so ABA and structural
3409    /// races cannot return to the same visible state unnoticed.
3410    pub fn path_state(&self, path: &Path) -> PathState {
3411        let Some(id) = self.lookup(path) else {
3412            return PathState::Absent;
3413        };
3414        let entry = self.entry(id);
3415        PathState::Present { kind: entry.kind, attrs: entry.attrs }
3416    }
3417
3418    /// Conditional baseline with target and nearest-ancestor ABA protection.
3419    pub fn expectation(&self, path: &Path) -> PathExpectation {
3420        let entry = self.entry_identity(path);
3421        PathExpectation::new(
3422            self.path_state(path),
3423            entry,
3424            entry.is_none().then(|| self.absence_guard_identity(path)).flatten(),
3425        )
3426    }
3427
3428    pub(crate) fn relaxed_expectation(&self, path: &Path) -> PathExpectation {
3429        PathExpectation::new(self.path_state(path), self.entry_identity(path), None)
3430    }
3431
3432    /// Exact commits applied since `clock`, oldest first.
3433    pub fn since(&self, clock: Clock) -> Since {
3434        let commits: Vec<Commit> =
3435            self.journal.iter().filter(|commit| commit.clock > clock).cloned().collect();
3436        Since {
3437            commits,
3438            clock: self.clock,
3439            state: self.state,
3440            truncated: clock < self.journal_floor,
3441        }
3442    }
3443
3444    /// Take the subtrees that producers escalated for re-scan.
3445    ///
3446    /// The caller is expected to hand these to the scan layer, which turns them back
3447    /// into precise commits. Escalation is closed-loop: draining this list without
3448    /// re-scanning is what makes an index silently diverge.
3449    pub fn take_pending_invalidations(&mut self) -> Vec<(PathBuf, InvalidateReason)> {
3450        std::mem::take(&mut self.pending_invalidations)
3451    }
3452
3453    /// Put unresolved invalidations back without minting a second public change.
3454    pub(crate) fn restore_pending_invalidations(
3455        &mut self,
3456        invalidations: Vec<(PathBuf, InvalidateReason)>,
3457    ) {
3458        self.pending_invalidations.extend(invalidations);
3459    }
3460
3461    /// Look up an entry id by path relative to the root.
3462    pub fn lookup(&self, path: &Path) -> Option<EntryId> {
3463        let mut current = EntryId::ROOT;
3464        for part in normalize(path)? {
3465            current = self.child(current, part)?;
3466        }
3467        Some(current)
3468    }
3469
3470    /// Owned, self-describing roll-up state for a directory by relative path.
3471    /// The empty path is the root.
3472    pub fn rollup(&self, path: &Path) -> Option<RollUp> {
3473        let id = self.lookup(path)?;
3474        let entry = self.entry(id);
3475        entry.kind.is_dir().then(|| self.named_rollup(&entry.rollup().all))
3476    }
3477
3478    /// Both fixed aggregate partitions for a directory by relative path.
3479    ///
3480    /// `Ok(None)` when the path is absent or not a directory.
3481    ///
3482    /// # Errors
3483    ///
3484    /// [`crate::Error::ControlStateNotObserved`] when the index did not observe control
3485    /// state, whatever the path, as [`Self::partition_total`] refuses.
3486    pub fn partition_rollup(&self, path: &Path) -> crate::Result<Option<PartitionRollUp>> {
3487        self.require_observed_controls()?;
3488        Ok(self
3489            .lookup(path)
3490            .map(|id| self.entry(id))
3491            .filter(|entry| entry.kind.is_dir())
3492            .map(|entry| self.named_partitions(entry.rollup())))
3493    }
3494
3495    /// Both constant-size aggregate partitions for a directory.
3496    ///
3497    /// `Ok(None)` when the path is absent or not a directory.
3498    ///
3499    /// # Errors
3500    ///
3501    /// [`crate::Error::ControlStateNotObserved`] when the index did not observe control
3502    /// state, whatever the path, as [`Self::partition_total`] refuses.
3503    pub fn partition_rollup_summary(
3504        &self,
3505        path: &Path,
3506    ) -> crate::Result<Option<PartitionRollUpSummary>> {
3507        self.require_observed_controls()?;
3508        Ok(self
3509            .lookup(path)
3510            .map(|id| self.entry(id))
3511            .filter(|entry| entry.kind.is_dir())
3512            .map(|entry| partition_summary(entry.rollup())))
3513    }
3514
3515    /// Whether a live entry is ignored, for the opened-root tree projection, without the
3516    /// observation check [`Self::is_ignored`] makes.
3517    ///
3518    /// An opened root always observes control state, so the retained bit is the exact
3519    /// classification; the assertion checks that invariant where it is cheap to.
3520    pub(crate) fn opened_is_ignored(&self, id: EntryId) -> bool {
3521        debug_assert!(self.observes_controls(), "an opened root observes control state");
3522        self.entry(id).ignored
3523    }
3524
3525    /// Capture one retained entry without repeating path lookup in a consumer.
3526    pub(crate) fn entry_value(&self, path: &Path) -> Option<crate::EntryValue> {
3527        let id = self.lookup(path)?;
3528        Some(self.entry_value_of(id, path))
3529    }
3530
3531    pub(crate) fn entry_value_of(&self, id: EntryId, path: &Path) -> crate::EntryValue {
3532        let entry = self.entry(id);
3533        crate::EntryValue {
3534            path: path.to_path_buf(),
3535            portable_path: crate::opened::read::portable_path(path),
3536            kind: entry.kind,
3537            attrs: entry.attrs,
3538            ignored: entry.ignored,
3539            classification: (entry.kind == EntryKind::File)
3540                .then(|| self.types.classify_name(path.file_name().unwrap_or_default())),
3541            rollup: entry.kind.is_dir().then(|| partition_summary(entry.rollup())),
3542            children_complete: entry.kind.is_dir().then(|| entry.directory().children_complete),
3543        }
3544    }
3545
3546    pub(crate) fn portable_children(&self, path: &Path) -> Option<&PortableChildren> {
3547        self.serving.as_ref()?.portable_children.get(path)
3548    }
3549
3550    pub(crate) fn portable_entries(&self) -> &BTreeMap<crate::PortablePath, EntryId> {
3551        &self.serving.as_ref().expect("opened-root reads require serving indexes").portable_entries
3552    }
3553
3554    #[cfg(test)]
3555    pub(crate) const fn serving_indexes_enabled(&self) -> bool {
3556        self.serving.is_some()
3557    }
3558
3559    fn insert_serving_entry(&mut self, path: &Path, kind: EntryKind, attrs: Attrs, id: EntryId) {
3560        if path.as_os_str().is_empty() || self.serving.is_none() {
3561            return;
3562        }
3563        let file = (kind == EntryKind::File).then(|| {
3564            (
3565                self.classify(path).file_type.as_str().to_string(),
3566                path.file_name(),
3567                self.entry(id).ignored,
3568                self.entry(id).parent,
3569            )
3570        });
3571        let arena = &self.arena;
3572        let Some(serving) = self.serving.as_mut() else {
3573            return;
3574        };
3575        if let Some((name, exact_name, ignored, parent)) = file {
3576            let semantic = serving.intern_semantic(&name);
3577            let mut ancestor = parent;
3578            while let Some(directory) = ancestor {
3579                let partition = serving.semantic_by_directory.entry(directory).or_default();
3580                merge_semantic(&mut partition.all, semantic, attrs);
3581                if !ignored {
3582                    merge_semantic(&mut partition.unignored, semantic, attrs);
3583                }
3584                ancestor = retained_parent(arena, directory);
3585            }
3586            if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
3587                let mut ancestor = parent;
3588                while let Some(directory) = ancestor {
3589                    let partition = serving.exact_name_by_directory.entry(directory).or_default();
3590                    merge_semantic(&mut partition.all, exact_name, attrs);
3591                    if !ignored {
3592                        merge_semantic(&mut partition.unignored, exact_name, attrs);
3593                    }
3594                    ancestor = retained_parent(arena, directory);
3595                }
3596            }
3597        }
3598        let portable = crate::opened::read::portable_path(path);
3599        serving.portable_entries.insert(portable.clone(), id);
3600        if kind == EntryKind::File {
3601            serving.recent_files.insert(RecentKey {
3602                mtime_ns: attrs.mtime_ns,
3603                portable_path: portable,
3604                id,
3605            });
3606        }
3607        let parent = path.parent().unwrap_or_else(|| Path::new("")).to_path_buf();
3608        let Some(name) = path.file_name().map(crate::opened::read::portable_component) else {
3609            return;
3610        };
3611        let children = serving.portable_children.entry(parent).or_default();
3612        if kind.is_dir() {
3613            children.directories.insert(name, id);
3614        } else {
3615            children.nondirectories.insert(name, id);
3616        }
3617    }
3618
3619    fn remove_serving_entry(&mut self, path: &Path, kind: EntryKind, attrs: Attrs, id: EntryId) {
3620        if path.as_os_str().is_empty() {
3621            return;
3622        }
3623        let Some(serving) = self.serving.as_mut() else {
3624            return;
3625        };
3626        let portable = crate::opened::read::portable_path(path);
3627        serving.portable_entries.remove(&portable);
3628        if kind == EntryKind::File {
3629            serving.recent_files.remove(&RecentKey {
3630                mtime_ns: attrs.mtime_ns,
3631                portable_path: portable,
3632                id,
3633            });
3634        }
3635        let parent = path.parent().unwrap_or_else(|| Path::new("")).to_path_buf();
3636        let remove_parent = if let Some(children) = serving.portable_children.get_mut(&parent) {
3637            if let Some(name) = path.file_name().map(crate::opened::read::portable_component) {
3638                if kind.is_dir() {
3639                    children.directories.remove(&name);
3640                } else {
3641                    children.nondirectories.remove(&name);
3642                }
3643            }
3644            children.directories.is_empty() && children.nondirectories.is_empty()
3645        } else {
3646            false
3647        };
3648        if remove_parent {
3649            serving.portable_children.remove(&parent);
3650        }
3651        if kind.is_dir() {
3652            serving.portable_children.remove(path);
3653        }
3654    }
3655
3656    fn remove_serving_file_semantics(&mut self, path: &Path, id: EntryId, attrs: Attrs) {
3657        // Only a regular file is interned and tallied (`insert_serving_entry`). A symlink
3658        // or special entry of the same classification would otherwise find a real file's
3659        // type and subtract from its tally, or find none and panic under the write guard.
3660        if self.serving.is_none() || self.entry(id).kind != EntryKind::File {
3661            return;
3662        }
3663        let name = self.classify(path).file_type.as_str().to_string();
3664        let exact_name = path.file_name();
3665        let ignored = self.entry(id).ignored;
3666        let parent = self.entry(id).parent;
3667        let arena = &self.arena;
3668        let serving = self.serving.as_mut().expect("checked above");
3669        let semantic = *serving
3670            .semantic_ids
3671            .get(&name)
3672            .expect("every served file has an interned semantic type");
3673        let mut empty = Vec::new();
3674        let mut ancestor = parent;
3675        while let Some(directory) = ancestor {
3676            let partition = serving
3677                .semantic_by_directory
3678                .get_mut(&directory)
3679                .expect("every served file contributes to every ancestor");
3680            unmerge_semantic(&mut partition.all, semantic, attrs);
3681            if !ignored {
3682                unmerge_semantic(&mut partition.unignored, semantic, attrs);
3683            }
3684            if partition.all.is_empty() && partition.unignored.is_empty() {
3685                empty.push(directory);
3686            }
3687            ancestor = retained_parent(arena, directory);
3688        }
3689        for ancestor in empty {
3690            serving.semantic_by_directory.remove(&ancestor);
3691        }
3692        serving.release_semantic(semantic, 1);
3693        if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
3694            let mut exact_empty = Vec::new();
3695            let mut ancestor = parent;
3696            while let Some(directory) = ancestor {
3697                let partition = serving
3698                    .exact_name_by_directory
3699                    .get_mut(&directory)
3700                    .expect("every declared exact-name file contributes to every ancestor");
3701                unmerge_semantic(&mut partition.all, exact_name, attrs);
3702                if !ignored {
3703                    unmerge_semantic(&mut partition.unignored, exact_name, attrs);
3704                }
3705                if partition.all.is_empty() && partition.unignored.is_empty() {
3706                    exact_empty.push(directory);
3707                }
3708                ancestor = retained_parent(arena, directory);
3709            }
3710            for ancestor in exact_empty {
3711                serving.exact_name_by_directory.remove(&ancestor);
3712            }
3713        }
3714    }
3715
3716    fn remove_serving_subtree_semantics(&mut self, root: EntryId, path: &Path) {
3717        if self.serving.is_none() {
3718            return;
3719        }
3720        match self.entry(root).kind {
3721            EntryKind::File => {
3722                let attrs = self.entry(root).attrs;
3723                self.remove_serving_file_semantics(path, root, attrs);
3724            }
3725            EntryKind::Dir => {
3726                let parent = self.entry(root).parent;
3727                let mut stack = vec![root];
3728                let mut directories = Vec::new();
3729                while let Some(id) = stack.pop() {
3730                    let entry = self.entry(id);
3731                    if !entry.kind.is_dir() {
3732                        continue;
3733                    }
3734                    directories.push(id);
3735                    stack.extend(self.child_ids(id));
3736                }
3737                let arena = &self.arena;
3738                let serving = self.serving.as_mut().expect("checked above");
3739                let contribution =
3740                    serving.semantic_by_directory.get(&root).cloned().unwrap_or_default();
3741                let exact_contribution =
3742                    serving.exact_name_by_directory.get(&root).cloned().unwrap_or_default();
3743                let mut empty = Vec::new();
3744                if !contribution.all.is_empty() || !contribution.unignored.is_empty() {
3745                    let mut ancestor = parent;
3746                    while let Some(directory) = ancestor {
3747                        let partition = serving
3748                            .semantic_by_directory
3749                            .get_mut(&directory)
3750                            .expect("a semantic subtree contributes to every ancestor");
3751                        unmerge_semantic_map(&mut partition.all, &contribution.all);
3752                        unmerge_semantic_map(&mut partition.unignored, &contribution.unignored);
3753                        if partition.all.is_empty() && partition.unignored.is_empty() {
3754                            empty.push(directory);
3755                        }
3756                        ancestor = retained_parent(arena, directory);
3757                    }
3758                }
3759                let mut exact_empty = Vec::new();
3760                if !exact_contribution.all.is_empty() || !exact_contribution.unignored.is_empty() {
3761                    let mut ancestor = parent;
3762                    while let Some(directory) = ancestor {
3763                        let partition = serving
3764                            .exact_name_by_directory
3765                            .get_mut(&directory)
3766                            .expect("an exact-name subtree contributes to every ancestor");
3767                        unmerge_semantic_map(&mut partition.all, &exact_contribution.all);
3768                        unmerge_semantic_map(
3769                            &mut partition.unignored,
3770                            &exact_contribution.unignored,
3771                        );
3772                        if partition.all.is_empty() && partition.unignored.is_empty() {
3773                            exact_empty.push(directory);
3774                        }
3775                        ancestor = retained_parent(arena, directory);
3776                    }
3777                }
3778                for ancestor in empty {
3779                    serving.semantic_by_directory.remove(&ancestor);
3780                }
3781                for ancestor in exact_empty {
3782                    serving.exact_name_by_directory.remove(&ancestor);
3783                }
3784                for directory in directories {
3785                    serving.semantic_by_directory.remove(&directory);
3786                    serving.exact_name_by_directory.remove(&directory);
3787                }
3788                for (semantic, tally) in contribution.all {
3789                    serving.release_semantic(semantic, tally.files);
3790                }
3791            }
3792            EntryKind::Symlink | EntryKind::Other => {}
3793        }
3794    }
3795
3796    fn move_serving_file_partition(
3797        &mut self,
3798        path: &Path,
3799        id: EntryId,
3800        previous_ignored: bool,
3801        current_ignored: bool,
3802    ) {
3803        if previous_ignored == current_ignored
3804            || self.serving.is_none()
3805            || self.entry(id).kind != EntryKind::File
3806        {
3807            return;
3808        }
3809        let name = self.classify(path).file_type.as_str().to_string();
3810        let exact_name = path.file_name();
3811        let attrs = self.entry(id).attrs;
3812        let parent = self.entry(id).parent;
3813        let arena = &self.arena;
3814        let serving = self.serving.as_mut().expect("checked above");
3815        let semantic = *serving
3816            .semantic_ids
3817            .get(&name)
3818            .expect("every served file has an interned semantic type");
3819        let mut ancestor = parent;
3820        while let Some(directory) = ancestor {
3821            let partition = serving
3822                .semantic_by_directory
3823                .get_mut(&directory)
3824                .expect("every served file contributes to every ancestor");
3825            if current_ignored {
3826                unmerge_semantic(&mut partition.unignored, semantic, attrs);
3827            } else {
3828                merge_semantic(&mut partition.unignored, semantic, attrs);
3829            }
3830            ancestor = retained_parent(arena, directory);
3831        }
3832        if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
3833            let mut ancestor = parent;
3834            while let Some(directory) = ancestor {
3835                let partition = serving
3836                    .exact_name_by_directory
3837                    .get_mut(&directory)
3838                    .expect("every declared exact-name file contributes to every ancestor");
3839                if current_ignored {
3840                    unmerge_semantic(&mut partition.unignored, exact_name, attrs);
3841                } else {
3842                    merge_semantic(&mut partition.unignored, exact_name, attrs);
3843                }
3844                ancestor = retained_parent(arena, directory);
3845            }
3846        }
3847    }
3848
3849    /// Attributes for any entry, by relative path.
3850    pub fn attrs(&self, path: &Path) -> Option<&Attrs> {
3851        Some(&self.entry(self.lookup(path)?).attrs)
3852    }
3853
3854    /// Kind of an entry, by relative path.
3855    pub fn kind(&self, path: &Path) -> Option<EntryKind> {
3856        Some(self.entry(self.lookup(path)?).kind)
3857    }
3858
3859    /// Effective fixed-control classification for one retained entry.
3860    ///
3861    /// `Ok(Some(ignored))` when a retained entry's governing controls are known;
3862    /// `Ok(None)` for a missing entry or one below a refused control source.
3863    ///
3864    /// # Errors
3865    ///
3866    /// [`crate::Error::ControlStateNotObserved`] when the index was built without
3867    /// observing control state, whatever the path. Every entry of such an index carries
3868    /// "not ignored" only because no rule was read, so that answer would be silently
3869    /// wrong for a tree that has a `.gitignore`.
3870    pub fn is_ignored(&self, path: &Path) -> crate::Result<Option<bool>> {
3871        self.require_observed_controls()?;
3872        Ok(self.ignored_classification(path))
3873    }
3874
3875    /// Known ignore classification of a retained path, or `None` when unavailable.
3876    ///
3877    /// A refusal may hide an ignore or a negation. Its descendants cannot be counted
3878    /// as known members of either population.
3879    pub fn ignored_classification(&self, path: &Path) -> Option<bool> {
3880        let id = self.lookup(path)?;
3881        self.ignored_classification_of(path, id)
3882    }
3883
3884    /// Classification for a retained entry whose handle the caller already has.
3885    pub(crate) fn ignored_classification_of(&self, path: &Path, id: EntryId) -> Option<bool> {
3886        if !self.observes_controls() || !self.control_classification_known(path) {
3887            return None;
3888        }
3889        self.try_entry(id).map(|entry| entry.ignored)
3890    }
3891
3892    pub(crate) fn control_classification_known(&self, path: &Path) -> bool {
3893        self.controls.classification_known(path)
3894            && (self.unreadable_control_paths.is_empty()
3895                || !path.parent().into_iter().flat_map(Path::ancestors).any(|directory| {
3896                    self.unreadable_control_paths
3897                        .iter()
3898                        .any(|control| control.parent() == Some(directory))
3899                }))
3900    }
3901
3902    /// Whether ignored classification is known throughout this retained subtree.
3903    pub fn ignored_classification_complete_below(&self, path: &Path) -> bool {
3904        self.observes_controls()
3905            && self.controls.classification_known(path)
3906            && !self.unreadable_control_paths.iter().any(|control| {
3907                control.starts_with(path)
3908                    || path
3909                        .parent()
3910                        .into_iter()
3911                        .flat_map(Path::ancestors)
3912                        .any(|directory| control.parent() == Some(directory))
3913            })
3914            && !self.controls.refusals().any(|refusal| {
3915                refusal.path.starts_with(path)
3916                    || path
3917                        .parent()
3918                        .into_iter()
3919                        .flat_map(Path::ancestors)
3920                        .any(|directory| refusal.path.parent() == Some(directory))
3921            })
3922    }
3923
3924    /// Borrow direct children of a directory as `(name, id)` pairs in name order.
3925    ///
3926    /// The iterator borrows this owned index and allocates nothing.
3927    pub fn children(
3928        &self,
3929        path: &Path,
3930    ) -> Option<impl DoubleEndedIterator<Item = (&OsStr, EntryId)> + ExactSizeIterator + '_> {
3931        let id = self.lookup(path)?;
3932        let entry = self.entry(id);
3933        entry.kind.is_dir().then(|| IndexChildren::new(self, entry))
3934    }
3935
3936    /// Borrow direct children of an entry id as `(name, id)` pairs in name order.
3937    ///
3938    /// Returns `None` for a stale handle. A live non-directory returns an empty iterator.
3939    pub fn children_of(
3940        &self,
3941        id: EntryId,
3942    ) -> Option<impl DoubleEndedIterator<Item = (&OsStr, EntryId)> + ExactSizeIterator + '_> {
3943        Some(IndexChildren::new(self, self.try_entry(id)?))
3944    }
3945
3946    /// Reconstruct an entry's path relative to the root by walking parent pointers.
3947    pub fn path_of(&self, id: EntryId) -> Option<PathBuf> {
3948        let mut parts = Vec::new();
3949        let mut current = Some(id);
3950        while let Some(node) = current {
3951            let entry = self.try_entry(node)?;
3952            if entry.parent.is_some() {
3953                parts.push(entry.name.as_os_str());
3954            }
3955            current = entry.parent;
3956        }
3957        parts.reverse();
3958        Some(parts.iter().collect())
3959    }
3960
3961    /// Owned, self-describing roll-up state for an entry id, if it is a directory.
3962    pub fn rollup_of(&self, id: EntryId) -> Option<RollUp> {
3963        let entry = self.try_entry(id)?;
3964        entry.kind.is_dir().then(|| self.named_rollup(&entry.rollup().all))
3965    }
3966
3967    /// Map-free totals of a directory's `all` and `unignored` partitions, in that order,
3968    /// for reporting paths that derive an ignored share.
3969    ///
3970    /// No observation check: in an index that observed no control state the two are equal,
3971    /// so a caller that has not checked [`Self::observes_controls`] derives a zero share
3972    /// rather than an error, and must not present it as one.
3973    pub(crate) fn partition_scalars_of(
3974        &self,
3975        id: EntryId,
3976    ) -> Option<(RollUpScalars, RollUpScalars)> {
3977        let entry = self.try_entry(id)?;
3978        entry.kind.is_dir().then(|| {
3979            let rollup = entry.rollup();
3980            (RollUpScalars::from(&rollup.all), RollUpScalars::from(&rollup.unignored))
3981        })
3982    }
3983
3984    /// Whether a live directory's in-scope child set is authoritative: the id form of
3985    /// [`Self::directory_complete`], for a reader that already holds the id. `None` for a
3986    /// stale handle or an entry that is not a directory.
3987    pub(crate) fn directory_complete_of(&self, id: EntryId) -> Option<bool> {
3988        let entry = self.try_entry(id)?;
3989        (entry.kind == EntryKind::Dir).then(|| entry.directory().children_complete)
3990    }
3991
3992    /// Attributes for an entry id, or `None` when the handle is stale.
3993    pub fn attrs_of(&self, id: EntryId) -> Option<&Attrs> {
3994        Some(&self.try_entry(id)?.attrs)
3995    }
3996
3997    /// Kind for an entry id, or `None` when the handle is stale.
3998    pub fn kind_of(&self, id: EntryId) -> Option<EntryKind> {
3999        Some(self.try_entry(id)?.kind)
4000    }
4001
4002    /// Name for an entry id. The root's name is empty; stale handles return `None`.
4003    pub fn name_of(&self, id: EntryId) -> Option<&OsStr> {
4004        Some(&self.try_entry(id)?.name)
4005    }
4006
4007    /// Sparse content tier, when analysis has been enabled.
4008    pub fn content(&self) -> Option<&ContentIndex> {
4009        self.content.as_deref()
4010    }
4011
4012    /// Precomputed content rollup for one relative directory.
4013    pub fn content_rollup(&self, path: &Path) -> Option<&ContentRollUp> {
4014        self.content()?.rollup(path)
4015    }
4016
4017    /// The analyzer set this index's content tier holds records for, or
4018    /// [`AnalysisSet::NONE`] when it holds no content tier.
4019    pub fn content_set(&self) -> AnalysisSet {
4020        self.content().and_then(ContentIndex::profile).unwrap_or(AnalysisSet::NONE)
4021    }
4022
4023    /// Whether the retained content tier lacks a record for any admitted regular file.
4024    ///
4025    /// Every requested analyzer records a coverage outcome, including binary, invalid
4026    /// UTF-8, and unsupported files. A count mismatch therefore means analysis is still
4027    /// pending, while deleting a file removes both its entry and its content record.
4028    pub(crate) fn content_has_pending(&self, profile: AnalysisSet) -> bool {
4029        if !profile.is_enabled() {
4030            return false;
4031        }
4032        let wanted = self.content_identity(profile);
4033        let Some(content) = self.content().and_then(|content| content.admit(&wanted)) else {
4034            return true;
4035        };
4036        if self.scope.population == crate::query::IgnoredEntries::Include {
4037            return u64::try_from(content.len()).unwrap_or(u64::MAX)
4038                < self.entry(EntryId::ROOT).rollup().files;
4039        }
4040        // Narrow populations retain control files for reconciliation and unknown
4041        // files until their governing rules can be verified. Neither is an analysis
4042        // candidate, so comparing against all retained regular files would make a
4043        // complete analysis look perpetually partial.
4044        let mut pending = false;
4045        self.for_each_analysis_file(profile, |_, _, attrs, path| {
4046            pending |=
4047                content.file(&path).is_none_or(|record| record.fingerprint != attrs.fingerprint());
4048        });
4049        pending
4050    }
4051
4052    /// The content tier identity this index gives records of `analysis`: its own entry tier,
4053    /// which holds its type rules, the analyzer set, and the analyzers' versions and options.
4054    pub fn content_identity(&self, analysis: AnalysisSet) -> crate::ContentTierIdentity {
4055        crate::ContentTierIdentity::for_request(
4056            crate::EntryTierIdentity::of_scope(self.scope),
4057            analysis,
4058        )
4059    }
4060
4061    /// Prepare the content tier to hold records of `request`'s identity, clearing it when
4062    /// it holds any other.
4063    pub(crate) fn prepare_content_analysis(&mut self, request: crate::content::AnalysisRequest) {
4064        if !request.profile.is_enabled() {
4065            return;
4066        }
4067        let identity = self.content_identity(request.profile);
4068        self.content.get_or_insert_with(|| Box::new(ContentIndex::default())).prepare(identity);
4069    }
4070
4071    pub(crate) fn set_content_tier_state(
4072        &mut self,
4073        source: Source,
4074        freshness: Freshness,
4075        observed_at_ns: Option<i64>,
4076    ) {
4077        if let Some(content) = self.content.as_deref_mut() {
4078            content.set_state(crate::content::ContentTierState {
4079                source,
4080                freshness,
4081                observed_at_ns,
4082            });
4083        }
4084    }
4085
4086    /// Capture every regular-file analysis candidate without retaining a lock or entry
4087    /// borrow across filesystem I/O.
4088    ///
4089    /// Crate-private until the request model (P1.3) decides whether an out-of-crate
4090    /// analyzer is a supported surface (`fdu-5upj`). A caller outside the crate cannot
4091    /// prepare the content tier, so every result it produced would commit as
4092    /// [`AnalysisApplyOutcome::Stale`]; [`analyze_index`] is the entry that works.
4093    ///
4094    /// [`analyze_index`]: crate::content::analyze_index
4095    pub(crate) fn analysis_candidates(&self, profile: AnalysisSet) -> Vec<AnalysisCandidate> {
4096        let root_files = self.entry(EntryId::ROOT).rollup().files;
4097        let mut candidates = Vec::with_capacity(usize::try_from(root_files).unwrap_or(0));
4098        self.for_each_analysis_file(profile, |id, revision, attrs, relative_path| {
4099            candidates.push(AnalysisCandidate {
4100                entry_id: id,
4101                revision,
4102                absolute_path: self.root_path.join(&relative_path),
4103                classification: self.classify(&relative_path),
4104                relative_path,
4105                attrs,
4106            });
4107        });
4108        candidates
4109    }
4110
4111    /// File identities restore matches against sidecar records, without classifying.
4112    ///
4113    /// The `HashMap` is keyed by relative path because load looks up each decoded record
4114    /// that way. Classification is omitted: cache-only restore commits the sidecar's
4115    /// stored classification, and the apply-path self-check cannot change that answer.
4116    pub(crate) fn restore_analysis_candidates(
4117        &self,
4118        profile: AnalysisSet,
4119    ) -> (HashMap<PathBuf, RestoreCandidate>, u64) {
4120        let root_files = self.entry(EntryId::ROOT).rollup().files;
4121        let mut candidates = HashMap::with_capacity(usize::try_from(root_files).unwrap_or(0));
4122        let mut visited = 0_u64;
4123        self.for_each_analysis_file(profile, |id, revision, attrs, relative_path| {
4124            visited = visited.saturating_add(1);
4125            candidates.insert(
4126                relative_path.clone(),
4127                RestoreCandidate { entry_id: id, revision, relative_path, attrs },
4128            );
4129        });
4130        (candidates, visited)
4131    }
4132
4133    fn for_each_analysis_file(
4134        &self,
4135        profile: AnalysisSet,
4136        mut visit: impl FnMut(EntryId, u64, Attrs, PathBuf),
4137    ) {
4138        if !profile.is_enabled() {
4139            return;
4140        }
4141        // Join the parent path this walk already holds. `path_of` would walk
4142        // ancestors per file for the same bytes.
4143        let mut stack = vec![(EntryId::ROOT, PathBuf::new())];
4144        while let Some((parent, parent_path)) = stack.pop() {
4145            for (name, id) in self.children_of(parent).into_iter().flatten() {
4146                let entry = self.entry(id);
4147                if entry.kind == EntryKind::Dir {
4148                    stack.push((id, parent_path.join(name)));
4149                    continue;
4150                }
4151                if entry.kind != EntryKind::File {
4152                    continue;
4153                }
4154                let relative_path = parent_path.join(name);
4155                if !self.scope.population.admits(entry.ignored)
4156                    || (self.scope.population != crate::query::IgnoredEntries::Include
4157                        && !self.control_classification_known(&relative_path))
4158                {
4159                    continue;
4160                }
4161                let revision = entry.revision;
4162                let attrs = entry.attrs;
4163                visit(id, revision, attrs, relative_path);
4164            }
4165        }
4166    }
4167
4168    /// The candidates `request` still has to read: every one, unless the content tier
4169    /// holds exactly `request`'s identity, and then those without a record whose
4170    /// fingerprint matches.
4171    pub(crate) fn pending_analysis_candidates(
4172        &self,
4173        request: crate::content::AnalysisRequest,
4174    ) -> Vec<AnalysisCandidate> {
4175        let wanted = self.content_identity(request.profile);
4176        // The tier refuses a record of any identity but its own, so one comparison here
4177        // decides for every record it holds.
4178        let held = self.content().and_then(|content| content.admit(&wanted));
4179        self.analysis_candidates(request.profile)
4180            .into_iter()
4181            .filter(|candidate| {
4182                held.and_then(|content| content.file(&candidate.relative_path)).is_none_or(
4183                    |record| {
4184                        record.fingerprint != candidate.attrs.fingerprint() || !record.is_reusable()
4185                    },
4186                )
4187            })
4188            .collect()
4189    }
4190
4191    /// Conditionally commit a worker result if its entry and metadata expectation still
4192    /// match, and the content tier was prepared for the identity the result was produced
4193    /// under.
4194    ///
4195    /// A result of another identity is [`AnalysisApplyOutcome::Stale`]: it answers another
4196    /// request than the one the tier holds, so committing it would mix records of two
4197    /// identities in one tier.
4198    ///
4199    /// Crate-private with [`Index::analysis_candidates`], and for the same reason.
4200    pub(crate) fn apply_analysis(
4201        &mut self,
4202        observation: AnalysisObservation,
4203    ) -> AnalysisApplyOutcome {
4204        self.apply_analysis_record(observation)
4205    }
4206
4207    /// Restore-path apply: insert the record and leave roll-ups for one rebuild.
4208    ///
4209    /// The caller must [`Self::rebuild_content_rollups`] before any query reads a
4210    /// directory total; sidecar load does that after the apply loop.
4211    pub(crate) fn apply_restored_analysis(
4212        &mut self,
4213        candidate: RestoreCandidate,
4214        analysis: crate::stored_state::AdmittedRecord<'_>,
4215    ) -> AnalysisApplyOutcome {
4216        let Some(entry) = self.try_entry(candidate.entry_id) else {
4217            return AnalysisApplyOutcome::Stale;
4218        };
4219        if entry.kind != EntryKind::File
4220            || entry.revision != candidate.revision
4221            || entry.attrs.fingerprint() != candidate.attrs.fingerprint()
4222        {
4223            return AnalysisApplyOutcome::Stale;
4224        }
4225        let Some(content) = self.content.as_mut() else {
4226            return AnalysisApplyOutcome::Stale;
4227        };
4228        if content.commit_without_rollup(candidate.relative_path, analysis) {
4229            AnalysisApplyOutcome::Applied
4230        } else {
4231            AnalysisApplyOutcome::Stale
4232        }
4233    }
4234
4235    pub(crate) fn rebuild_content_rollups(&mut self) {
4236        if let Some(content) = self.content.as_mut() {
4237            content.rebuild_rollups();
4238        }
4239    }
4240
4241    fn apply_analysis_record(&mut self, observation: AnalysisObservation) -> AnalysisApplyOutcome {
4242        let candidate = &observation.candidate;
4243        let Some(entry) = self.try_entry(candidate.entry_id) else {
4244            return AnalysisApplyOutcome::Stale;
4245        };
4246        if entry.kind != EntryKind::File
4247            || entry.revision != candidate.revision
4248            || entry.attrs.fingerprint() != candidate.attrs.fingerprint()
4249            || self.classify(&candidate.relative_path) != candidate.classification
4250        {
4251            return AnalysisApplyOutcome::Stale;
4252        }
4253        let Some(content) = self.content.as_mut() else {
4254            return AnalysisApplyOutcome::Stale;
4255        };
4256        if content.commit(
4257            candidate.relative_path.clone(),
4258            observation.profile,
4259            &observation.provenance,
4260            observation.analysis,
4261        ) {
4262            AnalysisApplyOutcome::Applied
4263        } else {
4264            AnalysisApplyOutcome::Stale
4265        }
4266    }
4267
4268    /// Drop all derived content while preserving metadata and snapshot compatibility.
4269    pub fn clear_content(&mut self) {
4270        self.content = None;
4271    }
4272
4273    // ---- internals ----
4274
4275    fn try_entry(&self, id: EntryId) -> Option<&Entry> {
4276        match self.arena.get(id.idx())? {
4277            Slot::Occupied { generation, entry } if *generation == id.generation => Some(entry),
4278            Slot::Occupied { .. } | Slot::Free { .. } => None,
4279        }
4280    }
4281
4282    fn expectation_matches(&self, op: &Op, expected: PathExpectation) -> bool {
4283        let current = self.path_state(op.path());
4284        // An operation whose target the index already holds changes nothing, whatever
4285        // happened to its baseline: another producer verified the same fact first and
4286        // there is no older state left to overwrite. Refusing it as stale cost the
4287        // observation handoff a full-root walk per convergent refresh, and three in a
4288        // row failed the root, for commits that would have applied as unchanged.
4289        if self.holds_target(op, current) {
4290            return true;
4291        }
4292        if current != expected.state {
4293            return false;
4294        }
4295
4296        let require_structure = match (op, expected.state) {
4297            (Op::Remove { .. }, _) => true,
4298            (Op::Upsert { kind, .. }, PathState::Present { kind: baseline, .. }) => {
4299                *kind != baseline
4300            }
4301            (
4302                Op::Upsert { .. }
4303                | Op::ControlUpsert { .. }
4304                | Op::ControlRemove { .. }
4305                | Op::InvalidateSubtree { .. },
4306                _,
4307            ) => false,
4308        };
4309        if !same_target(self.entry_identity(op.path()), expected.entry(), require_structure) {
4310            return false;
4311        }
4312
4313        match expected.absence_guard() {
4314            Some(expected) => self
4315                .absence_guard_identity(op.path())
4316                .is_some_and(|current| current.same_absence_guard(expected)),
4317            None => true,
4318        }
4319    }
4320
4321    /// Whether the index already holds what `op` would leave behind at `current`, the state
4322    /// of its path.
4323    ///
4324    /// An entry operation's target is a path state. A control operation's is the table:
4325    /// exactly its source retained at its path, or nothing retained there. The walk pushes
4326    /// a control file's entry and rules on one baseline, so both must converge together or
4327    /// the pair is refused for the rules alone. An invalidation always commits a change, so
4328    /// it is arbitrated on its baseline.
4329    fn holds_target(&self, op: &Op, current: PathState) -> bool {
4330        match op {
4331            Op::Upsert { kind, attrs, .. } => {
4332                current == PathState::Present { kind: *kind, attrs: *attrs }
4333            }
4334            Op::Remove { .. } => current == PathState::Absent,
4335            Op::ControlUpsert { path, source } => self.controls.source_is(path, source),
4336            Op::ControlRemove { path } => !self.controls.contains(path),
4337            Op::InvalidateSubtree { .. } => false,
4338        }
4339    }
4340
4341    fn absence_guard_identity(&self, path: &Path) -> Option<EntryIdentity> {
4342        let parts = normalize(path)?;
4343        let (_, ancestors) = parts.split_last()?;
4344        let mut current = EntryId::ROOT;
4345        for part in ancestors {
4346            let Some(child) = self.child(current, part) else {
4347                break;
4348            };
4349            current = child;
4350        }
4351        Some(self.identity(current))
4352    }
4353
4354    /// Prove that every accepted live upsert has a verified parent chain.
4355    ///
4356    /// The overlay follows batch order without touching the real index. That admits
4357    /// parent-first discovery batches and rejects a child whose missing or non-directory
4358    /// ancestry would otherwise be filled with guessed metadata.
4359    fn validate_known_ancestry(
4360        &self,
4361        ops: &[ObservationOp],
4362        accepted: &[bool],
4363    ) -> crate::Result<()> {
4364        if let Some((path, reconcile_from)) =
4365            self.unknown_ancestry(ops, accepted).into_iter().next()
4366        {
4367            return Err(crate::Error::UnknownAncestry { path, reconcile_from });
4368        }
4369        Ok(())
4370    }
4371
4372    fn accepted_operations(&self, ops: &[ObservationOp]) -> Vec<bool> {
4373        ops.iter()
4374            .map(|observed| match observed.expectation {
4375                Expectation::Any => true,
4376                Expectation::State(expected) => self.expectation_matches(&observed.op, expected),
4377            })
4378            .collect()
4379    }
4380
4381    /// Consume a walker-owned batch and prove every parent before mutation begins.
4382    ///
4383    /// Scanner batches contain only unconditional discoveries. The walker publishes a
4384    /// directory before any worker may enumerate it, so almost every parent resolves to
4385    /// an existing id. Serial batches may still contain a parent-first directory and its
4386    /// children together; those children retain the earlier operation index instead.
4387    /// The proof owns no duplicate paths and application performs no second path-tree
4388    /// search.
4389    ///
4390    /// A discovery can also find an entry whose kind the index no longer agrees with: a
4391    /// concurrent refresh may have replaced it after its directory was listed. Replacing a
4392    /// kind drops a subtree, so the parent ids proved here would not survive the batch.
4393    /// That rare batch is prepared for the general lane instead, which proves ancestry in
4394    /// operation order and replaces the entry as it would for any verified observation;
4395    /// the next observation of the path repairs a stale one.
4396    fn prepare_scanner_batch(
4397        &self,
4398        batch: crate::scan::ScannerBatch,
4399    ) -> crate::Result<PreparedObservation> {
4400        let ops = batch.into_ops();
4401        let mut parents = Vec::with_capacity(ops.len());
4402        let mut last_parent: Option<(&Path, ResolvedParent)> = None;
4403        let mut has_batch_parents = false;
4404        let mut path_comparisons = 0_u64;
4405        let mut replaces_kind = false;
4406
4407        for (op_index, observed) in ops.iter().enumerate() {
4408            if !matches!(observed.expectation, Expectation::Any) {
4409                return Err(crate::Error::UnsupportedScanConfig(
4410                    "scanner batches contain unconditional discoveries only",
4411                ));
4412            }
4413            let op = &observed.op;
4414            let path = op.path();
4415            if path.as_os_str().is_empty() {
4416                return Err(crate::Error::UnsupportedScanConfig(
4417                    "scanner batches cannot mutate the index root",
4418                ));
4419            }
4420            for component in path.components() {
4421                match component {
4422                    Component::Normal(_) => {}
4423                    Component::CurDir => {
4424                        return Err(crate::Error::UnsupportedScanConfig(
4425                            "scanner batches require canonical relative paths",
4426                        ));
4427                    }
4428                    Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
4429                        return Err(crate::Error::PathEscapesRoot(path.to_path_buf()));
4430                    }
4431                }
4432            }
4433            match op {
4434                Op::Upsert { .. } => {}
4435                Op::ControlUpsert { .. } | Op::ControlRemove { .. }
4436                    if crate::control::is_control_file(path) => {}
4437                Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {
4438                    return Err(crate::Error::InvalidControlPath(path.to_path_buf()));
4439                }
4440                Op::Remove { .. } | Op::InvalidateSubtree { .. } => {
4441                    return Err(crate::Error::UnsupportedScanConfig(
4442                        "scanner batches contain discoveries only",
4443                    ));
4444                }
4445            }
4446            if replaces_kind {
4447                // The general lane proves every remaining parent. Only the scanner input
4448                // contract above still applies to the rest of the batch.
4449                continue;
4450            }
4451
4452            let parent_path = path.parent().expect("a non-root relative path has a parent");
4453            if last_parent.is_some() {
4454                path_comparisons = path_comparisons.saturating_add(1);
4455            }
4456            let same_parent = last_parent
4457                .filter(|(previous, _)| *previous == parent_path)
4458                .map(|(_, parent)| parent);
4459            let parent = if let Some(parent) = same_parent {
4460                parent
4461            } else {
4462                self.lookup(parent_path)
4463                    .filter(|id| self.entry(*id).kind.is_dir())
4464                    .map(ResolvedParent::Existing)
4465                    .or_else(|| Self::earlier_scanner_parent(&ops, op_index, parent_path))
4466                    .ok_or_else(|| crate::Error::UnknownAncestry {
4467                        path: path.to_path_buf(),
4468                        reconcile_from: PathBuf::new(),
4469                    })?
4470            };
4471            if let (Op::Upsert { kind, .. }, ResolvedParent::Existing(parent)) = (op, parent) {
4472                if path.file_name().is_some_and(|name| {
4473                    self.child(parent, name).is_some_and(|child| self.entry(child).kind != *kind)
4474                }) {
4475                    replaces_kind = true;
4476                    continue;
4477                }
4478            }
4479            has_batch_parents |= matches!(parent, ResolvedParent::Earlier(_));
4480            parents.push(parent);
4481            last_parent = Some((parent_path, parent));
4482        }
4483
4484        if replaces_kind {
4485            return prepare_observation(&Observation::from_ops(ops));
4486        }
4487
4488        crate::counters::bump(|counts| {
4489            counts.ancestry_path_comparisons =
4490                counts.ancestry_path_comparisons.saturating_add(path_comparisons);
4491            counts.ancestry_parent_proofs = counts
4492                .ancestry_parent_proofs
4493                .saturating_add(u64::try_from(ops.len()).unwrap_or(u64::MAX));
4494        });
4495        Ok(PreparedObservation {
4496            ops,
4497            ancestry: PreparedAncestry::Scanner { parents, has_batch_parents },
4498            #[cfg(test)]
4499            reject_before_apply: false,
4500        })
4501    }
4502
4503    /// Resolve a parent produced earlier in the same scanner batch.
4504    fn earlier_scanner_parent(
4505        ops: &[ObservationOp],
4506        before: usize,
4507        parent_path: &Path,
4508    ) -> Option<ResolvedParent> {
4509        let (op_index, op) = ops[..before].iter().enumerate().rev().find(
4510            |(_, observed)| matches!(&observed.op, Op::Upsert { path, .. } if path == parent_path),
4511        )?;
4512        let Op::Upsert { kind, .. } = &op.op else {
4513            unreachable!("the search selected an upsert");
4514        };
4515        kind.is_dir().then_some(ResolvedParent::Earlier(op_index))
4516    }
4517
4518    /// Evaluate the complete resulting control table before any fact or reducer moves.
4519    ///
4520    /// Only a malformed control path fails here; a source the bounds cannot admit is
4521    /// refused inside the projection. Building the whole table first keeps a failing
4522    /// observation fault-atomic even when the same batch also moves ordinary entries.
4523    fn projected_controls(
4524        &self,
4525        ops: &[ObservationOp],
4526        accepted: &[bool],
4527    ) -> crate::Result<Option<crate::control::ControlTable>> {
4528        self.projected_controls_from(
4529            ops.iter()
4530                .zip(accepted)
4531                .filter_map(|(observed, accepted)| accepted.then_some(&observed.op)),
4532        )
4533    }
4534
4535    /// The table this batch would leave behind, or `None` when it leaves the current one.
4536    fn projected_controls_from<'a>(
4537        &self,
4538        ops: impl Iterator<Item = &'a Op> + Clone,
4539    ) -> crate::Result<Option<crate::control::ControlTable>> {
4540        if self.controls_unchanged_by(ops.clone()) {
4541            return Ok(None);
4542        }
4543        let mut projected = self.controls.clone();
4544        #[cfg(test)]
4545        CONTROL_PROJECTION_CLONES.with(|clones| clones.set(clones.get() + 1));
4546        let mut structure = StructuralOverlay::default();
4547        for op in ops {
4548            match op {
4549                Op::Upsert { path, kind, .. } => {
4550                    if crate::control::is_control_file(path) && *kind != EntryKind::File {
4551                        projected.remove(path)?;
4552                    }
4553                    if structure.kind(self, path) == Some(EntryKind::Dir) && !kind.is_dir() {
4554                        projected.remove_subtree(path);
4555                    }
4556                    structure.upsert(self, path, *kind);
4557                }
4558                Op::Remove { path } => {
4559                    if crate::control::is_control_file(path) {
4560                        projected.remove(path)?;
4561                    }
4562                    projected.remove_subtree(path);
4563                    structure.remove(self, path);
4564                }
4565                Op::ControlUpsert { path, source } => {
4566                    projected.upsert(path, source.clone())?;
4567                }
4568                Op::ControlRemove { path } => {
4569                    projected.remove(path)?;
4570                }
4571                Op::InvalidateSubtree { .. } => {}
4572            }
4573        }
4574        Ok(Some(projected))
4575    }
4576
4577    /// Whether no operation in the batch can change the retained control table.
4578    ///
4579    /// Only control ops write the table, and only a structural removal prunes it, so a
4580    /// batch whose control ops are all inert against this table and whose structural ops
4581    /// touch nothing it records leaves it exactly as it is. Each op is decided against the
4582    /// current table rather than against the projection, which is the same thing: an inert
4583    /// op leaves the state the next one is decided against unchanged.
4584    ///
4585    /// This is what keeps a warm revalidate of a tree past its budget from cloning the
4586    /// whole table for every batch of re-read refusals (fdu-hzm5), and a cold scan of a
4587    /// tree with no control files from projecting an empty table onto an empty one
4588    /// (fdu-pro1). A malformed control path is never inert, so the projection still
4589    /// reports it.
4590    fn controls_unchanged_by<'a>(&self, ops: impl Iterator<Item = &'a Op>) -> bool {
4591        // A vacant table decides every op from its kind alone, which is what the cold
4592        // no-controls lane costs per entry: there is nothing for a structural op to drop or
4593        // prune, and no source is inert against it, since `Unchanged` needs a retained
4594        // source and `Refuse` a matching refusal. A removal still asks, so a malformed
4595        // control path stays non-inert and the projection reports it.
4596        if self.controls.is_vacant() {
4597            return ops.into_iter().all(|op| match op {
4598                Op::ControlUpsert { .. } => false,
4599                Op::ControlRemove { path } => self.controls.remove_is_inert(path),
4600                Op::Upsert { .. } | Op::Remove { .. } | Op::InvalidateSubtree { .. } => true,
4601            });
4602        }
4603
4604        ops.into_iter().all(|op| match op {
4605            Op::ControlUpsert { path, source } => self.controls.upsert_is_inert(path, source),
4606            Op::ControlRemove { path } => self.controls.remove_is_inert(path),
4607            Op::Upsert { path, kind, .. } => {
4608                // The kind first: it is one discriminant test, where naming a control file
4609                // parses the path's last component.
4610                let drops_control = *kind != EntryKind::File
4611                    && crate::control::is_control_file(path)
4612                    && self.controls.contains(path);
4613                let prunes_subtree = !kind.is_dir() && self.controls.has_record_at_or_below(path);
4614                !drops_control && !prunes_subtree
4615            }
4616            Op::Remove { path } => {
4617                let drops_control =
4618                    crate::control::is_control_file(path) && self.controls.contains(path);
4619                !drops_control && !self.controls.has_record_at_or_below(path)
4620            }
4621            Op::InvalidateSubtree { .. } => true,
4622        })
4623    }
4624
4625    fn apply_control_transition<C: ConsequenceSink>(
4626        &mut self,
4627        projected: Option<crate::control::ControlTable>,
4628        stats: &mut ApplyStats,
4629        effects: &mut C,
4630    ) {
4631        let Some(projected) = projected else {
4632            return;
4633        };
4634        let changes = projected.changes_from(&self.controls);
4635        let refusals = projected.refusal_changes_from(&self.controls);
4636        if changes.is_empty() && refusals.is_empty() {
4637            return;
4638        }
4639        let affected: Vec<PathBuf> = changes
4640            .iter()
4641            .filter_map(|(path, _, _)| crate::control::ControlTable::affected_subtree(path).ok())
4642            .collect();
4643        self.controls = projected;
4644        stats.controls = u64::try_from(changes.len() + refusals.len()).unwrap_or(u64::MAX);
4645        for (path, previous, current) in changes {
4646            effects.change(|| EffectiveChange::ControlUpdated { path, previous, current });
4647        }
4648        // A refusal changes what classification covers, not what it says, so it moves no
4649        // entry by itself; the source it may have dropped arrived as a change above.
4650        for (path, previous, current) in refusals {
4651            effects.change(|| EffectiveChange::ControlRefusalUpdated { path, previous, current });
4652        }
4653        self.reclassify_controlled_subtrees(&affected, stats, effects);
4654    }
4655
4656    /// Re-evaluate only subtrees governed by changed controls, then rebuild the fixed
4657    /// unignored reducer from the resulting facts.
4658    fn reclassify_controlled_subtrees<C: ConsequenceSink>(
4659        &mut self,
4660        affected: &[PathBuf],
4661        stats: &mut ApplyStats,
4662        effects: &mut C,
4663    ) {
4664        let mut roots: Vec<PathBuf> = affected.to_vec();
4665        roots.sort();
4666        roots.dedup();
4667        let mut collapsed = Vec::new();
4668        for root in roots {
4669            if collapsed.iter().any(|ancestor: &PathBuf| root.starts_with(ancestor)) {
4670                continue;
4671            }
4672            collapsed.push(root);
4673        }
4674
4675        let mut moved = false;
4676        for root in collapsed {
4677            let Some(root_id) = self.lookup(&root) else {
4678                continue;
4679            };
4680            let children: Vec<(PathBuf, EntryId)> = self
4681                .children_of(root_id)
4682                .expect("controlled subtree root is live")
4683                .map(|(name, id)| (root.join(name), id))
4684                .collect();
4685            let mut queue = VecDeque::from(children);
4686            while let Some((path, id)) = queue.pop_front() {
4687                #[cfg(test)]
4688                RECLASSIFY_VISITS.with(|visits| visits.set(visits.get() + 1));
4689                let entry = self.entry(id);
4690                let parent_ignored = entry.parent.is_some_and(|parent| self.entry(parent).ignored);
4691                let current = entry.ignored;
4692                let next = parent_ignored
4693                    || self.controls.matcher_for(&path).is_ignored(entry.kind.is_dir());
4694                let descendants: Vec<(PathBuf, EntryId)> = self
4695                    .children_of(id)
4696                    .expect("controlled subtree entry is live")
4697                    .map(|(name, child)| (path.join(name), child))
4698                    .collect();
4699                if current != next {
4700                    self.move_serving_file_partition(&path, id, current, next);
4701                    self.entry_mut(id).ignored = next;
4702                    stats.reclassified += 1;
4703                    effects.change(|| EffectiveChange::Reclassified {
4704                        path: path.clone(),
4705                        previous_ignored: current,
4706                        current_ignored: next,
4707                    });
4708                    moved = true;
4709                }
4710                queue.extend(descendants);
4711            }
4712        }
4713        if moved {
4714            self.rebuild_unignored_rollups();
4715        }
4716    }
4717
4718    fn rebuild_unignored_rollups(&mut self) {
4719        let mut order = Vec::with_capacity(usize::try_from(self.live).unwrap_or(0));
4720        let mut stack = vec![EntryId::ROOT];
4721        while let Some(id) = stack.pop() {
4722            order.push(id);
4723            if self.entry(id).kind.is_dir() {
4724                stack.extend(self.child_ids(id));
4725            }
4726            if self.entry(id).kind.is_dir() {
4727                self.entry_mut(id).rollup_mut().unignored = InternedRollUp::default();
4728            }
4729        }
4730        for id in order.into_iter().rev() {
4731            let Some(parent) = self.entry(id).parent else {
4732                continue;
4733            };
4734            let contribution = self.contribution(id).unignored;
4735            self.entry_mut(parent).rollup_mut().unignored.merge(&contribution);
4736        }
4737    }
4738
4739    fn unknown_ancestry(
4740        &self,
4741        ops: &[ObservationOp],
4742        accepted: &[bool],
4743    ) -> Vec<(PathBuf, PathBuf)> {
4744        let mut structure = StructuralOverlay::default();
4745        let mut unknown = Vec::new();
4746        let mut overlay_inserts = 0_u64;
4747        let mut path_comparisons = 0_u64;
4748        let mut parent_proofs = 0_u64;
4749        let count_preflight = crate::counters::enabled();
4750        // The last directory this pass proved, with every ancestor of it. A producer
4751        // emits a directory's children together, so consecutive ops overwhelmingly
4752        // share a parent, and re-proving the same chain per op was the largest single
4753        // allocation cost of a cold scan (fdu-pro1): one component vector plus one
4754        // ancestor path rebuilt push-by-push, per entry, for an answer that had not
4755        // changed since the previous entry. The memo is invalidated wherever this loop
4756        // learns something that could change an answer -- a non-directory upsert or a
4757        // removal -- exactly like `ParentMemo` in the apply loop below.
4758        let mut proven_dir: Option<PathBuf> = None;
4759        let mut ancestor = PathBuf::new();
4760        for (observed, accepted) in ops.iter().zip(accepted) {
4761            if !accepted {
4762                continue;
4763            }
4764            match &observed.op {
4765                Op::Upsert { path, .. } | Op::ControlUpsert { path, .. }
4766                    if !path.as_os_str().is_empty() =>
4767                {
4768                    if count_preflight && proven_dir.is_some() {
4769                        path_comparisons = path_comparisons.saturating_add(1);
4770                    }
4771                    let same_proven_parent = matches!(
4772                        (path.parent(), proven_dir.as_deref()),
4773                        (Some(parent), Some(proven)) if parent == proven
4774                    );
4775                    if same_proven_parent {
4776                        if count_preflight {
4777                            parent_proofs = parent_proofs.saturating_add(1);
4778                        }
4779                    } else {
4780                        let mut reconcile_from = PathBuf::new();
4781                        let mut ancestry_known = true;
4782                        let parts = normalize(path).expect("prepared paths are canonical");
4783                        let (_, ancestors) = parts.split_last().expect("non-root path has a name");
4784                        ancestor.clear();
4785                        for part in ancestors {
4786                            ancestor.push(part);
4787                            if structure.kind(self, &ancestor) != Some(EntryKind::Dir) {
4788                                unknown.push((path.clone(), reconcile_from));
4789                                ancestry_known = false;
4790                                break;
4791                            }
4792                            reconcile_from.clone_from(&ancestor);
4793                        }
4794                        if !ancestry_known {
4795                            proven_dir = None;
4796                            continue;
4797                        }
4798                        if count_preflight {
4799                            parent_proofs = parent_proofs.saturating_add(1);
4800                        }
4801                        match &mut proven_dir {
4802                            Some(proven) => {
4803                                proven.clear();
4804                                path.parent().unwrap_or(Path::new("")).clone_into(proven);
4805                            }
4806                            None => {
4807                                proven_dir =
4808                                    Some(path.parent().unwrap_or(Path::new("")).to_path_buf());
4809                            }
4810                        }
4811                    }
4812                    if let Op::Upsert { kind, .. } = &observed.op {
4813                        structure.upsert(self, path, *kind);
4814                        if count_preflight {
4815                            overlay_inserts = overlay_inserts.saturating_add(1);
4816                        }
4817                        if !kind.is_dir() {
4818                            // This path may itself have been somebody's proven ancestor
4819                            // only if it was a directory before; the overlay knows, but
4820                            // the memo does not, so it forgets rather than reasons.
4821                            if proven_dir.as_deref().is_some_and(|proven| proven.starts_with(path))
4822                            {
4823                                proven_dir = None;
4824                            }
4825                        }
4826                    }
4827                }
4828                Op::Remove { path } if !path.as_os_str().is_empty() => {
4829                    structure.remove(self, path);
4830                    if proven_dir.as_deref().is_some_and(|proven| proven.starts_with(path)) {
4831                        proven_dir = None;
4832                    }
4833                }
4834                Op::Upsert { .. }
4835                | Op::Remove { .. }
4836                | Op::ControlUpsert { .. }
4837                | Op::ControlRemove { .. }
4838                | Op::InvalidateSubtree { .. } => {}
4839            }
4840        }
4841        if count_preflight {
4842            crate::counters::bump(|counts| {
4843                counts.ancestry_overlay_inserts =
4844                    counts.ancestry_overlay_inserts.saturating_add(overlay_inserts);
4845                counts.ancestry_path_comparisons =
4846                    counts.ancestry_path_comparisons.saturating_add(path_comparisons);
4847                counts.ancestry_parent_proofs =
4848                    counts.ancestry_parent_proofs.saturating_add(parent_proofs);
4849            });
4850        }
4851        unknown
4852    }
4853
4854    fn entry_identity(&self, path: &Path) -> Option<EntryIdentity> {
4855        Some(self.identity(self.lookup(path)?))
4856    }
4857
4858    fn identity(&self, id: EntryId) -> EntryIdentity {
4859        let entry = self.entry(id);
4860        EntryIdentity::new(
4861            id.slot,
4862            id.generation,
4863            entry.revision,
4864            entry.directory.as_deref().map_or(0, |directory| directory.children_revision),
4865            entry.kind.is_dir(),
4866        )
4867    }
4868
4869    fn bump_revision(entry: &mut Entry) {
4870        entry.revision = entry.revision.checked_add(1).expect("entry revision exhausted");
4871    }
4872
4873    fn bump_children_revision(entry: &mut Entry) {
4874        let directory = entry.directory_mut();
4875        directory.children_revision =
4876            directory.children_revision.checked_add(1).expect("entry children revision exhausted");
4877    }
4878
4879    fn child(&self, parent: EntryId, name: &OsStr) -> Option<EntryId> {
4880        let children = &self.entry(parent).directory.as_deref()?.children;
4881        match children {
4882            DirectoryChildren::Sorted(ids) => ids
4883                .binary_search_by(|id| self.entry(*id).name.as_os_str().cmp(name))
4884                .ok()
4885                .map(|position| ids[position]),
4886            DirectoryChildren::Mutable(children) => children.get(name).copied(),
4887        }
4888    }
4889
4890    fn child_ids(&self, parent: EntryId) -> ChildIds<'_> {
4891        self.entry(parent).directory().children.ids()
4892    }
4893
4894    /// Promote one compact, completed directory when its first mutation arrives.
4895    ///
4896    /// Detached indexes keep each name only on its child entry. Arbitrary public
4897    /// mutation needs keyed insertion and removal, so the touched parent pays the
4898    /// name clones once; untouched one-shot topology stays compact.
4899    fn promote_children(&mut self, parent: EntryId) {
4900        let ids = match &mut self.entry_mut(parent).directory_mut().children {
4901            DirectoryChildren::Sorted(ids) => std::mem::take(ids),
4902            DirectoryChildren::Mutable(_) => return,
4903        };
4904        let expected = ids.len();
4905        let children =
4906            ids.into_iter().map(|id| (self.entry(id).name.clone(), id)).collect::<BTreeMap<_, _>>();
4907        assert_eq!(
4908            children.len(),
4909            expected,
4910            "compact child names must remain unique before promotion"
4911        );
4912        self.entry_mut(parent).directory_mut().children = DirectoryChildren::Mutable(children);
4913    }
4914
4915    fn insert_child(&mut self, parent: EntryId, name: OsString, child: EntryId) {
4916        self.promote_children(parent);
4917        let entry = self.entry_mut(parent);
4918        let DirectoryChildren::Mutable(children) = &mut entry.directory_mut().children else {
4919            unreachable!("child promotion produces mutable storage")
4920        };
4921        children.insert(name, child);
4922        Self::bump_children_revision(entry);
4923    }
4924
4925    fn reserve_detached_children(&mut self, parent: EntryId, additional: usize) {
4926        let entry = self.entry_mut(parent);
4927        let DirectoryChildren::Sorted(children) = &mut entry.directory_mut().children else {
4928            unreachable!("detached directories retain sorted child storage")
4929        };
4930        children.reserve(additional);
4931    }
4932
4933    fn push_detached_child(&mut self, parent: EntryId, child: EntryId) {
4934        let entry = self.entry_mut(parent);
4935        let DirectoryChildren::Sorted(children) = &mut entry.directory_mut().children else {
4936            unreachable!("detached directories retain sorted child storage")
4937        };
4938        children.push(child);
4939        Self::bump_children_revision(entry);
4940    }
4941
4942    /// Merge a completed detached directory without cloning its retained roll-up.
4943    fn merge_detached_descendants(&mut self, parent: EntryId, child: EntryId) {
4944        debug_assert!(parent.idx() < child.idx(), "cold parents must precede descendants");
4945        let (parents, children) = self.arena.split_at_mut(child.idx());
4946        let child_rollup = match &children[0] {
4947            Slot::Occupied { generation, entry } if *generation == child.generation => {
4948                entry.rollup()
4949            }
4950            Slot::Occupied { .. } | Slot::Free { .. } => {
4951                panic!("detached child handle must be live: {child:?}")
4952            }
4953        };
4954        let parent_entry = match &mut parents[parent.idx()] {
4955            Slot::Occupied { generation, entry } if *generation == parent.generation => entry,
4956            Slot::Occupied { .. } | Slot::Free { .. } => {
4957                panic!("detached parent handle must be live: {parent:?}")
4958            }
4959        };
4960        parent_entry.rollup_mut().merge(child_rollup);
4961    }
4962
4963    fn remove_child(&mut self, parent: EntryId, name: &OsStr) {
4964        self.promote_children(parent);
4965        let entry = self.entry_mut(parent);
4966        let DirectoryChildren::Mutable(children) = &mut entry.directory_mut().children else {
4967            unreachable!("child promotion produces mutable storage")
4968        };
4969        if children.remove(name).is_some() {
4970            Self::bump_children_revision(entry);
4971        }
4972    }
4973
4974    fn entry(&self, id: EntryId) -> &Entry {
4975        self.try_entry(id).expect("internal entry handle must be live")
4976    }
4977
4978    fn entry_mut(&mut self, id: EntryId) -> &mut Entry {
4979        match self.arena.get_mut(id.idx()) {
4980            Some(Slot::Occupied { generation, entry }) if *generation == id.generation => entry,
4981            Some(Slot::Occupied { .. } | Slot::Free { .. }) | None => {
4982                panic!("internal entry handle must be live: {id:?}")
4983            }
4984        }
4985    }
4986
4987    fn alloc(&mut self, entry: Entry) -> EntryId {
4988        crate::counters::bump(|c| c.entries_allocated += 1);
4989        self.live += 1;
4990        if let Some(free_slot) = self.free_head {
4991            let free_idx = free_slot as usize;
4992            let (generation, next) = match &self.arena[free_idx] {
4993                Slot::Free { generation, next_free } => (*generation, *next_free),
4994                Slot::Occupied { .. } => unreachable!("free list pointed at a live slot"),
4995            };
4996            self.free_head = next;
4997            self.arena[free_idx] = Slot::Occupied { generation, entry };
4998            return EntryId { slot: free_slot, generation };
4999        }
5000        let slot = u32::try_from(self.arena.len()).expect("index arena exceeded u32 capacity");
5001        let id = EntryId { slot, generation: 0 };
5002        self.arena.push(Slot::Occupied { generation: 0, entry });
5003        id
5004    }
5005
5006    fn free(&mut self, id: EntryId) {
5007        let next_generation = match &self.arena[id.idx()] {
5008            Slot::Occupied { generation, .. } if *generation == id.generation => {
5009                generation.checked_add(1).expect("entry generation exhausted")
5010            }
5011            Slot::Occupied { .. } | Slot::Free { .. } => {
5012                panic!("internal entry handle must be live: {id:?}")
5013            }
5014        };
5015        self.arena[id.idx()] =
5016            Slot::Free { generation: next_generation, next_free: self.free_head };
5017        self.free_head = Some(id.slot);
5018        self.live -= 1;
5019    }
5020
5021    /// Wall-clock now, in nanoseconds since the epoch, or zero if the clock is before
5022    /// it. Provenance timestamps are for display, so a nonsensical clock reads as
5023    /// "unknown" rather than propagating an error through every constructor.
5024    fn now_unix_nanos() -> i64 {
5025        std::time::SystemTime::now()
5026            .duration_since(std::time::UNIX_EPOCH)
5027            .ok()
5028            .and_then(|since| i64::try_from(since.as_nanos()).ok())
5029            .unwrap_or(0)
5030    }
5031
5032    /// Provenance of one path: where its value came from, when, and how settled.
5033    ///
5034    /// Built on demand from the entry's stored source and the index's timestamps
5035    /// rather than read from a field, because the timestamps are shared by nearly
5036    /// every entry and storing them per entry would cost far more than the
5037    /// information is worth.
5038    ///
5039    /// # Two limitations, both tracked
5040    ///
5041    /// **This reports the entry's own provenance, not its subtree's.** A directory
5042    /// whose descendants are less trustworthy than itself will still report its own
5043    /// source, so a `Complete`/`Revalidated` directory can contain `Cached` children.
5044    /// Composition belongs in the roll-up, where it costs one merge rather than an
5045    /// O(subtree) walk per query, and it is not implemented yet (`fdu-fka6`,
5046    /// `fdu-b1ts`). Do not read a directory's provenance as a subtree guarantee.
5047    ///
5048    /// A completed reconciliation records one clocked [`StateTransition::Verified`]
5049    /// for its subtree, including when every entry was unchanged. Consumers of exact
5050    /// commits therefore observe the same provenance movement as readers of this view.
5051    pub fn provenance(&self, path: &Path) -> Option<Provenance> {
5052        let id = self.lookup(path)?;
5053        Some(self.provenance_of(id))
5054    }
5055
5056    fn provenance_of(&self, id: EntryId) -> Provenance {
5057        let entry = self.entry(id);
5058        let status = self.status_of(id);
5059        // A completed sweep over an ancestor verified this entry even if no delta ever
5060        // named it, so an interval beats the entry's own stamp.
5061        //
5062        // Only while the index still considers the path fresh, though. An
5063        // `InvalidateSubtree` marks paths `Stale` and a running sweep marks them
5064        // `Reconciling`; in both cases trust has been withdrawn since the interval was
5065        // recorded, and promoting anyway would produce the self-contradicting answer
5066        // "partial, and verified".
5067        //
5068        // This applies to entries a delta *did* name, too, not only the ones it
5069        // skipped. Those were stamped `Revalidated` by the sweep, but their timestamp
5070        // would otherwise come from `observed_at`, which dates `Revalidated` to when
5071        // the index was constructed. One sweep would then report two different "as of"
5072        // times for equally verified paths — the elided siblings dated correctly to the
5073        // sweep, the touched entries dated to construction — and a consumer comparing
5074        // two rows could not tell which discipline it was reading.
5075        //
5076        // `Scanned` is excluded because it is *stronger* than `Revalidated`: a path
5077        // walked fresh this session is not improved by a sweep having covered it, and
5078        // its own scan time is already the right answer.
5079        if entry.source >= Source::Revalidated {
5080            if let Some(path) = self.path_of(id) {
5081                if self.freshness_at(&path) == Freshness::Fresh {
5082                    if let Some(verified_at) = self.verified_at(&path) {
5083                        return Provenance {
5084                            source: Source::Revalidated,
5085                            observed_at_ns: verified_at,
5086                            status,
5087                        };
5088                    }
5089                }
5090            }
5091        }
5092        Provenance { source: entry.source, observed_at_ns: self.observed_at(entry.source), status }
5093    }
5094
5095    /// Whether this path's totals account for everything beneath it.
5096    ///
5097    /// Derived from the freshness marks rather than stored, and answering the coverage
5098    /// question only. `Reconciling` and `Stale` describe values whose *trust* is in
5099    /// doubt while their coverage is not: a cached subtree still accounts for every
5100    /// entry it knows about, and saying otherwise would report a complete cached
5101    /// baseline as if it were half-built. That distinction is [`Source`]'s job, and
5102    /// collapsing the two axes is what let a value that may shrink advertise itself as
5103    /// a lower bound that can only grow.
5104    ///
5105    /// Only [`Freshness::Partial`] — reconciliation errors left some of the subtree
5106    /// unread — is genuinely missing coverage.
5107    fn status_of(&self, id: EntryId) -> Status {
5108        let Some(path) = self.path_of(id) else {
5109            return Status::Complete;
5110        };
5111        match self.freshness_at(&path) {
5112            Freshness::Fresh | Freshness::Reconciling | Freshness::Stale => Status::Complete,
5113            Freshness::Partial => Status::Partial,
5114        }
5115    }
5116
5117    /// When an entry with this source was observed.
5118    const fn observed_at(&self, source: Source) -> i64 {
5119        match source {
5120            Source::Cached | Source::JournalScoped => self.captured_at_ns,
5121            Source::Scanned | Source::Revalidated => self.scanned_at_ns,
5122        }
5123    }
5124
5125    /// The start of the pass a snapshot of this index records as the one that wrote its
5126    /// image.
5127    pub(crate) const fn writing_pass_started_at_ns(&self) -> i64 {
5128        self.writing_pass_started_at_ns
5129    }
5130
5131    /// Record the pass start a loaded snapshot carried for the facts it restored.
5132    pub(crate) fn set_writing_pass_started_at_ns(&mut self, writing_pass_started_at_ns: i64) {
5133        self.writing_pass_started_at_ns = writing_pass_started_at_ns;
5134    }
5135
5136    /// Whether this index holds entry-tier facts no completed metadata write has recorded.
5137    pub(crate) const fn persistence_owed(&self) -> bool {
5138        self.persistence_owed
5139    }
5140
5141    /// Record that a metadata write of this index completed, or that a pass mutated it
5142    /// since the last one did.
5143    pub(crate) fn set_persistence_owed(&mut self, owed: bool) {
5144        self.persistence_owed = owed;
5145    }
5146
5147    /// Stamp deltas applied from here on with `source`, restoring the previous value
5148    /// when the returned guard value is passed back.
5149    ///
5150    /// Used by snapshot loading, which is replaying observations that describe a tree
5151    /// as it was, not as this process has seen it.
5152    pub(crate) fn set_applying_source(&mut self, source: Source, captured_at_ns: i64) -> Source {
5153        let previous = self.applying_source;
5154        self.applying_source = source;
5155        if source == Source::Cached {
5156            self.state.source = Source::Cached;
5157        }
5158        if captured_at_ns != 0 {
5159            self.captured_at_ns = captured_at_ns;
5160        }
5161        previous
5162    }
5163
5164    /// Intern an extension name and retain one file's reference to it.
5165    ///
5166    /// Every call must be matched by a [`Self::release_ext`] when that file leaves the
5167    /// index, which is what keeps the interner proportional to the extensions the tree
5168    /// currently holds rather than to every extension it has ever held.
5169    fn intern_ext(&mut self, name: &str) -> ExtId {
5170        if let Some(&id) = self.ext_ids.get(name) {
5171            let refcount =
5172                self.ext_refcounts.get_mut(id as usize).expect("a live id has a refcount");
5173            *refcount = refcount.checked_add(1).expect("extension refcount exhausted");
5174            return id;
5175        }
5176        let id = if let Some(id) = self.free_ext_ids.pop() {
5177            let slot = id as usize;
5178            self.ext_names[slot] = Some(name.to_string());
5179            self.ext_refcounts[slot] = 1;
5180            id
5181        } else {
5182            let id = ExtId::try_from(self.ext_names.len()).expect("extension interner exhausted");
5183            self.ext_names.push(Some(name.to_string()));
5184            self.ext_refcounts.push(1);
5185            id
5186        };
5187        self.ext_ids.insert(name.to_string(), id);
5188        id
5189    }
5190
5191    /// Drop one file's reference, freeing the id and its name after the last one.
5192    fn release_ext(&mut self, id: ExtId) {
5193        let slot = id as usize;
5194        let refcount = self.ext_refcounts.get_mut(slot).expect("a live id has a refcount");
5195        debug_assert!(*refcount > 0, "extension reference released twice");
5196        *refcount -= 1;
5197        if *refcount != 0 {
5198            return;
5199        }
5200        let name = self.ext_names[slot].take().expect("a live id has a name");
5201        let removed = self.ext_ids.remove(&name);
5202        debug_assert_eq!(removed, Some(id), "the interner's two maps disagreed");
5203        self.free_ext_ids.push(id);
5204    }
5205
5206    /// Resolve hot-path integer keys exactly once at a public query boundary.
5207    fn named_rollup(&self, rollup: &InternedRollUp) -> RollUp {
5208        let by_ext = rollup
5209            .by_ext
5210            .iter()
5211            .map(|(id, tally)| {
5212                let name = self
5213                    .ext_names
5214                    .get(*id as usize)
5215                    .and_then(Option::as_ref)
5216                    .expect("a live roll-up's extension id has a name");
5217                (name.clone(), *tally)
5218            })
5219            .collect();
5220        RollUp {
5221            files: rollup.files,
5222            dirs: rollup.dirs,
5223            bytes: rollup.bytes,
5224            allocated: rollup.allocated,
5225            newest_mtime_ns: rollup.newest_mtime_ns,
5226            by_ext,
5227        }
5228    }
5229
5230    fn named_partitions(&self, rollup: &InternedPartitionRollUp) -> PartitionRollUp {
5231        PartitionRollUp {
5232            all: self.named_rollup(&rollup.all),
5233            unignored: self.named_rollup(&rollup.unignored),
5234        }
5235    }
5236
5237    /// What an entry contributes to each of its ancestors.
5238    fn contribution(&self, id: EntryId) -> InternedPartitionRollUp {
5239        let entry = self.entry(id);
5240        match entry.kind {
5241            EntryKind::Dir => {
5242                let mut all = entry.rollup().all.clone();
5243                all.dirs += 1;
5244                let mut unignored = InternedRollUp::default();
5245                if !entry.ignored {
5246                    unignored = entry.rollup().unignored.clone();
5247                    unignored.dirs += 1;
5248                }
5249                InternedPartitionRollUp { all, unignored }
5250            }
5251            EntryKind::File => {
5252                let mut all = InternedRollUp {
5253                    files: 1,
5254                    dirs: 0,
5255                    bytes: entry.attrs.size,
5256                    allocated: entry.attrs.allocated,
5257                    newest_mtime_ns: entry.attrs.mtime_ns,
5258                    by_ext: BTreeMap::new(),
5259                };
5260                if let Some(ext_id) = entry.ext_id {
5261                    all.by_ext.insert(
5262                        ext_id,
5263                        ExtTally {
5264                            files: 1,
5265                            bytes: entry.attrs.size,
5266                            allocated: entry.attrs.allocated,
5267                        },
5268                    );
5269                }
5270                let unignored = if entry.ignored { InternedRollUp::default() } else { all.clone() };
5271                InternedPartitionRollUp { all, unignored }
5272            }
5273            EntryKind::Symlink | EntryKind::Other => InternedPartitionRollUp::default(),
5274        }
5275    }
5276
5277    fn merge_upward(
5278        &mut self,
5279        from_parent: Option<EntryId>,
5280        contribution: &InternedPartitionRollUp,
5281    ) {
5282        let mut current = from_parent;
5283        while let Some(id) = current {
5284            // Counted per level rather than per call: the O(depth) shape is the thing
5285            // worth seeing, and it is what S4's bottom-up pass would collapse.
5286            crate::counters::bump(|c| c.rollup_merges += 1);
5287            let entry = self.entry_mut(id);
5288            entry.rollup_mut().merge(contribution);
5289            current = entry.parent;
5290        }
5291    }
5292
5293    fn unmerge_upward(
5294        &mut self,
5295        from_parent: Option<EntryId>,
5296        contribution: &InternedPartitionRollUp,
5297    ) {
5298        let mut current = from_parent;
5299        while let Some(id) = current {
5300            let entry = self.entry_mut(id);
5301            entry.rollup_mut().unmerge(contribution);
5302            current = entry.parent;
5303        }
5304    }
5305
5306    /// Rebuild `newest_mtime_ns` from direct children, walking to the root.
5307    ///
5308    /// Every ancestor must be visited even when the nearest directory is already
5309    /// correct. Differential unmerge/re-merge can repair a single-child directory as
5310    /// it goes while leaving an ancestor with other contributors holding the removed
5311    /// maximum. Stopping at the first unchanged directory therefore strands a stale
5312    /// value higher in the tree.
5313    fn recompute_newest_upward(&mut self, from: Option<EntryId>) {
5314        let mut current = from;
5315        while let Some(id) = current {
5316            let mut newest: Option<i64> = None;
5317            for child in self.child_ids(id) {
5318                let child_entry = self.entry(child);
5319                let candidate = match child_entry.kind {
5320                    EntryKind::Dir => (child_entry.rollup().files > 0)
5321                        .then_some(child_entry.rollup().newest_mtime_ns),
5322                    EntryKind::File => Some(child_entry.attrs.mtime_ns),
5323                    EntryKind::Symlink | EntryKind::Other => None,
5324                };
5325                if let Some(candidate) = candidate {
5326                    newest = Some(newest.map_or(candidate, |current| current.max(candidate)));
5327                }
5328            }
5329            let newest = newest.unwrap_or(0);
5330            self.entry_mut(id).rollup_mut().all.newest_mtime_ns = newest;
5331
5332            let mut newest_unignored: Option<i64> = None;
5333            for child in self.child_ids(id) {
5334                let child_entry = self.entry(child);
5335                let candidate = match child_entry.kind {
5336                    EntryKind::Dir => (!child_entry.ignored
5337                        && child_entry.rollup().unignored.files > 0)
5338                        .then_some(child_entry.rollup().unignored.newest_mtime_ns),
5339                    EntryKind::File => (!child_entry.ignored).then_some(child_entry.attrs.mtime_ns),
5340                    EntryKind::Symlink | EntryKind::Other => None,
5341                };
5342                if let Some(candidate) = candidate {
5343                    newest_unignored =
5344                        Some(newest_unignored.map_or(candidate, |current| current.max(candidate)));
5345                }
5346            }
5347            let entry = self.entry_mut(id);
5348            entry.rollup_mut().unignored.newest_mtime_ns = newest_unignored.unwrap_or(0);
5349            current = entry.parent;
5350        }
5351    }
5352
5353    /// Resolve a parent chain already proved by [`Self::validate_known_ancestry`].
5354    fn resolve_dir_chain(&self, parts: &[&OsStr]) -> EntryId {
5355        let mut current = EntryId::ROOT;
5356        for part in parts {
5357            current = self
5358                .child(current, part)
5359                .expect("validated ancestry remains present under the writer lock");
5360            debug_assert!(self.entry(current).kind.is_dir());
5361        }
5362        current
5363    }
5364
5365    fn apply_upsert<C: ConsequenceSink>(
5366        &mut self,
5367        path: &Path,
5368        kind: EntryKind,
5369        attrs: Attrs,
5370        stats: &mut ApplyStats,
5371        effects: &mut C,
5372        parent_memo: &mut ParentMemo,
5373    ) -> bool {
5374        // A walker reports a directory's children consecutively, because that is the
5375        // order one `getdents64` batch hands them over, so the parent resolved for the
5376        // previous entry is almost always the parent of this one. Checking that first
5377        // turns the common case into a single path comparison and skips both the
5378        // component vector and the descent below.
5379        crate::counters::bump(|c| c.upserts += 1);
5380        if let (Some(dir), Some(name)) = (path.parent(), path.file_name()) {
5381            if let Some(parent) = parent_memo.get(dir) {
5382                crate::counters::bump(|c| c.parent_memo_hits += 1);
5383                return self.upsert_beneath(parent, name, path, kind, attrs, stats, effects);
5384            }
5385        }
5386        crate::counters::bump(|c| c.parent_resolutions += 1);
5387
5388        let Some(parts) = normalize(path) else {
5389            return false;
5390        };
5391        let source = self.applying_source;
5392
5393        let Some((name, ancestors)) = parts.split_last() else {
5394            // The root itself: only its own attributes can change. Its source is
5395            // stamped on both paths for the same reason every other entry's is — a
5396            // producer just looked at it — and the root is the entry where getting this
5397            // wrong costs the most, because the whole-tree totals hang off it and a
5398            // consumer reads its provenance to label the headline number.
5399            if self.entry(EntryId::ROOT).attrs == attrs {
5400                self.entry_mut(EntryId::ROOT).source = source;
5401                stats.unchanged += 1;
5402                return false;
5403            }
5404            let root = self.entry_mut(EntryId::ROOT);
5405            let previous = root.attrs;
5406            root.attrs = attrs;
5407            root.source = source;
5408            Self::bump_revision(root);
5409            stats.updated += 1;
5410            effects.change(|| EffectiveChange::Updated {
5411                path: PathBuf::new(),
5412                kind: EntryKind::Dir,
5413                previous,
5414                current: attrs,
5415            });
5416            return true;
5417        };
5418        let parent = self.resolve_dir_chain(ancestors);
5419        if let Some(dir) = path.parent() {
5420            parent_memo.set(dir, parent);
5421        }
5422        self.upsert_beneath(parent, name, path, kind, attrs, stats, effects)
5423    }
5424
5425    /// Apply one upsert beneath a parent whose id is already resolved.
5426    ///
5427    /// This is the whole of [`apply_upsert`] except for finding the parent, split out so
5428    /// that the memoized and the resolved paths share one body rather than two copies of
5429    /// the arbitration rules.  Every guard the delta contract requires still runs here:
5430    /// the caller has supplied a parent, not a decision.
5431    #[allow(clippy::too_many_arguments)]
5432    fn upsert_beneath<C: ConsequenceSink>(
5433        &mut self,
5434        parent: EntryId,
5435        name: &OsStr,
5436        path: &Path,
5437        kind: EntryKind,
5438        attrs: Attrs,
5439        stats: &mut ApplyStats,
5440        effects: &mut C,
5441    ) -> bool {
5442        let source = self.applying_source;
5443        let existing = self.child(parent, name);
5444
5445        if let Some(id) = existing {
5446            let entry = self.entry(id);
5447            if entry.kind == kind {
5448                if entry.attrs == attrs {
5449                    // Nothing about the value changed, but a producer just looked at
5450                    // it, and that is exactly what provenance records. Without this an
5451                    // entry verified by a revalidation sweep keeps reporting the source
5452                    // it was loaded with, and a consumer could never clear a
5453                    // stale-value indicator no matter how much checking happened.
5454                    self.entry_mut(id).source = source;
5455                    stats.unchanged += 1;
5456                    return false;
5457                }
5458                if kind.is_dir() {
5459                    // A directory's own attributes do not reach its ancestors' roll-ups,
5460                    // so there is nothing to re-merge.
5461                    let entry = self.entry_mut(id);
5462                    let previous = entry.attrs;
5463                    entry.attrs = attrs;
5464                    entry.source = source;
5465                    Self::bump_revision(entry);
5466                    stats.updated += 1;
5467                    effects.change(|| EffectiveChange::Updated {
5468                        path: path.to_path_buf(),
5469                        kind,
5470                        previous,
5471                        current: attrs,
5472                    });
5473                    return true;
5474                }
5475                let previous_attrs = entry.attrs;
5476                self.invalidate_content(path);
5477                if kind == EntryKind::File {
5478                    self.remove_serving_file_semantics(path, id, previous_attrs);
5479                }
5480                self.remove_serving_entry(path, kind, previous_attrs, id);
5481                let old = self.contribution(id);
5482                self.unmerge_upward(Some(parent), &old);
5483                let entry = self.entry_mut(id);
5484                let previous = entry.attrs;
5485                entry.attrs = attrs;
5486                entry.source = source;
5487                Self::bump_revision(entry);
5488                let new = self.contribution(id);
5489                self.merge_upward(Some(parent), &new);
5490                self.insert_serving_entry(path, kind, attrs, id);
5491                if new.newest_mtime_ns < old.newest_mtime_ns {
5492                    self.recompute_newest_upward(Some(parent));
5493                }
5494                stats.updated += 1;
5495                effects.change(|| EffectiveChange::Updated {
5496                    path: path.to_path_buf(),
5497                    kind,
5498                    previous,
5499                    current: attrs,
5500                });
5501                return true;
5502            }
5503            // The kind changed (a file became a directory, say). Remove and re-insert
5504            // rather than trying to mutate one shape into the other.
5505            //
5506            // This drops a subtree but cannot invalidate the memo: the memo holds this
5507            // entry's *parent*, and the subtree removed is rooted at the entry itself.
5508            // Clearing here would be untestable defensive code, which reads as a hazard
5509            // that does not exist.
5510            self.remove_entry(id, stats, effects);
5511        }
5512
5513        let ext_id =
5514            (kind == EntryKind::File).then(|| self.intern_ext(&crate::classify::ext_bucket(name)));
5515        let ignored =
5516            self.entry(parent).ignored || self.controls.matcher_for(path).is_ignored(kind.is_dir());
5517        let id = self.alloc(Entry::new(
5518            NewEntry {
5519                parent: Some(parent),
5520                name: name.to_os_string(),
5521                ext_id,
5522                ignored,
5523                source,
5524                kind,
5525                attrs,
5526            },
5527            false,
5528        ));
5529        self.insert_child(parent, name.to_os_string(), id);
5530        let contribution = self.contribution(id);
5531        self.merge_upward(Some(parent), &contribution);
5532        stats.inserted += 1;
5533        effects.change(|| EffectiveChange::Inserted { path: path.to_path_buf(), kind, attrs });
5534        self.insert_serving_entry(path, kind, attrs, id);
5535        true
5536    }
5537
5538    /// Insert one snapshot record beneath a parent whose id the caller already holds.
5539    ///
5540    /// The snapshot loader is not a producer.  It restores state that the delta contract
5541    /// already arbitrated and serialized, in the order it was written, with parents
5542    /// always preceding their children — so every fact [`apply_upsert`] rediscovers by
5543    /// resolving a path is a fact the loader was handed.  Routing it through the
5544    /// observation path made the loader pay, per record, a `PathBuf` join, an
5545    /// `Observation` vector, a `normalize` vector, and a descent from the root through
5546    /// one `BTreeMap` lookup per level, to arrive at a parent it had in a local variable.
5547    /// A callgrind profile of a 450k-entry load put the allocator at about 27% of the
5548    /// work and path-component iteration at about 15%; this removes both.
5549    ///
5550    /// It stays `pub(crate)` and takes an `EntryId` rather than a path precisely so it
5551    /// cannot become a second mutation surface: no external producer can reach it, and
5552    /// the guarantee that a loaded index equals the saved one is enforced by round-trip
5553    /// tests rather than by making deserialization impersonate a producer.
5554    ///
5555    /// Returns `None` when the parent is not a live directory or already holds `name`,
5556    /// which is how a corrupt snapshot fails closed.
5557    pub(crate) fn insert_loaded_child(
5558        &mut self,
5559        parent: EntryId,
5560        name: OsString,
5561        kind: EntryKind,
5562        attrs: Attrs,
5563    ) -> Option<EntryId> {
5564        let parent_entry = self.try_entry(parent)?;
5565        if parent_entry.kind != EntryKind::Dir || self.child(parent, &name).is_some() {
5566            return None;
5567        }
5568        let source = self.applying_source;
5569        let ext_id =
5570            (kind == EntryKind::File).then(|| self.intern_ext(&crate::classify::ext_bucket(&name)));
5571        let id = self.alloc(Entry::new(
5572            NewEntry {
5573                parent: Some(parent),
5574                name: name.clone(),
5575                ext_id,
5576                ignored: false,
5577                source,
5578                kind,
5579                attrs,
5580            },
5581            true,
5582        ));
5583        self.insert_child(parent, name, id);
5584        // Roll-ups stay eager. The same profile put `merge_upward` at about 3.5%, so
5585        // deferring it to a bottom-up pass would buy little and would introduce a window
5586        // in which the index is structurally complete but numerically wrong.
5587        let contribution = self.contribution(id);
5588        self.merge_upward(Some(parent), &contribution);
5589        // One-shot snapshot load constructs the index with serving off.
5590        // `insert_serving_entry` would discard a reconstructed path.
5591        if self.serving.is_some() {
5592            let path = self.path_of(id).expect("a newly loaded entry has a path");
5593            self.insert_serving_entry(&path, kind, attrs, id);
5594        }
5595        Some(id)
5596    }
5597
5598    fn apply_remove<C: ConsequenceSink>(
5599        &mut self,
5600        path: &Path,
5601        stats: &mut ApplyStats,
5602        effects: &mut C,
5603    ) -> bool {
5604        let Some(id) = self.lookup(path) else {
5605            stats.unchanged += 1;
5606            return false;
5607        };
5608        if id == EntryId::ROOT {
5609            stats.unchanged += 1;
5610            return false;
5611        }
5612        self.remove_entry(id, stats, effects);
5613        true
5614    }
5615
5616    fn remove_entry<C: ConsequenceSink>(
5617        &mut self,
5618        id: EntryId,
5619        stats: &mut ApplyStats,
5620        effects: &mut C,
5621    ) {
5622        let removed_root = self.path_of(id).expect("a live entry has a path");
5623        self.invalidate_content(&removed_root);
5624        self.remove_serving_subtree_semantics(id, &removed_root);
5625        let parent = self.entry(id).parent;
5626        let name = self.entry(id).name.clone();
5627        let contribution = self.contribution(id);
5628
5629        self.unmerge_upward(parent, &contribution);
5630        if let Some(parent) = parent {
5631            self.remove_child(parent, &name);
5632        }
5633
5634        // Free the subtree iteratively; a recursive drop would blow the stack on deep
5635        // trees, which is exactly the shape this engine is built for.
5636        let mut queue = VecDeque::from([(id, removed_root)]);
5637        while let Some((node, path)) = queue.pop_front() {
5638            let entry = self.entry(node);
5639            let kind = entry.kind;
5640            let attrs = entry.attrs;
5641            let children: Vec<(OsString, EntryId)> = self
5642                .children_of(node)
5643                .expect("removed subtree entry is live")
5644                .map(|(name, child)| (name.to_os_string(), child))
5645                .collect();
5646            let ext_id = entry.ext_id;
5647            for (name, child) in children {
5648                queue.push_back((child, path.join(name)));
5649            }
5650            self.remove_serving_entry(&path, kind, attrs, node);
5651            effects.change(|| EffectiveChange::Removed { path, kind, attrs });
5652            // Give the extension back before the entry itself goes, so the interner
5653            // holds only what the tree still contains.
5654            if let Some(ext_id) = ext_id {
5655                self.release_ext(ext_id);
5656            }
5657            self.free(node);
5658            stats.removed += 1;
5659        }
5660
5661        // The max may have lived in what was just removed.
5662        self.recompute_newest_upward(parent);
5663    }
5664
5665    fn invalidate_content(&mut self, path: &Path) {
5666        if let Some(content) = self.content.as_mut() {
5667            content.invalidate(path);
5668        }
5669    }
5670}
5671
5672/// Capture every child's expectation directly off its live entry, with no path work.
5673///
5674/// Both reconcile targets use this. The exclusive path once had a twin in `scan.rs`
5675/// that re-derived each expectation by joining a `PathBuf` and descending from the
5676/// root — two full descents and ~13 allocations per child to recover an `EntryId`
5677/// the iterator already held. The equivalence test below is what lets the twin stay
5678/// deleted.
5679pub(crate) fn collect_child_expectations(
5680    index: &Index,
5681    path: &Path,
5682) -> BTreeMap<OsString, PathExpectation> {
5683    index.children(path).map_or_else(BTreeMap::new, |children| {
5684        children
5685            .map(|(name, id)| {
5686                let entry = index.entry(id);
5687                let expectation = PathExpectation::new(
5688                    PathState::Present { kind: entry.kind, attrs: entry.attrs },
5689                    Some(index.identity(id)),
5690                    None,
5691                );
5692                (name.to_os_string(), expectation)
5693            })
5694            .collect()
5695    })
5696}
5697
5698/// Split a relative path into its normal components, rejecting anything that escapes.
5699///
5700/// Returns `None` for paths containing `..`, a root, or a prefix — an index keyed by
5701/// relative path has no way to represent those, and silently normalizing them away would
5702/// let a delta write outside the tree it claims to describe.
5703/// The components are borrowed from `path`, not copied out of it.
5704///
5705/// Owning them cost an allocation per component, and this runs twice for every
5706/// operation in every batch — once to validate the path and once to apply it. On a
5707/// tree averaging eight levels deep that was on the order of eighteen allocations per
5708/// entry, all of them holding bytes that the caller's `PathBuf` already owned and
5709/// outlives. Only the returned `Vec` allocates now, and only where a slice is
5710/// genuinely needed.
5711/// The parent directory resolved for the previous upsert in a batch.
5712///
5713/// A walker reports a directory's children consecutively, so resolving the parent path
5714/// once per directory rather than once per entry removes the dominant cost of applying a
5715/// cold scan: a callgrind profile attributed about 25 path-component comparisons per
5716/// entry to the descent, and the component vector `normalize` builds is an allocation
5717/// per entry on top of that.
5718///
5719/// It is a single slot rather than a map on purpose.  A map would keep entries alive
5720/// across structural changes and turn every miss into a hash, where consecutive runs are
5721/// what the walker actually produces; one slot captures those and costs a path
5722/// comparison when it misses.  The slot holds an id, so it must be cleared whenever a
5723/// removal could unmake it — [`Index::apply_remove`], an invalidation, and the
5724/// kind-change removal inside an upsert all do.
5725#[derive(Default)]
5726struct ParentMemo {
5727    entry: Option<(PathBuf, EntryId)>,
5728}
5729
5730impl ParentMemo {
5731    /// The id remembered for `dir`, if the last resolved parent was that directory.
5732    fn get(&self, dir: &Path) -> Option<EntryId> {
5733        self.entry.as_ref().filter(|(cached, _)| cached == dir).map(|&(_, id)| id)
5734    }
5735
5736    fn set(&mut self, dir: &Path, id: EntryId) {
5737        match &mut self.entry {
5738            // Overwriting in place keeps this to one allocation per directory rather
5739            // than one per run, which matters because a wide tree alternates often.
5740            Some((cached, cached_id)) => {
5741                cached.clear();
5742                cached.push(dir);
5743                *cached_id = id;
5744            }
5745            slot => *slot = Some((dir.to_path_buf(), id)),
5746        }
5747    }
5748
5749    fn clear(&mut self) {
5750        self.entry = None;
5751    }
5752}
5753
5754/// Structural effects of accepted operations evaluated before the real mutation.
5755#[derive(Default)]
5756struct StructuralOverlay {
5757    // Only point lookup and subtree retention use these keys; no iteration order is
5758    // observed. Ordering every path on lookup and insert dominated public preflight.
5759    entries: HashMap<PathBuf, EntryKind>,
5760    removed_roots: Vec<PathBuf>,
5761}
5762
5763impl StructuralOverlay {
5764    fn kind(&self, index: &Index, path: &Path) -> Option<EntryKind> {
5765        self.entries.get(path).copied().or_else(|| {
5766            (!self.removed_roots.iter().any(|removed| path.starts_with(removed)))
5767                .then(|| index.kind(path))
5768                .flatten()
5769        })
5770    }
5771
5772    fn upsert(&mut self, index: &Index, path: &Path, kind: EntryKind) {
5773        if self.kind(index, path).is_some_and(|current| current != kind) {
5774            self.remove(index, path);
5775        }
5776        self.entries.insert(path.to_path_buf(), kind);
5777    }
5778
5779    fn remove(&mut self, index: &Index, path: &Path) {
5780        if self.kind(index, path).is_none() {
5781            return;
5782        }
5783        self.entries.retain(|candidate, _| !candidate.starts_with(path));
5784        self.removed_roots.retain(|candidate| !candidate.starts_with(path));
5785        if !self.removed_roots.iter().any(|removed| path.starts_with(removed)) {
5786            self.removed_roots.push(path.to_path_buf());
5787        }
5788    }
5789}
5790
5791fn normalize(path: &Path) -> Option<Vec<&OsStr>> {
5792    let mut parts = Vec::new();
5793    for component in path.components() {
5794        match component {
5795            Component::Normal(part) => parts.push(part),
5796            Component::CurDir => {}
5797            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
5798        }
5799    }
5800    Some(parts)
5801}
5802
5803fn prepare_observation(observation: &Observation) -> crate::Result<PreparedObservation> {
5804    let mut ops = Vec::with_capacity(observation.len());
5805    for observed in &observation.ops {
5806        let path = canonical_relative_path(observed.op.path())?;
5807        let op = match &observed.op {
5808            Op::Upsert { kind, attrs, .. } => Op::Upsert { path, kind: *kind, attrs: *attrs },
5809            Op::Remove { .. } => Op::Remove { path },
5810            Op::ControlUpsert { source, .. } => {
5811                if !crate::control::is_control_file(&path) {
5812                    return Err(crate::Error::InvalidControlPath(path));
5813                }
5814                Op::ControlUpsert { path, source: source.clone() }
5815            }
5816            Op::ControlRemove { .. } => {
5817                if !crate::control::is_control_file(&path) {
5818                    return Err(crate::Error::InvalidControlPath(path));
5819                }
5820                Op::ControlRemove { path }
5821            }
5822            Op::InvalidateSubtree { reason, .. } => Op::InvalidateSubtree { path, reason: *reason },
5823        };
5824        ops.push(ObservationOp { op, expectation: observed.expectation });
5825    }
5826    Ok(PreparedObservation {
5827        ops,
5828        ancestry: PreparedAncestry::General,
5829        #[cfg(test)]
5830        reject_before_apply: false,
5831    })
5832}
5833
5834fn canonical_relative_path(path: &Path) -> crate::Result<PathBuf> {
5835    let mut canonical = PathBuf::with_capacity(path.as_os_str().as_encoded_bytes().len());
5836    for component in path.components() {
5837        match component {
5838            Component::Normal(part) => canonical.push(part),
5839            Component::CurDir => {}
5840            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
5841                return Err(crate::Error::PathEscapesRoot(path.to_path_buf()));
5842            }
5843        }
5844    }
5845    Ok(canonical)
5846}
5847
5848fn derive_impact(changes: &[EffectiveChange], state: &[StateTransition]) -> Impact {
5849    let mut domains = BTreeSet::new();
5850    let mut paths = BTreeSet::new();
5851    let mut all_dirty = false;
5852    let mut ancestor_visits = 0_u64;
5853    let count_impact = crate::counters::enabled();
5854
5855    for change in changes {
5856        match change {
5857            EffectiveChange::Inserted { .. } | EffectiveChange::Removed { .. } => {
5858                domains.extend([
5859                    ImpactDomain::Topology,
5860                    ImpactDomain::Metadata,
5861                    ImpactDomain::Classification,
5862                    ImpactDomain::Aggregates,
5863                    ImpactDomain::Content,
5864                ]);
5865            }
5866            EffectiveChange::Updated { .. } => {
5867                domains.extend([
5868                    ImpactDomain::Metadata,
5869                    ImpactDomain::Aggregates,
5870                    ImpactDomain::Content,
5871                ]);
5872            }
5873            EffectiveChange::ControlUpdated { .. } | EffectiveChange::Reclassified { .. } => {
5874                domains.extend([ImpactDomain::Classification, ImpactDomain::Aggregates]);
5875            }
5876            EffectiveChange::ControlRefusalUpdated { .. } => {
5877                domains.insert(ImpactDomain::Classification);
5878            }
5879            EffectiveChange::Invalidated { .. } => {
5880                domains.insert(ImpactDomain::State);
5881            }
5882        }
5883        insert_dirty_ancestors(
5884            change.path(),
5885            &mut paths,
5886            &mut all_dirty,
5887            count_impact,
5888            &mut ancestor_visits,
5889        );
5890    }
5891    for transition in state {
5892        domains.insert(ImpactDomain::State);
5893        insert_dirty_ancestors(
5894            transition.path(),
5895            &mut paths,
5896            &mut all_dirty,
5897            count_impact,
5898            &mut ancestor_visits,
5899        );
5900    }
5901
5902    if count_impact {
5903        let candidates =
5904            u64::try_from(changes.len().saturating_add(state.len())).unwrap_or(u64::MAX);
5905        let retained_dirty_paths = u64::try_from(paths.len()).unwrap_or(u64::MAX);
5906        crate::counters::bump(|counts| {
5907            counts.impact_candidates = counts.impact_candidates.saturating_add(candidates);
5908            counts.impact_ancestor_visits =
5909                counts.impact_ancestor_visits.saturating_add(ancestor_visits);
5910            counts.impact_retained_dirty_paths =
5911                counts.impact_retained_dirty_paths.saturating_add(retained_dirty_paths);
5912            counts.impact_all_dirty = counts.impact_all_dirty.saturating_add(u64::from(all_dirty));
5913        });
5914    }
5915
5916    Impact {
5917        domains: domains.into_iter().collect(),
5918        dirty_paths: if all_dirty { Vec::new() } else { paths.into_iter().collect() },
5919        all_dirty,
5920    }
5921}
5922
5923fn commit_work(observations: u64, stats: ApplyStats) -> Work {
5924    Work {
5925        observations,
5926        unchanged: stats.unchanged,
5927        stale: stats.stale,
5928        resource_refused: stats.resource_refused,
5929        ..Work::default()
5930    }
5931}
5932
5933fn insert_dirty_ancestors(
5934    path: &Path,
5935    paths: &mut BTreeSet<PathBuf>,
5936    all_dirty: &mut bool,
5937    count_impact: bool,
5938    ancestor_visits: &mut u64,
5939) {
5940    if *all_dirty {
5941        return;
5942    }
5943    for ancestor in path.ancestors() {
5944        if count_impact {
5945            *ancestor_visits = ancestor_visits.saturating_add(1);
5946        }
5947        paths.insert(ancestor.to_path_buf());
5948        if paths.len() > MAX_DIRTY_PATHS {
5949            paths.clear();
5950            *all_dirty = true;
5951            return;
5952        }
5953    }
5954}
5955
5956fn same_target(
5957    current: Option<EntryIdentity>,
5958    expected: Option<EntryIdentity>,
5959    require_structure: bool,
5960) -> bool {
5961    match (current, expected) {
5962        (Some(current), Some(expected)) => current.same_target(expected, require_structure),
5963        (None, None) => true,
5964        (Some(_), None) | (None, Some(_)) => false,
5965    }
5966}
5967
5968#[cfg(test)]
5969mod tests {
5970    use super::*;
5971    use crate::engine_contract::ObservationOp;
5972    use std::sync::{Arc, Barrier};
5973
5974    #[test]
5975    fn reusable_entry_keeps_directory_state_out_of_line() {
5976        let entry_bytes = std::mem::size_of::<Entry>();
5977        let slot_bytes = std::mem::size_of::<Slot>();
5978
5979        assert!(
5980            entry_bytes <= 136,
5981            "common entry storage must not inline directory-only maps and roll-ups: {entry_bytes} bytes"
5982        );
5983        assert!(
5984            slot_bytes <= entry_bytes + 16,
5985            "the arena slot must not add a second per-entry allocation: entry={entry_bytes}, slot={slot_bytes}"
5986        );
5987    }
5988
5989    #[test]
5990    fn detached_children_store_each_name_once_and_promote_on_mutation() {
5991        let mut builder = DetachedIndexBuilder::new(
5992            "/root",
5993            ScanScope::default(),
5994            crate::classify::TypeRegistry::compiled_shared(),
5995        );
5996        builder
5997            .push_directory(crate::scan::DetachedDirectory {
5998                path: PathBuf::new(),
5999                children: vec![
6000                    crate::scan::DetachedChild {
6001                        name: OsString::from("dir"),
6002                        kind: EntryKind::Dir,
6003                        attrs: Attrs::default(),
6004                        position: 0,
6005                    },
6006                    crate::scan::DetachedChild {
6007                        name: OsString::from("z.txt"),
6008                        kind: EntryKind::File,
6009                        attrs: file_attrs(1, 1),
6010                        position: 1,
6011                    },
6012                ],
6013                control: None,
6014            })
6015            .expect("detached root listing");
6016        builder
6017            .push_directory(crate::scan::DetachedDirectory {
6018                path: PathBuf::from("dir"),
6019                children: vec![
6020                    crate::scan::DetachedChild {
6021                        name: OsString::from("z.txt"),
6022                        kind: EntryKind::File,
6023                        attrs: file_attrs(2, 2),
6024                        position: 0,
6025                    },
6026                    crate::scan::DetachedChild {
6027                        name: OsString::from("a.txt"),
6028                        kind: EntryKind::File,
6029                        attrs: file_attrs(3, 3),
6030                        position: 1,
6031                    },
6032                ],
6033                control: None,
6034            })
6035            .expect("detached child listing");
6036        let mut index = builder.finish();
6037        let directory = index.lookup(Path::new("dir")).expect("detached directory");
6038
6039        assert!(index.entry(EntryId::ROOT).directory().children.is_sorted());
6040        assert!(index.entry(directory).directory().children.is_sorted());
6041        assert_eq!(
6042            index
6043                .children(Path::new("dir"))
6044                .expect("directory children")
6045                .map(|(name, _)| name.to_os_string())
6046                .collect::<Vec<_>>(),
6047            [OsString::from("a.txt"), OsString::from("z.txt")]
6048        );
6049
6050        index.apply_ok(&Observation::new(vec![upsert(
6051            "dir/m.txt",
6052            EntryKind::File,
6053            file_attrs(4, 4),
6054        )]));
6055
6056        assert!(index.entry(EntryId::ROOT).directory().children.is_sorted());
6057        assert!(index.entry(directory).directory().children.is_mutable());
6058        assert_eq!(
6059            index
6060                .children(Path::new("dir"))
6061                .expect("directory children")
6062                .map(|(name, _)| name.to_os_string())
6063                .collect::<Vec<_>>(),
6064            [OsString::from("a.txt"), OsString::from("m.txt"), OsString::from("z.txt")]
6065        );
6066    }
6067
6068    #[test]
6069    fn detached_builder_tolerates_a_duplicate_readdir_name() {
6070        let mut builder = DetachedIndexBuilder::new(
6071            "/root",
6072            ScanScope::default(),
6073            crate::classify::TypeRegistry::compiled_shared(),
6074        );
6075        let twice = |position, mtime_ns| crate::scan::DetachedChild {
6076            name: OsString::from("twice.txt"),
6077            kind: EntryKind::File,
6078            attrs: file_attrs(1, mtime_ns),
6079            position,
6080        };
6081        let result = builder.push_directory(crate::scan::DetachedDirectory {
6082            path: PathBuf::new(),
6083            children: vec![twice(0, 1), twice(1, 2)],
6084            control: None,
6085        });
6086        assert!(result.is_ok(), "a duplicate listing name must not fail the scan: {result:?}");
6087        let detached = builder.finish();
6088        assert_eq!(detached.total().files, 1);
6089        assert_eq!(detached.attrs(Path::new("twice.txt")), Some(&file_attrs(1, 2)));
6090
6091        // The streaming reducer tolerates the same input, and keeps the same observation.
6092        let mut streaming = Index::new("/root");
6093        streaming
6094            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
6095                upsert("twice.txt", EntryKind::File, file_attrs(1, 1)),
6096                upsert("twice.txt", EntryKind::File, file_attrs(1, 2)),
6097            ]))
6098            .expect("streaming tolerates a re-upsert");
6099        assert_eq!(streaming.total(), detached.total());
6100        assert_eq!(streaming.attrs(Path::new("twice.txt")), detached.attrs(Path::new("twice.txt")));
6101    }
6102
6103    #[test]
6104    fn detached_builder_accepts_the_repeated_walk_of_a_duplicated_directory() {
6105        let child = |name: &str, kind, attrs, position| crate::scan::DetachedChild {
6106            name: OsString::from(name),
6107            kind,
6108            attrs,
6109            position,
6110        };
6111        let listing = |path: &str, children| crate::scan::DetachedDirectory {
6112            path: PathBuf::from(path),
6113            children,
6114            control: None,
6115        };
6116        let mut builder = DetachedIndexBuilder::new(
6117            "/root",
6118            ScanScope::default(),
6119            crate::classify::TypeRegistry::compiled_shared(),
6120        );
6121        // The enumerator returned `dir` twice, and `swapped` first as a directory and then
6122        // as the file that replaced it.
6123        builder
6124            .push_directory(listing(
6125                "",
6126                vec![
6127                    child("dir", EntryKind::Dir, file_attrs(0, 1), 0),
6128                    child("swapped", EntryKind::Dir, file_attrs(0, 1), 1),
6129                    child("dir", EntryKind::Dir, file_attrs(0, 2), 2),
6130                    child("swapped", EntryKind::File, file_attrs(5, 2), 3),
6131                ],
6132            ))
6133            .expect("root listing with repeated names");
6134        // The walker lists `dir`, and everything below it, once per observation.
6135        for _ in 0..2 {
6136            builder
6137                .push_directory(listing(
6138                    "dir",
6139                    vec![child("nested", EntryKind::Dir, file_attrs(0, 3), 0)],
6140                ))
6141                .expect("each walk of the repeated directory");
6142            builder
6143                .push_directory(listing(
6144                    "dir/nested",
6145                    vec![child("file.txt", EntryKind::File, file_attrs(4, 4), 0)],
6146                ))
6147                .expect("each walk below the repeated directory");
6148        }
6149        // It also lists the directory observation that the file superseded.
6150        builder
6151            .push_directory(listing(
6152                "swapped",
6153                vec![child("stale.txt", EntryKind::File, file_attrs(6, 5), 0)],
6154            ))
6155            .expect("the superseded directory's walk");
6156        // A listing that no repeated name explains is still an ancestry failure.
6157        let error = builder
6158            .push_directory(listing("elsewhere", Vec::new()))
6159            .expect_err("a listing whose parent was never listed");
6160        assert!(matches!(
6161            error,
6162            crate::Error::UnknownAncestry { path, .. } if path == Path::new("elsewhere")
6163        ));
6164
6165        let index = builder.finish();
6166        assert_eq!(index.attrs(Path::new("dir")), Some(&file_attrs(0, 2)));
6167        assert_eq!(index.kind(Path::new("swapped")), Some(EntryKind::File));
6168        assert!(index.lookup(Path::new("swapped/stale.txt")).is_none());
6169        let total = index.total();
6170        assert_eq!((total.files, total.dirs, total.bytes), (2, 2, 9));
6171    }
6172
6173    fn file_attrs(size: u64, mtime_ns: i64) -> Attrs {
6174        Attrs {
6175            size,
6176            allocated: size.div_ceil(512) * 512,
6177            mtime_ns,
6178            ctime_ns: mtime_ns,
6179            inode: size.wrapping_mul(31).wrapping_add(mtime_ns.unsigned_abs()),
6180            dev: 1,
6181        }
6182    }
6183
6184    fn upsert(path: &str, kind: EntryKind, attrs: Attrs) -> Op {
6185        Op::Upsert { path: PathBuf::from(path), kind, attrs }
6186    }
6187
6188    fn assert_serving_indexes(index: &Index) {
6189        let serving = index.serving.as_ref().expect("test index has serving state");
6190        let mut entries = BTreeMap::new();
6191        let mut children = BTreeMap::<PathBuf, PortableChildren>::new();
6192        let mut recent_files = BTreeSet::new();
6193        let mut semantic_by_directory =
6194            BTreeMap::<EntryId, (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>)>::new();
6195        let declared_exact_names: BTreeSet<_> =
6196            index.types.exact_filenames().map(str::to_ascii_lowercase).collect();
6197        let mut exact_name_by_directory =
6198            BTreeMap::<EntryId, (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>)>::new();
6199        let mut semantic_refcounts = BTreeMap::<String, u64>::new();
6200        let mut pending = vec![(EntryId::ROOT, PathBuf::new(), vec![EntryId::ROOT])];
6201        while let Some((parent_id, parent_path, ancestors)) = pending.pop() {
6202            let facts: Vec<_> = index
6203                .children_of(parent_id)
6204                .expect("live directory")
6205                .map(|(name, id)| (name.to_os_string(), id))
6206                .collect();
6207            for (name, id) in facts {
6208                let path = parent_path.join(&name);
6209                let kind = index.kind_of(id).expect("live child");
6210                let portable = crate::opened::read::portable_path(&path);
6211                entries.insert(portable.clone(), id);
6212                if kind == EntryKind::File {
6213                    recent_files.insert(RecentKey {
6214                        mtime_ns: index.attrs(&path).expect("live child has attributes").mtime_ns,
6215                        portable_path: portable,
6216                        id,
6217                    });
6218                }
6219                if kind == EntryKind::File {
6220                    let semantic = index.classify(&path).file_type.as_str().to_string();
6221                    *semantic_refcounts.entry(semantic.clone()).or_default() += 1;
6222                    let attrs = *index.attrs(&path).expect("live child has attributes");
6223                    let ignored = index.entry(id).ignored;
6224                    for ancestor in &ancestors {
6225                        let partition = semantic_by_directory.entry(*ancestor).or_default();
6226                        let all = partition.0.entry(semantic.clone()).or_default();
6227                        all.files += 1;
6228                        all.bytes += attrs.size;
6229                        all.allocated += attrs.allocated;
6230                        if !ignored {
6231                            let unignored = partition.1.entry(semantic.clone()).or_default();
6232                            unignored.files += 1;
6233                            unignored.bytes += attrs.size;
6234                            unignored.allocated += attrs.allocated;
6235                        }
6236                    }
6237                    if let Some(exact_name) = name
6238                        .to_str()
6239                        .map(str::to_ascii_lowercase)
6240                        .filter(|name| declared_exact_names.contains(name))
6241                    {
6242                        for ancestor in &ancestors {
6243                            let partition = exact_name_by_directory.entry(*ancestor).or_default();
6244                            let all = partition.0.entry(exact_name.clone()).or_default();
6245                            all.files += 1;
6246                            all.bytes += attrs.size;
6247                            all.allocated += attrs.allocated;
6248                            if !ignored {
6249                                let unignored = partition.1.entry(exact_name.clone()).or_default();
6250                                unignored.files += 1;
6251                                unignored.bytes += attrs.size;
6252                                unignored.allocated += attrs.allocated;
6253                            }
6254                        }
6255                    }
6256                }
6257                let partition = children.entry(parent_path.clone()).or_default();
6258                let portable_name = crate::opened::read::portable_component(&name);
6259                if kind.is_dir() {
6260                    partition.directories.insert(portable_name, id);
6261                } else {
6262                    partition.nondirectories.insert(portable_name, id);
6263                }
6264                if kind.is_dir() {
6265                    let mut child_ancestors = ancestors.clone();
6266                    child_ancestors.insert(0, id);
6267                    pending.push((id, path, child_ancestors));
6268                }
6269            }
6270        }
6271
6272        assert_eq!(serving.portable_entries, entries);
6273        assert_eq!(serving.recent_files, recent_files);
6274        let actual_semantics: BTreeMap<_, _> = serving
6275            .semantic_by_directory
6276            .iter()
6277            .map(|(directory, partitions)| {
6278                let named = |source: &BTreeMap<u32, ExtTally>| {
6279                    source
6280                        .iter()
6281                        .map(|(semantic, tally)| {
6282                            let name = serving.semantic_names[*semantic as usize]
6283                                .as_ref()
6284                                .expect("live semantic has a name")
6285                                .clone();
6286                            (name, *tally)
6287                        })
6288                        .collect()
6289                };
6290                (*directory, (named(&partitions.all), named(&partitions.unignored)))
6291            })
6292            .collect();
6293        assert_eq!(actual_semantics, semantic_by_directory);
6294        let actual_exact_names: BTreeMap<_, _> = serving
6295            .exact_name_by_directory
6296            .iter()
6297            .map(|(directory, partitions)| {
6298                let named = |source: &BTreeMap<u32, ExtTally>| {
6299                    source
6300                        .iter()
6301                        .map(|(exact_name, tally)| {
6302                            (serving.exact_names[*exact_name as usize].clone(), *tally)
6303                        })
6304                        .collect()
6305                };
6306                (*directory, (named(&partitions.all), named(&partitions.unignored)))
6307            })
6308            .collect();
6309        assert_eq!(actual_exact_names, exact_name_by_directory);
6310        assert_eq!(
6311            serving.exact_names.iter().cloned().collect::<BTreeSet<_>>(),
6312            declared_exact_names
6313        );
6314        assert_eq!(
6315            serving.exact_name_ids,
6316            serving
6317                .exact_names
6318                .iter()
6319                .enumerate()
6320                .map(|(position, name)| {
6321                    (
6322                        name.clone(),
6323                        u32::try_from(position).expect("the exact-name vocabulary fits u32"),
6324                    )
6325                })
6326                .collect()
6327        );
6328        assert!(serving.exact_name_by_directory.len() <= index.arena.len());
6329        assert!(serving.exact_name_by_directory.values().all(|partitions| {
6330            partitions.all.len() <= serving.exact_names.len()
6331                && partitions.unignored.len() <= serving.exact_names.len()
6332                && partitions
6333                    .all
6334                    .keys()
6335                    .chain(partitions.unignored.keys())
6336                    .all(|name| (*name as usize) < serving.exact_names.len())
6337        }));
6338        let actual_refcounts: BTreeMap<_, _> = serving
6339            .semantic_ids
6340            .iter()
6341            .map(|(name, semantic)| (name.clone(), serving.semantic_refcounts[*semantic as usize]))
6342            .collect();
6343        assert_eq!(actual_refcounts, semantic_refcounts);
6344        assert_eq!(serving.portable_children, children);
6345
6346        // Every retained entry has a portable name, and the names are unique. The second
6347        // half is what the escaping has to earn: `%` is escaped in every name precisely so
6348        // a file called `x%FF` and one whose bytes are `x\xff` cannot collide here.
6349        assert_eq!(
6350            u64::try_from(serving.portable_entries.len()).expect("entry count fits u64"),
6351            index.len().saturating_sub(1),
6352            "every retained non-root entry has exactly one portable name"
6353        );
6354    }
6355
6356    #[test]
6357    fn portable_indexes_conserve_insert_kind_change_and_subtree_removal() {
6358        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6359            "/root",
6360            ScanScope::default(),
6361            crate::classify::TypeRegistry::compiled_shared(),
6362            DEFAULT_JOURNAL_CAPACITY_BYTES,
6363        );
6364        index.apply_ok(&Observation::new(vec![
6365            upsert("dir", EntryKind::Dir, Attrs::default()),
6366            upsert("dir/a", EntryKind::File, file_attrs(1, 1)),
6367            upsert("replace", EntryKind::File, file_attrs(2, 2)),
6368        ]));
6369        assert_serving_indexes(&index);
6370
6371        index.apply_ok(&Observation::new(vec![
6372            upsert("replace", EntryKind::Dir, Attrs::default()),
6373            upsert("replace/child", EntryKind::File, file_attrs(3, 3)),
6374        ]));
6375        assert_serving_indexes(&index);
6376
6377        index.apply_ok(&Observation::new(vec![upsert("dir/a", EntryKind::File, file_attrs(4, 9))]));
6378        assert_serving_indexes(&index);
6379        assert_eq!(
6380            index
6381                .serving
6382                .as_ref()
6383                .expect("opened test index")
6384                .recent_files
6385                .iter()
6386                .map(|entry| entry.portable_path.as_str())
6387                .collect::<Vec<_>>(),
6388            vec!["dir/a", "replace/child"]
6389        );
6390
6391        // Same-kind attribute updates of entries that hold no semantic tally: a symlink
6392        // re-created in place (`ln -sfn`) and a special entry replaced by another. Every
6393        // file here is extensionless, so each non-file shares its classification with a
6394        // real file, and a non-file update that touched semantics would move that file's
6395        // tally rather than fail loudly.
6396        index.apply_ok(&Observation::new(vec![
6397            upsert("dir/current", EntryKind::Symlink, file_attrs(5, 5)),
6398            upsert("dir/pipe", EntryKind::Other, file_attrs(6, 6)),
6399        ]));
6400        assert_serving_indexes(&index);
6401        index.apply_ok(&Observation::new(vec![
6402            upsert("dir/current", EntryKind::Symlink, file_attrs(7, 7)),
6403            upsert("dir/pipe", EntryKind::Other, file_attrs(8, 8)),
6404        ]));
6405        assert_serving_indexes(&index);
6406
6407        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("replace") }]));
6408        assert_serving_indexes(&index);
6409        assert_eq!(
6410            index.portable_entries().keys().map(crate::PortablePath::as_str).collect::<Vec<_>>(),
6411            vec!["dir", "dir/a", "dir/current", "dir/pipe"]
6412        );
6413    }
6414
6415    /// A symlink or special entry holds no semantic tally, so updating one must neither
6416    /// panic looking for a tally it never had nor subtract from a real file's.
6417    ///
6418    /// Both failures were reachable from ordinary filesystem churn on an opened root. With
6419    /// no file of the same classification, the update panicked inside the commit while the
6420    /// index write guard was held, poisoning the root. With one, it silently subtracted the
6421    /// link's attributes from that file's tally and released the file's interned type, so
6422    /// the file's own later removal panicked instead.
6423    #[test]
6424    fn non_file_attrs_updates_leave_file_semantics_untouched() {
6425        for kind in [EntryKind::Symlink, EntryKind::Other] {
6426            let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6427                "/root",
6428                ScanScope::default(),
6429                crate::classify::TypeRegistry::compiled_shared(),
6430                DEFAULT_JOURNAL_CAPACITY_BYTES,
6431            );
6432            index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(1, 1))]));
6433            assert_serving_indexes(&index);
6434            index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(1, 2))]));
6435            assert_serving_indexes(&index);
6436
6437            index.apply_ok(&Observation::new(vec![upsert(
6438                "notes",
6439                EntryKind::File,
6440                file_attrs(5, 3),
6441            )]));
6442            assert_serving_indexes(&index);
6443            index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(4, 4))]));
6444            assert_serving_indexes(&index);
6445
6446            index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("notes") }]));
6447            assert_serving_indexes(&index);
6448        }
6449    }
6450
6451    /// Escaping touches exactly two things and leaves everything else byte-identical.
6452    ///
6453    /// The rule is narrow on purpose: a byte that is not valid UTF-8, and `%` itself.
6454    /// Everything else — spaces, non-ASCII scalars, punctuation — passes through, because
6455    /// this produces a JSON string rather than a URL and mangling readable names would be
6456    /// a cost with no benefit.
6457    ///
6458    /// This test used to assert the opposite property, that the derived name could be
6459    /// turned back into a filesystem path with `PathBuf::from`. That held only while the
6460    /// derivation was the identity, and it is now unsound: `100%.txt` derives to
6461    /// `100%25.txt`, which names no file. The conversion was deleted rather than kept
6462    /// working, and callers ask the arena for a native path instead.
6463    #[test]
6464    fn escaping_touches_only_invalid_bytes_and_percent() {
6465        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6466            "/root",
6467            ScanScope::default(),
6468            crate::classify::TypeRegistry::compiled_shared(),
6469            DEFAULT_JOURNAL_CAPACITY_BYTES,
6470        );
6471        index.apply_ok(&Observation::new(vec![
6472            upsert("dir", EntryKind::Dir, Attrs::default()),
6473            upsert("dir/plain.txt", EntryKind::File, file_attrs(1, 1)),
6474            upsert("café", EntryKind::Dir, Attrs::default()),
6475            upsert("café/naïve.txt", EntryKind::File, file_attrs(2, 2)),
6476            upsert("日本語.md", EntryKind::File, file_attrs(3, 3)),
6477            upsert("a b", EntryKind::Dir, Attrs::default()),
6478            upsert("a b/c d.txt", EntryKind::File, file_attrs(5, 5)),
6479            upsert("100%.txt", EntryKind::File, file_attrs(4, 4)),
6480        ]));
6481
6482        let names: Vec<_> =
6483            index.portable_entries().keys().map(crate::PortablePath::as_str).collect();
6484        assert_eq!(
6485            names,
6486            vec![
6487                "100%25.txt",
6488                "a b",
6489                "a b/c d.txt",
6490                "café",
6491                "café/naïve.txt",
6492                "dir",
6493                "dir/plain.txt",
6494                "日本語.md",
6495            ],
6496            "only the literal percent is rewritten; separators, spaces and non-ASCII are not"
6497        );
6498    }
6499
6500    #[test]
6501    fn declared_exact_names_roll_up_by_ancestor_and_partition() {
6502        let types = Arc::new(
6503            crate::classify::TypeRegistry::from_manifest(
6504                "[[kind]]\nid = \"make\"\nfamily = \"code\"\nfilenames = [\"Makefile\"]\n",
6505            )
6506            .expect("custom registry"),
6507        );
6508        let scope =
6509            ScanScope { type_rules_fingerprint: types.fingerprint(), ..ScanScope::default() };
6510        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6511            "/root",
6512            scope,
6513            types,
6514            DEFAULT_JOURNAL_CAPACITY_BYTES,
6515        );
6516        index.apply_ok(&Observation::new(vec![
6517            upsert("Makefile", EntryKind::File, file_attrs(2, 1)),
6518            upsert("dir", EntryKind::Dir, Attrs::default()),
6519            upsert("dir/makefile", EntryKind::File, file_attrs(3, 2)),
6520            upsert("dir/notes", EntryKind::File, file_attrs(5, 3)),
6521        ]));
6522
6523        let serving = index.serving.as_ref().expect("opened test index");
6524        let exact_name = serving.exact_name_ids["makefile"];
6525        let root = &serving.exact_name_by_directory[&EntryId::ROOT];
6526        assert_eq!(root.all[&exact_name], ExtTally { files: 2, bytes: 5, allocated: 1_024 });
6527        assert_eq!(root.unignored, root.all);
6528
6529        let directory = index.lookup(Path::new("dir")).expect("directory");
6530        let nested = &serving.exact_name_by_directory[&directory];
6531        assert_eq!(nested.all[&exact_name], ExtTally { files: 1, bytes: 3, allocated: 512 });
6532        assert_eq!(nested.unignored, nested.all);
6533    }
6534
6535    #[test]
6536    #[ignore = "manual opened-root commit-cost evidence"]
6537    fn measure_opened_serving_commit_cost() {
6538        const DIRECTORY_COUNT: usize = 100;
6539        const FILES_PER_DIRECTORY: usize = 100;
6540        const SAMPLE_COUNT: usize = 7;
6541
6542        let mut operations = Vec::with_capacity(
6543            DIRECTORY_COUNT.saturating_mul(FILES_PER_DIRECTORY.saturating_add(1)),
6544        );
6545        for directory in 0..DIRECTORY_COUNT {
6546            let parent = format!("d{directory:03}");
6547            operations.push(upsert(&parent, EntryKind::Dir, Attrs::default()));
6548            for file in 0..FILES_PER_DIRECTORY {
6549                let size = u64::try_from(file).expect("the probe file count fits u64") + 1;
6550                let mtime = i64::try_from(file).expect("the probe file count fits i64");
6551                let name = if file == 0 {
6552                    format!("{parent}/Makefile")
6553                } else {
6554                    format!("{parent}/f{file:03}.rs")
6555                };
6556                operations.push(upsert(&name, EntryKind::File, file_attrs(size, mtime)));
6557            }
6558        }
6559        let observation = Observation::new(operations);
6560        let types = crate::classify::TypeRegistry::compiled_shared();
6561        let scope =
6562            ScanScope { type_rules_fingerprint: types.fingerprint(), ..ScanScope::default() };
6563        let measure = |opened: bool| {
6564            let mut index = if opened {
6565                Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6566                    "/root",
6567                    scope,
6568                    Arc::clone(&types),
6569                    DEFAULT_JOURNAL_CAPACITY_BYTES,
6570                )
6571            } else {
6572                Index::new_with_scope_types_and_journal_capacity_bytes(
6573                    "/root",
6574                    scope,
6575                    Arc::clone(&types),
6576                    DEFAULT_JOURNAL_CAPACITY_BYTES,
6577                )
6578            };
6579            let started = std::time::Instant::now();
6580            index.apply_ok(&observation);
6581            let elapsed = started.elapsed();
6582            std::hint::black_box(index.len());
6583            (elapsed, index)
6584        };
6585
6586        let _ = measure(false);
6587        let _ = measure(true);
6588        let mut detached = Vec::with_capacity(SAMPLE_COUNT);
6589        let mut opened = Vec::with_capacity(SAMPLE_COUNT);
6590        let mut last_opened = None;
6591        for sample in 0..SAMPLE_COUNT {
6592            if sample % 2 == 0 {
6593                detached.push(measure(false).0);
6594                let (duration, index) = measure(true);
6595                opened.push(duration);
6596                last_opened = Some(index);
6597            } else {
6598                let (duration, index) = measure(true);
6599                opened.push(duration);
6600                last_opened = Some(index);
6601                detached.push(measure(false).0);
6602            }
6603        }
6604        detached.sort_unstable();
6605        opened.sort_unstable();
6606        let detached_median = detached[SAMPLE_COUNT / 2];
6607        let opened_median = opened[SAMPLE_COUNT / 2];
6608        let ratio = opened_median.as_secs_f64() / detached_median.as_secs_f64();
6609
6610        let index = last_opened.expect("an opened sample ran");
6611        let serving = index.serving.as_ref().expect("opened sample has serving indexes");
6612        let semantic_rows: usize = serving
6613            .semantic_by_directory
6614            .values()
6615            .map(|partitions| partitions.all.len() + partitions.unignored.len())
6616            .sum();
6617        let exact_name_rows: usize = serving
6618            .exact_name_by_directory
6619            .values()
6620            .map(|partitions| partitions.all.len() + partitions.unignored.len())
6621            .sum();
6622        eprintln!(
6623            "entries={} detached_median_us={} opened_median_us={} ratio={ratio:.3} \
6624             portable_rows={} child_rows={} recent_rows={} semantic_rows={} exact_name_rows={} \
6625             exact_name_vocabulary={}",
6626            index.len(),
6627            detached_median.as_micros(),
6628            opened_median.as_micros(),
6629            serving.portable_entries.len(),
6630            serving
6631                .portable_children
6632                .values()
6633                .map(|children| children.directories.len() + children.nondirectories.len())
6634                .sum::<usize>(),
6635            serving.recent_files.len(),
6636            semantic_rows,
6637            exact_name_rows,
6638            serving.exact_names.len(),
6639        );
6640    }
6641
6642    #[test]
6643    fn detached_indexes_never_allocate_or_populate_serving_state() {
6644        let mut index = Index::new("/root");
6645        index.apply_ok(&Observation::new(vec![
6646            upsert("dir", EntryKind::Dir, Attrs::default()),
6647            upsert("dir/a", EntryKind::File, file_attrs(1, 1)),
6648        ]));
6649
6650        assert!(index.serving.is_none());
6651        assert!(index.portable_children(Path::new("dir")).is_none());
6652    }
6653
6654    #[test]
6655    fn insert_loaded_child_skips_serving_path_when_serving_is_off() {
6656        let mut index = Index::new("/root");
6657        let id = index
6658            .insert_loaded_child(
6659                EntryId::ROOT,
6660                OsString::from("a.rs"),
6661                EntryKind::File,
6662                file_attrs(4, 1),
6663            )
6664            .expect("parent is a live directory");
6665        assert!(!index.serving_indexes_enabled());
6666        assert_eq!(index.path_of(id), Some(PathBuf::from("a.rs")));
6667        assert_eq!(index.lookup(Path::new("a.rs")), Some(id));
6668        assert_eq!(index.total().files, 1);
6669    }
6670
6671    #[test]
6672    fn insert_loaded_child_fills_serving_when_enabled() {
6673        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6674            "/root",
6675            ScanScope::default(),
6676            crate::classify::TypeRegistry::compiled_shared(),
6677            DEFAULT_JOURNAL_CAPACITY_BYTES,
6678        );
6679        let id = index
6680            .insert_loaded_child(
6681                EntryId::ROOT,
6682                OsString::from("a.rs"),
6683                EntryKind::File,
6684                file_attrs(4, 1),
6685            )
6686            .expect("parent is a live directory");
6687        assert!(index.serving_indexes_enabled());
6688        assert_eq!(index.path_of(id), Some(PathBuf::from("a.rs")));
6689        let serving = index.serving.as_ref().expect("opened test index");
6690        assert!(serving.portable_entries.keys().any(|path| path.as_str() == "a.rs"));
6691    }
6692
6693    #[test]
6694    fn opened_entry_values_project_name_identity_without_retaining_it_on_detached_entries() {
6695        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6696            "/root",
6697            ScanScope::default(),
6698            crate::classify::TypeRegistry::compiled_shared(),
6699            DEFAULT_JOURNAL_CAPACITY_BYTES,
6700        );
6701        index.apply_ok(&Observation::new(vec![upsert(
6702            "bundle.umd.min.js",
6703            EntryKind::File,
6704            file_attrs(7, 1),
6705        )]));
6706
6707        let row = index.entry_value(Path::new("bundle.umd.min.js")).expect("entry value");
6708        let identity = row.classification.expect("regular files carry name identity");
6709        assert_eq!(identity.logical_extension(), Some(".min.js"));
6710        assert_eq!(identity.canonical_extension(), Some(".js"));
6711        assert_eq!(identity.kind_id(), Some("javascript"));
6712        assert_eq!(identity.content_family(), crate::classify::ContentFamily::Code);
6713    }
6714
6715    #[test]
6716    fn a_shared_snapshot_drops_opened_root_serving_state() {
6717        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6718            "/root",
6719            ScanScope::default(),
6720            crate::classify::TypeRegistry::compiled_shared(),
6721            DEFAULT_JOURNAL_CAPACITY_BYTES,
6722        );
6723        index.apply_ok(&Observation::new(vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))]));
6724        assert!(index.serving_indexes_enabled());
6725
6726        let snapshot = IndexHandle::new(index).snapshot().expect("detached snapshot");
6727
6728        assert!(!snapshot.serving_indexes_enabled());
6729        assert_eq!(snapshot.total().files, 1);
6730    }
6731
6732    #[test]
6733    #[should_panic(expected = "an index's registry must match its semantic scope")]
6734    fn an_index_cannot_claim_a_registry_different_from_its_scope() {
6735        let types = Arc::new(
6736            crate::classify::TypeRegistry::from_manifest(
6737                "[[kind]]\nid = \"notes\"\nfamily = \"prose\"\nextensions = [\"rs\"]\n",
6738            )
6739            .expect("custom registry"),
6740        );
6741
6742        let _ = Index::new_with_scope_and_types("/root", ScanScope::default(), types);
6743    }
6744
6745    /// The parent memo skips resolving a path when consecutive upserts share a parent,
6746    /// so every test below puts the op that could invalidate it *between* two upserts
6747    /// into the same directory — the arrangement where a stale hit would be believed.
6748    /// A memo that never cleared would still pass an ordinary scan-shaped workload,
6749    /// which is why these are written as batches rather than as separate applies: one
6750    /// `apply_validated` call is the memo's whole lifetime.
6751    #[test]
6752    fn parent_memo_does_not_survive_removing_the_directory_it_remembers() {
6753        let mut index = Index::new(PathBuf::from("/root"));
6754        index.apply_ok(&Observation::new(vec![
6755            upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
6756            upsert("dir/a.txt", EntryKind::File, file_attrs(10, 1)),
6757        ]));
6758
6759        // Rebuild the directory explicitly after the removal. The following child
6760        // must resolve through that new entry rather than a memoized id for the entry
6761        // that was just removed.
6762        index.apply_ok(&Observation::new(vec![
6763            upsert("dir/b.txt", EntryKind::File, file_attrs(20, 1)),
6764            Op::Remove { path: PathBuf::from("dir") },
6765            upsert("dir", EntryKind::Dir, file_attrs(0, 2)),
6766            upsert("dir/c.txt", EntryKind::File, file_attrs(30, 2)),
6767        ]));
6768
6769        let children = index.children(Path::new("dir")).expect("dir survives");
6770        let names: Vec<_> = children.map(|(name, _)| name.to_os_string()).collect();
6771        assert_eq!(names, vec![OsString::from("c.txt")], "only the re-added child remains");
6772        assert_eq!(index.total().bytes, 30, "totals match the surviving child");
6773    }
6774
6775    #[test]
6776    fn a_kind_change_mid_batch_leaves_the_memo_usable_for_the_next_sibling() {
6777        let mut index = Index::new(PathBuf::from("/root"));
6778        index.apply_ok(&Observation::new(vec![
6779            upsert("swap", EntryKind::Dir, file_attrs(0, 1)),
6780            upsert("swap/inner", EntryKind::Dir, file_attrs(0, 1)),
6781            upsert("swap/inner/deep.txt", EntryKind::File, file_attrs(40, 1)),
6782        ]));
6783
6784        // A kind change drops a subtree, which looks like it should invalidate the memo
6785        // and does not: the memo holds the changed entry's parent, and the subtree
6786        // removed is rooted at the entry itself. This pins that reasoning, so that if
6787        // the removal ever widens to touch the parent the failure lands here rather
6788        // than as a dangling id in a scan.
6789        index.apply_ok(&Observation::new(vec![
6790            upsert("swap/inner/other.txt", EntryKind::File, file_attrs(50, 1)),
6791            upsert("swap/inner", EntryKind::File, file_attrs(60, 2)),
6792            upsert("swap/sibling.txt", EntryKind::File, file_attrs(70, 2)),
6793        ]));
6794
6795        let children = index.children(Path::new("swap")).expect("swap survives");
6796        let names: Vec<_> = children.map(|(name, _)| name.to_os_string()).collect();
6797        assert_eq!(names, vec![OsString::from("inner"), OsString::from("sibling.txt")]);
6798        assert_eq!(index.total().files, 2, "inner counts once, as a file");
6799        assert_eq!(index.total().bytes, 130, "the dropped subtree's bytes are gone");
6800    }
6801
6802    #[test]
6803    fn parent_memo_distinguishes_directories_that_share_a_name_prefix() {
6804        let mut index = Index::new(PathBuf::from("/root"));
6805        // `src` and `src2` differ only after the memo's stored bytes end, which is the
6806        // comparison a prefix check rather than an equality check would get wrong.
6807        index.apply_ok(&Observation::new(vec![
6808            upsert("src", EntryKind::Dir, file_attrs(0, 1)),
6809            upsert("src2", EntryKind::Dir, file_attrs(0, 1)),
6810            upsert("src/one.txt", EntryKind::File, file_attrs(11, 1)),
6811            upsert("src2/two.txt", EntryKind::File, file_attrs(22, 1)),
6812            upsert("src/three.txt", EntryKind::File, file_attrs(33, 1)),
6813        ]));
6814
6815        let in_src: Vec<_> = index
6816            .children(Path::new("src"))
6817            .expect("src")
6818            .map(|(name, _)| name.to_os_string())
6819            .collect();
6820        let in_src2: Vec<_> = index
6821            .children(Path::new("src2"))
6822            .expect("src2")
6823            .map(|(name, _)| name.to_os_string())
6824            .collect();
6825        assert_eq!(in_src, vec![OsString::from("one.txt"), OsString::from("three.txt")]);
6826        assert_eq!(in_src2, vec![OsString::from("two.txt")]);
6827    }
6828
6829    #[test]
6830    fn parent_memo_leaves_root_level_entries_alone() {
6831        // A root-level path has `Some("")` as its parent, which must not be confused
6832        // with the root entry itself or with a sibling's empty-parent lookup.
6833        let mut index = Index::new(PathBuf::from("/root"));
6834        index.apply_ok(&Observation::new(vec![
6835            upsert("a.txt", EntryKind::File, file_attrs(5, 1)),
6836            upsert("b.txt", EntryKind::File, file_attrs(6, 1)),
6837            upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
6838            upsert("dir/c.txt", EntryKind::File, file_attrs(7, 1)),
6839            upsert("d.txt", EntryKind::File, file_attrs(8, 1)),
6840        ]));
6841
6842        let top: Vec<_> = index
6843            .children(Path::new(""))
6844            .expect("root children")
6845            .map(|(name, _)| name.to_os_string())
6846            .collect();
6847        assert_eq!(
6848            top,
6849            vec![
6850                OsString::from("a.txt"),
6851                OsString::from("b.txt"),
6852                OsString::from("d.txt"),
6853                OsString::from("dir"),
6854            ]
6855        );
6856        assert_eq!(index.total().files, 4);
6857        assert_eq!(index.total().bytes, 26);
6858    }
6859
6860    #[test]
6861    fn shared_queries_return_owned_values_and_release_the_lock() {
6862        let handle = IndexHandle::new(index_with_sample_tree());
6863        let retained_total = handle.total().expect("total");
6864        let retained_history = handle.since(Clock::ZERO).expect("history");
6865        let retained_children = handle.children(Path::new("src")).expect("children");
6866        let retained_snapshot = handle.snapshot().expect("snapshot");
6867
6868        let writer = handle.clone();
6869        let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
6870        let thread = std::thread::spawn(move || {
6871            let result = writer.apply(&Observation::new(vec![upsert(
6872                "concurrent.txt",
6873                EntryKind::File,
6874                file_attrs(7, 30),
6875            )]));
6876            done_tx.send(result).expect("report writer result");
6877        });
6878
6879        let outcome = done_rx
6880            .recv_timeout(std::time::Duration::from_secs(5))
6881            .expect("owned query results must not retain the read lock")
6882            .expect("writer apply");
6883        thread.join().expect("writer thread");
6884
6885        assert_eq!(outcome.inserted, 1);
6886        assert_eq!(retained_total.files, 3);
6887        assert!(!retained_history.commits.is_empty());
6888        assert_eq!(retained_children.expect("src directory").len(), 2);
6889        assert!(retained_snapshot.lookup(Path::new("concurrent.txt")).is_none());
6890        assert!(handle.kind(Path::new("concurrent.txt")).expect("query").is_some());
6891    }
6892
6893    #[test]
6894    fn cloned_indexes_are_independent_detached_images() {
6895        let original = index_with_sample_tree();
6896        let original_clock = original.clock();
6897        let mut detached = original.clone();
6898
6899        detached.apply_ok(&Observation::new(vec![upsert(
6900            "detached-only.txt",
6901            EntryKind::File,
6902            file_attrs(7, 30),
6903        )]));
6904
6905        assert_eq!(original.clock(), original_clock);
6906        assert!(original.lookup(Path::new("detached-only.txt")).is_none());
6907        assert!(detached.lookup(Path::new("detached-only.txt")).is_some());
6908        assert_eq!(detached.total().files, original.total().files + 1);
6909    }
6910
6911    #[test]
6912    fn captured_child_expectations_match_individual_path_lookups() {
6913        let index = index_with_sample_tree();
6914        let captured = collect_child_expectations(&index, Path::new("src"));
6915
6916        assert!(!captured.is_empty());
6917        for (name, expectation) in captured {
6918            assert_eq!(expectation, index.expectation(&Path::new("src").join(name)));
6919        }
6920    }
6921
6922    #[test]
6923    fn index_and_shared_handle_are_send_and_sync() {
6924        fn assert_send_sync<T: Send + Sync>() {}
6925
6926        assert_send_sync::<Index>();
6927        assert_send_sync::<IndexHandle>();
6928    }
6929
6930    #[test]
6931    fn simultaneous_writers_commit_unique_contiguous_clocks_in_journal_order() {
6932        let writer_count: usize = 8;
6933        let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
6934        let barrier: Arc<Barrier> = Arc::new(Barrier::new(writer_count));
6935        let (result_tx, result_rx) = std::sync::mpsc::sync_channel(writer_count);
6936
6937        std::thread::scope(|scope| {
6938            for worker_id in 0..writer_count {
6939                let worker: IndexHandle = handle.clone();
6940                let start: Arc<Barrier> = Arc::clone(&barrier);
6941                let results = result_tx.clone();
6942                scope.spawn(move || {
6943                    let ordinal: u64 = u64::try_from(worker_id + 1).expect("small worker count");
6944                    let path: String = format!("writer-{ordinal}.txt");
6945                    start.wait();
6946                    let outcome: crate::Result<ApplyOutcome> =
6947                        worker.apply(&Observation::new(vec![upsert(
6948                            &path,
6949                            EntryKind::File,
6950                            file_attrs(ordinal, i64::try_from(ordinal).expect("small ordinal")),
6951                        )]));
6952                    results.send((path, outcome)).expect("report writer result");
6953                });
6954            }
6955        });
6956        drop(result_tx);
6957
6958        let mut committed_clocks: Vec<u64> = Vec::with_capacity(writer_count);
6959        for (path, outcome) in result_rx {
6960            let commit = outcome.expect("writer apply").commit.expect("unique upsert must commit");
6961            committed_clocks.push(commit.clock.0);
6962            assert!(handle.kind(Path::new(&path)).expect("query committed path").is_some());
6963        }
6964        committed_clocks.sort_unstable();
6965
6966        let last_clock: u64 = u64::try_from(writer_count).expect("small writer count");
6967        let expected_clocks: Vec<u64> = (1..=last_clock).collect();
6968        assert_eq!(committed_clocks, expected_clocks);
6969        assert_eq!(handle.clock().expect("clock"), Clock(last_clock));
6970
6971        let journal_clocks: Vec<u64> = handle
6972            .since(Clock::ZERO)
6973            .expect("journal")
6974            .commits
6975            .iter()
6976            .map(|commit| commit.clock.0)
6977            .collect();
6978        assert_eq!(journal_clocks, expected_clocks);
6979    }
6980
6981    #[test]
6982    fn readers_observe_only_complete_states_around_a_large_batch() {
6983        let file_count: u64 = 2_048;
6984        let expected_bytes: u64 = file_count * (file_count + 1) / 2;
6985        let operations: Vec<Op> =
6986            std::iter::once(upsert("batch", EntryKind::Dir, file_attrs(0, 1)))
6987                .chain((1..=file_count).map(|ordinal| {
6988                    upsert(
6989                        &format!("batch/file-{ordinal}.bin"),
6990                        EntryKind::File,
6991                        file_attrs(ordinal, i64::try_from(ordinal).expect("small ordinal")),
6992                    )
6993                }))
6994                .collect();
6995        let observation: Observation = Observation::new(operations);
6996        let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
6997        let before: Index = handle.snapshot().expect("before snapshot");
6998        assert_eq!(before.total().files, 0);
6999
7000        let barrier: Arc<Barrier> = Arc::new(Barrier::new(2));
7001        let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
7002        std::thread::scope(|scope| {
7003            let writer: IndexHandle = handle.clone();
7004            let writer_start: Arc<Barrier> = Arc::clone(&barrier);
7005            scope.spawn(move || {
7006                writer_start.wait();
7007                let result: crate::Result<ApplyOutcome> = writer.apply(&observation);
7008                done_tx.send(result).expect("report batch result");
7009            });
7010
7011            let reader: IndexHandle = handle.clone();
7012            let reader_start: Arc<Barrier> = Arc::clone(&barrier);
7013            scope.spawn(move || {
7014                reader_start.wait();
7015                let deadline: std::time::Instant =
7016                    std::time::Instant::now() + std::time::Duration::from_secs(10);
7017                loop {
7018                    assert!(
7019                        std::time::Instant::now() < deadline,
7020                        "reader did not observe batch completion before the deadline"
7021                    );
7022                    let image: Index = reader.snapshot().expect("coherent reader snapshot");
7023                    let total = image.total();
7024                    match total.files {
7025                        0 => {
7026                            assert_eq!(total.bytes, 0);
7027                            assert_eq!(image.len(), 1);
7028                        }
7029                        count if count == file_count => {
7030                            assert_eq!(total.bytes, expected_bytes);
7031                            assert_eq!(total.dirs, 1);
7032                            assert_eq!(image.len(), file_count + 2);
7033                        }
7034                        partial => panic!("reader observed partial batch with {partial} files"),
7035                    }
7036
7037                    match done_rx.try_recv() {
7038                        Ok(result) => {
7039                            assert_eq!(result.expect("batch apply").inserted, file_count + 1);
7040                            break;
7041                        }
7042                        Err(std::sync::mpsc::TryRecvError::Empty) => {}
7043                        Err(std::sync::mpsc::TryRecvError::Disconnected) => {
7044                            panic!("batch writer disconnected")
7045                        }
7046                    }
7047                }
7048            });
7049        });
7050
7051        let after: Index = handle.snapshot().expect("after snapshot");
7052        assert_eq!(after.total().files, file_count);
7053        assert_eq!(after.total().bytes, expected_bytes);
7054        assert_eq!(after.len(), file_count + 2);
7055    }
7056
7057    #[test]
7058    fn poisoned_shared_lock_returns_typed_errors() {
7059        let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
7060        let poisoner: IndexHandle = handle.clone();
7061        let panic_result: std::thread::Result<()> = std::thread::spawn(move || {
7062            let _poison_guard = poisoner.write_index().expect("initial write lock");
7063            panic!("intentional lock poison");
7064        })
7065        .join();
7066        assert!(panic_result.is_err());
7067
7068        assert!(matches!(handle.total(), Err(crate::Error::IndexLockPoisoned)));
7069        assert!(matches!(
7070            handle.apply(&Observation::new(vec![upsert(
7071                "never-applied.txt",
7072                EntryKind::File,
7073                file_attrs(1, 1),
7074            )])),
7075            Err(crate::Error::IndexLockPoisoned)
7076        ));
7077    }
7078
7079    #[test]
7080    fn clock_exhaustion_rejects_before_any_mutation() {
7081        let mut index = index_with_sample_tree();
7082        index.clock = Clock(u64::MAX);
7083        let before_total = index.total();
7084        let before_len = index.len();
7085
7086        let error = index
7087            .apply(&Observation::new(vec![upsert(
7088                "too-late.txt",
7089                EntryKind::File,
7090                file_attrs(1, 1),
7091            )]))
7092            .expect_err("clock exhaustion must be typed");
7093
7094        assert!(matches!(error, crate::Error::ClockExhausted));
7095        assert_eq!(index.clock(), Clock(u64::MAX));
7096        assert_eq!(index.len(), before_len);
7097        assert_eq!(index.total(), before_total);
7098        assert!(index.lookup(Path::new("too-late.txt")).is_none());
7099    }
7100
7101    #[test]
7102    fn terminal_clock_still_accepts_no_op_and_stale_observations() {
7103        let mut index = index_with_sample_tree();
7104        let current = *index.attrs(Path::new("src/main.rs")).expect("sample attributes");
7105        let stale_baseline = index.expectation(Path::new("src/main.rs"));
7106        index.apply_ok(&Observation::new(vec![upsert(
7107            "src/main.rs",
7108            EntryKind::File,
7109            file_attrs(99, 99),
7110        )]));
7111        index.clock = Clock(u64::MAX);
7112        let before_total = index.total();
7113        let before_len = index.len();
7114        let before_journal = index.journal.clone();
7115
7116        let no_op = index
7117            .apply(&Observation::new(vec![upsert(
7118                "src/main.rs",
7119                EntryKind::File,
7120                file_attrs(99, 99),
7121            )]))
7122            .expect("a no-op needs no new clock");
7123        let stale = index
7124            .apply(&Observation::from_ops(vec![ObservationOp::if_state(
7125                upsert("src/main.rs", EntryKind::File, current),
7126                stale_baseline,
7127            )]))
7128            .expect("a rejected stale observation needs no new clock");
7129
7130        assert_eq!(no_op.unchanged, 1);
7131        assert!(no_op.commit.is_none());
7132        assert_eq!(stale.stale, 1);
7133        assert!(stale.commit.is_none());
7134        assert_eq!(index.clock(), Clock(u64::MAX));
7135        assert_eq!(index.len(), before_len);
7136        assert_eq!(index.total(), before_total);
7137        assert_eq!(index.journal, before_journal);
7138    }
7139
7140    #[test]
7141    fn delayed_conditional_observation_cannot_overwrite_newer_state() {
7142        let mut index = Index::new("/root");
7143        index.apply_ok(&Observation::new(vec![upsert(
7144            "file.txt",
7145            EntryKind::File,
7146            file_attrs(10, 1),
7147        )]));
7148
7149        let baseline = index.expectation(Path::new("file.txt"));
7150        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7151            upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
7152            baseline,
7153        )]);
7154
7155        index.apply_ok(&Observation::new(vec![upsert(
7156            "file.txt",
7157            EntryKind::File,
7158            file_attrs(30, 3),
7159        )]));
7160        let outcome = index.apply_ok(&delayed);
7161
7162        assert_eq!(outcome.stats.stale, 1);
7163        assert!(outcome.commit.is_none());
7164        assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 30);
7165    }
7166
7167    #[test]
7168    fn delayed_absent_child_cannot_replace_a_newer_parent_file() {
7169        let mut index = Index::new("/root");
7170        index.apply_ok(&Observation::new(vec![upsert("parent", EntryKind::Dir, file_attrs(0, 1))]));
7171        let child_baseline = index.expectation(Path::new("parent/child.txt"));
7172        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7173            upsert("parent/child.txt", EntryKind::File, file_attrs(10, 2)),
7174            child_baseline,
7175        )]);
7176
7177        index.apply_ok(&Observation::new(vec![upsert(
7178            "parent",
7179            EntryKind::File,
7180            file_attrs(20, 3),
7181        )]));
7182        let outcome = index.apply_ok(&delayed);
7183
7184        assert_eq!(outcome.stats.stale, 1);
7185        assert!(outcome.commit.is_none());
7186        assert_eq!(index.kind(Path::new("parent")), Some(EntryKind::File));
7187        assert!(index.lookup(Path::new("parent/child.txt")).is_none());
7188    }
7189
7190    #[test]
7191    fn conditional_observation_rejects_present_state_aba() {
7192        let mut index = Index::new("/root");
7193        index.apply_ok(&Observation::new(vec![upsert(
7194            "file.txt",
7195            EntryKind::File,
7196            file_attrs(10, 1),
7197        )]));
7198        let baseline = index.expectation(Path::new("file.txt"));
7199        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7200            upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
7201            baseline,
7202        )]);
7203
7204        index.apply_ok(&Observation::new(vec![upsert(
7205            "file.txt",
7206            EntryKind::File,
7207            file_attrs(30, 3),
7208        )]));
7209        index.apply_ok(&Observation::new(vec![upsert(
7210            "file.txt",
7211            EntryKind::File,
7212            file_attrs(10, 1),
7213        )]));
7214        let outcome = index.apply_ok(&delayed);
7215
7216        assert_eq!(outcome.stats.stale, 1);
7217        assert!(outcome.commit.is_none());
7218        assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 10);
7219    }
7220
7221    #[test]
7222    fn conditional_observation_rejects_absent_state_aba() {
7223        let mut index = Index::new("/root");
7224        let baseline = index.expectation(Path::new("file.txt"));
7225        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7226            upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
7227            baseline,
7228        )]);
7229
7230        index.apply_ok(&Observation::new(vec![upsert(
7231            "file.txt",
7232            EntryKind::File,
7233            file_attrs(30, 3),
7234        )]));
7235        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("file.txt") }]));
7236        let outcome = index.apply_ok(&delayed);
7237
7238        assert_eq!(outcome.stats.stale, 1);
7239        assert!(outcome.commit.is_none());
7240        assert!(index.lookup(Path::new("file.txt")).is_none());
7241    }
7242
7243    /// A delayed conditional upsert whose baseline moved to exactly its target is no
7244    /// conflict: the other producer verified the same fact first. It applies as unchanged,
7245    /// not stale, so a refresh that converges with the observation handoff does not send
7246    /// the handoff back for another full-root walk.
7247    #[test]
7248    fn convergent_conditional_upsert_applies_as_unchanged_not_stale() {
7249        let mut index = Index::new("/root");
7250        index.apply_ok(&Observation::new(vec![upsert(
7251            "file.txt",
7252            EntryKind::File,
7253            file_attrs(10, 1),
7254        )]));
7255        let baseline = index.expectation(Path::new("file.txt"));
7256        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7257            upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
7258            baseline,
7259        )]);
7260
7261        index.apply_ok(&Observation::new(vec![upsert(
7262            "file.txt",
7263            EntryKind::File,
7264            file_attrs(20, 2),
7265        )]));
7266        let outcome = index.apply_ok(&delayed);
7267
7268        assert_eq!(outcome.stats.stale, 0);
7269        assert_eq!(outcome.stats.unchanged, 1);
7270        assert!(outcome.commit.is_none());
7271        assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 20);
7272    }
7273
7274    #[test]
7275    fn convergent_conditional_remove_applies_as_unchanged_not_stale() {
7276        let mut index = Index::new("/root");
7277        index.apply_ok(&Observation::new(vec![
7278            upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
7279            upsert("dir/file.txt", EntryKind::File, file_attrs(10, 1)),
7280        ]));
7281        let baseline = index.expectation(Path::new("dir/file.txt"));
7282        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7283            Op::Remove { path: PathBuf::from("dir/file.txt") },
7284            baseline,
7285        )]);
7286
7287        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("dir/file.txt") }]));
7288        let outcome = index.apply_ok(&delayed);
7289
7290        assert_eq!(outcome.stats.stale, 0);
7291        assert_eq!(outcome.stats.unchanged, 1);
7292        assert!(outcome.commit.is_none());
7293        assert!(index.lookup(Path::new("dir/file.txt")).is_none());
7294    }
7295
7296    /// A control file's entry and rules are pushed on one baseline, so a refresh that
7297    /// verified both first leaves nothing for either to change. Both apply as unchanged; a
7298    /// control op whose rules the table does not hold is still refused on the moved
7299    /// baseline.
7300    #[test]
7301    fn convergent_conditional_control_ops_apply_as_unchanged_not_stale() {
7302        let path = PathBuf::from(".gitignore");
7303        let rules =
7304            |source: &[u8]| Op::ControlUpsert { path: path.clone(), source: source.to_vec() };
7305        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
7306        index.apply_ok(&Observation::new(vec![
7307            upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
7308            rules(b"before"),
7309        ]));
7310        let baseline = index.expectation(&path);
7311        index.apply_ok(&Observation::new(vec![
7312            upsert(".gitignore", EntryKind::File, file_attrs(7, 2)),
7313            rules(b"changed"),
7314        ]));
7315
7316        let outcome = index.apply_ok(&Observation::from_ops(vec![
7317            ObservationOp::if_state(
7318                upsert(".gitignore", EntryKind::File, file_attrs(7, 2)),
7319                baseline,
7320            ),
7321            ObservationOp::if_state(rules(b"changed"), baseline),
7322        ]));
7323        assert_eq!(outcome.stats.stale, 0);
7324        assert!(outcome.commit.is_none());
7325        let diverged = index.apply_ok(&Observation::from_ops(vec![ObservationOp::if_state(
7326            rules(b"other"),
7327            baseline,
7328        )]));
7329        assert_eq!(diverged.stats.stale, 1);
7330        assert!(index.controls().expect("control state observed").source_is(&path, b"changed"));
7331
7332        let baseline = index.expectation(&path);
7333        index.apply_ok(&Observation::new(vec![Op::Remove { path: path.clone() }]));
7334        let outcome = index.apply_ok(&Observation::from_ops(vec![
7335            ObservationOp::if_state(Op::Remove { path: path.clone() }, baseline),
7336            ObservationOp::if_state(Op::ControlRemove { path: path.clone() }, baseline),
7337        ]));
7338        assert_eq!(outcome.stats.stale, 0);
7339        assert!(outcome.commit.is_none());
7340        assert!(!index.controls().expect("control state observed").contains(&path));
7341    }
7342
7343    #[test]
7344    fn unrelated_mutation_does_not_stale_an_absent_path() {
7345        let mut index = Index::new("/root");
7346        index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
7347        let baseline = index.expectation(Path::new("dir/new.txt"));
7348        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7349            upsert("dir/new.txt", EntryKind::File, file_attrs(20, 2)),
7350            baseline,
7351        )]);
7352
7353        index.apply_ok(&Observation::new(vec![upsert(
7354            "other.txt",
7355            EntryKind::File,
7356            file_attrs(30, 3),
7357        )]));
7358        let outcome = index.apply_ok(&delayed);
7359
7360        assert_eq!(outcome.stats.stale, 0);
7361        assert_eq!(outcome.stats.inserted, 1);
7362        assert_eq!(index.attrs(Path::new("dir/new.txt")).expect("file").size, 20);
7363    }
7364
7365    #[test]
7366    fn directory_metadata_change_does_not_stale_an_absent_child() {
7367        let mut index = Index::new("/root");
7368        index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
7369        let baseline = index.expectation(Path::new("dir/new.txt"));
7370        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7371            upsert("dir/new.txt", EntryKind::File, file_attrs(20, 2)),
7372            baseline,
7373        )]);
7374
7375        index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 3))]));
7376        let outcome = index.apply_ok(&delayed);
7377
7378        assert_eq!(outcome.stats.stale, 0);
7379        assert_eq!(outcome.stats.inserted, 1);
7380    }
7381
7382    #[test]
7383    fn file_parent_metadata_change_stales_an_absent_child() {
7384        let mut index = Index::new("/root");
7385        index.apply_ok(&Observation::new(vec![upsert(
7386            "parent",
7387            EntryKind::File,
7388            file_attrs(10, 1),
7389        )]));
7390        let baseline = index.expectation(Path::new("parent/child.txt"));
7391        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7392            upsert("parent/child.txt", EntryKind::File, file_attrs(20, 2)),
7393            baseline,
7394        )]);
7395
7396        index.apply_ok(&Observation::new(vec![upsert(
7397            "parent",
7398            EntryKind::File,
7399            file_attrs(30, 3),
7400        )]));
7401        let outcome = index.apply_ok(&delayed);
7402
7403        assert_eq!(outcome.stats.stale, 1);
7404        assert_eq!(index.kind(Path::new("parent")), Some(EntryKind::File));
7405        assert!(index.lookup(Path::new("parent/child.txt")).is_none());
7406    }
7407
7408    #[test]
7409    fn delayed_directory_remove_cannot_delete_a_newer_child() {
7410        let mut index = Index::new("/root");
7411        index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
7412        let baseline = index.expectation(Path::new("dir"));
7413        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7414            Op::Remove { path: PathBuf::from("dir") },
7415            baseline,
7416        )]);
7417
7418        index.apply_ok(&Observation::new(vec![upsert(
7419            "dir/new.txt",
7420            EntryKind::File,
7421            file_attrs(20, 2),
7422        )]));
7423        let outcome = index.apply_ok(&delayed);
7424
7425        assert_eq!(outcome.stats.stale, 1);
7426        assert!(index.lookup(Path::new("dir/new.txt")).is_some());
7427    }
7428
7429    #[test]
7430    fn conditional_batch_is_validated_at_one_boundary() {
7431        let mut index = Index::new("/root");
7432        let first = index.expectation(Path::new("first.txt"));
7433        let second = index.expectation(Path::new("second.txt"));
7434
7435        let outcome = index.apply_ok(&Observation::from_ops(vec![
7436            ObservationOp::if_state(upsert("first.txt", EntryKind::File, file_attrs(10, 1)), first),
7437            ObservationOp::if_state(
7438                upsert("second.txt", EntryKind::File, file_attrs(20, 2)),
7439                second,
7440            ),
7441        ]));
7442
7443        assert_eq!(outcome.stats.inserted, 2);
7444        assert_eq!(outcome.stats.stale, 0);
7445    }
7446
7447    #[test]
7448    fn public_observation_outputs_use_canonical_encoded_paths() {
7449        let separator = std::path::MAIN_SEPARATOR;
7450        let dotted = PathBuf::from(format!("dotted{separator}.{separator}file.txt"));
7451        let dotted_canonical = PathBuf::from(format!("dotted{separator}file.txt"));
7452        let repeated = PathBuf::from(format!("repeated{separator}{separator}file.txt"));
7453        let repeated_canonical = PathBuf::from(format!("repeated{separator}file.txt"));
7454        let mut index = Index::new("/root");
7455
7456        let outcome = index.apply_ok(&Observation::new(vec![
7457            upsert("dotted", EntryKind::Dir, file_attrs(0, 1)),
7458            Op::Upsert { path: dotted, kind: EntryKind::File, attrs: file_attrs(10, 2) },
7459            upsert("repeated", EntryKind::Dir, file_attrs(0, 3)),
7460            Op::Upsert { path: repeated, kind: EntryKind::File, attrs: file_attrs(20, 4) },
7461        ]));
7462        let commit = outcome.commit.as_ref().expect("one exact commit");
7463
7464        for (actual, canonical) in [
7465            (commit.changes[1].path(), dotted_canonical.as_path()),
7466            (commit.changes[3].path(), repeated_canonical.as_path()),
7467        ] {
7468            assert_eq!(
7469                actual.as_os_str().as_encoded_bytes(),
7470                canonical.as_os_str().as_encoded_bytes()
7471            );
7472        }
7473
7474        for canonical in [&dotted_canonical, &repeated_canonical] {
7475            let dirty = commit
7476                .impact
7477                .dirty_paths
7478                .iter()
7479                .find(|candidate| candidate.as_path() == canonical)
7480                .expect("changed path is dirty");
7481            assert_eq!(
7482                dirty.as_os_str().as_encoded_bytes(),
7483                canonical.as_os_str().as_encoded_bytes()
7484            );
7485        }
7486    }
7487
7488    /// The trailing spellings `Path::components` hides reach public values canonically.
7489    ///
7490    /// `a/b/` and `a/b/.` compare equal to `a/b` component by component, so lookups never
7491    /// notice a preserved spelling; only a value that carries the bytes out does. The
7492    /// reproduction this pins is an unknown-ancestry error that named `a/b/` (PR #51
7493    /// review COMMIT-4).
7494    #[test]
7495    fn trailing_path_spellings_leave_errors_and_changes_canonical() {
7496        let separator = std::path::MAIN_SEPARATOR;
7497        let canonical = format!("a{separator}b");
7498        for spelling in [format!("a{separator}b{separator}"), format!("a{separator}b{separator}.")]
7499        {
7500            let file = |mtime_ns| Op::Upsert {
7501                path: PathBuf::from(&spelling),
7502                kind: EntryKind::File,
7503                attrs: file_attrs(1, mtime_ns),
7504            };
7505            let mut index = Index::new("/root");
7506
7507            let error = index
7508                .apply(&Observation::new(vec![file(1)]))
7509                .expect_err("a child of an unknown directory is refused");
7510            let crate::Error::UnknownAncestry { path, .. } = error else {
7511                panic!("expected unknown ancestry for {spelling:?}, got {error}");
7512            };
7513            assert_eq!(path.as_os_str().as_encoded_bytes(), canonical.as_bytes(), "{spelling:?}");
7514
7515            let outcome = index.apply_ok(&Observation::new(vec![
7516                upsert("a", EntryKind::Dir, file_attrs(0, 1)),
7517                file(2),
7518            ]));
7519            let commit = outcome.commit.as_ref().expect("one exact commit");
7520            assert_eq!(
7521                commit.changes[1].path().as_os_str().as_encoded_bytes(),
7522                canonical.as_bytes(),
7523                "{spelling:?}"
7524            );
7525        }
7526    }
7527
7528    #[test]
7529    fn malformed_batch_is_rejected_before_any_index_mutation() {
7530        let invalid_paths = [
7531            PathBuf::from("../escape"),
7532            PathBuf::from(format!("{}absolute", std::path::MAIN_SEPARATOR)),
7533        ];
7534
7535        for invalid_path in invalid_paths {
7536            for invalid_first in [false, true] {
7537                let mut index = index_with_sample_tree();
7538                let before_clock = index.clock;
7539                let before_live = index.live;
7540                let before_total = index.total();
7541                let before_journal = index.journal.clone();
7542                let before_journal_cost = index.journal_cost;
7543                let before_journal_floor = index.journal_floor;
7544                let before_invalidations = index.pending_invalidations.clone();
7545                let before_freshness_epoch = index.freshness_epoch;
7546                let before_freshness = index.freshness();
7547                let valid = upsert("new.txt", EntryKind::File, file_attrs(99, 99));
7548                let invalid = Op::InvalidateSubtree {
7549                    path: invalid_path.clone(),
7550                    reason: InvalidateReason::Requested,
7551                };
7552                let ops = if invalid_first { vec![invalid, valid] } else { vec![valid, invalid] };
7553
7554                let error = index.apply(&Observation::new(ops)).expect_err("malformed batch");
7555
7556                assert!(
7557                    matches!(error, crate::Error::PathEscapesRoot(path) if path == invalid_path)
7558                );
7559                assert_eq!(index.clock, before_clock);
7560                assert_eq!(index.live, before_live);
7561                assert_eq!(index.total(), before_total);
7562                assert_eq!(index.journal, before_journal);
7563                assert_eq!(index.journal_cost, before_journal_cost);
7564                assert_eq!(index.journal_floor, before_journal_floor);
7565                assert_eq!(index.pending_invalidations, before_invalidations);
7566                assert_eq!(index.freshness_epoch, before_freshness_epoch);
7567                assert_eq!(index.freshness(), before_freshness);
7568                assert!(index.lookup(Path::new("new.txt")).is_none());
7569            }
7570        }
7571    }
7572
7573    #[cfg(windows)]
7574    #[test]
7575    fn windows_prefix_is_rejected_before_mutation() {
7576        let mut index = index_with_sample_tree();
7577        let before_clock = index.clock();
7578
7579        let error = index
7580            .apply(&Observation::new(vec![Op::Remove { path: PathBuf::from(r"C:\escape") }]))
7581            .expect_err("prefixed path");
7582
7583        assert!(
7584            matches!(error, crate::Error::PathEscapesRoot(path) if path == Path::new(r"C:\escape"))
7585        );
7586        assert_eq!(index.clock(), before_clock);
7587    }
7588
7589    fn index_with_sample_tree() -> Index {
7590        let mut index = Index::new("/root");
7591        index.apply_ok(&Observation::new(vec![
7592            upsert("src", EntryKind::Dir, Attrs::default()),
7593            upsert("src/main.rs", EntryKind::File, file_attrs(100, 10)),
7594            upsert("src/lib.rs", EntryKind::File, file_attrs(200, 20)),
7595            upsert("docs", EntryKind::Dir, Attrs::default()),
7596            upsert("docs/guide.md", EntryKind::File, file_attrs(300, 30)),
7597        ]));
7598        index
7599    }
7600
7601    #[test]
7602    fn the_extension_interner_reclaims_ids_after_churn() {
7603        // The long-lived case: a watched tree that keeps creating and deleting files
7604        // with distinct extensions. Without reclamation both interner maps grow for the
7605        // life of the process, which for `fdu --watch` means forever.
7606        let mut index = Index::new("/root");
7607        for sequence in 0..128 {
7608            let path = PathBuf::from(format!("build.out-{sequence}"));
7609            index.apply_ok(&Observation::new(vec![Op::Upsert {
7610                path: path.clone(),
7611                kind: EntryKind::File,
7612                attrs: file_attrs(1, sequence),
7613            }]));
7614            index.apply_ok(&Observation::new(vec![Op::Remove { path }]));
7615        }
7616
7617        assert!(index.ext_ids.is_empty(), "no extension survives the file that named it");
7618        assert_eq!(index.ext_names.len(), 1, "128 dead extensions reuse one interner slot");
7619        assert!(index.total().by_ext.is_empty());
7620    }
7621
7622    #[test]
7623    fn a_reclaimed_extension_id_does_not_alias_a_live_tally() {
7624        // Reissuing a slot is only safe if nothing still points at it. Keep one file on
7625        // the recycled extension while another one comes and goes.
7626        let mut index = Index::new("/root");
7627        index.apply_ok(&Observation::new(vec![
7628            upsert("keep.rs", EntryKind::File, file_attrs(10, 1)),
7629            upsert("drop.tmp", EntryKind::File, file_attrs(20, 2)),
7630        ]));
7631        let retained = index.total();
7632        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("drop.tmp") }]));
7633        index.apply_ok(&Observation::new(vec![upsert(
7634            "next.bak",
7635            EntryKind::File,
7636            file_attrs(30, 3),
7637        )]));
7638
7639        let tallies = index.total().by_ext;
7640        assert_eq!(tallies[".rs"], ExtTally { files: 1, bytes: 10, allocated: 512 });
7641        assert_eq!(tallies[".bak"], ExtTally { files: 1, bytes: 30, allocated: 512 });
7642        assert!(!tallies.contains_key(".tmp"), "the removed extension is gone");
7643        assert_eq!(
7644            retained.by_ext[".tmp"],
7645            ExtTally { files: 1, bytes: 20, allocated: 512 },
7646            "an owned roll-up stays self-describing after its interner slot is reused"
7647        );
7648        assert!(!retained.by_ext.contains_key(".bak"));
7649    }
7650
7651    #[test]
7652    fn rollups_aggregate_up_the_tree() {
7653        let index = index_with_sample_tree();
7654
7655        let total = index.total();
7656        assert_eq!(total.files, 3);
7657        assert_eq!(total.dirs, 2);
7658        assert_eq!(total.bytes, 600);
7659        assert_eq!(total.newest_mtime_ns, 30);
7660
7661        let src = index.rollup(Path::new("src")).expect("src is a directory");
7662        assert_eq!(src.files, 2);
7663        assert_eq!(src.dirs, 0);
7664        assert_eq!(src.bytes, 300);
7665        assert_eq!(src.newest_mtime_ns, 20);
7666    }
7667
7668    #[test]
7669    fn rollups_conserve_hand_counted_populations_through_updates_and_subtree_replacement() {
7670        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
7671        let attrs =
7672            |size, allocated, inode| Attrs { size, allocated, inode, dev: 1, ..Attrs::default() };
7673        index.apply_ok(&Observation::new(vec![
7674            upsert(".gitignore", EntryKind::File, attrs(6, 512, 1)),
7675            upsert("a", EntryKind::Dir, Attrs::default()),
7676            upsert("a/keep.rs", EntryKind::File, attrs(7, 512, 99)),
7677            upsert("a/drop.log", EntryKind::File, attrs(11, 1_024, 3)),
7678            upsert("z.rs", EntryKind::File, attrs(5, 4_096, 99)),
7679            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
7680        ]));
7681
7682        let total = index.partition_total().expect("control state observed");
7683        assert_eq!(
7684            (total.all.files, total.all.dirs, total.all.bytes, total.all.allocated),
7685            (4, 1, 29, 6_144)
7686        );
7687        assert_eq!(
7688            (total.unignored.files, total.unignored.bytes, total.unignored.allocated),
7689            (3, 18, 5_120)
7690        );
7691        assert_eq!(index.rollup(Path::new("a")).expect("directory").bytes, 18);
7692        assert_eq!(
7693            total.all.by_ext[".rs"].files, 2,
7694            "two paths with one inode each contribute a file"
7695        );
7696
7697        index.apply_ok(&Observation::new(vec![upsert(
7698            "a/drop.log",
7699            EntryKind::File,
7700            attrs(13, 1_536, 3),
7701        )]));
7702        let total = index.partition_total().expect("control state observed");
7703        assert_eq!((total.all.files, total.all.bytes, total.all.allocated), (4, 31, 6_656));
7704        assert_eq!(
7705            (total.unignored.files, total.unignored.bytes, total.unignored.allocated),
7706            (3, 18, 5_120)
7707        );
7708
7709        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("a") }]));
7710        let total = index.partition_total().expect("control state observed");
7711        assert_eq!(
7712            (total.all.files, total.all.dirs, total.all.bytes, total.all.allocated),
7713            (2, 0, 11, 4_608)
7714        );
7715        assert_eq!(total.all, total.unignored);
7716
7717        index.apply_ok(&Observation::new(vec![
7718            upsert("a", EntryKind::Dir, Attrs::default()),
7719            upsert("a/final.log", EntryKind::File, attrs(17, 4_096, 4)),
7720        ]));
7721        let total = index.partition_total().expect("control state observed");
7722        assert_eq!(
7723            (total.all.files, total.all.dirs, total.all.bytes, total.all.allocated),
7724            (3, 1, 28, 8_704)
7725        );
7726        assert_eq!(
7727            (
7728                total.unignored.files,
7729                total.unignored.dirs,
7730                total.unignored.bytes,
7731                total.unignored.allocated
7732            ),
7733            (2, 1, 11, 4_608)
7734        );
7735        assert_eq!(index.rollup(Path::new("a")).expect("directory").bytes, 17);
7736    }
7737
7738    #[test]
7739    fn symlinks_and_special_nodes_do_not_contribute_regular_file_tallies() {
7740        let mut index = Index::new("/root");
7741        index.apply_ok(&Observation::new(vec![
7742            upsert("regular.txt", EntryKind::File, file_attrs(10, 10)),
7743            upsert("link.rs", EntryKind::Symlink, file_attrs(99, 99)),
7744            upsert("socket.md", EntryKind::Other, file_attrs(88, 88)),
7745        ]));
7746
7747        let total = index.total();
7748        assert_eq!(total.files, 1);
7749        assert_eq!(total.bytes, 10);
7750        assert_eq!(total.allocated, 512);
7751        assert_eq!(total.newest_mtime_ns, 10);
7752        assert_eq!(total.by_ext[".txt"], ExtTally { files: 1, bytes: 10, allocated: 512 });
7753        assert!(!total.by_ext.contains_key(".rs"));
7754        assert!(!total.by_ext.contains_key(".md"));
7755
7756        index.apply_ok(&Observation::new(vec![upsert(
7757            "link.rs",
7758            EntryKind::File,
7759            file_attrs(99, 99),
7760        )]));
7761        assert_eq!(index.total().files, 2);
7762        assert_eq!(index.total().bytes, 109);
7763
7764        index.apply_ok(&Observation::new(vec![upsert(
7765            "link.rs",
7766            EntryKind::Symlink,
7767            file_attrs(99, 99),
7768        )]));
7769        assert_eq!(index.total().files, 1);
7770        assert_eq!(index.total().bytes, 10);
7771    }
7772
7773    #[test]
7774    fn per_extension_tallies_roll_up_hierarchically() {
7775        let index = index_with_sample_tree();
7776
7777        let total = index.total();
7778        assert_eq!(total.by_ext[".rs"], ExtTally { files: 2, bytes: 300, allocated: 1024 });
7779        assert_eq!(total.by_ext[".md"], ExtTally { files: 1, bytes: 300, allocated: 512 });
7780
7781        // Per-directory breakdown, which no surveyed tool provides.
7782        let src = index.rollup(Path::new("src")).expect("src is a directory");
7783        assert_eq!(src.by_ext[".rs"], ExtTally { files: 2, bytes: 300, allocated: 1024 });
7784        assert!(!src.by_ext.contains_key(".md"));
7785    }
7786
7787    #[test]
7788    fn per_extension_allocated_tracks_apparent_bytes_separately() {
7789        // Both size metrics ride in the same tally, so a report asked for allocated bytes
7790        // keeps its per-type breakdown instead of silently answering in apparent bytes.
7791        let mut index = Index::new("/root");
7792        index.apply_ok(&Observation::new(vec![
7793            // Two small files: apparent bytes are tiny, allocation rounds each to a block.
7794            upsert("a.rs", EntryKind::File, file_attrs(1, 10)),
7795            upsert("b.rs", EntryKind::File, file_attrs(2, 20)),
7796        ]));
7797
7798        let rs = index.total().by_ext[".rs"];
7799        assert_eq!((rs.files, rs.bytes), (2, 3));
7800        assert_eq!(rs.allocated, 1024, "each file occupies one 512-byte block");
7801
7802        // Removing one file withdraws its allocation from the tally, not just its bytes.
7803        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("b.rs") }]));
7804        let rs = index.total().by_ext[".rs"];
7805        assert_eq!((rs.files, rs.bytes, rs.allocated), (1, 1, 512));
7806    }
7807
7808    #[test]
7809    fn upsert_with_matching_fingerprint_is_a_no_op() {
7810        let mut index = index_with_sample_tree();
7811        let before = index.total();
7812        let mark = index.clock();
7813
7814        let stats = index.apply_ok(&Observation::new(vec![upsert(
7815            "src/main.rs",
7816            EntryKind::File,
7817            file_attrs(100, 10),
7818        )]));
7819
7820        assert_eq!(stats.unchanged, 1);
7821        assert_eq!(stats.updated, 0);
7822        assert_eq!(index.total(), before);
7823        assert_eq!(index.clock(), mark);
7824        assert!(index.since(mark).commits.is_empty());
7825    }
7826
7827    #[test]
7828    fn commit_contains_only_effective_mutations() {
7829        let mut index = index_with_sample_tree();
7830        let outcome = index.apply_ok(&Observation::new(vec![
7831            upsert("src/main.rs", EntryKind::File, file_attrs(100, 10)),
7832            upsert("new.txt", EntryKind::File, file_attrs(4, 4)),
7833            Op::Remove { path: PathBuf::from("missing.txt") },
7834        ]));
7835
7836        assert_eq!(outcome.stats.unchanged, 2);
7837        let commit = outcome.commit.expect("one effective insert");
7838        assert_eq!(commit.changes.len(), 1);
7839        assert_eq!(commit.changes[0].path(), Path::new("new.txt"));
7840    }
7841
7842    #[test]
7843    fn mutation_counters_match_exact_batch_and_consequence_totals() {
7844        struct DisableCounters;
7845
7846        impl Drop for DisableCounters {
7847            fn drop(&mut self) {
7848                crate::counters::enable(false);
7849                crate::counters::test_thread_reset();
7850            }
7851        }
7852
7853        let _serial = crate::counters::test_serial();
7854        crate::counters::enable(true);
7855        let _disable = DisableCounters;
7856        let observation = Observation::new(vec![
7857            upsert("a", EntryKind::Dir, Attrs::default()),
7858            upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
7859            upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
7860        ]);
7861
7862        crate::counters::test_thread_reset();
7863        let mut baseline = Index::new("/root");
7864        baseline.apply_baseline_ok(&observation);
7865        let counts = crate::counters::test_thread_snapshot();
7866        assert_eq!(counts.baseline_batches, 1);
7867        assert_eq!(counts.baseline_accepted_ops, 3);
7868        assert_eq!(counts.opened_batches, 0);
7869        assert_eq!(counts.public_batches, 0);
7870        assert_eq!(counts.ancestry_overlay_inserts, 3);
7871        assert_eq!(counts.ancestry_path_comparisons, 2);
7872        assert_eq!(counts.ancestry_parent_proofs, 3);
7873        assert_eq!(counts.effect_paths, 0);
7874        assert_eq!(counts.effect_path_bytes, 0);
7875        assert_eq!(counts.impact_candidates, 0);
7876        assert_eq!(counts.impact_ancestor_visits, 0);
7877        assert_eq!(counts.impact_retained_dirty_paths, 0);
7878        assert_eq!(counts.impact_all_dirty, 0);
7879        assert_eq!(counts.journal_cloned_commits, 0);
7880        assert_eq!(counts.journal_retained_commits, 0);
7881
7882        crate::counters::test_thread_reset();
7883        let mut public = Index::new("/root");
7884        public.apply_ok(&observation);
7885        let counts = crate::counters::test_thread_snapshot();
7886        assert_eq!(counts.baseline_batches, 0);
7887        assert_eq!(counts.opened_batches, 0);
7888        assert_eq!(counts.public_batches, 1);
7889        assert_eq!(counts.public_accepted_ops, 3);
7890        assert_eq!(counts.effect_paths, 3);
7891        assert_eq!(counts.journal_cloned_commits, 1);
7892        assert_eq!(counts.journal_retained_commits, 1);
7893        assert_eq!(counts.journal_oversized_commits, 0);
7894        assert_eq!(counts.journal_dropped_commits, 0);
7895
7896        crate::counters::test_thread_reset();
7897        let opened = IndexHandle::new(Index::new("/root"));
7898        opened
7899            .apply_discovery(&observation, DiscoveryCommit::default())
7900            .expect("opened discovery batch");
7901        let counts = crate::counters::test_thread_snapshot();
7902        assert_eq!(counts.baseline_batches, 0);
7903        assert_eq!(counts.opened_batches, 1);
7904        assert_eq!(counts.opened_accepted_ops, 3);
7905        assert_eq!(counts.public_batches, 0);
7906
7907        crate::counters::test_thread_reset();
7908        let mut scanner = Index::new("/root");
7909        scanner
7910            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
7911                upsert("a", EntryKind::Dir, Attrs::default()),
7912                upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
7913                upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
7914            ]))
7915            .expect("private scanner batch");
7916        let counts = crate::counters::test_thread_snapshot();
7917        assert_eq!(counts.baseline_batches, 1);
7918        assert_eq!(counts.baseline_accepted_ops, 3);
7919        assert_eq!(counts.ancestry_overlay_inserts, 0);
7920        assert_eq!(counts.ancestry_path_comparisons, 2);
7921        assert_eq!(counts.ancestry_parent_proofs, 3);
7922        assert_eq!(counts.parent_resolutions, 0);
7923        assert_eq!(counts.parent_memo_hits, 0);
7924        assert_eq!(scanner.total().dirs, 1);
7925        assert_eq!(scanner.total().files, 2);
7926
7927        crate::counters::test_thread_reset();
7928        let opened_scanner = IndexHandle::new(Index::new("/root"));
7929        opened_scanner
7930            .apply_scanner_discovery_bounded(
7931                crate::scan::ScannerBatch::from_ops(vec![
7932                    upsert("a", EntryKind::Dir, Attrs::default()),
7933                    upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
7934                    upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
7935                ]),
7936                DiscoveryCommit::default(),
7937                None,
7938            )
7939            .expect("opened scanner batch");
7940        let counts = crate::counters::test_thread_snapshot();
7941        assert_eq!(counts.opened_batches, 1);
7942        assert_eq!(counts.opened_accepted_ops, 3);
7943        assert_eq!(counts.ancestry_overlay_inserts, 0);
7944        assert_eq!(counts.effect_paths, 3);
7945        assert_eq!(counts.journal_retained_commits, 1);
7946
7947        // Room for exactly two one-file commits; measured before the counters reset so the
7948        // probe's own journal work is not counted.
7949        let two_commits = 2 * commit_cost(vec![upsert("one", EntryKind::File, file_attrs(1, 1))]);
7950        crate::counters::test_thread_reset();
7951        let mut bounded = Index::new("/root");
7952        bounded.journal_capacity_bytes = two_commits;
7953        bounded.apply_ok(&Observation::new(vec![upsert("one", EntryKind::File, file_attrs(1, 1))]));
7954        bounded.apply_ok(&Observation::new(vec![upsert("two", EntryKind::File, file_attrs(2, 2))]));
7955        bounded.journal_capacity_bytes = 1;
7956        bounded.apply_ok(&Observation::new(vec![upsert(
7957            "three",
7958            EntryKind::File,
7959            file_attrs(3, 3),
7960        )]));
7961        let counts = crate::counters::test_thread_snapshot();
7962        assert_eq!(counts.journal_cloned_commits, 2);
7963        assert_eq!(counts.journal_retained_commits, 2);
7964        assert_eq!(counts.journal_oversized_commits, 1);
7965        assert_eq!(counts.journal_dropped_commits, 2);
7966    }
7967
7968    #[test]
7969    fn detached_and_exact_reducers_produce_the_same_facts_and_stats() {
7970        fn fact_image(index: &Index) -> Vec<(PathBuf, EntryKind, Attrs, bool, Source, bool)> {
7971            let mut image = Vec::new();
7972            let mut frontier = VecDeque::from([EntryId::ROOT]);
7973            while let Some(id) = frontier.pop_front() {
7974                let entry = index.entry(id);
7975                if entry.kind.is_dir() {
7976                    frontier.extend(index.child_ids(id));
7977                }
7978                image.push((
7979                    index.path_of(id).expect("live entry path"),
7980                    entry.kind,
7981                    entry.attrs,
7982                    entry.ignored,
7983                    entry.source,
7984                    entry.directory.as_deref().is_none_or(|directory| directory.children_complete),
7985                ));
7986            }
7987            image.sort_by(|left, right| left.0.cmp(&right.0));
7988            image
7989        }
7990
7991        fn assert_same_facts(detached: &Index, exact: &Index) {
7992            assert_eq!(fact_image(detached), fact_image(exact));
7993            assert_eq!(detached.total(), exact.total());
7994            let partitions =
7995                |index: &Index| index.named_partitions(index.entry(EntryId::ROOT).rollup());
7996            assert_eq!(partitions(detached), partitions(exact));
7997            let controls = |index: &Index| {
7998                index
7999                    .controls
8000                    .sources()
8001                    .map(|(path, source)| (path, source.to_vec()))
8002                    .collect::<Vec<_>>()
8003            };
8004            assert_eq!(controls(detached), controls(exact));
8005            assert_eq!(detached.state, exact.state);
8006            assert_eq!(detached.issues, exact.issues);
8007            let freshness = |index: &Index| {
8008                index
8009                    .freshness_marks
8010                    .iter()
8011                    .map(|(path, mark)| (path.clone(), mark.state, mark.epoch))
8012                    .collect::<Vec<_>>()
8013            };
8014            assert_eq!(freshness(detached), freshness(exact));
8015            assert_eq!(detached.verified, exact.verified);
8016            assert_eq!(detached.pending_invalidations, exact.pending_invalidations);
8017        }
8018
8019        let mut detached = Index::new("/root");
8020        let mut exact = detached.clone();
8021        let batches = [
8022            Observation::new(vec![
8023                upsert("a", EntryKind::Dir, file_attrs(0, 1)),
8024                upsert("a/one.rs", EntryKind::File, file_attrs(10, 2)),
8025                upsert("a/two.txt", EntryKind::File, file_attrs(20, 3)),
8026            ]),
8027            Observation::new(vec![
8028                upsert("a/one.rs", EntryKind::File, file_attrs(30, 4)),
8029                Op::Remove { path: PathBuf::from("a/two.txt") },
8030                Op::InvalidateSubtree {
8031                    path: PathBuf::from("a"),
8032                    reason: InvalidateReason::VerificationFailed,
8033                },
8034            ]),
8035        ];
8036
8037        for batch in batches {
8038            let detached_stats = detached.apply_baseline(&batch).expect("detached batch");
8039            let exact_outcome = exact.apply(&batch).expect("exact batch");
8040            assert_eq!(detached_stats, exact_outcome.stats);
8041            exact.establish_baseline();
8042            assert_same_facts(&detached, &exact);
8043        }
8044    }
8045
8046    #[test]
8047    fn exact_commit_records_verified_ancestry_and_kind_replacement() {
8048        let mut index = Index::new("/root");
8049        let inserted = index.apply_ok(&Observation::new(vec![
8050            upsert("unknown", EntryKind::Dir, file_attrs(0, 1)),
8051            upsert("unknown/deep", EntryKind::Dir, file_attrs(0, 2)),
8052            upsert("unknown/deep/file.txt", EntryKind::File, file_attrs(10, 3)),
8053        ]));
8054        let inserted = inserted.commit.expect("ancestry commit");
8055        assert_eq!(
8056            inserted.changes.iter().map(EffectiveChange::path).collect::<Vec<_>>(),
8057            [Path::new("unknown"), Path::new("unknown/deep"), Path::new("unknown/deep/file.txt")]
8058        );
8059        assert!(
8060            inserted
8061                .changes
8062                .iter()
8063                .all(|change| matches!(change, EffectiveChange::Inserted { .. }))
8064        );
8065
8066        let replaced = index.apply_ok(&Observation::new(vec![upsert(
8067            "unknown",
8068            EntryKind::File,
8069            file_attrs(20, 2),
8070        )]));
8071        let replaced = replaced.commit.expect("replacement commit");
8072        assert_eq!(
8073            replaced.changes,
8074            vec![
8075                EffectiveChange::Removed {
8076                    path: "unknown".into(),
8077                    kind: EntryKind::Dir,
8078                    attrs: file_attrs(0, 1),
8079                },
8080                EffectiveChange::Removed {
8081                    path: "unknown/deep".into(),
8082                    kind: EntryKind::Dir,
8083                    attrs: file_attrs(0, 2),
8084                },
8085                EffectiveChange::Removed {
8086                    path: "unknown/deep/file.txt".into(),
8087                    kind: EntryKind::File,
8088                    attrs: file_attrs(10, 3),
8089                },
8090                EffectiveChange::Inserted {
8091                    path: "unknown".into(),
8092                    kind: EntryKind::File,
8093                    attrs: file_attrs(20, 2),
8094                },
8095            ]
8096        );
8097        assert_eq!(
8098            replaced.impact.domains,
8099            vec![
8100                ImpactDomain::Topology,
8101                ImpactDomain::Metadata,
8102                ImpactDomain::Classification,
8103                ImpactDomain::Aggregates,
8104                ImpactDomain::Content,
8105            ]
8106        );
8107        assert_eq!(
8108            replaced.impact.dirty_paths,
8109            vec![
8110                PathBuf::new(),
8111                "unknown".into(),
8112                "unknown/deep".into(),
8113                "unknown/deep/file.txt".into(),
8114            ]
8115        );
8116    }
8117
8118    #[test]
8119    fn rejected_prepared_commit_is_fault_atomic() {
8120        let mut index = index_with_sample_tree();
8121        let before_clock = index.clock();
8122        let before_total = index.total();
8123        let before_len = index.len();
8124        let before_history = index.since(Clock::ZERO);
8125        let mut prepared = prepare_observation(&Observation::new(vec![upsert(
8126            "new/deep.txt",
8127            EntryKind::File,
8128            file_attrs(99, 99),
8129        )]))
8130        .expect("valid preparation");
8131        prepared.reject_before_apply = true;
8132
8133        let error = index.commit_prepared(prepared, true).expect_err("injected preflight");
8134
8135        assert!(matches!(error, crate::Error::CommitRejected("injected reducer preflight")));
8136        assert_eq!(index.clock(), before_clock);
8137        assert_eq!(index.total(), before_total);
8138        assert_eq!(index.len(), before_len);
8139        assert_eq!(index.since(Clock::ZERO), before_history);
8140        assert!(index.lookup(Path::new("new")).is_none());
8141    }
8142
8143    #[test]
8144    fn reconciliation_state_moves_through_exact_commits() {
8145        let mut index = Index::new("/root");
8146        let (started, start) = index.begin_reconcile(Path::new("src")).expect("begin");
8147        let start = start.expect("start commit");
8148        assert!(start.changes.is_empty());
8149        assert_eq!(
8150            start.state,
8151            vec![
8152                StateTransition::Freshness {
8153                    path: "src".into(),
8154                    previous: Freshness::Fresh,
8155                    current: Freshness::Reconciling,
8156                },
8157                StateTransition::IndexState {
8158                    previous: IndexState::default(),
8159                    current: IndexState {
8160                        freshness: Freshness::Reconciling,
8161                        ..IndexState::default()
8162                    },
8163                },
8164            ]
8165        );
8166
8167        let finish = index
8168            .finish_reconcile(
8169                Path::new("src"),
8170                started,
8171                true,
8172                &[],
8173                &[],
8174                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
8175            )
8176            .expect("finish")
8177            .commit
8178            .expect("finish commit");
8179        assert!(finish.changes.is_empty());
8180        assert_eq!(
8181            finish.state,
8182            vec![
8183                StateTransition::Verified { path: "src".into() },
8184                StateTransition::Freshness {
8185                    path: "src".into(),
8186                    previous: Freshness::Reconciling,
8187                    current: Freshness::Fresh,
8188                },
8189                StateTransition::IndexState {
8190                    previous: IndexState {
8191                        freshness: Freshness::Reconciling,
8192                        ..IndexState::default()
8193                    },
8194                    current: IndexState::default(),
8195                },
8196            ]
8197        );
8198    }
8199
8200    #[cfg(feature = "watch")]
8201    #[test]
8202    fn observation_failure_cannot_overwrite_a_terminal_resource_stop() {
8203        let handle = IndexHandle::new(Index::new("/root"));
8204        handle
8205            .transition_discovery(DiscoveryTransition::BudgetRefused(Issue::resource_budget(0)))
8206            .expect("stop for budget");
8207        let stopped = handle.state().expect("stopped state");
8208        let clock = handle.clock().expect("stopped clock");
8209
8210        let outcome = handle
8211            .transition_observation(ObservationTransition::Failed(Issue::from_error(
8212                &crate::Error::WatchStopped,
8213            )))
8214            .expect("late observer failure is ignored");
8215
8216        assert_eq!(outcome.commit, None);
8217        assert_eq!(handle.state().expect("terminal state"), stopped);
8218        assert_eq!(handle.clock().expect("terminal clock"), clock);
8219    }
8220
8221    /// Once a root has stopped or failed, discovery can neither expand it nor reopen it.
8222    #[test]
8223    fn a_terminal_root_refuses_every_discovery_commit() {
8224        let terminals = [
8225            DiscoveryTransition::BudgetRefused(Issue::resource_budget(1)),
8226            DiscoveryTransition::Failed(Issue::from_error(&crate::Error::OpenedIndexClosed)),
8227        ];
8228        for terminal in terminals {
8229            let handle = IndexHandle::new(Index::new("/root"));
8230            handle.transition_discovery(DiscoveryTransition::Begin).expect("begin");
8231            handle
8232                .apply_discovery(
8233                    &Observation::new(vec![upsert("dir", EntryKind::Dir, Attrs::default())]),
8234                    DiscoveryCommit::default(),
8235                )
8236                .expect("listing before the stop");
8237            handle.transition_discovery(terminal.clone()).expect("terminal transition");
8238            let state = handle.state().expect("terminal state");
8239            let clock = handle.clock().expect("terminal clock");
8240
8241            let late = [
8242                (
8243                    vec![upsert("dir/late.txt", EntryKind::File, file_attrs(1, 1))],
8244                    DiscoveryCommit {
8245                        directory_complete: Some(PathBuf::from("dir")),
8246                        transition: None,
8247                    },
8248                ),
8249                (
8250                    Vec::new(),
8251                    DiscoveryCommit {
8252                        directory_complete: None,
8253                        transition: Some(DiscoveryTransition::Finish),
8254                    },
8255                ),
8256                (
8257                    Vec::new(),
8258                    DiscoveryCommit {
8259                        directory_complete: None,
8260                        transition: Some(DiscoveryTransition::Inaccessible {
8261                            issues: vec![Issue::resource_budget(2)],
8262                            omitted: 0,
8263                        }),
8264                    },
8265                ),
8266            ];
8267            for (ops, discovery) in late {
8268                assert!(
8269                    matches!(
8270                        handle.apply_discovery(&Observation::new(ops), discovery.clone()),
8271                        Err(crate::Error::OpenedIndexStopped)
8272                    ),
8273                    "after {terminal:?}, {discovery:?} was accepted"
8274                );
8275            }
8276            assert_eq!(handle.state().expect("state"), state, "after {terminal:?}");
8277            assert_eq!(handle.clock().expect("clock"), clock, "after {terminal:?}");
8278            assert_eq!(handle.kind(Path::new("dir/late.txt")).expect("lookup"), None);
8279        }
8280    }
8281
8282    #[test]
8283    fn replaying_the_same_delta_twice_changes_nothing() {
8284        let mut index = Index::new("/root");
8285        let delta = Observation::new(vec![
8286            upsert("a", EntryKind::Dir, Attrs::default()),
8287            upsert("a/f.txt", EntryKind::File, file_attrs(10, 1)),
8288        ]);
8289        index.apply_ok(&delta);
8290        let after_first = index.total();
8291        let stats = index.apply_ok(&delta);
8292
8293        assert_eq!(stats.unchanged, 2);
8294        assert_eq!(index.total(), after_first);
8295        assert_eq!(index.len(), 3);
8296    }
8297
8298    #[test]
8299    fn changed_size_updates_every_ancestor() {
8300        let mut index = index_with_sample_tree();
8301        index.apply_ok(&Observation::new(vec![upsert(
8302            "src/main.rs",
8303            EntryKind::File,
8304            file_attrs(150, 11),
8305        )]));
8306
8307        assert_eq!(index.rollup(Path::new("src")).expect("dir").bytes, 350);
8308        assert_eq!(index.total().bytes, 650);
8309        assert_eq!(index.total().by_ext[".rs"], ExtTally { files: 2, bytes: 350, allocated: 1024 });
8310    }
8311
8312    #[test]
8313    fn allocated_size_change_updates_rollups_even_when_fingerprint_matches() {
8314        let mut index = Index::new("/root");
8315        let original = Attrs { allocated: 512, ..file_attrs(100, 10) };
8316        index.apply_ok(&Observation::new(vec![upsert("file.bin", EntryKind::File, original)]));
8317
8318        let repacked = Attrs { allocated: 4096, ..original };
8319        let outcome =
8320            index.apply_ok(&Observation::new(vec![upsert("file.bin", EntryKind::File, repacked)]));
8321
8322        assert_eq!(outcome.stats.updated, 1);
8323        assert_eq!(outcome.stats.unchanged, 0);
8324        assert_eq!(index.total().allocated, 4096);
8325        assert_eq!(index.attrs(Path::new("file.bin")), Some(&repacked));
8326    }
8327
8328    #[test]
8329    fn newest_mtime_preserves_pre_epoch_values_through_updates_and_removals() {
8330        let mut index = Index::new("/root");
8331        index.apply_ok(&Observation::new(vec![
8332            upsert("newer.txt", EntryKind::File, file_attrs(10, -10)),
8333            upsert("older.txt", EntryKind::File, file_attrs(20, -20)),
8334        ]));
8335
8336        assert_eq!(index.total().newest_mtime_ns, -10);
8337
8338        index.apply_ok(&Observation::new(vec![upsert(
8339            "newer.txt",
8340            EntryKind::File,
8341            file_attrs(10, -30),
8342        )]));
8343        assert_eq!(index.total().newest_mtime_ns, -20);
8344
8345        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("older.txt") }]));
8346        assert_eq!(index.total().newest_mtime_ns, -30);
8347    }
8348
8349    #[test]
8350    fn removing_a_file_corrects_sums_and_rebuilds_the_max() {
8351        let mut index = index_with_sample_tree();
8352        // guide.md holds the newest mtime for the whole tree.
8353        let stats = index
8354            .apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("docs/guide.md") }]));
8355
8356        assert_eq!(stats.removed, 1);
8357        let total = index.total();
8358        assert_eq!(total.files, 2);
8359        assert_eq!(total.bytes, 300);
8360        assert_eq!(total.newest_mtime_ns, 20, "max must fall back to src/lib.rs");
8361        assert!(!total.by_ext.contains_key(".md"), "emptied tallies are dropped");
8362    }
8363
8364    #[test]
8365    fn removing_a_directory_cascades_to_descendants() {
8366        let mut index = index_with_sample_tree();
8367        let stats = index
8368            .apply(&Observation::new(vec![Op::Remove { path: PathBuf::from("src") }]))
8369            .expect("valid observation");
8370
8371        assert_eq!(stats.removed, 3, "the directory and both files");
8372        let total = index.total();
8373        assert_eq!(total.files, 1);
8374        assert_eq!(total.dirs, 1);
8375        assert_eq!(total.bytes, 300);
8376        assert!(index.lookup(Path::new("src/main.rs")).is_none());
8377        assert!(!total.by_ext.contains_key(".rs"));
8378    }
8379
8380    #[test]
8381    fn freed_slots_are_reused() {
8382        let mut index = index_with_sample_tree();
8383        let before = index.len();
8384        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("src") }]));
8385        index.apply_ok(&Observation::new(vec![
8386            upsert("other", EntryKind::Dir, Attrs::default()),
8387            upsert("other/x.rs", EntryKind::File, file_attrs(1, 1)),
8388            upsert("other/y.rs", EntryKind::File, file_attrs(1, 1)),
8389        ]));
8390        assert_eq!(index.len(), before, "three freed slots, three new entries");
8391    }
8392
8393    #[test]
8394    fn stale_entry_handle_does_not_alias_a_reused_slot() {
8395        let mut index = Index::new("/root");
8396        index.apply_ok(&Observation::new(vec![upsert(
8397            "first.txt",
8398            EntryKind::File,
8399            file_attrs(1, 1),
8400        )]));
8401        let stale = index.lookup(Path::new("first.txt")).expect("first id");
8402        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("first.txt") }]));
8403        index.apply_ok(&Observation::new(vec![upsert(
8404            "second.txt",
8405            EntryKind::File,
8406            file_attrs(2, 2),
8407        )]));
8408        let current = index.lookup(Path::new("second.txt")).expect("second id");
8409
8410        assert_ne!(stale, current, "generation participates in handle identity");
8411        assert!(index.attrs_of(stale).is_none());
8412        assert!(index.kind_of(stale).is_none());
8413        assert!(index.name_of(stale).is_none());
8414        assert!(index.path_of(stale).is_none());
8415        assert!(index.children_of(stale).is_none());
8416        assert!(index.rollup_of(stale).is_none());
8417        assert_eq!(index.attrs_of(current).map(|attrs| attrs.size), Some(2));
8418    }
8419
8420    #[test]
8421    fn parent_first_batch_establishes_exact_ancestry() {
8422        let mut index = Index::new("/root");
8423        let deep = file_attrs(0, 1);
8424        let nested = file_attrs(0, 2);
8425        let tree = file_attrs(0, 3);
8426        index.apply_ok(&Observation::new(vec![
8427            upsert("deep", EntryKind::Dir, deep),
8428            upsert("deep/nested", EntryKind::Dir, nested),
8429            upsert("deep/nested/tree", EntryKind::Dir, tree),
8430            upsert("deep/nested/tree/file.txt", EntryKind::File, file_attrs(42, 7)),
8431        ]));
8432
8433        assert_eq!(index.total().files, 1);
8434        assert_eq!(index.total().dirs, 3);
8435        assert_eq!(index.total().bytes, 42);
8436        assert_eq!(index.rollup(Path::new("deep/nested")).expect("created").files, 1);
8437        assert_eq!(index.attrs(Path::new("deep")), Some(&deep));
8438        assert_eq!(index.attrs(Path::new("deep/nested")), Some(&nested));
8439        assert_eq!(index.attrs(Path::new("deep/nested/tree")), Some(&tree));
8440    }
8441
8442    #[test]
8443    fn scanner_parent_proof_matches_public_parent_first_application() {
8444        let ops = vec![
8445            upsert("deep", EntryKind::Dir, file_attrs(0, 1)),
8446            upsert("deep/nested", EntryKind::Dir, file_attrs(0, 2)),
8447            upsert("deep/nested/file.txt", EntryKind::File, file_attrs(42, 3)),
8448        ];
8449        let mut scanner = Index::new("/root");
8450        let scanner_stats = scanner
8451            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(ops.clone()))
8452            .expect("scanner proof");
8453        let mut public = Index::new("/root");
8454        let public_stats = public.apply(&Observation::new(ops)).expect("public proof").stats;
8455
8456        assert_eq!(scanner_stats, public_stats);
8457        assert_eq!(scanner.total(), public.total());
8458        assert_eq!(scanner.len(), public.len());
8459        for path in ["deep", "deep/nested", "deep/nested/file.txt"] {
8460            assert_eq!(scanner.kind(Path::new(path)), public.kind(Path::new(path)));
8461            assert_eq!(scanner.attrs(Path::new(path)), public.attrs(Path::new(path)));
8462        }
8463    }
8464
8465    #[test]
8466    fn scanner_parent_proof_rejects_unknown_ancestry_before_mutation() {
8467        let mut index = Index::new("/root");
8468        let before = index.total();
8469        let error = index
8470            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![upsert(
8471                "missing/child.txt",
8472                EntryKind::File,
8473                file_attrs(1, 1),
8474            )]))
8475            .expect_err("scanner proof must reject an unknown parent");
8476
8477        assert!(matches!(
8478            error,
8479            crate::Error::UnknownAncestry { path, .. }
8480                if path == Path::new("missing/child.txt")
8481        ));
8482        assert_eq!(index.total(), before);
8483        assert_eq!(index.len(), 1);
8484        assert_eq!(index.clock(), Clock::ZERO);
8485    }
8486
8487    #[test]
8488    fn scanner_kind_replacement_is_proved_by_the_general_lane() {
8489        let mut index = Index::new("/root");
8490        index.apply_ok(&Observation::new(vec![
8491            upsert("a", EntryKind::Dir, file_attrs(0, 1)),
8492            upsert("a/old.txt", EntryKind::File, file_attrs(7, 1)),
8493        ]));
8494        let before = index.total();
8495        let before_clock = index.clock();
8496
8497        // Once `a` is a file nothing can attach below it, and the batch is refused before
8498        // any fact moves, exactly as a public observation would be.
8499        let error = index
8500            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
8501                upsert("a", EntryKind::File, file_attrs(2, 2)),
8502                upsert("a/new.txt", EntryKind::File, file_attrs(3, 2)),
8503            ]))
8504            .expect_err("a child cannot attach after its parent became a file");
8505
8506        assert!(matches!(
8507            error,
8508            crate::Error::UnknownAncestry { path, .. } if path == Path::new("a/new.txt")
8509        ));
8510        assert_eq!(index.total(), before);
8511        assert_eq!(index.clock(), before_clock);
8512        assert_eq!(index.kind(Path::new("a")), Some(EntryKind::Dir));
8513        assert!(index.lookup(Path::new("a/old.txt")).is_some());
8514        assert!(index.lookup(Path::new("a/new.txt")).is_none());
8515
8516        // The replacement on its own is an ordinary verified observation.
8517        index
8518            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![upsert(
8519                "a",
8520                EntryKind::File,
8521                file_attrs(2, 2),
8522            )]))
8523            .expect("a scanner batch can replace an entry's kind");
8524        assert_eq!(index.kind(Path::new("a")), Some(EntryKind::File));
8525        assert!(index.lookup(Path::new("a/old.txt")).is_none());
8526        let total = index.total();
8527        assert_eq!((total.files, total.dirs, total.bytes), (1, 0, 2));
8528    }
8529
8530    #[test]
8531    fn scanner_discovery_survives_a_kind_changed_by_a_concurrent_refresh() {
8532        let handle = IndexHandle::new(Index::new("/root"));
8533        handle
8534            .apply_scanner_discovery_bounded(
8535                crate::scan::ScannerBatch::from_ops(vec![upsert(
8536                    "p",
8537                    EntryKind::Dir,
8538                    Attrs::default(),
8539                )]),
8540                DiscoveryCommit::default(),
8541                None,
8542            )
8543            .expect("root listing");
8544        // A refresh, on the general lane, saw that p/d is now a file on disk.
8545        handle
8546            .apply(&Observation::new(vec![upsert("p/d", EntryKind::File, file_attrs(1, 1))]))
8547            .expect("refresh insert");
8548        // Discovery's pending batch still carries the directory observation it listed.
8549        let outcome = handle.apply_scanner_discovery_bounded(
8550            crate::scan::ScannerBatch::from_ops(vec![upsert(
8551                "p/d",
8552                EntryKind::Dir,
8553                Attrs::default(),
8554            )]),
8555            DiscoveryCommit { directory_complete: Some(PathBuf::from("p")), transition: None },
8556            None,
8557        );
8558        assert!(outcome.is_ok(), "discovery must not die on a kind race: {outcome:?}");
8559        // The listed observation replaces the entry, as any verified observation does, and
8560        // the directory it came from is complete.
8561        assert_eq!(handle.kind(Path::new("p/d")).expect("kind read"), Some(EntryKind::Dir));
8562        assert_eq!(handle.directory_complete(Path::new("p")).expect("read"), Some(true));
8563    }
8564
8565    #[test]
8566    fn live_upsert_refuses_unknown_ancestry_without_mutation() {
8567        let mut index = Index::new("/root");
8568        let before = index.clock();
8569
8570        let error = index
8571            .apply(&Observation::new(vec![upsert(
8572                "unknown/deep/file.txt",
8573                EntryKind::File,
8574                file_attrs(10, 1),
8575            )]))
8576            .expect_err("live input must not invent parent metadata");
8577
8578        assert!(matches!(
8579            error,
8580            crate::Error::UnknownAncestry { path, reconcile_from }
8581                if path == Path::new("unknown/deep/file.txt")
8582                    && reconcile_from.as_os_str().is_empty()
8583        ));
8584        assert_eq!(index.clock(), before);
8585        assert_eq!(index.len(), 1);
8586        assert!(index.since(before).commits.is_empty());
8587    }
8588
8589    #[test]
8590    fn explicit_kind_replacement_precedes_attaching_a_child() {
8591        let mut index = Index::new("/root");
8592        index.apply_ok(&Observation::new(vec![upsert(
8593            "conflict",
8594            EntryKind::File,
8595            file_attrs(9, 1),
8596        )]));
8597
8598        let outcome = index.apply_ok(&Observation::new(vec![
8599            upsert("conflict", EntryKind::Dir, file_attrs(0, 2)),
8600            upsert("conflict/child.txt", EntryKind::File, file_attrs(4, 2)),
8601        ]));
8602
8603        assert_eq!(index.kind(Path::new("conflict")), Some(EntryKind::Dir));
8604        assert!(index.lookup(Path::new("conflict/child.txt")).is_some());
8605        assert_eq!(index.total().files, 1);
8606        assert_eq!(index.total().dirs, 1);
8607        assert_eq!(index.total().bytes, 4);
8608        assert_eq!(outcome.removed, 1);
8609    }
8610
8611    #[test]
8612    fn kind_change_replaces_the_entry() {
8613        let mut index = Index::new("/root");
8614        index.apply_ok(&Observation::new(vec![upsert(
8615            "thing",
8616            EntryKind::File,
8617            file_attrs(50, 5),
8618        )]));
8619        assert_eq!(index.total().files, 1);
8620
8621        index.apply_ok(&Observation::new(vec![upsert("thing", EntryKind::Dir, Attrs::default())]));
8622        let total = index.total();
8623        assert_eq!(total.files, 0);
8624        assert_eq!(total.dirs, 1);
8625        assert_eq!(total.bytes, 0);
8626    }
8627
8628    #[test]
8629    fn paths_are_reconstructed_from_parent_pointers() {
8630        let index = index_with_sample_tree();
8631        let id = index.lookup(Path::new("src/main.rs")).expect("present");
8632        assert_eq!(index.path_of(id), Some(PathBuf::from("src/main.rs")));
8633        assert_eq!(index.path_of(EntryId::ROOT), Some(PathBuf::new()));
8634    }
8635
8636    #[test]
8637    fn since_returns_commits_after_a_clock() {
8638        let mut index = Index::new("/root");
8639        index.apply_ok(&Observation::new(vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))]));
8640        let mark = index.clock();
8641        index.apply_ok(&Observation::new(vec![upsert("b.txt", EntryKind::File, file_attrs(2, 2))]));
8642
8643        let since = index.since(mark);
8644        assert!(!since.truncated);
8645        assert_eq!(since.commits.len(), 1);
8646        assert_eq!(since.commits[0].changes[0].path(), Path::new("b.txt"));
8647
8648        assert_eq!(index.since(index.clock()).commits.len(), 0);
8649    }
8650
8651    /// The bytes one batch is charged when it commits against an empty tree.
8652    fn commit_cost(ops: Vec<Op>) -> usize {
8653        let mut probe = Index::new("/root");
8654        probe.apply_ok(&Observation::new(ops)).commit.expect("effective commit").retained_cost()
8655    }
8656
8657    #[test]
8658    fn oversized_single_batch_is_not_retained() {
8659        let batch = || {
8660            vec![
8661                upsert("a.txt", EntryKind::File, file_attrs(1, 1)),
8662                upsert("b.txt", EntryKind::File, file_attrs(2, 2)),
8663                upsert("c.txt", EntryKind::File, file_attrs(3, 3)),
8664            ]
8665        };
8666        let mut index = Index::with_journal_capacity_bytes("/root", commit_cost(batch()) - 1);
8667        let outcome = index.apply_ok(&Observation::new(batch()));
8668
8669        assert_eq!(outcome.commit.as_ref().expect("committed").changes.len(), 3);
8670        let since = index.since(Clock::ZERO);
8671        assert!(since.truncated);
8672        assert!(since.commits.is_empty());
8673    }
8674
8675    #[test]
8676    fn journal_eviction_charges_the_complete_retained_payload() {
8677        let first = || {
8678            vec![
8679                upsert("a.txt", EntryKind::File, file_attrs(1, 1)),
8680                upsert("b.txt", EntryKind::File, file_attrs(2, 2)),
8681            ]
8682        };
8683        let second = || {
8684            vec![
8685                upsert("c.txt", EntryKind::File, file_attrs(3, 3)),
8686                upsert("d.txt", EntryKind::File, file_attrs(4, 4)),
8687            ]
8688        };
8689        // Room for the second commit and all but one byte of the first.
8690        let capacity = commit_cost(first()) + commit_cost(second()) - 1;
8691        let mut index = Index::with_journal_capacity_bytes("/root", capacity);
8692        index.apply_ok(&Observation::new(first()));
8693        index.apply_ok(&Observation::new(second()));
8694
8695        let since = index.since(Clock::ZERO);
8696        assert!(since.truncated);
8697        assert_eq!(since.commits.len(), 1);
8698        assert_eq!(since.commits[0].changes.len(), 2);
8699        assert_eq!(since.commits[0].changes[0].path(), Path::new("c.txt"));
8700    }
8701
8702    /// Two commits of one inserted file each: the same item count, but the second names a
8703    /// path whose bytes alone dwarf the first commit. A budget counted in items held both
8704    /// and let a long-path tree retain tens of mebibytes under a 64 Ki budget; a budget in
8705    /// bytes evicts the first.
8706    #[test]
8707    fn journal_eviction_is_charged_in_path_bytes() {
8708        let long_name = format!("{}.txt", "n".repeat(4096));
8709        let short = || vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))];
8710        let long = || vec![upsert(&long_name, EntryKind::File, file_attrs(2, 2))];
8711        let short_cost = commit_cost(short());
8712        let long_cost = commit_cost(long());
8713        assert!(
8714            long_cost > short_cost + 4096,
8715            "path bytes must be charged: short {short_cost}, long {long_cost}"
8716        );
8717
8718        let mut index = Index::with_journal_capacity_bytes("/root", short_cost + long_cost - 1);
8719        index.apply_ok(&Observation::new(short()));
8720        index.apply_ok(&Observation::new(long()));
8721
8722        let since = index.since(Clock::ZERO);
8723        assert!(since.truncated, "an item budget kept both commits; a byte budget cannot");
8724        assert_eq!(since.commits.len(), 1);
8725        assert_eq!(since.commits[0].changes[0].path(), Path::new(&long_name));
8726    }
8727
8728    #[test]
8729    fn impact_drops_an_overflowing_path_set_instead_of_truncating_it() {
8730        let mut index = Index::new("/root");
8731        let ops = (0..=MAX_DIRTY_PATHS)
8732            .map(|which| {
8733                upsert(
8734                    &format!("file-{which}.txt"),
8735                    EntryKind::File,
8736                    file_attrs(u64::try_from(which).expect("bounded"), 1),
8737                )
8738            })
8739            .collect();
8740
8741        let commit =
8742            index.apply_ok(&Observation::new(ops)).commit.expect("overflowing impact commit");
8743
8744        assert!(commit.impact.all_dirty);
8745        assert!(commit.impact.dirty_paths.is_empty(), "a partial path list must not escape");
8746        assert_eq!(commit.changes.len(), MAX_DIRTY_PATHS + 1);
8747    }
8748
8749    #[test]
8750    fn invalidations_are_queued_for_the_scan_layer() {
8751        let mut index = Index::new("/root");
8752        let stats = index.apply_ok(&Observation::new(vec![Op::InvalidateSubtree {
8753            path: PathBuf::from("src"),
8754            reason: InvalidateReason::WatchOverflow,
8755        }]));
8756
8757        assert_eq!(stats.invalidated, 1);
8758        let pending = index.take_pending_invalidations();
8759        assert_eq!(pending.len(), 1);
8760        assert_eq!(pending[0].0, PathBuf::from("src"));
8761        assert_eq!(pending[0].1, InvalidateReason::WatchOverflow);
8762        assert!(index.take_pending_invalidations().is_empty(), "drained once");
8763    }
8764
8765    #[test]
8766    fn paths_escaping_the_root_are_rejected() {
8767        assert!(normalize(Path::new("../escape")).is_none());
8768        assert!(normalize(Path::new("/absolute")).is_none());
8769        assert_eq!(
8770            normalize(Path::new("./a/b")).expect("relative"),
8771            vec![OsString::from("a"), OsString::from("b")]
8772        );
8773
8774        let mut index = Index::new("/root");
8775        let upsert_error = index
8776            .apply(&Observation::new(vec![upsert("../escape", EntryKind::File, file_attrs(1, 1))]))
8777            .expect_err("escaping upsert");
8778        assert!(matches!(upsert_error, crate::Error::PathEscapesRoot(_)));
8779        assert_eq!(index.total().files, 0);
8780
8781        let invalidation_error = index
8782            .apply(&Observation::new(vec![Op::InvalidateSubtree {
8783                path: PathBuf::from("../outside"),
8784                reason: InvalidateReason::Requested,
8785            }]))
8786            .expect_err("escaping invalidation");
8787        assert!(matches!(invalidation_error, crate::Error::PathEscapesRoot(_)));
8788        assert!(index.take_pending_invalidations().is_empty());
8789        assert_eq!(index.freshness(), Freshness::Fresh);
8790    }
8791
8792    #[cfg(unix)]
8793    #[test]
8794    fn distinct_non_utf8_names_have_distinct_identity() {
8795        use std::ffi::OsString;
8796        use std::os::unix::ffi::OsStringExt;
8797
8798        let first = PathBuf::from(OsString::from_vec(vec![b'n', 0x80]));
8799        let second = PathBuf::from(OsString::from_vec(vec![b'n', 0x81]));
8800        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
8801            "/root",
8802            ScanScope::default(),
8803            crate::classify::TypeRegistry::compiled_shared(),
8804            DEFAULT_JOURNAL_CAPACITY_BYTES,
8805        );
8806        index.apply_ok(&Observation::new(vec![
8807            Op::Upsert { path: first.clone(), kind: EntryKind::File, attrs: file_attrs(10, 1) },
8808            Op::Upsert { path: second.clone(), kind: EntryKind::File, attrs: file_attrs(20, 2) },
8809        ]));
8810
8811        assert_eq!(index.total().files, 2);
8812        assert_eq!(index.total().bytes, 30);
8813        assert!(index.lookup(&first).is_some());
8814        assert!(index.lookup(&second).is_some());
8815        assert_serving_indexes(&index);
8816    }
8817
8818    #[cfg(unix)]
8819    #[test]
8820    fn a_non_utf8_parent_still_lists_its_children() {
8821        use std::ffi::OsString;
8822        use std::os::unix::ffi::OsStringExt;
8823
8824        let directory = PathBuf::from(OsString::from_vec(vec![b'd', 0x80]));
8825        let child = directory.join("child");
8826        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
8827            "/root",
8828            ScanScope::default(),
8829            crate::classify::TypeRegistry::compiled_shared(),
8830            DEFAULT_JOURNAL_CAPACITY_BYTES,
8831        );
8832        index.apply_ok(&Observation::new(vec![
8833            Op::Upsert { path: directory.clone(), kind: EntryKind::Dir, attrs: Attrs::default() },
8834            Op::Upsert { path: child, kind: EntryKind::File, attrs: file_attrs(1, 1) },
8835        ]));
8836
8837        assert_serving_indexes(&index);
8838        // The directory's own name escapes to `d%80`, and its child is reachable beneath
8839        // it. While the encoding was partial this directory had no portable name, so its
8840        // whole subtree was unlistable and the assertion here counted the loss instead.
8841        assert_eq!(
8842            index.portable_children(&directory).map(|children| children.nondirectories.len()),
8843            Some(1)
8844        );
8845        assert!(
8846            index.portable_entries().keys().any(|portable| portable.as_str() == "d%80/child"),
8847            "a child under a non-utf8 directory is listed at its escaped path"
8848        );
8849    }
8850
8851    #[cfg(unix)]
8852    #[test]
8853    fn non_utf8_stem_keeps_ascii_extension_tally() {
8854        use std::ffi::OsString;
8855        use std::os::unix::ffi::OsStringExt;
8856
8857        let path = PathBuf::from(OsString::from_vec(vec![b'n', 0x80, b'.', b'R', b'S']));
8858        let mut index = Index::new("/root");
8859        index.apply_ok(&Observation::new(vec![Op::Upsert {
8860            path,
8861            kind: EntryKind::File,
8862            attrs: file_attrs(10, 1),
8863        }]));
8864
8865        let tallies = index.total().by_ext;
8866        assert_eq!(
8867            tallies.get(".rs"),
8868            Some(&ExtTally { files: 1, bytes: 10, allocated: file_attrs(10, 1).allocated })
8869        );
8870    }
8871
8872    #[test]
8873    fn restore_candidates_count_visited_files_not_pathbuf_aliases() {
8874        use crate::content::AnalysisSet;
8875
8876        let mut index = Index::new("/root");
8877        let attrs = file_attrs(10, 1);
8878        assert!(
8879            index
8880                .insert_loaded_child(EntryId::ROOT, OsString::from("a"), EntryKind::File, attrs)
8881                .is_some()
8882        );
8883        assert!(
8884            index
8885                .insert_loaded_child(EntryId::ROOT, OsString::from("a/"), EntryKind::File, attrs)
8886                .is_some()
8887        );
8888        let (candidates, visited) =
8889            index.restore_analysis_candidates(AnalysisSet::NONE.with_lines());
8890        assert_eq!(visited, 2, "each visited regular file is a completeness slot");
8891        assert_eq!(candidates.len(), 1, "PathBuf keys merge trailing-separator aliases");
8892    }
8893
8894    #[test]
8895    fn restore_candidates_match_analysis_file_identities() {
8896        use crate::content::AnalysisSet;
8897
8898        let mut index = Index::new("/root");
8899        index.apply_ok(&Observation::new(vec![
8900            upsert("src", EntryKind::Dir, file_attrs(0, 1)),
8901            upsert("src/nested", EntryKind::Dir, file_attrs(0, 2)),
8902            upsert("src/nested/lib.rs", EntryKind::File, file_attrs(10, 1)),
8903            upsert("README.md", EntryKind::File, file_attrs(20, 2)),
8904        ]));
8905        let profile = AnalysisSet::NONE.with_lines();
8906        let live = index.analysis_candidates(profile);
8907        let (restore, visited) = index.restore_analysis_candidates(profile);
8908        assert_eq!(restore.len(), live.len());
8909        assert_eq!(visited, u64::try_from(live.len()).expect("candidate count fits u64"));
8910        assert_eq!(restore.len(), 2);
8911        for candidate in &live {
8912            assert_eq!(
8913                index.path_of(candidate.entry_id).as_deref(),
8914                Some(candidate.relative_path.as_path())
8915            );
8916            let restored = restore.get(&candidate.relative_path).expect("same relative path");
8917            assert_eq!(restored.entry_id, candidate.entry_id);
8918            assert_eq!(restored.revision, candidate.revision);
8919            assert_eq!(restored.attrs.fingerprint(), candidate.attrs.fingerprint());
8920        }
8921    }
8922
8923    #[test]
8924    fn content_results_commit_conditionally_and_metadata_changes_invalidate_them() {
8925        use crate::content::{
8926            AnalysisApplyOutcome, AnalysisRequest, AnalysisSet, AnalyzerOutcome, BasicMetrics,
8927            ContentProvenance, FileAnalysis,
8928        };
8929
8930        let mut index = Index::new("/root");
8931        index.apply_ok(&Observation::new(vec![
8932            upsert("src", EntryKind::Dir, file_attrs(0, 1)),
8933            upsert("src/lib.rs", EntryKind::File, file_attrs(10, 1)),
8934        ]));
8935        let profile = AnalysisSet::NONE.with_lines();
8936        let candidate =
8937            index.analysis_candidates(profile).into_iter().next().expect("file candidate");
8938        let analysis = FileAnalysis {
8939            fingerprint: candidate.attrs.fingerprint(),
8940            bytes: candidate.attrs.size,
8941            detection: candidate.classification.clone().into(),
8942            lines: AnalyzerOutcome::analyzed(BasicMetrics {
8943                physical_lines: 2,
8944                nonblank_lines: 2,
8945                ..BasicMetrics::default()
8946            }),
8947            code: None,
8948            words: None,
8949            error: None,
8950        };
8951        let provenance = ContentProvenance::for_request(
8952            AnalysisRequest { profile, ..AnalysisRequest::default() },
8953            crate::classify::type_rule_fingerprint(),
8954        );
8955        let observation = AnalysisObservation {
8956            candidate: candidate.clone(),
8957            profile,
8958            provenance: provenance.clone(),
8959            analysis: analysis.clone(),
8960        };
8961        assert_eq!(
8962            index.apply_analysis(observation.clone()),
8963            AnalysisApplyOutcome::Stale,
8964            "an index prepared for no content identity holds no record"
8965        );
8966        assert!(index.content().is_none());
8967
8968        index.prepare_content_analysis(AnalysisRequest { profile, ..AnalysisRequest::default() });
8969        assert_eq!(index.content_set(), profile);
8970        assert_eq!(index.apply_analysis(observation), AnalysisApplyOutcome::Applied);
8971        assert_eq!(
8972            index
8973                .content_rollup(Path::new(""))
8974                .expect("content root")
8975                .total
8976                .lines
8977                .metrics
8978                .physical_lines,
8979            2
8980        );
8981
8982        index.apply_ok(&Observation::new(vec![upsert(
8983            "src/lib.rs",
8984            EntryKind::File,
8985            file_attrs(20, 2),
8986        )]));
8987        assert!(index.content_rollup(Path::new("")).is_none());
8988        assert_eq!(
8989            index.apply_analysis(AnalysisObservation { candidate, profile, provenance, analysis }),
8990            AnalysisApplyOutcome::Stale
8991        );
8992    }
8993
8994    #[test]
8995    fn operational_content_failures_are_retained_but_retried_until_recovery() {
8996        use crate::content::{
8997            AnalysisApplyOutcome, AnalysisRequest, AnalysisSet, AnalyzerOutcome, BasicMetrics,
8998            ContentProvenance, CoverageReason, FileAnalysis,
8999        };
9000
9001        let mut index = Index::new("/root");
9002        index.apply_ok(&Observation::new(vec![
9003            upsert("src", EntryKind::Dir, file_attrs(0, 1)),
9004            upsert("src/lib.rs", EntryKind::File, file_attrs(10, 1)),
9005        ]));
9006        let profile = AnalysisSet::LINES_ONLY;
9007        let request = AnalysisRequest { profile, ..AnalysisRequest::default() };
9008        index.prepare_content_analysis(request);
9009        let candidate = index.pending_analysis_candidates(request).pop().expect("candidate");
9010        let provenance =
9011            ContentProvenance::for_request(request, crate::classify::type_rule_fingerprint());
9012        let record = |reason, error: &str| FileAnalysis {
9013            fingerprint: candidate.attrs.fingerprint(),
9014            bytes: candidate.attrs.size,
9015            detection: candidate.classification.clone().into(),
9016            lines: AnalyzerOutcome::unavailable(reason),
9017            code: None,
9018            words: None,
9019            error: Some(error.to_owned()),
9020        };
9021
9022        for (reason, error) in [
9023            (CoverageReason::IoError, "read failed"),
9024            (CoverageReason::ChangedDuringRead, "changed during read"),
9025        ] {
9026            assert_eq!(
9027                index.apply_analysis(AnalysisObservation {
9028                    candidate: candidate.clone(),
9029                    profile,
9030                    provenance: provenance.clone(),
9031                    analysis: record(reason, error),
9032                }),
9033                AnalysisApplyOutcome::Applied
9034            );
9035            let retained = index.content().expect("content").file(Path::new("src/lib.rs"));
9036            assert_eq!(retained.and_then(FileAnalysis::operational_failure), Some(reason));
9037            assert_eq!(
9038                index.pending_analysis_candidates(request).len(),
9039                1,
9040                "an operational failure must remain pending"
9041            );
9042        }
9043
9044        let recovered = FileAnalysis {
9045            fingerprint: candidate.attrs.fingerprint(),
9046            bytes: candidate.attrs.size,
9047            detection: candidate.classification.clone().into(),
9048            lines: AnalyzerOutcome::analyzed(BasicMetrics {
9049                physical_lines: 1,
9050                nonblank_lines: 1,
9051                raw_words: 1,
9052                ..BasicMetrics::default()
9053            }),
9054            code: None,
9055            words: None,
9056            error: None,
9057        };
9058        assert_eq!(
9059            index.apply_analysis(AnalysisObservation {
9060                candidate,
9061                profile,
9062                provenance,
9063                analysis: recovered,
9064            }),
9065            AnalysisApplyOutcome::Applied
9066        );
9067        assert!(index.pending_analysis_candidates(request).is_empty());
9068        assert_eq!(
9069            index
9070                .content()
9071                .expect("content")
9072                .file(Path::new("src/lib.rs"))
9073                .and_then(FileAnalysis::operational_failure),
9074            None
9075        );
9076    }
9077
9078    /// An index that read no control file cannot say what is ignored, so it says that,
9079    /// rather than calling every entry unignored.
9080    #[test]
9081    fn an_index_that_did_not_observe_controls_refuses_ignore_questions() {
9082        let mut index =
9083            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
9084        assert!(!index.observes_controls());
9085        index.apply_ok(&Observation::new(vec![upsert(
9086            "debug.log",
9087            EntryKind::File,
9088            file_attrs(10, 1),
9089        )]));
9090        for path in ["debug.log", "absent.log"] {
9091            assert!(
9092                matches!(
9093                    index.is_ignored(Path::new(path)),
9094                    Err(crate::Error::ControlStateNotObserved)
9095                ),
9096                "{path}"
9097            );
9098        }
9099        assert!(matches!(index.controls(), Err(crate::Error::ControlStateNotObserved)));
9100
9101        let mut observed =
9102            Index::new_with_scope("/root", crate::test_support::observing_controls());
9103        assert!(observed.observes_controls());
9104        observed.apply_ok(&Observation::new(vec![upsert(
9105            "debug.log",
9106            EntryKind::File,
9107            file_attrs(10, 1),
9108        )]));
9109        assert_eq!(observed.is_ignored(Path::new("debug.log")).ok(), Some(Some(false)));
9110        assert_eq!(observed.is_ignored(Path::new("absent.log")).ok(), Some(None));
9111        assert!(observed.controls().is_ok_and(crate::control::ControlTable::is_empty));
9112    }
9113
9114    /// Control input to an index that observes no control state is refused, typed, and
9115    /// changes nothing. Accepted, it installed a table and reclassified entries under a
9116    /// scope that says no rule was read, so `is_ignored` refused over classification the
9117    /// index held and a snapshot saved from it loaded into an open that turned observation
9118    /// off as an exact match (`fdu-agb6`). A stale conditional control op is refused as
9119    /// well: the refusal is about the index's scope, not its state.
9120    #[test]
9121    fn an_index_that_does_not_observe_controls_refuses_control_input() {
9122        let mut index =
9123            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
9124        index.apply_ok(&Observation::new(vec![
9125            upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9126            upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
9127        ]));
9128        let stale_baseline = index.expectation(Path::new(".gitignore"));
9129        index.apply_ok(&Observation::new(vec![upsert(
9130            ".gitignore",
9131            EntryKind::File,
9132            file_attrs(7, 3),
9133        )]));
9134        let clock = index.clock();
9135        let total = index.total();
9136        let controls = || {
9137            [
9138                Op::ControlUpsert {
9139                    path: PathBuf::from(".gitignore"),
9140                    source: b"*.log\n".to_vec(),
9141                },
9142                Op::ControlRemove { path: PathBuf::from(".gitignore") },
9143            ]
9144        };
9145
9146        for control in controls() {
9147            let batch = Observation::new(vec![
9148                upsert("new.txt", EntryKind::File, file_attrs(1, 4)),
9149                control.clone(),
9150            ]);
9151            assert!(
9152                matches!(index.apply(&batch), Err(crate::Error::ControlStateNotObserved)),
9153                "{control:?}"
9154            );
9155            assert!(
9156                matches!(index.apply_baseline(&batch), Err(crate::Error::ControlStateNotObserved)),
9157                "{control:?}"
9158            );
9159            let stale = Observation::from_ops(vec![ObservationOp::if_state(
9160                control.clone(),
9161                stale_baseline,
9162            )]);
9163            assert!(
9164                matches!(index.apply(&stale), Err(crate::Error::ControlStateNotObserved)),
9165                "stale {control:?}"
9166            );
9167        }
9168        assert_eq!(index.clock(), clock, "a refused batch commits nothing");
9169        assert_eq!(index.total(), total);
9170        assert!(index.lookup(Path::new("new.txt")).is_none());
9171        assert!(index.control_table().is_empty());
9172
9173        let mut table = crate::control::ControlTable::default();
9174        table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control source");
9175        assert!(matches!(
9176            index.install_controls(table),
9177            Err(crate::Error::ControlStateNotObserved)
9178        ));
9179        assert!(index.control_table().is_empty());
9180
9181        let mut observed =
9182            Index::new_with_scope("/root", crate::test_support::observing_controls());
9183        for control in controls() {
9184            observed
9185                .apply(&Observation::new(vec![
9186                    upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9187                    control,
9188                ]))
9189                .expect("an observing index accepts control input");
9190        }
9191    }
9192
9193    /// The unignored partition and a shared child's ignore bit are ignore facts too. On an
9194    /// index that read no rule the partition equals `all` and every bit reads "not
9195    /// ignored" only because nobody looked, so they refuse the way `is_ignored` does,
9196    /// while the `all` roll-up and the children themselves still answer (`fdu-agb6`).
9197    #[test]
9198    fn an_index_that_did_not_observe_controls_states_no_partition_or_child_ignore_fact() {
9199        let tree = Observation::new(vec![
9200            upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
9201            upsert("dir/debug.log", EntryKind::File, file_attrs(10, 2)),
9202        ]);
9203        let mut unobserved =
9204            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
9205        unobserved.apply_ok(&tree);
9206        assert!(matches!(unobserved.partition_total(), Err(crate::Error::ControlStateNotObserved)));
9207        for path in ["", "dir", "dir/debug.log", "absent"] {
9208            assert!(
9209                matches!(
9210                    unobserved.partition_rollup(Path::new(path)),
9211                    Err(crate::Error::ControlStateNotObserved)
9212                ),
9213                "{path}"
9214            );
9215            assert!(
9216                matches!(
9217                    unobserved.partition_rollup_summary(Path::new(path)),
9218                    Err(crate::Error::ControlStateNotObserved)
9219                ),
9220                "{path}"
9221            );
9222        }
9223        assert_eq!(unobserved.total().files, 1, "the all partition still answers");
9224        let children = IndexHandle::new(unobserved)
9225            .children(Path::new(""))
9226            .expect("children read")
9227            .expect("root directory");
9228        assert_eq!(children.len(), 1);
9229        assert_eq!(children[0].ignored, None);
9230        assert_eq!(children[0].partitions, None);
9231        assert_eq!(children[0].rollup.as_ref().map(|rollup| rollup.files), Some(1));
9232
9233        let mut observed =
9234            Index::new_with_scope("/root", crate::test_support::observing_controls());
9235        observed.apply_ok(&tree);
9236        assert_eq!(observed.partition_total().expect("control state observed").unignored.files, 1);
9237        assert!(
9238            observed.partition_rollup(Path::new("dir")).expect("control state observed").is_some()
9239        );
9240        assert_eq!(
9241            observed
9242                .partition_rollup_summary(Path::new("dir/debug.log"))
9243                .expect("control state observed"),
9244            None,
9245            "a file has no partitions"
9246        );
9247        let children = IndexHandle::new(observed)
9248            .children(Path::new(""))
9249            .expect("children read")
9250            .expect("root directory");
9251        assert_eq!(children[0].ignored, Some(false));
9252        assert_eq!(
9253            children[0].partitions.as_ref().map(|partitions| partitions.unignored.files),
9254            Some(1)
9255        );
9256    }
9257
9258    #[test]
9259    fn control_changes_atomically_move_fixed_partitions_without_changing_all() {
9260        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9261        index.apply_ok(&Observation::new(vec![
9262            upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9263            upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
9264            upsert("keep.rs", EntryKind::File, file_attrs(20, 3)),
9265            upsert("docs", EntryKind::Dir, file_attrs(0, 4)),
9266            upsert("docs/other.log", EntryKind::File, file_attrs(30, 5)),
9267            upsert("docs/keep.log", EntryKind::File, file_attrs(40, 6)),
9268        ]));
9269        let before = index.partition_total().expect("control state observed");
9270
9271        let outcome = index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
9272            path: PathBuf::from(".gitignore"),
9273            source: b"*.log\n".to_vec(),
9274        }]));
9275        let partitions = index.partition_total().expect("control state observed");
9276
9277        assert_eq!(partitions.all, before.all, "classification never changes all facts");
9278        assert_eq!(partitions.all.files, 5);
9279        assert_eq!(partitions.unignored.files, 2);
9280        assert_eq!(partitions.unignored.bytes, 26);
9281        assert_eq!(outcome.controls, 1);
9282        assert_eq!(outcome.reclassified, 3);
9283        assert_eq!(
9284            index.is_ignored(Path::new("debug.log")).expect("control state observed"),
9285            Some(true)
9286        );
9287        assert_eq!(
9288            index.is_ignored(Path::new("keep.rs")).expect("control state observed"),
9289            Some(false)
9290        );
9291
9292        let commit = outcome.commit.expect("control and classification commit together");
9293        assert!(matches!(
9294            commit.changes.first(),
9295            Some(EffectiveChange::ControlUpdated { path, previous: None, current: Some(_) })
9296                if path == Path::new(".gitignore")
9297        ));
9298        assert_eq!(
9299            commit
9300                .changes
9301                .iter()
9302                .filter(|change| matches!(change, EffectiveChange::Reclassified { .. }))
9303                .count(),
9304            3
9305        );
9306    }
9307
9308    #[test]
9309    fn serving_semantics_follow_ignore_reclassification_exactly() {
9310        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
9311            "/root",
9312            crate::test_support::observing_controls(),
9313            crate::classify::TypeRegistry::compiled_shared(),
9314            DEFAULT_JOURNAL_CAPACITY_BYTES,
9315        );
9316        index.apply_ok(&Observation::new(vec![
9317            upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9318            upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
9319            upsert("keep.rs", EntryKind::File, file_attrs(20, 3)),
9320            upsert("Makefile", EntryKind::File, file_attrs(30, 4)),
9321        ]));
9322        assert_serving_indexes(&index);
9323
9324        index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
9325            path: PathBuf::from(".gitignore"),
9326            source: b"*.log\nMakefile\n".to_vec(),
9327        }]));
9328        assert_serving_indexes(&index);
9329
9330        index.apply_ok(&Observation::new(vec![Op::ControlRemove {
9331            path: PathBuf::from(".gitignore"),
9332        }]));
9333        assert_serving_indexes(&index);
9334    }
9335
9336    #[test]
9337    fn nested_negation_edit_and_last_control_deletion_reclassify_exactly() {
9338        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9339        index.apply_ok(&Observation::new(vec![
9340            upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9341            upsert("docs", EntryKind::Dir, file_attrs(0, 2)),
9342            upsert("docs/.gitignore", EntryKind::File, file_attrs(10, 3)),
9343            upsert("docs/keep.log", EntryKind::File, file_attrs(40, 4)),
9344            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
9345            Op::ControlUpsert {
9346                path: PathBuf::from("docs/.gitignore"),
9347                source: b"!keep.log\n".to_vec(),
9348            },
9349        ]));
9350        assert_eq!(
9351            index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
9352            Some(false)
9353        );
9354
9355        let edited = index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
9356            path: PathBuf::from("docs/.gitignore"),
9357            source: b"# no exception\n".to_vec(),
9358        }]));
9359        assert_eq!(edited.reclassified, 1);
9360        assert_eq!(
9361            index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
9362            Some(true)
9363        );
9364
9365        let removed = index
9366            .apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from(".gitignore") }]));
9367        assert_eq!(removed.controls, 1, "removing the retained row removes its control state");
9368        assert_eq!(removed.reclassified, 1);
9369        assert_eq!(
9370            index.controls().expect("control state observed").len(),
9371            1,
9372            "the nested control remains"
9373        );
9374        assert_eq!(
9375            index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
9376            Some(false)
9377        );
9378
9379        index.apply_ok(&Observation::new(vec![Op::Remove {
9380            path: PathBuf::from("docs/.gitignore"),
9381        }]));
9382        assert!(index.controls().expect("control state observed").is_empty());
9383        assert_eq!(
9384            index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
9385            Some(false)
9386        );
9387    }
9388
9389    #[test]
9390    fn replacing_batch_ancestors_prunes_retained_and_transient_controls() {
9391        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9392        index
9393            .apply(&Observation::new(vec![
9394                upsert("docs", EntryKind::Dir, file_attrs(0, 1)),
9395                upsert("docs/.gitignore", EntryKind::File, file_attrs(6, 2)),
9396                Op::ControlUpsert { path: "docs/.gitignore".into(), source: b"*.log\n".to_vec() },
9397            ]))
9398            .expect("retained control");
9399
9400        let outcome = index
9401            .apply(&Observation::new(vec![
9402                upsert("scratch", EntryKind::Dir, file_attrs(0, 3)),
9403                upsert("scratch/.gitignore", EntryKind::File, file_attrs(6, 4)),
9404                Op::ControlUpsert {
9405                    path: "scratch/.gitignore".into(),
9406                    source: b"*.log\n".to_vec(),
9407                },
9408                upsert("scratch", EntryKind::File, file_attrs(1, 5)),
9409                upsert("scratch", EntryKind::Dir, file_attrs(0, 6)),
9410                upsert("scratch/new.log", EntryKind::File, file_attrs(7, 7)),
9411                Op::Remove { path: "docs".into() },
9412                upsert("docs", EntryKind::Dir, file_attrs(0, 8)),
9413                upsert("docs/new.log", EntryKind::File, file_attrs(9, 9)),
9414            ]))
9415            .expect("mixed control and structural batch");
9416
9417        assert!(index.controls().expect("control state observed").is_empty());
9418        assert_eq!(
9419            index.is_ignored(Path::new("scratch/new.log")).expect("control state observed"),
9420            Some(false)
9421        );
9422        assert_eq!(
9423            index.is_ignored(Path::new("docs/new.log")).expect("control state observed"),
9424            Some(false)
9425        );
9426        assert_eq!(outcome.stats.controls, 1, "only the retained control has a net change");
9427        let controls = outcome
9428            .commit
9429            .as_ref()
9430            .expect("one exact commit")
9431            .changes
9432            .iter()
9433            .filter(|change| matches!(change, EffectiveChange::ControlUpdated { .. }))
9434            .collect::<Vec<_>>();
9435        assert!(matches!(
9436            controls.as_slice(),
9437            [EffectiveChange::ControlUpdated { path, previous: Some(_), current: None }]
9438                if path == Path::new("docs/.gitignore")
9439        ));
9440    }
9441
9442    /// A control the budget cannot admit is refused inside the commit that carried it: the
9443    /// batch's ordinary entries land, the refusal is a change of its own, and a source it
9444    /// replaces is dropped and its entries reclassified.
9445    #[test]
9446    fn an_over_budget_control_is_refused_while_its_batch_commits() {
9447        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
9448            "/root",
9449            crate::test_support::observing_controls(),
9450            crate::classify::TypeRegistry::compiled_shared(),
9451            DEFAULT_JOURNAL_CAPACITY_BYTES,
9452        );
9453        index.apply_ok(&Observation::new(vec![
9454            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
9455            upsert("debug.log", EntryKind::File, file_attrs(10, 1)),
9456        ]));
9457        assert_eq!(index.is_ignored(Path::new("debug.log")).expect("observed"), Some(true));
9458        let mut oversized = crate::control::source_at_test_limit();
9459        oversized.push(b'a');
9460
9461        let outcome = index.apply_ok(&Observation::new(vec![
9462            upsert("ordinary.txt", EntryKind::File, file_attrs(1, 2)),
9463            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: oversized },
9464        ]));
9465
9466        assert!(index.lookup(Path::new("ordinary.txt")).is_some(), "ordinary work commits");
9467        assert_eq!(index.total().files, 2);
9468        assert_eq!(
9469            index.is_ignored(Path::new("debug.log")).expect("observed"),
9470            None,
9471            "the refused replacement leaves classification unknown"
9472        );
9473        let changes = &outcome.commit.as_ref().expect("one commit").changes;
9474        let refused = Some(crate::control::ControlRefusalReason::Budget);
9475        assert!(changes.iter().any(|change| matches!(
9476            change,
9477            EffectiveChange::ControlUpdated { previous: Some(_), current: None, .. }
9478        )));
9479        assert!(changes.iter().any(|change| matches!(
9480            change,
9481            EffectiveChange::ControlRefusalUpdated { previous: None, current, .. }
9482                if *current == refused
9483        )));
9484        let crate::control::ControlCoverage::Observed(coverage) = index.control_coverage() else {
9485            panic!("an observing index reports observed coverage");
9486        };
9487        assert_eq!((coverage.applied, coverage.refused), (0, 1));
9488        assert_eq!(coverage.refusals[0].path, Path::new(".gitignore"));
9489
9490        // Removing the refused file lifts the refusal in a commit of its own.
9491        let lifted = index.apply_ok(&Observation::new(vec![Op::ControlRemove {
9492            path: PathBuf::from(".gitignore"),
9493        }]));
9494        assert!(matches!(
9495            lifted.commit.as_ref().expect("lifting a refusal commits").changes.as_slice(),
9496            [EffectiveChange::ControlRefusalUpdated { previous, current: None, .. }]
9497                if *previous == refused
9498        ));
9499        assert_eq!(
9500            index.control_coverage(),
9501            crate::control::ControlCoverage::Observed(crate::control::ControlObservation {
9502                limits: crate::control::ControlLimits::default(),
9503                applied: 0,
9504                rules: 0,
9505                refused: 0,
9506                refusals: Vec::new(),
9507            })
9508        );
9509    }
9510
9511    /// A batch that cannot change the control table does not copy it.
9512    ///
9513    /// Every warm revalidate re-reads each refused `.gitignore`, because nothing is
9514    /// retained where one was refused, and re-upserts it; projecting each such batch copied
9515    /// the whole table, once per batch, to arrive at the table it started from (fdu-hzm5).
9516    /// A batch that does change it still projects.
9517    #[test]
9518    fn a_batch_that_cannot_change_the_control_table_does_not_copy_it() {
9519        let projection_clones = |index: &mut Index, ops: Vec<Op>| {
9520            CONTROL_PROJECTION_CLONES.with(|clones| clones.set(0));
9521            let outcome = index.apply_ok(&Observation::new(ops));
9522            (CONTROL_PROJECTION_CLONES.with(std::cell::Cell::get), outcome)
9523        };
9524        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9525
9526        // The cold lane first: against a table that records nothing, a batch of ordinary
9527        // entries has nothing to drop or prune, so it never projects (fdu-pro1).
9528        let (clones, _) = projection_clones(
9529            &mut index,
9530            vec![
9531                upsert("cold", EntryKind::Dir, file_attrs(0, 1)),
9532                upsert("cold/file.txt", EntryKind::File, file_attrs(1, 1)),
9533            ],
9534        );
9535        assert_eq!(clones, 0, "an empty table has nothing a structural batch can change");
9536
9537        let mut over_budget = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
9538        over_budget.push(b'\n');
9539        index.apply_ok(&Observation::new(vec![
9540            upsert("keep", EntryKind::Dir, file_attrs(0, 1)),
9541            upsert("keep/file.txt", EntryKind::File, file_attrs(3, 1)),
9542            upsert("vendor", EntryKind::Dir, file_attrs(0, 1)),
9543            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
9544            Op::ControlUpsert {
9545                path: PathBuf::from("vendor/.gitignore"),
9546                source: over_budget.clone(),
9547            },
9548        ]));
9549        let coverage = index.control_coverage();
9550
9551        // A warm revalidate's shape: the refused source re-read, the retained one re-read
9552        // unchanged, and ordinary entries beside them.
9553        let (clones, outcome) = projection_clones(
9554            &mut index,
9555            vec![
9556                upsert("keep/file.txt", EntryKind::File, file_attrs(4, 1)),
9557                Op::ControlUpsert {
9558                    path: PathBuf::from(".gitignore"),
9559                    source: b"*.log\n".to_vec(),
9560                },
9561                Op::ControlUpsert { path: PathBuf::from("vendor/.gitignore"), source: over_budget },
9562            ],
9563        );
9564        assert_eq!(clones, 0, "no control op changes anything");
9565        assert_eq!(index.control_coverage(), coverage);
9566        assert!(!outcome.commit.expect("the file's size changed").changes.iter().any(
9567            |change| matches!(
9568                change,
9569                EffectiveChange::ControlUpdated { .. }
9570                    | EffectiveChange::ControlRefusalUpdated { .. }
9571            )
9572        ));
9573
9574        // Removing the refused file is a change, so this batch projects.
9575        let (clones, _) = projection_clones(
9576            &mut index,
9577            vec![Op::ControlRemove { path: PathBuf::from("vendor/.gitignore") }],
9578        );
9579        assert_eq!(clones, 1);
9580        assert_eq!(index.controls().expect("observed").refused_len(), 0);
9581    }
9582
9583    /// A structural removal takes the refusals under it along, even when no rule is retained.
9584    #[test]
9585    fn removing_a_subtree_lifts_the_refusals_beneath_it() {
9586        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9587        let mut line = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
9588        line.push(b'\n');
9589        index.apply_ok(&Observation::new(vec![
9590            upsert("vendor", EntryKind::Dir, file_attrs(0, 1)),
9591            upsert("vendor/.gitignore", EntryKind::File, file_attrs(16_386, 1)),
9592            Op::ControlUpsert { path: PathBuf::from("vendor/.gitignore"), source: line },
9593        ]));
9594        assert_eq!(index.controls().expect("observed").refused_len(), 1);
9595
9596        let outcome =
9597            index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("vendor") }]));
9598
9599        assert_eq!(index.controls().expect("observed").refused_len(), 0);
9600        assert!(outcome.commit.expect("commit").changes.iter().any(|change| matches!(
9601            change,
9602            EffectiveChange::ControlRefusalUpdated { current: None, .. }
9603        )));
9604    }
9605
9606    #[test]
9607    fn one_sweep_reports_one_as_of_time_for_everything_it_verified() {
9608        // Found by reviewing the PR #6 provenance work against the composable-CLI
9609        // merge. A revalidation sweep elides entries whose attributes did not change,
9610        // so within one pass some paths are named by a delta and some are not. Both
9611        // were verified at the same moment and must say so identically: if the
9612        // delta-touched entry dates itself to index construction while its untouched
9613        // sibling dates itself to the sweep, a consumer sorting rows by age is
9614        // comparing two different clocks and cannot tell.
9615        let mut index = Index::new("/root");
9616        index.set_applying_source(Source::Cached, 1_000);
9617        index.apply_ok(&Observation::new(vec![
9618            Op::Upsert { path: "a".into(), kind: EntryKind::Dir, attrs: file_attrs(0, 1) },
9619            Op::Upsert {
9620                path: "a/kept.txt".into(),
9621                kind: EntryKind::File,
9622                attrs: file_attrs(1, 1),
9623            },
9624            Op::Upsert {
9625                path: "a/changed.txt".into(),
9626                kind: EntryKind::File,
9627                attrs: file_attrs(2, 2),
9628            },
9629        ]));
9630
9631        // A sweep re-observes both: one is unchanged and elided, one is updated.
9632        index.set_applying_source(Source::Revalidated, 2_000);
9633        index.begin_reconcile(Path::new("")).expect("begin reconciliation");
9634        index.apply_ok(&Observation::new(vec![
9635            Op::Upsert {
9636                path: "a/kept.txt".into(),
9637                kind: EntryKind::File,
9638                attrs: file_attrs(1, 1),
9639            },
9640            Op::Upsert {
9641                path: "a/changed.txt".into(),
9642                kind: EntryKind::File,
9643                attrs: file_attrs(9, 2),
9644            },
9645        ]));
9646        index
9647            .finish_reconcile(
9648                Path::new(""),
9649                0,
9650                true,
9651                &[],
9652                &[],
9653                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9654            )
9655            .expect("finish reconciliation");
9656
9657        let kept = index.provenance(Path::new("a/kept.txt")).expect("present");
9658        let changed = index.provenance(Path::new("a/changed.txt")).expect("present");
9659        assert!(kept.is_verified() && changed.is_verified(), "the sweep covered both");
9660        assert_eq!(
9661            kept.observed_at_ns, changed.observed_at_ns,
9662            "one sweep, one as-of time: {kept:?} vs {changed:?}"
9663        );
9664    }
9665
9666    #[test]
9667    fn withdrawn_trust_beats_a_verification_interval() {
9668        // A verification interval records that a sweep once covered a path. If the
9669        // index has since withdrawn trust — an InvalidateSubtree marking it Stale, or
9670        // a sweep in progress marking it Reconciling — the interval must not promote
9671        // it, or provenance answers "partial, and verified" in one breath.
9672        let mut index = Index::new("/root");
9673        // The entry arrives the way a snapshot load delivers it: unverified.
9674        index.set_applying_source(Source::Cached, 1_000);
9675        index.apply_baseline_ok(&Observation::new(vec![
9676            Op::Upsert { path: PathBuf::from("a"), kind: EntryKind::Dir, attrs: Attrs::default() },
9677            Op::Upsert {
9678                path: PathBuf::from("a/file.txt"),
9679                kind: EntryKind::File,
9680                attrs: Attrs { size: 1, ..Attrs::default() },
9681            },
9682        ]));
9683        assert_eq!(
9684            index.provenance(Path::new("a/file.txt")).expect("present").source,
9685            Source::Cached,
9686            "nothing has checked it yet"
9687        );
9688        // A completed sweep then covers the whole tree.
9689        index
9690            .finish_reconcile(
9691                Path::new(""),
9692                0,
9693                true,
9694                &[],
9695                &[],
9696                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9697            )
9698            .expect("finish reconciliation");
9699        let path = Path::new("a/file.txt");
9700        assert_eq!(
9701            index.provenance(path).expect("present").source,
9702            Source::Revalidated,
9703            "a completed sweep covers this path"
9704        );
9705
9706        // Now withdraw trust over the subtree.
9707        index.mark_unfresh(Path::new("a"), Freshness::Stale);
9708        let provenance = index.provenance(path).expect("present");
9709        assert!(
9710            !provenance.is_verified(),
9711            "an invalidated path must not read as verified: {provenance:?}"
9712        );
9713        assert_eq!(
9714            provenance.status,
9715            Status::Complete,
9716            "withdrawing trust changes how far to believe the value, not how much of \
9717             the subtree it covers: the cached total still accounts for every entry \
9718             beneath this path, and reporting it as Partial would tell a consumer the \
9719             number is still being built when it is merely unverified"
9720        );
9721    }
9722
9723    #[test]
9724    fn verification_intervals_stay_bounded() {
9725        // Repeated scoped sweeps of sibling subtrees must not grow without bound;
9726        // dropping the oldest only ever under-claims trust.
9727        let mut index = Index::new("/root");
9728        for which in 0..(MAX_VERIFIED_INTERVALS * 2) {
9729            index
9730                .finish_reconcile(
9731                    &PathBuf::from(format!("dir-{which}")),
9732                    0,
9733                    true,
9734                    &[],
9735                    &[],
9736                    ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9737                )
9738                .expect("finish reconciliation");
9739        }
9740        assert!(
9741            index.verified.len() <= MAX_VERIFIED_INTERVALS,
9742            "interval list grew to {}",
9743            index.verified.len()
9744        );
9745    }
9746
9747    #[test]
9748    fn retained_walk_issues_are_the_same_first_paths_for_every_arrival_order() {
9749        let make = |order: Vec<usize>| {
9750            order
9751                .into_iter()
9752                .map(|number| {
9753                    crate::Error::io(
9754                        PathBuf::from(format!("/root/file-{number:02}")),
9755                        std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
9756                    )
9757                })
9758                .collect::<Vec<_>>()
9759        };
9760        let mut reverse_order: Vec<_> = (0..66).rev().collect();
9761        reverse_order.push(65);
9762        let mut shuffled_order: Vec<_> = (0..66).step_by(2).collect();
9763        shuffled_order.extend((0..66).skip(1).step_by(2));
9764        shuffled_order.push(65);
9765
9766        let mut reverse = Index::new("/root");
9767        let mut reverse_errors = make(reverse_order);
9768        reverse.record_walk_errors(&mut reverse_errors);
9769        let mut shuffled = Index::new("/root");
9770        let mut shuffled_errors = make(shuffled_order);
9771        shuffled.record_walk_errors(&mut shuffled_errors);
9772
9773        let reverse_paths: Vec<_> =
9774            reverse.issues().iter().map(|issue| issue.path.clone().expect("path")).collect();
9775        let shuffled_paths: Vec<_> =
9776            shuffled.issues().iter().map(|issue| issue.path.clone().expect("path")).collect();
9777        assert_eq!(reverse_paths, shuffled_paths);
9778        assert_eq!(reverse_paths.len(), MAX_RETAINED_ISSUES);
9779        assert_eq!(reverse_paths.first().map(PathBuf::as_path), Some(Path::new("file-00")));
9780        assert_eq!(reverse_paths.last().map(PathBuf::as_path), Some(Path::new("file-63")));
9781        assert_eq!(reverse.state().issues.omitted, 2);
9782        assert_eq!(shuffled.state().issues.omitted, 2);
9783        assert_eq!(reverse.issue_epochs.len(), reverse.issues.len());
9784        assert_eq!(shuffled.issue_epochs.len(), shuffled.issues.len());
9785    }
9786
9787    #[test]
9788    fn repeated_partial_root_pass_replaces_bounded_issues_and_omitted_count() {
9789        let root = Path::new("/root");
9790        let mut index = Index::new(root);
9791        let run = |index: &mut Index, range: std::ops::Range<usize>| {
9792            let errors: Vec<_> = range
9793                .clone()
9794                .map(|number| {
9795                    crate::Error::io(
9796                        root.join(format!("file-{number:02}")),
9797                        std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
9798                    )
9799                })
9800                .collect();
9801            let failed: Vec<_> =
9802                range.map(|number| PathBuf::from(format!("file-{number:02}"))).collect();
9803            let (started, _) = index.begin_reconcile(Path::new("")).expect("begin");
9804            index
9805                .finish_reconcile(
9806                    Path::new(""),
9807                    started,
9808                    false,
9809                    &[],
9810                    &failed,
9811                    ReconcileErrors { errors: &errors, terminal: None, disproves_old: true },
9812                )
9813                .expect("finish");
9814        };
9815
9816        run(&mut index, 0..66);
9817        assert_eq!(index.state().issues.omitted, 2);
9818        run(&mut index, 10..76);
9819
9820        assert_eq!(index.state().issues.omitted, 2, "a retry replaces the old omission count");
9821        assert_eq!(index.omitted_issue_epochs.len(), 1, "sequential failures use one bucket");
9822        assert_eq!(index.issues().len(), crate::MAX_RETAINED_ISSUES);
9823        assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("file-10")));
9824        assert_eq!(index.issues()[63].path.as_deref(), Some(Path::new("file-73")));
9825    }
9826
9827    #[test]
9828    fn partial_pass_preserves_an_issue_published_after_it_began() {
9829        let root = Path::new("/root");
9830        let mut index = Index::new(root);
9831        let (started, _) = index.begin_reconcile(Path::new("")).expect("begin");
9832        index.mark_unfresh(Path::new("concurrent"), Freshness::Stale);
9833        index.retain_issue(Issue::from_error_under(
9834            root,
9835            &crate::Error::io(
9836                root.join("concurrent"),
9837                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "concurrent"),
9838            ),
9839        ));
9840        let pass_error = crate::Error::io(
9841            root.join("pass"),
9842            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "pass"),
9843        );
9844
9845        index
9846            .finish_reconcile(
9847                Path::new(""),
9848                started,
9849                false,
9850                &[],
9851                &[PathBuf::from("pass")],
9852                ReconcileErrors { errors: &[pass_error], terminal: None, disproves_old: true },
9853            )
9854            .expect("finish");
9855
9856        let paths: Vec<_> =
9857            index.issues().iter().filter_map(|issue| issue.path.as_deref()).collect();
9858        assert_eq!(paths, [Path::new("concurrent"), Path::new("pass")]);
9859    }
9860
9861    #[test]
9862    fn older_root_closer_preserves_newer_pass_omissions_and_partial_coverage() {
9863        let root = Path::new("/root");
9864        let mut index = Index::new(root);
9865        for number in 0..66 {
9866            let path = PathBuf::from(format!("a-{number:02}"));
9867            index.retain_issue(Issue::observation_gap(
9868                &path,
9869                crate::InvalidateReason::WatchOverflow,
9870            ));
9871        }
9872        index.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
9873        assert_eq!(index.state.issues.omitted, 2);
9874
9875        let (older, _) = index.begin_reconcile(Path::new("")).expect("begin older pass");
9876        let (newer, _) = index.begin_reconcile(Path::new("")).expect("begin newer pass");
9877        let newer_errors = ["z-one", "z-two"].map(|path| {
9878            crate::Error::io(
9879                root.join(path),
9880                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
9881            )
9882        });
9883        index
9884            .finish_reconcile(
9885                Path::new(""),
9886                newer,
9887                false,
9888                &[],
9889                &[PathBuf::from("z-one"), PathBuf::from("z-two")],
9890                ReconcileErrors { errors: &newer_errors, terminal: None, disproves_old: true },
9891            )
9892            .expect("finish newer pass");
9893        assert_eq!(index.state.issues.omitted, 2);
9894
9895        index
9896            .finish_reconcile(
9897                Path::new(""),
9898                older,
9899                true,
9900                &[],
9901                &[],
9902                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9903            )
9904            .expect("finish older pass");
9905
9906        assert_eq!(index.state.issues.omitted, 2, "the older closer cannot erase newer omissions");
9907        assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
9908    }
9909
9910    #[test]
9911    fn an_unvisited_aborted_scope_does_not_disprove_its_old_issue() {
9912        let root = Path::new("/root");
9913        let mut index = Index::new(root);
9914        index.retain_issue(Issue::from_error_under(
9915            root,
9916            &crate::Error::io(
9917                root.join("later/blocked"),
9918                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "old failure"),
9919            ),
9920        ));
9921        let (started, _) = index.begin_reconcile(Path::new("later")).expect("begin later scope");
9922
9923        index
9924            .finish_reconcile(
9925                Path::new("later"),
9926                started,
9927                false,
9928                &[],
9929                &[],
9930                ReconcileErrors { errors: &[], terminal: None, disproves_old: false },
9931            )
9932            .expect("close skipped scope");
9933
9934        assert_eq!(index.issues().len(), 1);
9935        assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("later/blocked")));
9936    }
9937    #[test]
9938    fn older_pass_cannot_publish_errors_after_newer_clean_verification() {
9939        let root = Path::new("/root");
9940        let mut index = Index::new(root);
9941        let (older, _) = index.begin_reconcile(Path::new("")).expect("begin older pass");
9942        let (newer, _) = index.begin_reconcile(Path::new("")).expect("begin newer pass");
9943        index
9944            .finish_reconcile(
9945                Path::new(""),
9946                newer,
9947                true,
9948                &[],
9949                &[],
9950                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9951            )
9952            .expect("finish newer pass");
9953        let stale_error = crate::Error::io(
9954            root.join("stale"),
9955            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "older failure"),
9956        );
9957
9958        index
9959            .finish_reconcile(
9960                Path::new(""),
9961                older,
9962                false,
9963                &[],
9964                &[PathBuf::from("stale")],
9965                ReconcileErrors { errors: &[stale_error], terminal: None, disproves_old: true },
9966            )
9967            .expect("finish superseded older pass");
9968
9969        assert!(index.issues().is_empty());
9970        assert_eq!(index.state.coverage, Coverage::Complete);
9971        assert_eq!(index.state.freshness, Freshness::Fresh);
9972    }
9973
9974    #[test]
9975    fn newer_unchanged_verification_refuses_an_older_conditional_fact() {
9976        let mut index = Index::new("/root");
9977        index
9978            .apply(&Observation::new(vec![Op::Upsert {
9979                path: PathBuf::from("same"),
9980                kind: EntryKind::File,
9981                attrs: file_attrs(1, 1),
9982            }]))
9983            .expect("fixture");
9984        let handle = IndexHandle::new(index);
9985        let baseline = handle.expectation(Path::new("same")).expect("baseline");
9986        let (older, _) = handle.begin_reconcile(Path::new("")).expect("begin older pass");
9987        let (newer, _) = handle.begin_reconcile(Path::new("")).expect("begin newer pass");
9988        handle
9989            .finish_reconcile(
9990                Path::new(""),
9991                newer,
9992                true,
9993                &[],
9994                &[],
9995                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9996            )
9997            .expect("finish unchanged newer pass");
9998
9999        let stale = handle
10000            .apply_reconcile(
10001                older,
10002                &Observation::from_ops(vec![ObservationOp::if_state(
10003                    Op::Remove { path: PathBuf::from("same") },
10004                    baseline,
10005                )]),
10006            )
10007            .expect("arbitrate older fact");
10008
10009        assert_eq!(stale.stats.stale, 1);
10010        assert!(stale.commit.is_none());
10011        assert!(handle.attrs(Path::new("same")).expect("attrs").is_some());
10012    }
10013
10014    #[test]
10015    fn newer_disjoint_verification_does_not_refuse_an_older_fact() {
10016        let mut index = Index::new("/root");
10017        index
10018            .apply(&Observation::new(vec![
10019                Op::Upsert {
10020                    path: PathBuf::from("a"),
10021                    kind: EntryKind::File,
10022                    attrs: file_attrs(1, 1),
10023                },
10024                Op::Upsert {
10025                    path: PathBuf::from("b"),
10026                    kind: EntryKind::File,
10027                    attrs: file_attrs(1, 1),
10028                },
10029            ]))
10030            .expect("fixture");
10031        let handle = IndexHandle::new(index);
10032        let baseline = handle.expectation(Path::new("a")).expect("baseline");
10033        let (older, _) = handle.begin_reconcile(Path::new("a")).expect("begin a");
10034        let (newer, _) = handle.begin_reconcile(Path::new("b")).expect("begin b");
10035        handle
10036            .finish_reconcile(
10037                Path::new("b"),
10038                newer,
10039                true,
10040                &[],
10041                &[],
10042                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10043            )
10044            .expect("finish b");
10045
10046        let applied = handle
10047            .apply_reconcile(
10048                older,
10049                &Observation::from_ops(vec![ObservationOp::if_state(
10050                    Op::Remove { path: PathBuf::from("a") },
10051                    baseline,
10052                )]),
10053            )
10054            .expect("apply disjoint a fact");
10055
10056        assert_eq!(applied.stats.removed, 1);
10057        assert_eq!(applied.stats.stale, 0);
10058        assert!(handle.attrs(Path::new("a")).expect("attrs").is_none());
10059    }
10060    #[test]
10061    fn newer_child_verification_preserves_older_sibling_failure() {
10062        let root = Path::new("/root");
10063        let mut index = Index::new(root);
10064        index.apply_ok(&Observation::new(
10065            ["a", "a/old", "b", "b/blocked", "healthy"]
10066                .map(|path| Op::Upsert {
10067                    path: PathBuf::from(path),
10068                    kind: EntryKind::Dir,
10069                    attrs: Attrs::default(),
10070                })
10071                .to_vec(),
10072        ));
10073        index.set_initial_scan_freshness(&[]);
10074        let (older, _) = index.begin_reconcile(Path::new("")).expect("older root");
10075        let (newer, _) = index.begin_reconcile(Path::new("a")).expect("newer child");
10076        index
10077            .finish_reconcile(
10078                Path::new("a"),
10079                newer,
10080                true,
10081                &[],
10082                &[],
10083                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10084            )
10085            .expect("verify a");
10086        let errors = ["a/old", "b/blocked"].map(|path| {
10087            crate::Error::io(
10088                root.join(path),
10089                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed read"),
10090            )
10091        });
10092        index
10093            .finish_reconcile(
10094                Path::new(""),
10095                older,
10096                false,
10097                &[],
10098                &[PathBuf::from("a/old"), PathBuf::from("b/blocked")],
10099                ReconcileErrors { errors: &errors, terminal: None, disproves_old: true },
10100            )
10101            .expect("finish older root");
10102        for path in ["", "a", "a/old", "b", "healthy"] {
10103            assert_eq!(index.directory_complete(Path::new(path)), Some(true), "{path}");
10104        }
10105        assert_eq!(index.directory_complete(Path::new("b/blocked")), Some(false));
10106        assert_eq!(index.issues().len(), 1);
10107        assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("b/blocked")));
10108        assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10109        assert_eq!(index.freshness_at(Path::new("a")), Freshness::Fresh);
10110        assert_eq!(index.freshness_at(Path::new("b/blocked")), Freshness::Partial);
10111    }
10112    #[test]
10113    fn failure_published_after_a_newer_pass_began_keeps_its_issue_with_its_mark() {
10114        // A root pass begins, a child pass begins under it, and the root pass fails on
10115        // that child and closes first. Its `Partial` mark is minted after the child pass
10116        // began, so the child's clean finish leaves the mark in place; the issue that
10117        // explains the mark must follow the same rule, or the root reports a partial with
10118        // no explanation until another root pass runs.
10119        let root = Path::new("/root");
10120        let mut index = Index::new(root);
10121        index.apply_ok(&Observation::new(
10122            ["x", "x/deep", "healthy"]
10123                .map(|path| Op::Upsert {
10124                    path: PathBuf::from(path),
10125                    kind: EntryKind::Dir,
10126                    attrs: Attrs::default(),
10127                })
10128                .to_vec(),
10129        ));
10130        index.set_initial_scan_freshness(&[]);
10131        let (older_root, _) = index.begin_reconcile(Path::new("")).expect("older root");
10132        let (newer_child, _) = index.begin_reconcile(Path::new("x")).expect("newer child");
10133        let error = crate::Error::io(
10134            root.join("x"),
10135            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed read"),
10136        );
10137        index
10138            .finish_reconcile(
10139                Path::new(""),
10140                older_root,
10141                false,
10142                &[],
10143                &[PathBuf::from("x")],
10144                ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
10145            )
10146            .expect("older root fails on x");
10147        assert_eq!(index.freshness_at(Path::new("x")), Freshness::Partial);
10148        assert_eq!(index.issues().len(), 1);
10149
10150        index
10151            .finish_reconcile(
10152                Path::new("x"),
10153                newer_child,
10154                true,
10155                &[],
10156                &[],
10157                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10158            )
10159            .expect("newer child verifies clean");
10160
10161        let explained = !index.issues().is_empty();
10162        let partial = index.freshness_at(Path::new("x")) == Freshness::Partial;
10163        assert_eq!(
10164            partial,
10165            explained,
10166            "a surviving partial mark and its issue must be kept or dropped together: \
10167             partial={partial}, issues={:?}",
10168            index.issues()
10169        );
10170        assert!(partial, "the mark minted after the child pass began is the newer claim");
10171        assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("x")));
10172        assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10173        assert_eq!(index.state.freshness, Freshness::Partial);
10174        let request = crate::query::Request::new(
10175            crate::query::Basis::held_by(&index),
10176            crate::query::Query::default(),
10177            std::time::UNIX_EPOCH,
10178        );
10179        let status = crate::query::TreeStatus::of(&index, &request);
10180        assert!(!status.complete);
10181        assert_eq!(status.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10182        assert_eq!(status.errors.len(), 1, "an incomplete status names its cause");
10183    }
10184    #[test]
10185    fn cold_scan_failure_does_not_verify_unknown_descendants_or_unscoped_work() {
10186        let mut index = Index::new("/root");
10187        index.apply_ok(&Observation::new(
10188            ["blocked", "blocked/nested", "healthy"]
10189                .map(|path| Op::Upsert {
10190                    path: PathBuf::from(path),
10191                    kind: EntryKind::Dir,
10192                    attrs: Attrs::default(),
10193                })
10194                .to_vec(),
10195        ));
10196        let error = crate::Error::io(
10197            PathBuf::from("/root/blocked"),
10198            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed listing"),
10199        );
10200        index.set_initial_scan_freshness(&[error]);
10201        assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
10202        assert_eq!(index.directory_complete(Path::new("")), Some(true));
10203        assert_eq!(index.freshness_at(Path::new("")), Freshness::Partial);
10204        for path in ["blocked", "blocked/nested"] {
10205            assert_eq!(index.directory_complete(Path::new(path)), Some(false), "{path}");
10206            assert_eq!(index.freshness_at(Path::new(path)), Freshness::Partial, "{path}");
10207        }
10208        index.set_initial_scan_freshness(&[crate::Error::Snapshot("unscoped failure".into())]);
10209        assert_eq!(index.directory_complete(Path::new("healthy")), Some(false));
10210        assert_eq!(index.freshness_at(Path::new("healthy")), Freshness::Partial);
10211    }
10212
10213    #[test]
10214    fn unscoped_failure_publishes_listing_withdrawal_when_root_state_is_unchanged() {
10215        let mut index = Index::new("/root");
10216        index.apply_ok(&Observation::new(vec![Op::Upsert {
10217            path: PathBuf::from("healthy"),
10218            kind: EntryKind::Dir,
10219            attrs: Attrs::default(),
10220        }]));
10221        index.set_initial_scan_freshness(&[]);
10222        index.mark_unfresh(Path::new("elsewhere"), Freshness::Partial);
10223        index.state.freshness = Freshness::Partial;
10224        index.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
10225        let error = crate::Error::Snapshot("unscoped failure".into());
10226        index.retain_issue(Issue::from_error_under(&index.root_path, &error));
10227        let progress = index.state.progress;
10228        let (epoch, _) = index.begin_reconcile(Path::new("")).expect("begin partial root");
10229        let before = index.state;
10230        let clock = index.clock;
10231        let finished = index
10232            .finish_reconcile(
10233                Path::new(""),
10234                epoch,
10235                false,
10236                &[],
10237                &[],
10238                ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
10239            )
10240            .expect("finish failure");
10241        assert_eq!(index.state, before, "aggregate root state remains identical");
10242        assert_eq!(index.state.progress, progress, "discovery progress is cumulative");
10243        assert_eq!(index.directory_complete(Path::new("healthy")), Some(false));
10244        let commit = finished.commit.expect("withdrawal must publish even without another effect");
10245        assert!(index.clock > clock);
10246        assert_eq!(commit.clock, index.clock);
10247        assert!(
10248            commit
10249                .state
10250                .iter()
10251                .all(|effect| matches!(effect, StateTransition::DirectoryIncomplete { .. }))
10252        );
10253        assert!(
10254            commit
10255                .state
10256                .contains(&StateTransition::DirectoryIncomplete { path: PathBuf::from("healthy") })
10257        );
10258        assert!(commit.impact.dirty_paths.contains(&PathBuf::from("healthy")));
10259    }
10260
10261    #[test]
10262    fn omitted_failed_entry_withdraws_parent_listing_without_tainting_siblings() {
10263        let mut index = Index::new("/root");
10264        index.apply_ok(&Observation::new(vec![Op::Upsert {
10265            path: PathBuf::from("healthy"),
10266            kind: EntryKind::Dir,
10267            attrs: Attrs::default(),
10268        }]));
10269        let error = crate::Error::io(
10270            PathBuf::from("/root/missing"),
10271            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "metadata failed"),
10272        );
10273        index.set_initial_scan_freshness(&[error]);
10274        assert_eq!(index.directory_complete(Path::new("")), Some(false));
10275        assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
10276        index.set_initial_scan_freshness(&[]);
10277        let (epoch, _) = index.begin_reconcile(Path::new("")).expect("begin root");
10278        let error = crate::Error::io(
10279            PathBuf::from("/root/missing"),
10280            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "metadata failed"),
10281        );
10282        index
10283            .finish_reconcile(
10284                Path::new(""),
10285                epoch,
10286                false,
10287                &[],
10288                &[PathBuf::from("missing")],
10289                ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
10290            )
10291            .expect("partial root");
10292        assert_eq!(index.directory_complete(Path::new("")), Some(false));
10293        assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
10294    }
10295
10296    #[test]
10297    fn complete_older_root_does_not_verify_a_newer_failed_child() {
10298        let root = Path::new("/root");
10299        let mut index = Index::new(root);
10300        let (older, _) = index.begin_reconcile(Path::new("")).expect("older root");
10301        let (newer, _) = index.begin_reconcile(Path::new("child")).expect("newer child");
10302        let error = crate::Error::io(
10303            root.join("child"),
10304            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "new failure"),
10305        );
10306        index
10307            .finish_reconcile(
10308                Path::new("child"),
10309                newer,
10310                false,
10311                &[],
10312                &[PathBuf::from("child")],
10313                ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
10314            )
10315            .expect("failed child");
10316        let finish = index
10317            .finish_reconcile(
10318                Path::new(""),
10319                older,
10320                true,
10321                &[],
10322                &[],
10323                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10324            )
10325            .expect("complete older walk");
10326        assert_eq!(index.issues().len(), 1);
10327        assert_eq!(index.freshness_at(Path::new("child")), Freshness::Partial);
10328        assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10329        assert!(!finish.commit.iter().flat_map(|commit| commit.state.iter()).any(|state| {
10330            matches!(state, StateTransition::Verified { path } if path.as_os_str().is_empty())
10331        }));
10332    }
10333
10334    #[test]
10335    fn reconciliation_scope_budget_preserves_issues_and_newer_facts() {
10336        let mut index = Index::new("/root");
10337        index.apply_ok(&Observation::new(vec![Op::Upsert {
10338            path: PathBuf::from("kept"),
10339            kind: EntryKind::File,
10340            attrs: file_attrs(1, 1),
10341        }]));
10342        index.retain_issue(Issue::provider_failure(
10343            Some(Path::new("unvisited")),
10344            "earlier failure".into(),
10345        ));
10346        let (older, _) = index.begin_reconcile(Path::new("")).expect("older pass");
10347        let budget = index.active_reconciles[&older].scope_budget;
10348        assert_eq!(budget, 2, "root and kept file define the evidence budget");
10349        for number in 0..100 {
10350            let path = PathBuf::from(format!("missing-{number}"));
10351            let (newer, _) = index.begin_reconcile(&path).expect("newer pass");
10352            index
10353                .finish_reconcile(
10354                    &path,
10355                    newer,
10356                    true,
10357                    &[],
10358                    &[],
10359                    ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10360                )
10361                .expect("newer verification");
10362            if let ReconcileEvidence::Scopes(scopes) = &index.active_reconciles[&older].evidence {
10363                assert!(scopes.len() <= budget);
10364            }
10365        }
10366        assert!(matches!(index.active_reconciles[&older].evidence, ReconcileEvidence::Retry));
10367        index.apply_ok(&Observation::new(vec![Op::Upsert {
10368            path: PathBuf::from("kept"),
10369            kind: EntryKind::File,
10370            attrs: file_attrs(9, 2),
10371        }]));
10372        let finished = index
10373            .finish_reconcile(
10374                Path::new(""),
10375                older,
10376                true,
10377                &[],
10378                &[],
10379                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10380            )
10381            .expect("close interrupted pass");
10382        assert!(finished.retry);
10383        assert!(finished.commit.is_some());
10384        assert_eq!(index.total_scalars().bytes, 9);
10385        assert!(
10386            index
10387                .issues()
10388                .iter()
10389                .any(|issue| issue.path.as_deref() == Some(Path::new("unvisited")))
10390        );
10391        assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10392        assert!(index.active_reconciles.is_empty(), "closed passes retain no shadow history");
10393    }
10394}