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    freshness_epoch: u64,
932    freshness_marks: BTreeMap<PathBuf, FreshnessMark>,
933    /// Coherent opened-root state. Detached indexes retain the settled default and do
934    /// not acquire live identity or worker ownership by carrying this value.
935    state: IndexState,
936    /// Bounded diagnostic details summarized by `state.issues`.
937    issues: Vec<Issue>,
938    /// The freshness epoch at which each retained issue was last observed, in step with
939    /// `issues`. A reconciliation only disproves an issue observed before it began.
940    issue_epochs: Vec<u64>,
941    /// Omitted issue counts grouped by the active reconciliation boundary that owns them.
942    /// At most one group exists before/between each active boundary, so this is bounded by
943    /// concurrent passes rather than by the number of failures.
944    omitted_issue_epochs: BTreeMap<u64, u64>,
945    /// Optional commit-maintained state for interactive opened-root projections.
946    ///
947    /// Detached indexes deliberately carry `None`, including the standalone CLI's
948    /// one-shot scan. Only [`crate::OpenedIndex`] enables this allocation.
949    serving: Option<Box<ServingIndexes>>,
950}
951
952#[derive(Clone, Copy)]
953pub(crate) struct ReconcileErrors<'a> {
954    pub(crate) errors: &'a [crate::Error],
955    pub(crate) terminal: Option<&'a crate::Error>,
956    pub(crate) disproves_old: bool,
957}
958
959#[derive(Clone, Debug)]
960struct ActiveReconcile {
961    path: PathBuf,
962    // One scope per entry present when this pass began. This bounds concurrent
963    // verification history by retained state, including on a root-only index.
964    scope_budget: usize,
965    evidence: ReconcileEvidence,
966}
967
968#[derive(Clone, Debug)]
969enum ReconcileEvidence {
970    Scopes(BTreeSet<PathBuf>),
971    Retry,
972}
973
974impl ActiveReconcile {
975    fn supersede(&mut self, path: &Path) {
976        if self.path.starts_with(path) {
977            self.evidence = ReconcileEvidence::Scopes(BTreeSet::from([path.to_path_buf()]));
978            return;
979        }
980        let ReconcileEvidence::Scopes(scopes) = &mut self.evidence else {
981            return;
982        };
983        if scopes.iter().any(|newer| path.starts_with(newer)) {
984            return;
985        }
986        scopes.retain(|newer| !newer.starts_with(path));
987        if scopes.len() == self.scope_budget {
988            // Discard proof, never widen it: the caller must retry this pass.
989            self.evidence = ReconcileEvidence::Retry;
990        } else {
991            scopes.insert(path.to_path_buf());
992        }
993    }
994
995    fn refuses(&self, observation: &Observation, index: &Index) -> bool {
996        match &self.evidence {
997            ReconcileEvidence::Retry => true,
998            ReconcileEvidence::Scopes(scopes) => observation.ops.iter().any(|op| {
999                scopes
1000                    .iter()
1001                    .any(|path| op.op.path().starts_with(path) || path.starts_with(op.op.path()))
1002                    && !index.holds_target(&op.op, index.path_state(op.op.path()))
1003            }),
1004        }
1005    }
1006}
1007
1008pub(crate) struct ReconcileFinish {
1009    pub(crate) commit: Option<Commit>,
1010    pub(crate) retry: bool,
1011}
1012
1013enum ChildIds<'a> {
1014    Sorted(std::slice::Iter<'a, EntryId>),
1015    Mutable(std::collections::btree_map::Values<'a, OsString, EntryId>),
1016}
1017
1018impl Iterator for ChildIds<'_> {
1019    type Item = EntryId;
1020
1021    fn next(&mut self) -> Option<Self::Item> {
1022        match self {
1023            Self::Sorted(ids) => ids.next().copied(),
1024            Self::Mutable(ids) => ids.next().copied(),
1025        }
1026    }
1027
1028    fn size_hint(&self) -> (usize, Option<usize>) {
1029        let len = self.len();
1030        (len, Some(len))
1031    }
1032}
1033
1034impl DoubleEndedIterator for ChildIds<'_> {
1035    fn next_back(&mut self) -> Option<Self::Item> {
1036        match self {
1037            Self::Sorted(ids) => ids.next_back().copied(),
1038            Self::Mutable(ids) => ids.next_back().copied(),
1039        }
1040    }
1041}
1042
1043impl ExactSizeIterator for ChildIds<'_> {
1044    fn len(&self) -> usize {
1045        match self {
1046            Self::Sorted(ids) => ids.len(),
1047            Self::Mutable(ids) => ids.len(),
1048        }
1049    }
1050}
1051
1052enum IndexChildren<'a> {
1053    Empty,
1054    Sorted { index: &'a Index, ids: std::slice::Iter<'a, EntryId> },
1055    Mutable(std::collections::btree_map::Iter<'a, OsString, EntryId>),
1056}
1057
1058impl<'a> IndexChildren<'a> {
1059    fn new(index: &'a Index, entry: &'a Entry) -> Self {
1060        match entry.directory.as_deref().map(|directory| &directory.children) {
1061            None => Self::Empty,
1062            Some(DirectoryChildren::Sorted(ids)) => Self::Sorted { index, ids: ids.iter() },
1063            Some(DirectoryChildren::Mutable(children)) => Self::Mutable(children.iter()),
1064        }
1065    }
1066}
1067
1068impl<'a> Iterator for IndexChildren<'a> {
1069    type Item = (&'a OsStr, EntryId);
1070
1071    fn next(&mut self) -> Option<Self::Item> {
1072        match self {
1073            Self::Empty => None,
1074            Self::Sorted { index, ids } => {
1075                let id = *ids.next()?;
1076                Some((index.entry(id).name.as_os_str(), id))
1077            }
1078            Self::Mutable(children) => children.next().map(|(name, id)| (name.as_os_str(), *id)),
1079        }
1080    }
1081
1082    fn size_hint(&self) -> (usize, Option<usize>) {
1083        let len = self.len();
1084        (len, Some(len))
1085    }
1086}
1087
1088impl DoubleEndedIterator for IndexChildren<'_> {
1089    fn next_back(&mut self) -> Option<Self::Item> {
1090        match self {
1091            Self::Empty => None,
1092            Self::Sorted { index, ids } => {
1093                let id = *ids.next_back()?;
1094                Some((index.entry(id).name.as_os_str(), id))
1095            }
1096            Self::Mutable(children) => {
1097                children.next_back().map(|(name, id)| (name.as_os_str(), *id))
1098            }
1099        }
1100    }
1101}
1102
1103impl ExactSizeIterator for IndexChildren<'_> {
1104    fn len(&self) -> usize {
1105        match self {
1106            Self::Empty => 0,
1107            Self::Sorted { ids, .. } => ids.len(),
1108            Self::Mutable(children) => children.len(),
1109        }
1110    }
1111}
1112
1113#[derive(Clone, Copy, Debug)]
1114struct FreshnessMark {
1115    state: Freshness,
1116    epoch: u64,
1117}
1118
1119/// Shareable owner for serving readers while reconciliation applies short writes.
1120#[derive(Clone, Debug)]
1121pub struct IndexHandle {
1122    inner: Arc<RwLock<Index>>,
1123}
1124
1125/// Index-owned part of one progressive discovery commit.
1126///
1127/// The producer may combine one of these with entry observations; the opened commit
1128/// policy updates exact file progress and publishes one atomic fact-and-state commit.
1129#[derive(Clone, Debug, Default)]
1130pub(crate) struct DiscoveryCommit {
1131    pub(crate) directory_complete: Option<PathBuf>,
1132    pub(crate) transition: Option<DiscoveryTransition>,
1133}
1134
1135#[derive(Clone, Debug)]
1136pub(crate) enum DiscoveryTransition {
1137    Begin,
1138    Finish,
1139    BudgetRefused(Issue),
1140    Inaccessible { issues: Vec<Issue>, omitted: u64 },
1141    Cancelled,
1142    Failed(Issue),
1143}
1144
1145/// Index-owned lifecycle transitions for the optional observation producer.
1146#[derive(Clone, Debug)]
1147#[cfg_attr(not(feature = "watch"), allow(dead_code))]
1148pub(crate) enum ObservationTransition {
1149    /// Baseline discovery finished and the observer is closing its registration gap.
1150    Reconciling,
1151    /// The observer is active and its baseline handoff has been verified.
1152    ///
1153    /// Persistent inaccessible boundaries do not prevent observation of the readable
1154    /// scope, but they keep coverage partial and their causes remain inspectable.
1155    Watching { issues: Vec<Issue>, omitted: u64 },
1156    /// A reconciliation while watching could not read part of the scope.
1157    ///
1158    /// The subtree it covered is already partial and is not retried on every later event,
1159    /// so its causes are retained here, where partial freshness can be explained. Both
1160    /// watch drivers publish it: the opened root's observer and `Watcher::apply_next`.
1161    Unreadable { issues: Vec<Issue>, omitted: u64 },
1162    /// Observation could not establish or retain a trustworthy live boundary.
1163    Failed(Issue),
1164}
1165
1166impl IndexHandle {
1167    /// Wrap an owned index in the shared single-writer owner.
1168    pub fn new(index: Index) -> Self {
1169        Self { inner: Arc::new(RwLock::new(index)) }
1170    }
1171
1172    fn read_index(&self) -> crate::Result<std::sync::RwLockReadGuard<'_, Index>> {
1173        self.inner.read().map_err(|_| crate::Error::IndexLockPoisoned)
1174    }
1175
1176    fn write_index(&self) -> crate::Result<std::sync::RwLockWriteGuard<'_, Index>> {
1177        self.inner.write().map_err(|_| crate::Error::IndexLockPoisoned)
1178    }
1179
1180    /// Evaluate one owned result while holding exactly one coherent read boundary.
1181    pub(crate) fn read_with<T>(&self, read: impl FnOnce(&Index) -> T) -> crate::Result<T> {
1182        let index = self.read_index()?;
1183        Ok(read(&index))
1184    }
1185
1186    #[cfg(test)]
1187    pub(crate) fn poison_for_test(&self) {
1188        let handle = self.clone();
1189        std::thread::spawn(move || handle.panic_holding_the_write_lock_for_test())
1190            .join()
1191            .expect_err("injected index panic");
1192    }
1193
1194    /// Panic on this thread while holding the write lock, as a commit that panics does,
1195    /// leaving the lock poisoned.
1196    #[cfg(test)]
1197    pub(crate) fn panic_holding_the_write_lock_for_test(&self) -> ! {
1198        let _guard = self.inner.write().expect("test index write lock");
1199        panic!("inject index poison");
1200    }
1201
1202    /// Arbitrate and apply one observation under the single-writer lock.
1203    pub fn apply(&self, observation: &Observation) -> crate::Result<ApplyOutcome> {
1204        let prepared = prepare_observation(observation)?;
1205        let outcome = self.write_index()?.commit_prepared(prepared, true)?;
1206        record_batch(BatchProvenance::Public, observation.len(), outcome.stats);
1207        Ok(outcome)
1208    }
1209
1210    pub(crate) fn apply_discovery(
1211        &self,
1212        observation: &Observation,
1213        discovery: DiscoveryCommit,
1214    ) -> crate::Result<ApplyOutcome> {
1215        let prepared = prepare_observation(observation)?;
1216        let outcome = self.write_index()?.commit_prepared_with(
1217            prepared,
1218            true,
1219            Some(discovery),
1220            None,
1221            None,
1222            true,
1223        )?;
1224        record_batch(BatchProvenance::Opened, observation.len(), outcome.stats);
1225        Ok(outcome)
1226    }
1227
1228    #[cfg(feature = "watch")]
1229    pub(crate) fn apply_opened(
1230        &self,
1231        observation: &Observation,
1232        max_files: Option<u64>,
1233    ) -> crate::Result<ApplyOutcome> {
1234        let prepared = prepare_observation(observation)?;
1235        let outcome = self
1236            .write_index()?
1237            .commit_prepared_with(prepared, true, None, None, max_files, true)?;
1238        record_batch(BatchProvenance::Opened, observation.len(), outcome.stats);
1239        Ok(outcome)
1240    }
1241
1242    pub(crate) fn apply_scanner_discovery_bounded(
1243        &self,
1244        batch: crate::scan::ScannerBatch,
1245        discovery: DiscoveryCommit,
1246        max_files: Option<u64>,
1247    ) -> crate::Result<ApplyOutcome> {
1248        let observed = batch.len();
1249        let mut index = self.write_index()?;
1250        let prepared = index.prepare_scanner_batch(batch)?;
1251        let outcome =
1252            index.commit_prepared_with(prepared, true, Some(discovery), None, max_files, true)?;
1253        record_batch(BatchProvenance::Opened, observed, outcome.stats);
1254        Ok(outcome)
1255    }
1256
1257    pub(crate) fn transition_discovery(
1258        &self,
1259        transition: DiscoveryTransition,
1260    ) -> crate::Result<ApplyOutcome> {
1261        self.apply_discovery(
1262            &Observation::new(Vec::new()),
1263            DiscoveryCommit { directory_complete: None, transition: Some(transition) },
1264        )
1265    }
1266
1267    #[cfg(feature = "watch")]
1268    pub(crate) fn transition_observation(
1269        &self,
1270        transition: ObservationTransition,
1271    ) -> crate::Result<ApplyOutcome> {
1272        let prepared = prepare_observation(&Observation::default())?;
1273        let outcome = self.write_index()?.commit_prepared_with(
1274            prepared,
1275            true,
1276            None,
1277            Some(transition),
1278            None,
1279            true,
1280        )?;
1281        record_batch(BatchProvenance::Opened, 0, outcome.stats);
1282        Ok(outcome)
1283    }
1284
1285    /// Absolute filesystem root, copied without retaining the read lock.
1286    pub fn root_path(&self) -> crate::Result<PathBuf> {
1287        Ok(self.read_index()?.root_path().to_path_buf())
1288    }
1289
1290    /// Semantic scan scope represented by the shared index.
1291    pub fn scope(&self) -> crate::Result<ScanScope> {
1292        Ok(self.read_index()?.scope())
1293    }
1294
1295    /// Trust state for the whole index.
1296    pub fn freshness(&self) -> crate::Result<Freshness> {
1297        Ok(self.read_index()?.freshness())
1298    }
1299
1300    /// Trust state for one subtree.
1301    pub fn freshness_at(&self, path: &Path) -> crate::Result<Freshness> {
1302        Ok(self.read_index()?.freshness_at(path))
1303    }
1304
1305    /// Clock of the most recently committed delta.
1306    pub fn clock(&self) -> crate::Result<Clock> {
1307        Ok(self.read_index()?.clock())
1308    }
1309
1310    /// Number of live entries, including the root.
1311    pub fn len(&self) -> crate::Result<u64> {
1312        Ok(self.read_index()?.len())
1313    }
1314
1315    /// Whether the index contains only its root.
1316    pub fn is_empty(&self) -> crate::Result<bool> {
1317        Ok(self.read_index()?.is_empty())
1318    }
1319
1320    /// Owned roll-up totals for the whole tree.
1321    pub fn total(&self) -> crate::Result<RollUp> {
1322        Ok(self.read_index()?.total())
1323    }
1324
1325    /// Coherent opened-root state at the returned clock.
1326    pub(crate) fn state(&self) -> crate::Result<IndexState> {
1327        Ok(self.read_index()?.state())
1328    }
1329
1330    #[allow(dead_code)] // Consumed by the opened-root coherent read checkpoint.
1331    pub(crate) fn issues(&self) -> crate::Result<Vec<Issue>> {
1332        Ok(self.read_index()?.issues().to_vec())
1333    }
1334
1335    /// Whether a known directory has an authoritative in-scope child set.
1336    #[allow(dead_code)] // Consumed by the opened-root coherent read checkpoint.
1337    pub(crate) fn directory_complete(&self, path: &Path) -> crate::Result<Option<bool>> {
1338        Ok(self.read_index()?.directory_complete(path))
1339    }
1340
1341    /// Owned roll-up state for a relative directory path.
1342    pub fn rollup(&self, path: &Path) -> crate::Result<Option<RollUp>> {
1343        Ok(self.read_index()?.rollup(path))
1344    }
1345
1346    /// Owned metadata for a relative path.
1347    pub fn attrs(&self, path: &Path) -> crate::Result<Option<Attrs>> {
1348        Ok(self.read_index()?.attrs(path).copied())
1349    }
1350
1351    /// Entry kind for a relative path.
1352    pub fn kind(&self, path: &Path) -> crate::Result<Option<EntryKind>> {
1353        Ok(self.read_index()?.kind(path))
1354    }
1355
1356    /// Current visible state for a relative path.
1357    pub fn path_state(&self, path: &Path) -> crate::Result<PathState> {
1358        Ok(self.read_index()?.path_state(path))
1359    }
1360
1361    /// Conditional baseline for a producer operating on a shared index.
1362    pub fn expectation(&self, path: &Path) -> crate::Result<PathExpectation> {
1363        Ok(self.read_index()?.expectation(path))
1364    }
1365
1366    /// Owned exact commits after `clock`.
1367    pub fn since(&self, clock: Clock) -> crate::Result<Since> {
1368        Ok(self.read_index()?.since(clock))
1369    }
1370
1371    /// Direct children captured coherently at one read boundary.
1372    ///
1373    /// On an index that did not observe control state each child's
1374    /// [`ChildSnapshot::ignored`] and [`ChildSnapshot::partitions`] are `None`, the way
1375    /// [`Index::is_ignored`] refuses; the names, metadata, and roll-ups still answer.
1376    pub fn children(&self, path: &Path) -> crate::Result<Option<Vec<ChildSnapshot>>> {
1377        let index = self.read_index()?;
1378        let Some(children) = index.children(path) else {
1379            return Ok(None);
1380        };
1381        let observed = index.observes_controls();
1382        Ok(Some(
1383            children
1384                .map(|(name, id)| {
1385                    let entry = index.entry(id);
1386                    ChildSnapshot {
1387                        id,
1388                        name: name.to_os_string(),
1389                        kind: entry.kind,
1390                        attrs: entry.attrs,
1391                        ignored: observed.then_some(entry.ignored),
1392                        rollup: entry
1393                            .kind
1394                            .is_dir()
1395                            .then(|| index.named_rollup(&entry.rollup().all)),
1396                        partitions: (observed && entry.kind.is_dir())
1397                            .then(|| index.named_partitions(entry.rollup())),
1398                    }
1399                })
1400                .collect(),
1401        ))
1402    }
1403
1404    /// Capture one coherent owned index image, releasing the lock before callers do
1405    /// serialization, filesystem I/O, conversion, or other potentially blocking work.
1406    pub fn snapshot(&self) -> crate::Result<Index> {
1407        let mut snapshot = self.read_index()?.clone();
1408        snapshot.serving = None;
1409        Ok(snapshot)
1410    }
1411
1412    pub(crate) fn child_states(
1413        &self,
1414        path: &Path,
1415    ) -> crate::Result<BTreeMap<OsString, PathExpectation>> {
1416        let index = self.read_index()?;
1417        Ok(collect_child_expectations(&index, path))
1418    }
1419
1420    /// Child baselines for one opened-root listing, with whether the index does not yet
1421    /// hold that directory's child set as complete, read at one boundary.
1422    pub(crate) fn listing_baseline(
1423        &self,
1424        path: &Path,
1425    ) -> crate::Result<(BTreeMap<OsString, PathExpectation>, bool)> {
1426        let index = self.read_index()?;
1427        Ok((collect_child_expectations(&index, path), index.directory_complete(path) != Some(true)))
1428    }
1429
1430    pub(crate) fn has_control(&self, path: &Path) -> crate::Result<bool> {
1431        Ok(self.read_index()?.control_table().contains(path))
1432    }
1433
1434    pub(crate) fn take_pending_invalidations(
1435        &self,
1436    ) -> crate::Result<Vec<(PathBuf, InvalidateReason)>> {
1437        Ok(self.write_index()?.take_pending_invalidations())
1438    }
1439
1440    pub(crate) fn restore_pending_invalidations(
1441        &self,
1442        invalidations: Vec<(PathBuf, InvalidateReason)>,
1443    ) -> crate::Result<()> {
1444        self.write_index()?.restore_pending_invalidations(invalidations);
1445        Ok(())
1446    }
1447
1448    pub(crate) fn begin_reconcile(&self, path: &Path) -> crate::Result<(u64, Option<Commit>)> {
1449        self.write_index()?.begin_reconcile(path)
1450    }
1451
1452    pub(crate) fn finish_reconcile(
1453        &self,
1454        path: &Path,
1455        started_at: u64,
1456        complete: bool,
1457        listed_incomplete: &[PathBuf],
1458        failed_paths: &[PathBuf],
1459        errors: ReconcileErrors<'_>,
1460    ) -> crate::Result<ReconcileFinish> {
1461        self.write_index()?.finish_reconcile(
1462            path,
1463            started_at,
1464            complete,
1465            listed_incomplete,
1466            failed_paths,
1467            errors,
1468        )
1469    }
1470
1471    pub(crate) fn apply_reconcile(
1472        &self,
1473        started_at: u64,
1474        observation: &Observation,
1475    ) -> crate::Result<ApplyOutcome> {
1476        self.apply_reconcile_with(started_at, observation, None, BatchProvenance::Public)
1477    }
1478
1479    pub(crate) fn apply_opened_reconcile(
1480        &self,
1481        started_at: u64,
1482        observation: &Observation,
1483        max_files: Option<u64>,
1484    ) -> crate::Result<ApplyOutcome> {
1485        self.apply_reconcile_with(started_at, observation, max_files, BatchProvenance::Opened)
1486    }
1487
1488    fn apply_reconcile_with(
1489        &self,
1490        started_at: u64,
1491        observation: &Observation,
1492        max_files: Option<u64>,
1493        provenance: BatchProvenance,
1494    ) -> crate::Result<ApplyOutcome> {
1495        let prepared = prepare_observation(observation)?;
1496        let mut index = self.write_index()?;
1497        if index
1498            .active_reconciles
1499            .get(&started_at)
1500            .is_some_and(|active| active.refuses(observation, &index))
1501        {
1502            let stats = ApplyStats {
1503                stale: u64::try_from(observation.len()).unwrap_or(u64::MAX),
1504                ..ApplyStats::default()
1505            };
1506            record_batch(provenance, observation.len(), stats);
1507            return Ok(ApplyOutcome { stats, commit: None });
1508        }
1509        let outcome = index.commit_prepared_with(
1510            prepared,
1511            true,
1512            None,
1513            None,
1514            max_files,
1515            matches!(provenance, BatchProvenance::Opened),
1516        )?;
1517        record_batch(provenance, observation.len(), outcome.stats);
1518        Ok(outcome)
1519    }
1520
1521    #[cfg(feature = "watch")]
1522    pub(crate) fn apply_if_clock(
1523        &self,
1524        clock: Clock,
1525        observation: &Observation,
1526    ) -> crate::Result<Option<ApplyOutcome>> {
1527        let prepared = prepare_observation(observation)?;
1528        let mut index = self.write_index()?;
1529        if index.clock() != clock {
1530            return Ok(None);
1531        }
1532        index.commit_prepared(prepared, true).map(Some)
1533    }
1534
1535    #[cfg(feature = "watch")]
1536    pub(crate) fn apply_opened_if_clock(
1537        &self,
1538        clock: Clock,
1539        observation: &Observation,
1540        max_files: Option<u64>,
1541    ) -> crate::Result<Option<ApplyOutcome>> {
1542        let prepared = prepare_observation(observation)?;
1543        let mut index = self.write_index()?;
1544        if index.clock() != clock {
1545            return Ok(None);
1546        }
1547        index.commit_prepared_with(prepared, true, None, None, max_files, true).map(Some)
1548    }
1549
1550    #[cfg(feature = "watch")]
1551    pub(crate) fn unknown_ancestry(
1552        &self,
1553        observation: &Observation,
1554    ) -> crate::Result<Vec<(PathBuf, PathBuf)>> {
1555        let prepared = prepare_observation(observation)?;
1556        let index = self.read_index()?;
1557        let accepted = index.accepted_operations(&prepared.ops);
1558        Ok(index.unknown_ancestry(&prepared.ops, &accepted))
1559    }
1560
1561    #[cfg(feature = "watch")]
1562    pub(crate) fn watch_boundary(&self) -> crate::Result<(PathBuf, ScanScope, Clock)> {
1563        let index = self.read_index()?;
1564        Ok((index.root_path().to_path_buf(), index.scope(), index.clock()))
1565    }
1566
1567    #[cfg(feature = "watch")]
1568    pub(crate) fn invalidate_root(&self, reason: InvalidateReason) -> crate::Result<ApplyOutcome> {
1569        self.apply(&Observation::new(vec![Op::InvalidateSubtree { path: PathBuf::new(), reason }]))
1570    }
1571}
1572
1573/// Private parent-first builder for a cold index that is not yet externally visible.
1574///
1575/// Directory groups arrive while filesystem workers are still running. Applying each
1576/// group here overlaps structural construction with the walk without turning the cold
1577/// path back into public observations or manufacturing one full path per file.
1578pub(crate) struct DetachedIndexBuilder {
1579    index: Index,
1580    directory_ids: HashMap<PathBuf, EntryId>,
1581    /// Directories whose name one listing repeated. The walker lists each of them, and
1582    /// everything below it, once per observation.
1583    repeated_directories: Vec<PathBuf>,
1584    inserted: u64,
1585}
1586
1587impl DetachedIndexBuilder {
1588    pub(crate) fn new(
1589        root_path: impl Into<PathBuf>,
1590        scope: ScanScope,
1591        types: std::sync::Arc<crate::classify::TypeRegistry>,
1592    ) -> Self {
1593        let mut index = Index::new_with_scope_and_types(root_path, scope, types);
1594        index.entry_mut(EntryId::ROOT).directory_mut().children =
1595            DirectoryChildren::Sorted(Vec::new());
1596        Self {
1597            index,
1598            directory_ids: HashMap::from([(PathBuf::new(), EntryId::ROOT)]),
1599            repeated_directories: Vec::new(),
1600            inserted: 0,
1601        }
1602    }
1603
1604    /// Refuse control sources past either of `limits` while building.
1605    pub(crate) fn with_control_limits(mut self, limits: crate::control::ControlLimits) -> Self {
1606        self.index.set_control_limits(limits);
1607        self
1608    }
1609
1610    /// Consume one listing after its parent listing has already been consumed.
1611    ///
1612    /// An enumerator can repeat a name while its directory is modified, which the
1613    /// streaming reducer absorbs as a re-upsert. Here a listing keeps the last observation
1614    /// of each name. A directory observed twice is also listed twice, and so is everything
1615    /// below it: the first listing to arrive for each such directory builds it, and a
1616    /// repeat is accepted without being applied again. A filesystem race must not fail
1617    /// the scan.
1618    pub(crate) fn push_directory(
1619        &mut self,
1620        directory: crate::scan::DetachedDirectory,
1621    ) -> crate::Result<()> {
1622        let crate::scan::DetachedDirectory { path, mut children, control } = directory;
1623        // No descendant can become claimable until its parent's listing has been sent,
1624        // so the first listing of a directory finds its lookup entry. Retire the entry
1625        // now instead of retaining every walked directory path until the end of the scan.
1626        let Some(parent) = self.directory_ids.remove(&path) else {
1627            if self.repeated_directories.iter().any(|repeated| path.starts_with(repeated)) {
1628                return Ok(());
1629            }
1630            return Err(crate::Error::UnknownAncestry { path, reconcile_from: PathBuf::new() });
1631        };
1632
1633        // The worker publishes this listing before descendants become claimable. Apply
1634        // its complete fixed-control state before classifying any sibling, and every
1635        // later child listing will therefore inherit all governing controls without a
1636        // post-build subtree reclassification pass.
1637        if let Some(control) = control {
1638            match control {
1639                Op::ControlUpsert { path, source } => {
1640                    self.index.controls.upsert(&path, source)?;
1641                }
1642                Op::ControlRemove { path } => {
1643                    self.index.controls.remove(&path)?;
1644                }
1645                _ => unreachable!("detached directory retains only fixed-control operations"),
1646            }
1647            self.inserted = self.inserted.saturating_add(1);
1648        }
1649
1650        // Allocate in name order, the order the directory retains its children in, keeping
1651        // the last observation of a repeated name. The sort is unstable so that it needs
1652        // no scratch allocation; the enumeration position is what keeps "last" exact.
1653        children.sort_unstable_by(|left, right| {
1654            left.name.cmp(&right.name).then(left.position.cmp(&right.position))
1655        });
1656        let repeated_directories = &mut self.repeated_directories;
1657        children.dedup_by(|later, kept| {
1658            if later.name != kept.name {
1659                return false;
1660            }
1661            if later.kind.is_dir() || kept.kind.is_dir() {
1662                let repeated = path.join(&kept.name);
1663                if repeated_directories.last() != Some(&repeated) {
1664                    repeated_directories.push(repeated);
1665                }
1666            }
1667            std::mem::swap(later, kept);
1668            true
1669        });
1670
1671        let parent_ignored = self.index.entry(parent).ignored;
1672        let mut match_path =
1673            (!parent_ignored && !self.index.controls.is_empty()).then(|| path.clone());
1674        self.index.reserve_detached_children(parent, children.len());
1675        for child in children {
1676            let crate::scan::DetachedChild { name, kind, attrs, .. } = child;
1677            crate::counters::bump(|counts| counts.upserts += 1);
1678            let ext_id = (kind == EntryKind::File)
1679                .then(|| self.index.intern_ext(&crate::classify::ext_bucket(&name)));
1680            let (ignored, child_path) = if let Some(scratch) = &mut match_path {
1681                scratch.push(&name);
1682                let ignored = self.index.controls.matcher_for(scratch).is_ignored(kind.is_dir());
1683                let child_path = kind.is_dir().then(|| scratch.clone());
1684                let popped = scratch.pop();
1685                debug_assert!(popped);
1686                (ignored, child_path)
1687            } else {
1688                (parent_ignored, kind.is_dir().then(|| path.join(&name)))
1689            };
1690            let child_id = self.index.alloc(Entry::new_detached(
1691                NewEntry {
1692                    parent: Some(parent),
1693                    name,
1694                    ext_id,
1695                    ignored,
1696                    source: Source::Scanned,
1697                    kind,
1698                    attrs,
1699                },
1700                false,
1701            ));
1702            self.index.push_detached_child(parent, child_id);
1703            // Fold the child's own direct contribution while filesystem work is still
1704            // in flight. Files are now complete; directories will add only their
1705            // descendant roll-up in the short bottom-up finish pass.
1706            let direct = self.index.contribution(child_id);
1707            crate::counters::bump(|counts| counts.rollup_merges += 1);
1708            self.index.entry_mut(parent).rollup_mut().merge(&direct);
1709            if let Some(child_path) = child_path {
1710                self.directory_ids.insert(child_path, child_id);
1711            }
1712            self.inserted = self.inserted.saturating_add(1);
1713        }
1714        Ok(())
1715    }
1716
1717    /// Complete the private baseline after every directory listing has arrived.
1718    pub(crate) fn finish(mut self) -> Index {
1719        // Parents are allocated before descendants, so reverse arena order is a valid
1720        // bottom-up traversal. Direct contributions were merged during the pipelined
1721        // build; only completed directory descendants remain to propagate here.
1722        for slot in (1..self.index.arena.len()).rev() {
1723            let id = EntryId {
1724                slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
1725                generation: 0,
1726            };
1727            if !self.index.entry(id).kind.is_dir() {
1728                continue;
1729            }
1730            let parent = self.index.entry(id).parent.expect("every non-root entry has a parent");
1731            // The directory's own count was merged when its parent listing arrived.
1732            crate::counters::bump(|counts| counts.rollup_merges += 1);
1733            self.index.merge_detached_descendants(parent, id);
1734        }
1735
1736        crate::counters::bump(|counts| {
1737            counts.baseline_batches = counts.baseline_batches.saturating_add(1);
1738            counts.baseline_accepted_ops =
1739                counts.baseline_accepted_ops.saturating_add(self.inserted);
1740        });
1741        self.index.establish_baseline();
1742        self.index
1743    }
1744}
1745
1746impl Index {
1747    /// Create an empty index rooted at `root_path`, under [`ScanScope::default`].
1748    ///
1749    /// That is the scope of [`ScanConfig::default`](crate::ScanConfig), which observes
1750    /// control state, so this index answers [`Self::is_ignored`], [`Self::controls`], and
1751    /// the partition accessors ([`Self::partition_total`], [`Self::partition_rollup`], and
1752    /// [`Self::partition_rollup_summary`]), and it accepts control input. Build any other
1753    /// scope, including one that turns control observation off, with
1754    /// [`Self::new_with_scope`].
1755    pub fn new(root_path: impl Into<PathBuf>) -> Self {
1756        Self::new_with_scope(root_path, ScanScope::default())
1757    }
1758
1759    /// Create an empty index with an explicit semantic scan scope.
1760    ///
1761    /// Its control table applies the default
1762    /// [`ControlLimits`](crate::control::ControlLimits), whatever limits the scope was
1763    /// taken under, so a snapshot of it saves only when those agree. Build an index for any
1764    /// other limits with [`Self::new_with_config`].
1765    pub fn new_with_scope(root_path: impl Into<PathBuf>, scope: ScanScope) -> Self {
1766        Self::new_with_scope_and_types(
1767            root_path,
1768            scope,
1769            crate::classify::TypeRegistry::compiled_shared(),
1770        )
1771    }
1772
1773    /// Create an empty index with the scope, file-type rules, and control limits of
1774    /// `config`, as the scans behind [`crate::open`] and [`crate::OpenedIndex`] do.
1775    ///
1776    /// The scope and the control table come from one configuration, so the table enforces
1777    /// exactly the limits the scope's ignore-rules identity claims.
1778    pub fn new_with_config(root_path: impl Into<PathBuf>, config: &crate::ScanConfig) -> Self {
1779        let mut index =
1780            Self::new_with_scope_and_types(root_path, config.scope(), config.types_shared());
1781        index.set_control_limits(config.control_limits);
1782        index
1783    }
1784
1785    /// Create an index whose registry is part of its validated semantic scope.
1786    pub(crate) fn new_with_scope_and_types(
1787        root_path: impl Into<PathBuf>,
1788        scope: ScanScope,
1789        types: std::sync::Arc<crate::classify::TypeRegistry>,
1790    ) -> Self {
1791        Self::new_with_scope_types_and_journal_capacity_bytes(
1792            root_path,
1793            scope,
1794            types,
1795            DEFAULT_JOURNAL_CAPACITY_BYTES,
1796        )
1797    }
1798
1799    pub(crate) fn new_with_scope_types_and_journal_capacity_bytes(
1800        root_path: impl Into<PathBuf>,
1801        scope: ScanScope,
1802        types: std::sync::Arc<crate::classify::TypeRegistry>,
1803        journal_capacity_bytes: usize,
1804    ) -> Self {
1805        assert_eq!(
1806            scope.type_rules_fingerprint,
1807            types.fingerprint(),
1808            "an index's registry must match its semantic scope"
1809        );
1810        Self::new_with_journal_capacity_bytes(root_path, scope, journal_capacity_bytes, types, None)
1811    }
1812
1813    /// Create the retained index behind an opened root, including its serving orders.
1814    pub(crate) fn new_opened_with_scope_types_and_journal_capacity_bytes(
1815        root_path: impl Into<PathBuf>,
1816        scope: ScanScope,
1817        types: std::sync::Arc<crate::classify::TypeRegistry>,
1818        journal_capacity_bytes: usize,
1819    ) -> Self {
1820        assert_eq!(
1821            scope.type_rules_fingerprint,
1822            types.fingerprint(),
1823            "an index's registry must match its semantic scope"
1824        );
1825        let serving = ServingIndexes::for_types(&types);
1826        Self::new_with_journal_capacity_bytes(
1827            root_path,
1828            scope,
1829            journal_capacity_bytes,
1830            types,
1831            Some(Box::new(serving)),
1832        )
1833    }
1834
1835    fn new_with_journal_capacity_bytes(
1836        root_path: impl Into<PathBuf>,
1837        scope: ScanScope,
1838        journal_capacity_bytes: usize,
1839        types: std::sync::Arc<crate::classify::TypeRegistry>,
1840        serving: Option<Box<ServingIndexes>>,
1841    ) -> Self {
1842        let root = Entry::new(
1843            NewEntry {
1844                parent: None,
1845                name: OsString::new(),
1846                ext_id: None,
1847                ignored: false,
1848                source: Source::Scanned,
1849                kind: EntryKind::Dir,
1850                attrs: Attrs::default(),
1851            },
1852            true,
1853        );
1854        let constructed_at_ns = Self::now_unix_nanos();
1855        Self {
1856            root_path: root_path.into(),
1857            scope,
1858            arena: vec![Slot::Occupied { generation: 0, entry: root }],
1859            free_head: None,
1860            live: 1,
1861            clock: Clock::ZERO,
1862            journal: VecDeque::new(),
1863            journal_cost: 0,
1864            journal_capacity_bytes,
1865            journal_floor: Clock::ZERO,
1866            pending_invalidations: Vec::new(),
1867            freshness_epoch: 0,
1868            freshness_marks: BTreeMap::new(),
1869            state: IndexState::default(),
1870            issues: Vec::new(),
1871            issue_epochs: Vec::new(),
1872            omitted_issue_epochs: BTreeMap::new(),
1873            serving,
1874            applying_source: Source::Scanned,
1875            scanned_at_ns: constructed_at_ns,
1876            captured_at_ns: 0,
1877            writing_pass_started_at_ns: constructed_at_ns,
1878            persistence_owed: true,
1879            active_root_reconciles: BTreeMap::new(),
1880            active_reconciles: BTreeMap::new(),
1881            verified: Vec::new(),
1882            ext_names: Vec::new(),
1883            ext_ids: BTreeMap::new(),
1884            ext_refcounts: Vec::new(),
1885            free_ext_ids: Vec::new(),
1886            content: None,
1887            types,
1888            controls: crate::control::ControlTable::default(),
1889        }
1890    }
1891
1892    /// The file-type rules this index classifies against.
1893    pub fn types(&self) -> &crate::classify::TypeRegistry {
1894        self.types.as_ref()
1895    }
1896
1897    /// Exact fixed control state retained by this detached index.
1898    ///
1899    /// # Errors
1900    ///
1901    /// [`crate::Error::ControlStateNotObserved`] when the index was built without
1902    /// observing control state ([`ScanScope::observes_controls`]). Its table is empty
1903    /// because nothing was read, and returning it would claim the tree has no control
1904    /// files.
1905    pub fn controls(&self) -> crate::Result<&crate::control::ControlTable> {
1906        self.require_observed_controls()?;
1907        Ok(&self.controls)
1908    }
1909
1910    /// Whether this index observed `.gitignore` control state, and so can answer
1911    /// [`Self::is_ignored`] and [`Self::controls`].
1912    pub const fn observes_controls(&self) -> bool {
1913        self.scope.observes_controls()
1914    }
1915
1916    /// Whether this index observed `.gitignore` control state, and under which limits.
1917    pub fn control_identity(&self) -> crate::ControlTierIdentity {
1918        if self.observes_controls() {
1919            crate::ControlTierIdentity::Observed { limits: self.controls.limits() }
1920        } else {
1921            crate::ControlTierIdentity::NotObserved
1922        }
1923    }
1924
1925    /// The identity of every tier a snapshot of this index holds.
1926    pub fn snapshot_identity(&self) -> crate::SnapshotIdentity {
1927        crate::SnapshotIdentity {
1928            entries: crate::EntryTierIdentity::of_scope(self.scope),
1929            controls: self.control_identity(),
1930        }
1931    }
1932
1933    fn require_observed_controls(&self) -> crate::Result<()> {
1934        if self.observes_controls() { Ok(()) } else { Err(crate::Error::ControlStateNotObserved) }
1935    }
1936
1937    /// Whether a batch carries control input this index must refuse because its scope
1938    /// observes no control state.
1939    ///
1940    /// Accepted, such input installed a table and reclassified entries under a scope that
1941    /// says no rule was read: `is_ignored` refused over classification the index held, and
1942    /// a snapshot saved from it loaded into an open that turned observation off as an exact
1943    /// scope match (`fdu-agb6`). Every operation counts, accepted or stale, so the refusal
1944    /// does not depend on the index's state.
1945    fn carries_unobserved_control_input(&self, ops: &[ObservationOp]) -> bool {
1946        !self.observes_controls()
1947            && ops.iter().any(|observed| {
1948                matches!(observed.op, Op::ControlUpsert { .. } | Op::ControlRemove { .. })
1949            })
1950    }
1951
1952    /// The retained control table whatever the scope: empty when nothing was observed.
1953    ///
1954    /// For the engine's own maintenance, which compares what it retains against what it
1955    /// reads and so needs no claim about coverage.
1956    pub(crate) fn control_table(&self) -> &crate::control::ControlTable {
1957        &self.controls
1958    }
1959
1960    /// Whether this index's ignore classification applies every control file in scope.
1961    ///
1962    /// [`crate::control::ControlCoverage::NotObserved`] when the index read no control
1963    /// file. Otherwise the limits, the applied and refused counts, and the first refused
1964    /// files. Sizes and counts are exact either way; only the ignored and unignored split
1965    /// below a refused file is not.
1966    pub fn control_coverage(&self) -> crate::control::ControlCoverage {
1967        if self.observes_controls() {
1968            crate::control::ControlCoverage::Observed(self.controls.observation())
1969        } else {
1970            crate::control::ControlCoverage::NotObserved
1971        }
1972    }
1973
1974    /// Refuse control sources past either of `limits`, as the scan configuration that
1975    /// builds this index asks. Set once, before any control input arrives: a table's
1976    /// refusals are only meaningful under the limits that made them.
1977    pub(crate) fn set_control_limits(&mut self, limits: crate::control::ControlLimits) {
1978        debug_assert!(self.controls.is_vacant(), "the control limits are set before any control");
1979        self.controls = crate::control::ControlTable::with_limits(limits);
1980    }
1981
1982    /// Refuse `limits` unless they are the ones this index's scope was taken under.
1983    ///
1984    /// A scope that observes control state names its limits in its ignore-rules identity,
1985    /// and an index's scope and its table must never disagree: a table refusing under other
1986    /// limits would be served, and saved, as if it applied the scope's. A scope that
1987    /// observes nothing retains no table, so its limits decide nothing.
1988    ///
1989    /// The guard is for [`crate::snapshot::save`], whose index may have been built with a
1990    /// scope and a table set apart (as [`Self::new_with_scope`] does), and for callers of
1991    /// [`Self::install_controls`] other than the loader. On the load path it cannot fail,
1992    /// because the loader builds the scope and the table from the same header limits.
1993    pub(crate) fn require_control_limits_in_scope(
1994        &self,
1995        limits: crate::control::ControlLimits,
1996    ) -> crate::Result<()> {
1997        if self.scope.observes_controls()
1998            && (crate::ControlTierIdentity::Observed { limits }).ignore_rules_fingerprint()
1999                != self.scope.ignore_rules_fingerprint
2000        {
2001            return Err(crate::Error::ControlLimitsOutsideScope { limits });
2002        }
2003        Ok(())
2004    }
2005
2006    /// Install a complete control table while restoring a detached snapshot.
2007    pub(crate) fn install_controls(
2008        &mut self,
2009        controls: crate::control::ControlTable,
2010    ) -> crate::Result<()> {
2011        self.require_control_limits_in_scope(controls.limits())?;
2012        // Every source a table retains was admitted under its own budget, and the charge
2013        // does not depend on admission order, so a larger total was not written by one.
2014        if controls.limits().budget.is_some_and(|budget| controls.retained_cost() > budget) {
2015            return Err(crate::Error::Snapshot(
2016                "a snapshot's control table exceeds its own control budget".into(),
2017            ));
2018        }
2019        // A scope that observed no control state retains no table and refuses nothing; a
2020        // snapshot carrying either under such a scope was not written by a scan that
2021        // honoured it.
2022        if !controls.is_vacant() {
2023            self.require_observed_controls()?;
2024        }
2025        // Every entry's ignored bit agrees with the table it replaces, so when neither table
2026        // governs anything no bit can move. Walking the tree to confirm it allocated a path
2027        // per entry on every snapshot load, including the common load with no controls.
2028        let unchanged = controls.is_empty() && self.controls.is_empty();
2029        self.controls = controls;
2030        if unchanged {
2031            return Ok(());
2032        }
2033        let mut stats = ApplyStats::default();
2034        let mut effects = NoConsequences;
2035        self.reclassify_controlled_subtrees(&[PathBuf::new()], &mut stats, &mut effects);
2036        Ok(())
2037    }
2038
2039    /// Share the registry with background analysis workers.
2040    pub(crate) fn types_shared(&self) -> std::sync::Arc<crate::classify::TypeRegistry> {
2041        std::sync::Arc::clone(&self.types)
2042    }
2043
2044    /// Classify one relative path under this index's rules, without opening the file.
2045    pub fn classify(&self, relative_path: &Path) -> crate::classify::Classification {
2046        crate::classify::classify_with(&self.types, relative_path, None)
2047    }
2048
2049    #[cfg(test)]
2050    fn with_journal_capacity_bytes(
2051        root_path: impl Into<PathBuf>,
2052        journal_capacity_bytes: usize,
2053    ) -> Self {
2054        Self::new_with_journal_capacity_bytes(
2055            root_path,
2056            ScanScope::default(),
2057            journal_capacity_bytes,
2058            crate::classify::TypeRegistry::compiled_shared(),
2059            None,
2060        )
2061    }
2062
2063    /// The absolute path this index is rooted at.
2064    pub fn root_path(&self) -> &Path {
2065        &self.root_path
2066    }
2067
2068    /// Semantic scope represented by this index and any snapshot written from it.
2069    pub const fn scope(&self) -> ScanScope {
2070        self.scope
2071    }
2072
2073    /// Trust state for the whole index.
2074    pub fn freshness(&self) -> Freshness {
2075        self.freshness_at(Path::new(""))
2076    }
2077
2078    /// The freshness the coherent [`IndexState`] publishes for the root.
2079    ///
2080    /// Subtree marks decide it, with one exception: while the observation handoff owns the
2081    /// root -- the `Reconciling` phase -- the root does not become `Fresh` until `Watching`
2082    /// says the handoff verified it. The handoff's own full pass clears the root's mark
2083    /// before the hints captured behind it are drained, and `Fresh` beside `Reconciling`
2084    /// promised a verified root the handoff had not delivered yet. Stale and partial marks
2085    /// still show through, since they say something the handoff has not yet disproved.
2086    fn published_freshness(&self) -> Freshness {
2087        let derived = self.freshness();
2088        if self.state.phase == LifecyclePhase::Reconciling && derived == Freshness::Fresh {
2089            Freshness::Reconciling
2090        } else {
2091            derived
2092        }
2093    }
2094
2095    /// Coherent state at the current clock.
2096    pub(crate) const fn state(&self) -> IndexState {
2097        self.state
2098    }
2099
2100    #[allow(dead_code)] // Consumed through `IndexHandle` by the next vertical slice.
2101    pub(crate) fn issues(&self) -> &[Issue] {
2102        &self.issues
2103    }
2104
2105    /// Whether the complete in-scope child set of a known directory is authoritative.
2106    #[allow(dead_code)] // Consumed through `IndexHandle` by the next vertical slice.
2107    pub(crate) fn directory_complete(&self, path: &Path) -> Option<bool> {
2108        let id = self.lookup(path)?;
2109        let entry = self.entry(id);
2110        (entry.kind == EntryKind::Dir).then(|| entry.directory().children_complete)
2111    }
2112
2113    /// Trust state for one subtree, including any stale descendant it contains.
2114    pub fn freshness_at(&self, path: &Path) -> Freshness {
2115        self.freshness_marks
2116            .iter()
2117            .filter(|(marked, _)| path.starts_with(marked) || marked.starts_with(path))
2118            .map(|(_, mark)| mark.state)
2119            .max_by_key(|state| state.rank())
2120            .unwrap_or(Freshness::Fresh)
2121    }
2122
2123    /// The clock of the most recently applied commit.
2124    pub fn clock(&self) -> Clock {
2125        self.clock
2126    }
2127
2128    /// Number of live entries, including the root.
2129    pub fn len(&self) -> u64 {
2130        self.live
2131    }
2132
2133    /// True when the index holds nothing but its root.
2134    pub fn is_empty(&self) -> bool {
2135        self.live <= 1
2136    }
2137
2138    /// Owned, self-describing roll-up state for the whole tree.
2139    pub fn total(&self) -> RollUp {
2140        self.named_rollup(&self.entry(EntryId::ROOT).rollup().all)
2141    }
2142
2143    /// Both fixed aggregate partitions for the complete tree.
2144    ///
2145    /// # Errors
2146    ///
2147    /// [`crate::Error::ControlStateNotObserved`] when the index did not observe control
2148    /// state: its unignored partition equals `all` only because no rule was read.
2149    /// [`Self::total`] answers the `all` partition for any index.
2150    pub fn partition_total(&self) -> crate::Result<PartitionRollUp> {
2151        self.require_observed_controls()?;
2152        Ok(self.named_partitions(self.entry(EntryId::ROOT).rollup()))
2153    }
2154
2155    /// Map-free whole-tree totals for in-crate reporting paths.
2156    pub(crate) fn total_scalars(&self) -> RollUpScalars {
2157        RollUpScalars::from(&self.entry(EntryId::ROOT).rollup().all)
2158    }
2159
2160    /// Arbitrate a producer observation and commit its effective mutations.
2161    ///
2162    /// Conditional operations are accepted only while their baseline still matches.
2163    /// No-ops and stale operations do not advance the clock or enter the journal.
2164    ///
2165    /// A control operation on an index that does not observe control state
2166    /// ([`Self::observes_controls`]) fails the whole batch with
2167    /// [`crate::Error::ControlStateNotObserved`], whatever its baseline.
2168    pub fn apply(&mut self, observation: &Observation) -> crate::Result<ApplyOutcome> {
2169        let prepared = prepare_observation(observation)?;
2170        let outcome = self.commit_prepared(prepared, true)?;
2171        record_batch(BatchProvenance::Public, observation.len(), outcome.stats);
2172        Ok(outcome)
2173    }
2174
2175    /// Arbitrate and atomically apply normalized producer input.
2176    fn commit_prepared(
2177        &mut self,
2178        prepared: PreparedObservation,
2179        journal: bool,
2180    ) -> crate::Result<ApplyOutcome> {
2181        self.commit_prepared_with(prepared, journal, None, None, None, false)
2182    }
2183
2184    fn commit_prepared_with(
2185        &mut self,
2186        prepared: PreparedObservation,
2187        journal: bool,
2188        discovery: Option<DiscoveryCommit>,
2189        observation: Option<ObservationTransition>,
2190        max_files: Option<u64>,
2191        track_file_progress: bool,
2192    ) -> crate::Result<ApplyOutcome> {
2193        if prepared.ops.is_empty()
2194            && discovery.as_ref().is_none_or(|discovery| {
2195                discovery.directory_complete.is_none() && discovery.transition.is_none()
2196            })
2197            && observation.is_none()
2198        {
2199            return Ok(ApplyOutcome::default());
2200        }
2201
2202        // A stopped or failed root is terminal for discovery. A listing that lands after
2203        // the stop -- a refresh can trip the shared budget while discovery is mid-walk --
2204        // may neither expand the retained set nor carry a transition that reopens the
2205        // phase: `Finish` would declare the root `Ready`, and an inaccessible boundary
2206        // would relabel why its coverage is partial.
2207        if discovery.is_some()
2208            && matches!(self.state.phase, LifecyclePhase::Stopped | LifecyclePhase::Failed)
2209        {
2210            return Err(crate::Error::OpenedIndexStopped);
2211        }
2212
2213        let mut discovery = discovery;
2214        if let Some(path) =
2215            discovery.as_mut().and_then(|discovery| discovery.directory_complete.as_mut())
2216        {
2217            // The transition this commit publishes carries the canonical relative path,
2218            // not the producer's spelling: a `DirectoryComplete` was only ever canonical
2219            // because discovery happened to build it that way.
2220            let canonical = canonical_relative_path(path)?;
2221            let Some(id) = self.lookup(&canonical) else {
2222                return Err(crate::Error::InvalidDirectoryCompletion(canonical));
2223            };
2224            if self.entry(id).kind != EntryKind::Dir {
2225                return Err(crate::Error::InvalidDirectoryCompletion(canonical));
2226            }
2227            *path = canonical;
2228        }
2229
2230        #[cfg(test)]
2231        if prepared.reject_before_apply {
2232            return Err(crate::Error::CommitRejected("injected reducer preflight"));
2233        }
2234
2235        let Some(next_clock) = self.clock.checked_next() else {
2236            // At the terminal clock, an all-no-op or all-stale batch is still a valid
2237            // observation. Probe on a detached clone to distinguish it from a real
2238            // change without touching the original index.
2239            let mut probe = self.clone();
2240            probe.clock = Clock(self.clock.0 - 1);
2241            let outcome = probe.commit_prepared_with(
2242                prepared,
2243                false,
2244                discovery,
2245                observation,
2246                max_files,
2247                track_file_progress,
2248            )?;
2249            return if outcome.commit.is_some() {
2250                Err(crate::Error::ClockExhausted)
2251            } else {
2252                Ok(outcome)
2253            };
2254        };
2255
2256        let observed = u64::try_from(prepared.ops.len()).unwrap_or(u64::MAX);
2257        let mut effects = ExactConsequences::default();
2258        let stats = self.reduce_prepared(
2259            &prepared,
2260            discovery,
2261            observation,
2262            max_files,
2263            track_file_progress,
2264            &mut effects,
2265        )?;
2266
2267        if effects.is_empty() {
2268            return Ok(ApplyOutcome::from_commit(stats, None));
2269        }
2270
2271        let commit =
2272            self.publish_effects(next_clock, effects, commit_work(observed, stats), journal);
2273        Ok(ApplyOutcome::from_commit(stats, Some(commit)))
2274    }
2275
2276    /// Apply one prepared batch through the shared fact and roll-up reducer.
2277    ///
2278    /// `C` is selected once by the caller. The exact instantiation retains closures as
2279    /// commits; the detached instantiation erases them, including their path copies.
2280    fn reduce_prepared<C: ConsequenceSink>(
2281        &mut self,
2282        prepared: &PreparedObservation,
2283        discovery: Option<DiscoveryCommit>,
2284        observation: Option<ObservationTransition>,
2285        max_files: Option<u64>,
2286        track_file_progress: bool,
2287        effects: &mut C,
2288    ) -> crate::Result<ApplyStats> {
2289        if self.carries_unobserved_control_input(&prepared.ops) {
2290            return Err(crate::Error::ControlStateNotObserved);
2291        }
2292        if matches!(prepared.ancestry, PreparedAncestry::Scanner { .. }) {
2293            debug_assert!(observation.is_none());
2294            return self.reduce_scanner_prepared(
2295                prepared,
2296                discovery,
2297                max_files,
2298                track_file_progress,
2299                effects,
2300            );
2301        }
2302        let mut stats = ApplyStats::default();
2303        let mut parent_memo = ParentMemo::default();
2304        let accepted = self.accepted_operations(&prepared.ops);
2305        stats.stale = u64::try_from(accepted.iter().filter(|accepted| !**accepted).count())
2306            .unwrap_or(u64::MAX);
2307        self.validate_known_ancestry(&prepared.ops, &accepted)?;
2308        let projected_controls = self.projected_controls(&prepared.ops, &accepted)?;
2309
2310        for (observed, accepted) in prepared.ops.iter().zip(accepted) {
2311            if !accepted {
2312                continue;
2313            }
2314            let op = &observed.op;
2315            if let (Some(max_files), Op::Upsert { path, kind, .. }) = (max_files, op) {
2316                if self.files_after_upsert(path, *kind) > max_files {
2317                    stats.resource_refused = stats.resource_refused.saturating_add(1);
2318                    continue;
2319                }
2320            }
2321            match op {
2322                Op::Upsert { path, kind, attrs } => {
2323                    self.apply_upsert(path, *kind, *attrs, &mut stats, effects, &mut parent_memo);
2324                }
2325                Op::Remove { path } => {
2326                    // A removal takes a subtree with it, so a remembered id inside that
2327                    // subtree would dangle. Both of the non-upsert arms drop the memo
2328                    // rather than reason about whether this particular path could be an
2329                    // ancestor of it: the memo is refilled by the next upsert, so the
2330                    // cost of being conservative is one path resolution.
2331                    parent_memo.clear();
2332                    self.apply_remove(path, &mut stats, effects);
2333                }
2334                Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {
2335                    // The complete table was already prepared above. It is installed
2336                    // once, after ordinary structural mutations, so classification and
2337                    // both reducer partitions become visible atomically.
2338                    parent_memo.clear();
2339                }
2340                Op::InvalidateSubtree { path, reason } => {
2341                    parent_memo.clear();
2342                    let previous_index_state = self.state;
2343                    let previous = self.freshness_at(path);
2344                    self.pending_invalidations.push((path.clone(), *reason));
2345                    self.mark_unfresh(path, Freshness::Stale);
2346                    let current = self.freshness_at(path);
2347                    self.state.freshness = self.published_freshness();
2348                    if matches!(
2349                        reason,
2350                        InvalidateReason::WatchOverflow
2351                            | InvalidateReason::UnpairedRename
2352                            | InvalidateReason::WatchSetupRace
2353                            | InvalidateReason::VerificationFailed
2354                            | InvalidateReason::UnknownAncestry
2355                            | InvalidateReason::WatchContention
2356                    ) {
2357                        self.retain_issue(Issue::observation_gap(path, *reason));
2358                    }
2359                    stats.invalidated += 1;
2360                    effects.change(|| EffectiveChange::Invalidated {
2361                        path: path.clone(),
2362                        reason: *reason,
2363                    });
2364                    if previous != current {
2365                        effects.state(|| StateTransition::Freshness {
2366                            path: path.clone(),
2367                            previous,
2368                            current,
2369                        });
2370                    }
2371                    if previous_index_state != self.state {
2372                        effects.state(|| StateTransition::IndexState {
2373                            previous: previous_index_state,
2374                            current: self.state,
2375                        });
2376                    }
2377                }
2378            }
2379        }
2380
2381        self.finish_reduction(
2382            projected_controls,
2383            &mut stats,
2384            discovery,
2385            observation,
2386            max_files,
2387            track_file_progress,
2388            effects,
2389        );
2390
2391        Ok(stats)
2392    }
2393
2394    /// Apply one scanner batch using only the parent identities proved above.
2395    fn reduce_scanner_prepared<C: ConsequenceSink>(
2396        &mut self,
2397        prepared: &PreparedObservation,
2398        discovery: Option<DiscoveryCommit>,
2399        max_files: Option<u64>,
2400        track_file_progress: bool,
2401        effects: &mut C,
2402    ) -> crate::Result<ApplyStats> {
2403        let PreparedAncestry::Scanner { parents, has_batch_parents } = &prepared.ancestry else {
2404            unreachable!("scanner reduction requires scanner ancestry");
2405        };
2406        debug_assert_eq!(prepared.ops.len(), parents.len());
2407        let projection_started = crate::counters::enabled().then(std::time::Instant::now);
2408        let projected_controls =
2409            self.projected_controls_from(prepared.ops.iter().map(|observed| &observed.op))?;
2410        if let Some(started) = projection_started {
2411            let elapsed = elapsed_micros(started);
2412            crate::counters::bump(|counts| {
2413                counts.scanner_control_projection_us =
2414                    counts.scanner_control_projection_us.saturating_add(elapsed);
2415            });
2416        }
2417        let mut stats = ApplyStats::default();
2418        let mut applied_ids = has_batch_parents.then(|| vec![None; prepared.ops.len()]);
2419
2420        for (op_index, (observed, parent)) in prepared.ops.iter().zip(parents.iter()).enumerate() {
2421            match &observed.op {
2422                Op::Upsert { path, kind, attrs } => {
2423                    if let Some(max_files) = max_files {
2424                        if self.files_after_upsert(path, *kind) > max_files {
2425                            stats.resource_refused = stats.resource_refused.saturating_add(1);
2426                            continue;
2427                        }
2428                    }
2429                    let parent = match parent {
2430                        ResolvedParent::Existing(parent) => *parent,
2431                        ResolvedParent::Earlier(parent_op) => applied_ids
2432                            .as_ref()
2433                            .and_then(|ids| ids.get(*parent_op))
2434                            .copied()
2435                            .flatten()
2436                            .expect("a proved parent directory was applied earlier"),
2437                    };
2438                    let name = path.file_name().expect("scanner upserts are not root mutations");
2439                    crate::counters::bump(|counts| counts.upserts += 1);
2440                    self.upsert_beneath(parent, name, path, *kind, *attrs, &mut stats, effects);
2441                    if kind.is_dir() {
2442                        if let Some(ids) = &mut applied_ids {
2443                            ids[op_index] = self.child(parent, name);
2444                        }
2445                    }
2446                }
2447                Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {}
2448                Op::Remove { .. } | Op::InvalidateSubtree { .. } => {
2449                    unreachable!("scanner preparation rejects non-discovery operations");
2450                }
2451            }
2452        }
2453
2454        self.finish_reduction(
2455            projected_controls,
2456            &mut stats,
2457            discovery,
2458            None,
2459            max_files,
2460            track_file_progress,
2461            effects,
2462        );
2463
2464        Ok(stats)
2465    }
2466
2467    #[allow(clippy::too_many_arguments)] // One shared tail keeps both reducer lanes identical.
2468    fn finish_reduction<C: ConsequenceSink>(
2469        &mut self,
2470        projected_controls: Option<crate::control::ControlTable>,
2471        stats: &mut ApplyStats,
2472        discovery: Option<DiscoveryCommit>,
2473        observation: Option<ObservationTransition>,
2474        max_files: Option<u64>,
2475        track_file_progress: bool,
2476        effects: &mut C,
2477    ) {
2478        self.apply_control_transition(projected_controls, stats, effects);
2479        let mut discovery = discovery;
2480        if stats.resource_refused > 0 {
2481            let max_files = max_files.expect("resource refusal requires a file limit");
2482            let discovery = discovery.get_or_insert_with(DiscoveryCommit::default);
2483            discovery.directory_complete = None;
2484            discovery.transition =
2485                Some(DiscoveryTransition::BudgetRefused(Issue::resource_budget(max_files)));
2486        }
2487        self.apply_opened_state(discovery, observation, track_file_progress, effects);
2488    }
2489
2490    fn apply_opened_state<C: ConsequenceSink>(
2491        &mut self,
2492        discovery: Option<DiscoveryCommit>,
2493        observation: Option<ObservationTransition>,
2494        track_file_progress: bool,
2495        effects: &mut C,
2496    ) {
2497        if discovery.is_none() && observation.is_none() && !track_file_progress {
2498            return;
2499        }
2500        let previous = self.state;
2501        if track_file_progress {
2502            self.state.progress.files_retained = self.total_scalars().files;
2503        }
2504
2505        if let Some(discovery) = discovery {
2506            if let Some(path) = discovery.directory_complete {
2507                let id = self.lookup(&path).expect("discovery completion was preflighted");
2508                if !self.entry(id).directory().children_complete {
2509                    self.entry_mut(id).directory_mut().children_complete = true;
2510                    self.state.progress.directories_complete =
2511                        self.state.progress.directories_complete.saturating_add(1);
2512                    effects.state(|| StateTransition::DirectoryComplete { path });
2513                }
2514            }
2515
2516            if let Some(transition) = discovery.transition {
2517                match transition {
2518                    DiscoveryTransition::Begin => {
2519                        for slot in &mut self.arena {
2520                            if let Slot::Occupied { entry, .. } = slot {
2521                                if entry.kind == EntryKind::Dir {
2522                                    entry.directory_mut().children_complete = false;
2523                                }
2524                            }
2525                        }
2526                        self.state = IndexState {
2527                            phase: LifecyclePhase::Discovering,
2528                            coverage: Coverage::Partial(CoverageReason::Building),
2529                            freshness: Freshness::Fresh,
2530                            source: Source::Scanned,
2531                            progress: DiscoveryProgress::default(),
2532                            issues: crate::IssueSummary::default(),
2533                        };
2534                        self.issues.clear();
2535                        self.issue_epochs.clear();
2536                        self.omitted_issue_epochs.clear();
2537                    }
2538                    DiscoveryTransition::Finish => {
2539                        self.state.phase = LifecyclePhase::Ready;
2540                        if self.state.coverage == Coverage::Partial(CoverageReason::Building) {
2541                            self.state.coverage = Coverage::Complete;
2542                        }
2543                        self.state.freshness = if self.state.coverage == Coverage::Complete {
2544                            Freshness::Fresh
2545                        } else {
2546                            Freshness::Partial
2547                        };
2548                    }
2549                    DiscoveryTransition::BudgetRefused(issue) => {
2550                        let already_stopped_for_budget = self.state.phase
2551                            == LifecyclePhase::Stopped
2552                            && self.state.coverage == Coverage::Partial(CoverageReason::Budget);
2553                        self.state.phase = LifecyclePhase::Stopped;
2554                        self.state.coverage = Coverage::Partial(CoverageReason::Budget);
2555                        self.state.freshness = Freshness::Fresh;
2556                        if !already_stopped_for_budget {
2557                            self.retain_issue(issue);
2558                        }
2559                    }
2560                    DiscoveryTransition::Inaccessible { issues, omitted } => {
2561                        if self.state.coverage != Coverage::Partial(CoverageReason::Budget) {
2562                            self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
2563                            self.state.freshness = Freshness::Partial;
2564                        }
2565                        for issue in issues {
2566                            self.retain_issue(issue);
2567                        }
2568                        self.retain_omitted(omitted);
2569                    }
2570                    DiscoveryTransition::Cancelled => {
2571                        self.state.phase = LifecyclePhase::Stopped;
2572                        if self.state.coverage != Coverage::Complete {
2573                            self.state.coverage = Coverage::Partial(CoverageReason::Cancelled);
2574                        }
2575                    }
2576                    DiscoveryTransition::Failed(issue) => {
2577                        self.state.phase = LifecyclePhase::Failed;
2578                        self.state.coverage = Coverage::Partial(CoverageReason::Failed);
2579                        self.state.freshness = Freshness::Partial;
2580                        self.retain_issue(issue);
2581                    }
2582                }
2583            }
2584        }
2585
2586        if let Some(observation) = observation {
2587            match observation {
2588                ObservationTransition::Reconciling => {
2589                    if self.state.phase == LifecyclePhase::Ready {
2590                        self.state.phase = LifecyclePhase::Reconciling;
2591                        self.state.freshness = Freshness::Reconciling;
2592                    }
2593                }
2594                ObservationTransition::Watching { issues, omitted } => {
2595                    if self.state.phase == LifecyclePhase::Reconciling {
2596                        self.state.phase = LifecyclePhase::Watching;
2597                        if !issues.is_empty() || omitted > 0 {
2598                            self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
2599                            for issue in issues {
2600                                self.retain_issue(issue);
2601                            }
2602                            self.retain_omitted(omitted);
2603                        } else if self.state.coverage
2604                            == Coverage::Partial(CoverageReason::Inaccessible)
2605                        {
2606                            // The handoff has just read the whole root without one error, so
2607                            // a boundary discovery could not read no longer exists. Coverage
2608                            // says what can be known now. That complete pass has already
2609                            // dropped the issues it disproved and recorded completeness for
2610                            // every directory it listed, so nothing below contradicts it.
2611                            self.state.coverage = Coverage::Complete;
2612                        }
2613                        self.state.freshness = self.freshness();
2614                        if self.state.coverage != Coverage::Complete {
2615                            self.state.freshness = Freshness::Partial;
2616                        }
2617                    }
2618                }
2619                ObservationTransition::Unreadable { issues, omitted } => {
2620                    // `Ready` is a shared index watched without an opened-root lifecycle,
2621                    // which never leaves that phase; a stopped or failed root keeps nothing.
2622                    if matches!(self.state.phase, LifecyclePhase::Watching | LifecyclePhase::Ready)
2623                    {
2624                        for issue in issues {
2625                            self.retain_issue(issue);
2626                        }
2627                        self.retain_omitted(omitted);
2628                    }
2629                }
2630                ObservationTransition::Failed(issue) => {
2631                    if self.state.phase != LifecyclePhase::Stopped {
2632                        self.state.phase = LifecyclePhase::Failed;
2633                        self.state.freshness = Freshness::Partial;
2634                        self.retain_issue(issue);
2635                    }
2636                }
2637            }
2638        }
2639
2640        if previous != self.state {
2641            effects.state(|| StateTransition::IndexState { previous, current: self.state });
2642        }
2643    }
2644
2645    /// Exact regular-file total that would remain after one upsert at the current
2646    /// commit boundary.
2647    fn files_after_upsert(&self, path: &Path, kind: EntryKind) -> u64 {
2648        let current_total = self.total_scalars().files;
2649        let Some(id) = self.lookup(path) else {
2650            return current_total.saturating_add(u64::from(kind == EntryKind::File));
2651        };
2652        let current = self.entry(id);
2653        if current.kind == kind {
2654            return current_total;
2655        }
2656        let removed = match current.kind {
2657            EntryKind::File => 1,
2658            EntryKind::Dir => current.rollup().all.files,
2659            _ => 0,
2660        };
2661        current_total.saturating_sub(removed).saturating_add(u64::from(kind == EntryKind::File))
2662    }
2663
2664    /// Retain one issue, once per cause.
2665    ///
2666    /// A cause is its kind and path: a boundary a producer meets again on every re-walk --
2667    /// an unreadable directory, a gap the observer keeps reporting at one place -- is one
2668    /// issue. Without a key, routine repeats filled the bounded list and every later
2669    /// distinct issue was omitted with no text. A repeat only records that the cause was
2670    /// seen again, which a later reconciliation needs; the retained text stays as it was,
2671    /// since changing what a read returns without a commit would let one version answer two
2672    /// ways. An issue without a path has nothing to key on, so only an identical one counts
2673    /// as a repeat.
2674    fn retain_issue(&mut self, issue: Issue) {
2675        self.retain_issue_at(issue, self.freshness_epoch);
2676    }
2677
2678    fn retain_issue_at(&mut self, issue: Issue, epoch: u64) {
2679        let repeat = self.issues.iter().position(|retained| same_issue_cause(retained, &issue));
2680        if let Some(position) = repeat {
2681            self.issue_epochs[position] = epoch;
2682        } else {
2683            let position = self
2684                .issues
2685                .binary_search_by(|retained| compare_issues(retained, &issue))
2686                .unwrap_or_else(|position| position);
2687            if position < MAX_RETAINED_ISSUES {
2688                self.issues.insert(position, issue);
2689                self.issue_epochs.insert(position, epoch);
2690                if self.issues.len() > MAX_RETAINED_ISSUES {
2691                    self.issues.pop();
2692                    let omitted_epoch =
2693                        self.issue_epochs.pop().expect("issue epochs stay parallel");
2694                    self.retain_omitted_at(1, omitted_epoch);
2695                }
2696            } else {
2697                self.retain_omitted_at(1, epoch);
2698            }
2699            self.state.issues.retained = u64::try_from(self.issues.len()).unwrap_or(u64::MAX);
2700        }
2701    }
2702
2703    fn retain_omitted(&mut self, count: u64) {
2704        if count == 0 {
2705            return;
2706        }
2707        let epoch =
2708            self.active_reconciles.keys().next_back().copied().unwrap_or(self.freshness_epoch);
2709        self.retain_omitted_at(count, epoch);
2710    }
2711
2712    fn retain_omitted_at(&mut self, count: u64, epoch: u64) {
2713        let retained = self.omitted_issue_epochs.entry(epoch).or_default();
2714        *retained = retained.saturating_add(count);
2715        self.state.issues.omitted = self.state.issues.omitted.saturating_add(count);
2716    }
2717
2718    fn drop_disproven_omitted(&mut self, started_at: u64) {
2719        self.omitted_issue_epochs.retain(|epoch, _| *epoch >= started_at);
2720        self.state.issues.omitted =
2721            self.omitted_issue_epochs.values().fold(0_u64, |sum, count| sum.saturating_add(*count));
2722    }
2723
2724    fn compact_omitted_epochs(&mut self) {
2725        let mut compact = BTreeMap::new();
2726        for (epoch, count) in std::mem::take(&mut self.omitted_issue_epochs) {
2727            let owner =
2728                self.active_reconciles.range(..=epoch).next_back().map_or(0, |(epoch, _)| *epoch);
2729            let retained = compact.entry(owner).or_insert(0_u64);
2730            *retained = retained.saturating_add(count);
2731        }
2732        self.omitted_issue_epochs = compact;
2733    }
2734
2735    /// Drop retained issues a reconciliation that visited `path` has disproved.
2736    ///
2737    /// An issue about a path at or below `path`, published before the pass began, described
2738    /// something the pass has just read without an error: a directory that could not be
2739    /// listed, an entry whose metadata could not be read, a control the index refused. It is
2740    /// no longer true, and keeping it would explain a state the root is not in. Three kinds
2741    /// of issue survive. An observation gap records that the observer lost precision and had
2742    /// to recover, which the recovery does not undo. An issue without a path cannot be placed
2743    /// under the pass. And an issue published at or after `started_at` came from a pass
2744    /// that closed while this one ran, whose `Partial` mark this pass leaves in place: the
2745    /// issue is stamped with the same epoch as that mark so the two survive together. The
2746    /// omitted count stays: what it counted was never retained.
2747    fn drop_disproven_issues(&mut self, path: &Path, started_at: u64) {
2748        let mut position = 0;
2749        while position < self.issues.len() {
2750            let issue = &self.issues[position];
2751            let disproven = issue.kind != crate::IssueKind::ObservationGap
2752                && issue.path.as_deref().is_some_and(|issue_path| issue_path.starts_with(path))
2753                && self.issue_epochs[position] < started_at;
2754            if disproven {
2755                self.issues.remove(position);
2756                self.issue_epochs.remove(position);
2757            } else {
2758                position += 1;
2759            }
2760        }
2761        self.state.issues.retained = u64::try_from(self.issues.len()).unwrap_or(u64::MAX);
2762    }
2763
2764    /// Mint and optionally retain one fully evaluated transition.
2765    ///
2766    /// Every fact-only, state-only, or combined mutation reaches this function after
2767    /// its fallible validation and preflight work is complete.
2768    fn publish_effects(
2769        &mut self,
2770        next_clock: Clock,
2771        effects: ExactConsequences,
2772        work: Work,
2773        journal: bool,
2774    ) -> Commit {
2775        debug_assert!(!effects.is_empty());
2776        if crate::counters::enabled() {
2777            let effect_paths = u64::try_from(effects.changes.len()).unwrap_or(u64::MAX);
2778            let effect_path_bytes = effects.changes.iter().fold(0_u64, |total, change| {
2779                total.saturating_add(
2780                    u64::try_from(change.path().as_os_str().as_encoded_bytes().len())
2781                        .unwrap_or(u64::MAX),
2782                )
2783            });
2784            crate::counters::bump(|counts| {
2785                counts.effect_paths = counts.effect_paths.saturating_add(effect_paths);
2786                counts.effect_path_bytes =
2787                    counts.effect_path_bytes.saturating_add(effect_path_bytes);
2788            });
2789        }
2790        let commit = Commit {
2791            clock: next_clock,
2792            impact: derive_impact(&effects.changes, &effects.state),
2793            changes: effects.changes,
2794            state: effects.state,
2795            work,
2796        };
2797        self.clock = next_clock;
2798        if journal {
2799            self.retain_commit(&commit);
2800        }
2801        commit
2802    }
2803
2804    fn retain_commit(&mut self, commit: &Commit) {
2805        let cost = commit.retained_cost();
2806        if cost > self.journal_capacity_bytes {
2807            let dropped = u64::try_from(self.journal.len()).unwrap_or(u64::MAX);
2808            crate::counters::bump(|counts| {
2809                counts.journal_oversized_commits =
2810                    counts.journal_oversized_commits.saturating_add(1);
2811                counts.journal_dropped_commits =
2812                    counts.journal_dropped_commits.saturating_add(dropped);
2813            });
2814            self.journal.clear();
2815            self.journal_cost = 0;
2816            self.journal_floor = commit.clock;
2817            return;
2818        }
2819
2820        while self.journal_cost + cost > self.journal_capacity_bytes {
2821            if let Some(dropped) = self.journal.pop_front() {
2822                crate::counters::bump(|counts| {
2823                    counts.journal_dropped_commits =
2824                        counts.journal_dropped_commits.saturating_add(1);
2825                });
2826                self.journal_cost -= dropped.retained_cost();
2827                self.journal_floor = dropped.clock;
2828            }
2829        }
2830        self.journal_cost += cost;
2831        self.journal.push_back(commit.clone());
2832        crate::counters::bump(|counts| {
2833            counts.journal_cloned_commits = counts.journal_cloned_commits.saturating_add(1);
2834            counts.journal_retained_commits = counts.journal_retained_commits.saturating_add(1);
2835        });
2836    }
2837
2838    /// Apply trusted bootstrap data without exposing it as live change history.
2839    pub(crate) fn apply_baseline(
2840        &mut self,
2841        observation: &Observation,
2842    ) -> crate::Result<ApplyStats> {
2843        let prepared = prepare_observation(observation)?;
2844        #[cfg(test)]
2845        if prepared.reject_before_apply {
2846            return Err(crate::Error::CommitRejected("injected reducer preflight"));
2847        }
2848        let mut effects = NoConsequences;
2849        let stats = self.reduce_prepared(&prepared, None, None, None, false, &mut effects)?;
2850        record_batch(BatchProvenance::Baseline, observation.len(), stats);
2851        self.establish_baseline();
2852        Ok(stats)
2853    }
2854
2855    /// Apply one owned filesystem-walker batch without constructing public history.
2856    #[cfg(test)]
2857    pub(crate) fn apply_scanner_baseline(
2858        &mut self,
2859        batch: crate::scan::ScannerBatch,
2860    ) -> crate::Result<ApplyStats> {
2861        let observed = batch.len();
2862        let prepare_started = crate::counters::enabled().then(std::time::Instant::now);
2863        let prepared = self.prepare_scanner_batch(batch)?;
2864        if let Some(started) = prepare_started {
2865            let elapsed = elapsed_micros(started);
2866            crate::counters::bump(|counts| {
2867                counts.scanner_prepare_us = counts.scanner_prepare_us.saturating_add(elapsed);
2868            });
2869        }
2870        let mut effects = NoConsequences;
2871        let reduce_started = crate::counters::enabled().then(std::time::Instant::now);
2872        // Dispatch on the prepared lane: a batch that replaces a kind is general.
2873        let stats = self.reduce_prepared(&prepared, None, None, None, false, &mut effects)?;
2874        if let Some(started) = reduce_started {
2875            let elapsed = elapsed_micros(started);
2876            crate::counters::bump(|counts| {
2877                counts.scanner_reduce_us = counts.scanner_reduce_us.saturating_add(elapsed);
2878            });
2879        }
2880        record_batch(BatchProvenance::Baseline, observed, stats);
2881        self.establish_baseline();
2882        Ok(stats)
2883    }
2884
2885    #[cfg(test)]
2886    pub(crate) fn apply_ok(&mut self, observation: &Observation) -> ApplyOutcome {
2887        self.apply(observation).expect("test observation must be valid")
2888    }
2889
2890    #[cfg(test)]
2891    pub(crate) fn apply_baseline_ok(&mut self, observation: &Observation) -> ApplyStats {
2892        self.apply_baseline(observation).expect("test baseline must be valid")
2893    }
2894
2895    /// Mark the current tree as the process baseline.
2896    pub(crate) fn establish_baseline(&mut self) {
2897        self.clock = Clock::ZERO;
2898        self.journal.clear();
2899        self.journal_cost = 0;
2900        self.journal_floor = Clock::ZERO;
2901        self.pending_invalidations.clear();
2902    }
2903
2904    /// Mark the whole index as not verified against the filesystem.
2905    ///
2906    /// Used by the cache-only open path: a snapshot records the freshness it had when it
2907    /// was written, and replaying that verbatim would let an unverified answer claim
2908    /// currency it has not earned.
2909    pub(crate) fn mark_unverified(&mut self) {
2910        self.freshness_marks.clear();
2911        self.mark_unfresh(Path::new(""), Freshness::Stale);
2912        self.state.source = Source::Cached;
2913        self.state.freshness = Freshness::Stale;
2914    }
2915
2916    pub(crate) fn set_initial_freshness(&mut self, complete: bool) {
2917        self.freshness_marks.clear();
2918        if complete {
2919            for slot in &mut self.arena {
2920                if let Slot::Occupied { entry, .. } = slot {
2921                    if entry.kind == EntryKind::Dir {
2922                        entry.directory_mut().children_complete = true;
2923                    }
2924                }
2925            }
2926            self.state.phase = LifecyclePhase::Ready;
2927            self.state.coverage = Coverage::Complete;
2928            self.state.freshness = Freshness::Fresh;
2929        } else {
2930            self.mark_unfresh(Path::new(""), Freshness::Partial);
2931            self.state.phase = LifecyclePhase::Ready;
2932            self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
2933            self.state.freshness = Freshness::Partial;
2934        }
2935    }
2936
2937    /// Finish a cold walk using all of its failures, before diagnostic retention bounds
2938    /// discard any paths. A scoped failure withdraws its unverified listing boundary;
2939    /// ancestors still have their own listings, and readers fold eligible descendants.
2940    /// An unscoped failure cannot establish completeness anywhere in the walk.
2941    pub(crate) fn set_initial_scan_freshness(&mut self, errors: &[crate::Error]) {
2942        self.set_initial_freshness(errors.is_empty());
2943        if errors.is_empty() {
2944            return;
2945        }
2946        for slot in &mut self.arena {
2947            if let Slot::Occupied { entry, .. } = slot {
2948                if entry.kind.is_dir() {
2949                    entry.directory_mut().children_complete = false;
2950                }
2951            }
2952        }
2953        let mut failed = Vec::with_capacity(errors.len());
2954        for error in errors {
2955            let Some(path) = Issue::from_error_under(&self.root_path, error).path else {
2956                return;
2957            };
2958            if path.is_absolute() || path.as_os_str().is_empty() {
2959                return;
2960            }
2961            failed.push(path);
2962        }
2963        failed.sort();
2964        failed.dedup();
2965        let listings: Vec<_> =
2966            failed.iter().map(|path| self.failed_listing_boundary(path)).collect();
2967        self.freshness_marks.clear();
2968        for path in &failed {
2969            self.mark_unfresh(path, Freshness::Partial);
2970        }
2971        // The walk has terminated and each failure is scoped above. Every retained
2972        // directory outside those boundaries therefore has its complete in-scope
2973        // listing. In particular, never promote descendants of a failed listing.
2974        for slot in 0..self.arena.len() {
2975            let Slot::Occupied { generation, entry } = &self.arena[slot] else {
2976                continue;
2977            };
2978            if !entry.kind.is_dir() {
2979                continue;
2980            }
2981            let id = EntryId {
2982                slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
2983                generation: *generation,
2984            };
2985            let Some(path) = self.path_of(id) else {
2986                continue;
2987            };
2988            self.entry_mut(id).directory_mut().children_complete =
2989                !listings.iter().zip(&failed).any(|(boundary, failure)| {
2990                    path == *boundary || (boundary == failure && path.starts_with(boundary))
2991                });
2992        }
2993    }
2994
2995    /// A retained failed directory has an unknown child set. If the failed entry was
2996    /// omitted (for example after a metadata failure), its nearest retained directory
2997    /// cannot claim a complete listing either.
2998    fn failed_listing_boundary(&self, failed: &Path) -> PathBuf {
2999        let mut boundary = failed.to_path_buf();
3000        loop {
3001            if self.lookup(&boundary).is_some_and(|id| self.entry(id).kind.is_dir()) {
3002                return boundary;
3003            }
3004            if !boundary.pop() {
3005                return PathBuf::new();
3006            }
3007        }
3008    }
3009
3010    pub(crate) fn record_walk_errors(&mut self, errors: &mut Vec<crate::Error>) {
3011        self.issues.clear();
3012        self.issue_epochs.clear();
3013        self.omitted_issue_epochs.clear();
3014        self.state.issues = crate::IssueSummary::default();
3015        let root = self.root_path.clone();
3016        crate::scan::normalize_walk_errors(&root, errors);
3017        for error in errors {
3018            self.retain_issue(Issue::from_error_under(&root, error));
3019        }
3020    }
3021
3022    pub(crate) fn begin_reconcile(&mut self, path: &Path) -> crate::Result<(u64, Option<Commit>)> {
3023        let path = canonical_relative_path(path)?;
3024        let next_clock = self.clock.checked_next().ok_or(crate::Error::ClockExhausted)?;
3025        let previous_index_state = self.state;
3026        let previous = self.freshness_at(&path);
3027        let epoch = self.mark_unfresh(&path, Freshness::Reconciling);
3028        self.active_reconciles.insert(
3029            epoch,
3030            ActiveReconcile {
3031                path: path.clone(),
3032                scope_budget: usize::try_from(self.len()).unwrap_or(usize::MAX),
3033                evidence: ReconcileEvidence::Scopes(BTreeSet::new()),
3034            },
3035        );
3036        if path.as_os_str().is_empty() {
3037            self.active_root_reconciles.insert(epoch, Self::now_unix_nanos());
3038        }
3039        let current = self.freshness_at(&path);
3040        self.state.freshness = self.published_freshness();
3041        let commit = if previous == current && previous_index_state == self.state {
3042            None
3043        } else {
3044            let mut state = Vec::new();
3045            if previous != current {
3046                state.push(StateTransition::Freshness { path, previous, current });
3047            }
3048            if previous_index_state != self.state {
3049                state.push(StateTransition::IndexState {
3050                    previous: previous_index_state,
3051                    current: self.state,
3052                });
3053            }
3054            let effects = ExactConsequences { state, ..ExactConsequences::default() };
3055            Some(self.publish_effects(next_clock, effects, Work::default(), true))
3056        };
3057        Ok((epoch, commit))
3058    }
3059
3060    /// Close one reconciliation opened by [`Self::begin_reconcile`].
3061    ///
3062    /// `listed_incomplete` names the directories the pass listed in full, with no error
3063    /// inside them, that the index did not hold as complete when it listed them. Each is
3064    /// recorded as complete in this commit whether or not the whole pass completed, exactly
3065    /// as discovery's listing commit records the directories it lists, unless a producer
3066    /// invalidated or began verifying it after this pass started: that producer's own pass
3067    /// owns its listing now. A caller passes none when a conditional commit lost a race.
3068    pub(crate) fn finish_reconcile(
3069        &mut self,
3070        path: &Path,
3071        started_at: u64,
3072        complete: bool,
3073        listed_incomplete: &[PathBuf],
3074        failed_paths: &[PathBuf],
3075        errors: ReconcileErrors<'_>,
3076    ) -> crate::Result<ReconcileFinish> {
3077        let path = canonical_relative_path(path)?;
3078        let next_clock = self.clock.checked_next().ok_or(crate::Error::ClockExhausted)?;
3079        let previous_index_state = self.state;
3080        let previous = self.freshness_at(&path);
3081        // Retired evidence is needed only until every older overlapping pass closes.
3082        let evidence = self.active_reconciles.get(&started_at).map_or_else(
3083            || ReconcileEvidence::Scopes(BTreeSet::new()),
3084            |active| active.evidence.clone(),
3085        );
3086        let superseded = match evidence {
3087            ReconcileEvidence::Scopes(scopes) => scopes,
3088            ReconcileEvidence::Retry => {
3089                self.active_root_reconciles.remove(&started_at);
3090                self.active_reconciles.remove(&started_at);
3091                self.compact_omitted_epochs();
3092                self.mark_unfresh(&path, Freshness::Partial);
3093                if self.state.coverage == Coverage::Complete {
3094                    self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
3095                }
3096                self.state.freshness = self.published_freshness();
3097                self.retain_issue(Issue::provider_failure(
3098                    Some(&path),
3099                    "reconciliation interrupted by newer verification; retry this scope"
3100                        .to_string(),
3101                ));
3102                let current = self.freshness_at(&path);
3103                let mut state = Vec::new();
3104                if previous != current {
3105                    state.push(StateTransition::Freshness { path, previous, current });
3106                }
3107                if previous_index_state != self.state {
3108                    state.push(StateTransition::IndexState {
3109                        previous: previous_index_state,
3110                        current: self.state,
3111                    });
3112                }
3113                let commit = if state.is_empty() {
3114                    None
3115                } else {
3116                    let effects = ExactConsequences { state, ..ExactConsequences::default() };
3117                    Some(self.publish_effects(next_clock, effects, Work::default(), true))
3118                };
3119                return Ok(ReconcileFinish { commit, retry: true });
3120            }
3121        };
3122        let fully_superseded = superseded.iter().any(|newer| path.starts_with(newer));
3123        if errors.disproves_old && !fully_superseded {
3124            for (epoch, active) in &mut self.active_reconciles {
3125                if *epoch < started_at
3126                    && (active.path.starts_with(&path) || path.starts_with(&active.path))
3127                {
3128                    active.supersede(&path);
3129                }
3130            }
3131        }
3132        self.freshness_marks
3133            .retain(|marked, mark| !marked.starts_with(&path) || mark.epoch > started_at);
3134        if fully_superseded {
3135            self.active_root_reconciles.remove(&started_at);
3136            self.active_reconciles.remove(&started_at);
3137            self.compact_omitted_epochs();
3138            let current = self.freshness_at(&path);
3139            self.state.freshness = self.published_freshness();
3140            if self.state.coverage == Coverage::Partial(CoverageReason::Inaccessible) {
3141                self.state.freshness = Freshness::Partial;
3142            }
3143            let mut state = Vec::new();
3144            if previous != current {
3145                state.push(StateTransition::Freshness { path, previous, current });
3146            }
3147            if previous_index_state != self.state {
3148                state.push(StateTransition::IndexState {
3149                    previous: previous_index_state,
3150                    current: self.state,
3151                });
3152            }
3153            if state.is_empty() {
3154                return Ok(ReconcileFinish { commit: None, retry: false });
3155            }
3156            let effects = ExactConsequences { state, ..ExactConsequences::default() };
3157            return Ok(ReconcileFinish {
3158                commit: Some(self.publish_effects(next_clock, effects, Work::default(), true)),
3159                retry: false,
3160            });
3161        }
3162        let still_owned =
3163            |candidate: &Path| !superseded.iter().any(|newer| candidate.starts_with(newer));
3164        // Each listed directory is recorded on its own listing, complete pass or not: the
3165        // walk names only those it listed in full with no error inside them, as discovery
3166        // decides per directory, and the caller passes none when a commit lost a race. A
3167        // directory another producer invalidated or began verifying after this pass
3168        // started is left to that producer's pass, so decide here, before this pass's own
3169        // partial mark below would read as such a newer claim.
3170        let recordable: Vec<&PathBuf> = listed_incomplete
3171            .iter()
3172            .filter(|directory| {
3173                directory.starts_with(&path)
3174                    && still_owned(directory)
3175                    && !self.freshness_marks.iter().any(|(marked, mark)| {
3176                        mark.epoch > started_at && directory.starts_with(marked)
3177                    })
3178            })
3179            .collect();
3180        let scoped_failures: Vec<&PathBuf> = failed_paths
3181            .iter()
3182            .filter(|failed| failed.starts_with(&path) && still_owned(failed))
3183            .collect();
3184        // If every failure in this scope was subsequently verified, the older
3185        // pass's remaining evidence is complete. Arbitration/resource refusals do
3186        // not qualify: their caller cannot disprove prior state.
3187        let complete = complete
3188            || (errors.disproves_old
3189                && failed_paths.iter().any(|failed| failed.starts_with(&path))
3190                && scoped_failures.is_empty()
3191                && errors.errors.iter().chain(errors.terminal).all(|error| {
3192                    Issue::from_error_under(&self.root_path, error)
3193                        .path
3194                        .is_some_and(|failed| !failed.starts_with(&path) || !still_owned(&failed))
3195                }));
3196        if errors.disproves_old && self.state.phase != LifecyclePhase::Failed {
3197            self.drop_disproven_issues(&path, started_at);
3198        }
3199        if errors.disproves_old
3200            && self.state.phase != LifecyclePhase::Failed
3201            && path.as_os_str().is_empty()
3202        {
3203            self.drop_disproven_omitted(started_at);
3204        }
3205        let mut state = Vec::new();
3206        // Completeness describes this directory's own listing, not its descendants.
3207        // Withdraw old listing evidence at failures before publishing the partial pass.
3208        // Do not touch successful ancestors: readers compose only eligible descendants,
3209        // and an excluded failed child must not poison an otherwise complete subtree.
3210        if !complete && (!errors.errors.is_empty() || errors.terminal.is_some()) {
3211            let boundaries: Vec<_> = if scoped_failures.is_empty() {
3212                vec![(path.clone(), true)]
3213            } else {
3214                scoped_failures
3215                    .iter()
3216                    .map(|failed| {
3217                        let boundary = self.failed_listing_boundary(failed);
3218                        let subtree = boundary == **failed;
3219                        (boundary, subtree)
3220                    })
3221                    .collect()
3222            };
3223            for slot in 0..self.arena.len() {
3224                let Slot::Occupied { generation, entry } = &self.arena[slot] else {
3225                    continue;
3226                };
3227                if !entry.kind.is_dir() || !entry.directory().children_complete {
3228                    continue;
3229                }
3230                let id = EntryId {
3231                    slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
3232                    generation: *generation,
3233                };
3234                let Some(directory) = self.path_of(id) else {
3235                    continue;
3236                };
3237                if boundaries.iter().any(|(boundary, subtree)| {
3238                    directory == *boundary || (*subtree && directory.starts_with(boundary))
3239                }) && still_owned(&directory)
3240                    && !self.freshness_marks.iter().any(|(marked, mark)| {
3241                        mark.epoch > started_at && directory.starts_with(marked)
3242                    })
3243                {
3244                    self.entry_mut(id).directory_mut().children_complete = false;
3245                    state.push(StateTransition::DirectoryIncomplete { path: directory });
3246                }
3247            }
3248        }
3249        // A complete older walk plus successful newer child verification still proves
3250        // the entire scope. A newer failed child must keep its own evidence and mark.
3251        let verified_scope = superseded.is_empty()
3252            || (complete
3253                && superseded.iter().all(|newer| self.freshness_at(newer) == Freshness::Fresh));
3254        if verified_scope && (complete || !scoped_failures.is_empty()) {
3255            // A sweep stat'd every entry beneath `path` except the precise failure paths,
3256            // which carry stronger `Partial` marks below. Record the successful interval
3257            // once rather than manufacturing millions of unchanged entry updates.
3258            let now = Self::now_unix_nanos();
3259            self.verified.retain(|(verified_path, _)| !verified_path.starts_with(&path));
3260            self.verified.push((path.clone(), now));
3261            if self.verified.len() > MAX_VERIFIED_INTERVALS {
3262                let excess = self.verified.len() - MAX_VERIFIED_INTERVALS;
3263                self.verified.sort_by_key(|(_, at)| *at);
3264                self.verified.drain(..excess);
3265            }
3266            state.push(StateTransition::Verified { path: path.clone() });
3267        }
3268        if complete {
3269            if path.as_os_str().is_empty() {
3270                if let Some(started) = self.active_root_reconciles.remove(&started_at) {
3271                    self.writing_pass_started_at_ns = started;
3272                }
3273                self.state.source = self.applying_source;
3274            }
3275        } else {
3276            if scoped_failures.is_empty() {
3277                self.mark_unfresh(&path, Freshness::Partial);
3278            } else {
3279                for failed in scoped_failures {
3280                    self.mark_unfresh(failed, Freshness::Partial);
3281                }
3282            }
3283            if !errors.errors.is_empty() || errors.terminal.is_some() {
3284                self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
3285            }
3286            if path.as_os_str().is_empty() {
3287                if let Some(started) = self.active_root_reconciles.remove(&started_at) {
3288                    self.writing_pass_started_at_ns = started;
3289                }
3290                self.state.source = self.applying_source;
3291            }
3292        }
3293        // Retain this pass's failures at the epoch its `Partial` marks were just minted at,
3294        // not at `started_at`: a pass that began after this one and closes clean later
3295        // keeps the marks (minted after it began) and must keep the issues that explain
3296        // them, or a partial subtree would have no cause until the next root pass.
3297        // `started_at` is only this pass's own disproof threshold, applied above.
3298        for error in errors.errors.iter().chain(errors.terminal) {
3299            let issue = Issue::from_error_under(&self.root_path, error);
3300            if issue
3301                .path
3302                .as_deref()
3303                .is_none_or(|issue_path| issue_path.starts_with(&path) && still_owned(issue_path))
3304            {
3305                self.retain_issue(issue);
3306            }
3307        }
3308        for directory in recordable {
3309            let Some(id) = self.lookup(directory) else {
3310                continue;
3311            };
3312            let entry = self.entry_mut(id);
3313            if entry.kind != EntryKind::Dir || entry.directory().children_complete {
3314                continue;
3315            }
3316            entry.directory_mut().children_complete = true;
3317            self.state.progress.directories_complete =
3318                self.state.progress.directories_complete.saturating_add(1);
3319            state.push(StateTransition::DirectoryComplete { path: directory.clone() });
3320        }
3321
3322        if complete
3323            && self.state.coverage == Coverage::Partial(CoverageReason::Inaccessible)
3324            && !self.issues.iter().any(|issue| issue.kind != crate::IssueKind::ObservationGap)
3325            && self.state.issues.omitted == 0
3326            && self.arena.iter().all(|slot| {
3327                let Slot::Occupied { entry, .. } = slot else {
3328                    return true;
3329                };
3330                entry.kind != EntryKind::Dir || entry.directory().children_complete
3331            })
3332        {
3333            self.state.coverage = Coverage::Complete;
3334        }
3335
3336        let current = self.freshness_at(&path);
3337        self.state.freshness = self.published_freshness();
3338        if self.state.coverage == Coverage::Partial(CoverageReason::Inaccessible) {
3339            self.state.freshness = Freshness::Partial;
3340        }
3341        self.active_reconciles.remove(&started_at);
3342        self.compact_omitted_epochs();
3343        if previous != current {
3344            state.push(StateTransition::Freshness { path: path.clone(), previous, current });
3345        }
3346        if previous_index_state != self.state {
3347            state.push(StateTransition::IndexState {
3348                previous: previous_index_state,
3349                current: self.state,
3350            });
3351        }
3352        if state.is_empty() {
3353            return Ok(ReconcileFinish { commit: None, retry: false });
3354        }
3355        let effects = ExactConsequences { state, ..ExactConsequences::default() };
3356        Ok(ReconcileFinish {
3357            commit: Some(self.publish_effects(next_clock, effects, Work::default(), true)),
3358            retry: false,
3359        })
3360    }
3361
3362    /// When a completed reconciliation last covered this path, if one did.
3363    fn verified_at(&self, path: &Path) -> Option<i64> {
3364        self.verified
3365            .iter()
3366            .filter(|(covered, _)| path.starts_with(covered))
3367            .map(|(_, at)| *at)
3368            .max()
3369    }
3370
3371    fn mark_unfresh(&mut self, path: &Path, state: Freshness) -> u64 {
3372        self.freshness_epoch =
3373            self.freshness_epoch.checked_add(1).expect("freshness epoch exhausted");
3374        let epoch = self.freshness_epoch;
3375        self.freshness_marks.insert(path.to_path_buf(), FreshnessMark { state, epoch });
3376        epoch
3377    }
3378
3379    /// Current user-visible state for one path.
3380    ///
3381    /// Conditional producers should capture [`Self::expectation`] so ABA and structural
3382    /// races cannot return to the same visible state unnoticed.
3383    pub fn path_state(&self, path: &Path) -> PathState {
3384        let Some(id) = self.lookup(path) else {
3385            return PathState::Absent;
3386        };
3387        let entry = self.entry(id);
3388        PathState::Present { kind: entry.kind, attrs: entry.attrs }
3389    }
3390
3391    /// Conditional baseline with target and nearest-ancestor ABA protection.
3392    pub fn expectation(&self, path: &Path) -> PathExpectation {
3393        let entry = self.entry_identity(path);
3394        PathExpectation::new(
3395            self.path_state(path),
3396            entry,
3397            entry.is_none().then(|| self.absence_guard_identity(path)).flatten(),
3398        )
3399    }
3400
3401    pub(crate) fn relaxed_expectation(&self, path: &Path) -> PathExpectation {
3402        PathExpectation::new(self.path_state(path), self.entry_identity(path), None)
3403    }
3404
3405    /// Exact commits applied since `clock`, oldest first.
3406    pub fn since(&self, clock: Clock) -> Since {
3407        let commits: Vec<Commit> =
3408            self.journal.iter().filter(|commit| commit.clock > clock).cloned().collect();
3409        Since {
3410            commits,
3411            clock: self.clock,
3412            state: self.state,
3413            truncated: clock < self.journal_floor,
3414        }
3415    }
3416
3417    /// Take the subtrees that producers escalated for re-scan.
3418    ///
3419    /// The caller is expected to hand these to the scan layer, which turns them back
3420    /// into precise commits. Escalation is closed-loop: draining this list without
3421    /// re-scanning is what makes an index silently diverge.
3422    pub fn take_pending_invalidations(&mut self) -> Vec<(PathBuf, InvalidateReason)> {
3423        std::mem::take(&mut self.pending_invalidations)
3424    }
3425
3426    /// Put unresolved invalidations back without minting a second public change.
3427    pub(crate) fn restore_pending_invalidations(
3428        &mut self,
3429        invalidations: Vec<(PathBuf, InvalidateReason)>,
3430    ) {
3431        self.pending_invalidations.extend(invalidations);
3432    }
3433
3434    /// Look up an entry id by path relative to the root.
3435    pub fn lookup(&self, path: &Path) -> Option<EntryId> {
3436        let mut current = EntryId::ROOT;
3437        for part in normalize(path)? {
3438            current = self.child(current, part)?;
3439        }
3440        Some(current)
3441    }
3442
3443    /// Owned, self-describing roll-up state for a directory by relative path.
3444    /// The empty path is the root.
3445    pub fn rollup(&self, path: &Path) -> Option<RollUp> {
3446        let id = self.lookup(path)?;
3447        let entry = self.entry(id);
3448        entry.kind.is_dir().then(|| self.named_rollup(&entry.rollup().all))
3449    }
3450
3451    /// Both fixed aggregate partitions for a directory by relative path.
3452    ///
3453    /// `Ok(None)` when the path is absent or not a directory.
3454    ///
3455    /// # Errors
3456    ///
3457    /// [`crate::Error::ControlStateNotObserved`] when the index did not observe control
3458    /// state, whatever the path, as [`Self::partition_total`] refuses.
3459    pub fn partition_rollup(&self, path: &Path) -> crate::Result<Option<PartitionRollUp>> {
3460        self.require_observed_controls()?;
3461        Ok(self
3462            .lookup(path)
3463            .map(|id| self.entry(id))
3464            .filter(|entry| entry.kind.is_dir())
3465            .map(|entry| self.named_partitions(entry.rollup())))
3466    }
3467
3468    /// Both constant-size aggregate partitions for a directory.
3469    ///
3470    /// `Ok(None)` when the path is absent or not a directory.
3471    ///
3472    /// # Errors
3473    ///
3474    /// [`crate::Error::ControlStateNotObserved`] when the index did not observe control
3475    /// state, whatever the path, as [`Self::partition_total`] refuses.
3476    pub fn partition_rollup_summary(
3477        &self,
3478        path: &Path,
3479    ) -> crate::Result<Option<PartitionRollUpSummary>> {
3480        self.require_observed_controls()?;
3481        Ok(self
3482            .lookup(path)
3483            .map(|id| self.entry(id))
3484            .filter(|entry| entry.kind.is_dir())
3485            .map(|entry| partition_summary(entry.rollup())))
3486    }
3487
3488    /// Whether a live entry is ignored, for the opened-root tree projection, without the
3489    /// observation check [`Self::is_ignored`] makes.
3490    ///
3491    /// An opened root always observes control state, so the retained bit is the exact
3492    /// classification; the assertion checks that invariant where it is cheap to.
3493    pub(crate) fn opened_is_ignored(&self, id: EntryId) -> bool {
3494        debug_assert!(self.observes_controls(), "an opened root observes control state");
3495        self.entry(id).ignored
3496    }
3497
3498    /// Capture one retained entry without repeating path lookup in a consumer.
3499    pub(crate) fn entry_value(&self, path: &Path) -> Option<crate::EntryValue> {
3500        let id = self.lookup(path)?;
3501        Some(self.entry_value_of(id, path))
3502    }
3503
3504    pub(crate) fn entry_value_of(&self, id: EntryId, path: &Path) -> crate::EntryValue {
3505        let entry = self.entry(id);
3506        crate::EntryValue {
3507            path: path.to_path_buf(),
3508            portable_path: crate::opened::read::portable_path(path),
3509            kind: entry.kind,
3510            attrs: entry.attrs,
3511            ignored: entry.ignored,
3512            classification: (entry.kind == EntryKind::File)
3513                .then(|| self.types.classify_name(path.file_name().unwrap_or_default())),
3514            rollup: entry.kind.is_dir().then(|| partition_summary(entry.rollup())),
3515            children_complete: entry.kind.is_dir().then(|| entry.directory().children_complete),
3516        }
3517    }
3518
3519    pub(crate) fn portable_children(&self, path: &Path) -> Option<&PortableChildren> {
3520        self.serving.as_ref()?.portable_children.get(path)
3521    }
3522
3523    pub(crate) fn portable_entries(&self) -> &BTreeMap<crate::PortablePath, EntryId> {
3524        &self.serving.as_ref().expect("opened-root reads require serving indexes").portable_entries
3525    }
3526
3527    #[cfg(test)]
3528    pub(crate) const fn serving_indexes_enabled(&self) -> bool {
3529        self.serving.is_some()
3530    }
3531
3532    fn insert_serving_entry(&mut self, path: &Path, kind: EntryKind, attrs: Attrs, id: EntryId) {
3533        if path.as_os_str().is_empty() || self.serving.is_none() {
3534            return;
3535        }
3536        let file = (kind == EntryKind::File).then(|| {
3537            (
3538                self.classify(path).file_type.as_str().to_string(),
3539                path.file_name(),
3540                self.entry(id).ignored,
3541                self.entry(id).parent,
3542            )
3543        });
3544        let arena = &self.arena;
3545        let Some(serving) = self.serving.as_mut() else {
3546            return;
3547        };
3548        if let Some((name, exact_name, ignored, parent)) = file {
3549            let semantic = serving.intern_semantic(&name);
3550            let mut ancestor = parent;
3551            while let Some(directory) = ancestor {
3552                let partition = serving.semantic_by_directory.entry(directory).or_default();
3553                merge_semantic(&mut partition.all, semantic, attrs);
3554                if !ignored {
3555                    merge_semantic(&mut partition.unignored, semantic, attrs);
3556                }
3557                ancestor = retained_parent(arena, directory);
3558            }
3559            if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
3560                let mut ancestor = parent;
3561                while let Some(directory) = ancestor {
3562                    let partition = serving.exact_name_by_directory.entry(directory).or_default();
3563                    merge_semantic(&mut partition.all, exact_name, attrs);
3564                    if !ignored {
3565                        merge_semantic(&mut partition.unignored, exact_name, attrs);
3566                    }
3567                    ancestor = retained_parent(arena, directory);
3568                }
3569            }
3570        }
3571        let portable = crate::opened::read::portable_path(path);
3572        serving.portable_entries.insert(portable.clone(), id);
3573        if kind == EntryKind::File {
3574            serving.recent_files.insert(RecentKey {
3575                mtime_ns: attrs.mtime_ns,
3576                portable_path: portable,
3577                id,
3578            });
3579        }
3580        let parent = path.parent().unwrap_or_else(|| Path::new("")).to_path_buf();
3581        let Some(name) = path.file_name().map(crate::opened::read::portable_component) else {
3582            return;
3583        };
3584        let children = serving.portable_children.entry(parent).or_default();
3585        if kind.is_dir() {
3586            children.directories.insert(name, id);
3587        } else {
3588            children.nondirectories.insert(name, id);
3589        }
3590    }
3591
3592    fn remove_serving_entry(&mut self, path: &Path, kind: EntryKind, attrs: Attrs, id: EntryId) {
3593        if path.as_os_str().is_empty() {
3594            return;
3595        }
3596        let Some(serving) = self.serving.as_mut() else {
3597            return;
3598        };
3599        let portable = crate::opened::read::portable_path(path);
3600        serving.portable_entries.remove(&portable);
3601        if kind == EntryKind::File {
3602            serving.recent_files.remove(&RecentKey {
3603                mtime_ns: attrs.mtime_ns,
3604                portable_path: portable,
3605                id,
3606            });
3607        }
3608        let parent = path.parent().unwrap_or_else(|| Path::new("")).to_path_buf();
3609        let remove_parent = if let Some(children) = serving.portable_children.get_mut(&parent) {
3610            if let Some(name) = path.file_name().map(crate::opened::read::portable_component) {
3611                if kind.is_dir() {
3612                    children.directories.remove(&name);
3613                } else {
3614                    children.nondirectories.remove(&name);
3615                }
3616            }
3617            children.directories.is_empty() && children.nondirectories.is_empty()
3618        } else {
3619            false
3620        };
3621        if remove_parent {
3622            serving.portable_children.remove(&parent);
3623        }
3624        if kind.is_dir() {
3625            serving.portable_children.remove(path);
3626        }
3627    }
3628
3629    fn remove_serving_file_semantics(&mut self, path: &Path, id: EntryId, attrs: Attrs) {
3630        // Only a regular file is interned and tallied (`insert_serving_entry`). A symlink
3631        // or special entry of the same classification would otherwise find a real file's
3632        // type and subtract from its tally, or find none and panic under the write guard.
3633        if self.serving.is_none() || self.entry(id).kind != EntryKind::File {
3634            return;
3635        }
3636        let name = self.classify(path).file_type.as_str().to_string();
3637        let exact_name = path.file_name();
3638        let ignored = self.entry(id).ignored;
3639        let parent = self.entry(id).parent;
3640        let arena = &self.arena;
3641        let serving = self.serving.as_mut().expect("checked above");
3642        let semantic = *serving
3643            .semantic_ids
3644            .get(&name)
3645            .expect("every served file has an interned semantic type");
3646        let mut empty = Vec::new();
3647        let mut ancestor = parent;
3648        while let Some(directory) = ancestor {
3649            let partition = serving
3650                .semantic_by_directory
3651                .get_mut(&directory)
3652                .expect("every served file contributes to every ancestor");
3653            unmerge_semantic(&mut partition.all, semantic, attrs);
3654            if !ignored {
3655                unmerge_semantic(&mut partition.unignored, semantic, attrs);
3656            }
3657            if partition.all.is_empty() && partition.unignored.is_empty() {
3658                empty.push(directory);
3659            }
3660            ancestor = retained_parent(arena, directory);
3661        }
3662        for ancestor in empty {
3663            serving.semantic_by_directory.remove(&ancestor);
3664        }
3665        serving.release_semantic(semantic, 1);
3666        if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
3667            let mut exact_empty = Vec::new();
3668            let mut ancestor = parent;
3669            while let Some(directory) = ancestor {
3670                let partition = serving
3671                    .exact_name_by_directory
3672                    .get_mut(&directory)
3673                    .expect("every declared exact-name file contributes to every ancestor");
3674                unmerge_semantic(&mut partition.all, exact_name, attrs);
3675                if !ignored {
3676                    unmerge_semantic(&mut partition.unignored, exact_name, attrs);
3677                }
3678                if partition.all.is_empty() && partition.unignored.is_empty() {
3679                    exact_empty.push(directory);
3680                }
3681                ancestor = retained_parent(arena, directory);
3682            }
3683            for ancestor in exact_empty {
3684                serving.exact_name_by_directory.remove(&ancestor);
3685            }
3686        }
3687    }
3688
3689    fn remove_serving_subtree_semantics(&mut self, root: EntryId, path: &Path) {
3690        if self.serving.is_none() {
3691            return;
3692        }
3693        match self.entry(root).kind {
3694            EntryKind::File => {
3695                let attrs = self.entry(root).attrs;
3696                self.remove_serving_file_semantics(path, root, attrs);
3697            }
3698            EntryKind::Dir => {
3699                let parent = self.entry(root).parent;
3700                let mut stack = vec![root];
3701                let mut directories = Vec::new();
3702                while let Some(id) = stack.pop() {
3703                    let entry = self.entry(id);
3704                    if !entry.kind.is_dir() {
3705                        continue;
3706                    }
3707                    directories.push(id);
3708                    stack.extend(self.child_ids(id));
3709                }
3710                let arena = &self.arena;
3711                let serving = self.serving.as_mut().expect("checked above");
3712                let contribution =
3713                    serving.semantic_by_directory.get(&root).cloned().unwrap_or_default();
3714                let exact_contribution =
3715                    serving.exact_name_by_directory.get(&root).cloned().unwrap_or_default();
3716                let mut empty = Vec::new();
3717                if !contribution.all.is_empty() || !contribution.unignored.is_empty() {
3718                    let mut ancestor = parent;
3719                    while let Some(directory) = ancestor {
3720                        let partition = serving
3721                            .semantic_by_directory
3722                            .get_mut(&directory)
3723                            .expect("a semantic subtree contributes to every ancestor");
3724                        unmerge_semantic_map(&mut partition.all, &contribution.all);
3725                        unmerge_semantic_map(&mut partition.unignored, &contribution.unignored);
3726                        if partition.all.is_empty() && partition.unignored.is_empty() {
3727                            empty.push(directory);
3728                        }
3729                        ancestor = retained_parent(arena, directory);
3730                    }
3731                }
3732                let mut exact_empty = Vec::new();
3733                if !exact_contribution.all.is_empty() || !exact_contribution.unignored.is_empty() {
3734                    let mut ancestor = parent;
3735                    while let Some(directory) = ancestor {
3736                        let partition = serving
3737                            .exact_name_by_directory
3738                            .get_mut(&directory)
3739                            .expect("an exact-name subtree contributes to every ancestor");
3740                        unmerge_semantic_map(&mut partition.all, &exact_contribution.all);
3741                        unmerge_semantic_map(
3742                            &mut partition.unignored,
3743                            &exact_contribution.unignored,
3744                        );
3745                        if partition.all.is_empty() && partition.unignored.is_empty() {
3746                            exact_empty.push(directory);
3747                        }
3748                        ancestor = retained_parent(arena, directory);
3749                    }
3750                }
3751                for ancestor in empty {
3752                    serving.semantic_by_directory.remove(&ancestor);
3753                }
3754                for ancestor in exact_empty {
3755                    serving.exact_name_by_directory.remove(&ancestor);
3756                }
3757                for directory in directories {
3758                    serving.semantic_by_directory.remove(&directory);
3759                    serving.exact_name_by_directory.remove(&directory);
3760                }
3761                for (semantic, tally) in contribution.all {
3762                    serving.release_semantic(semantic, tally.files);
3763                }
3764            }
3765            EntryKind::Symlink | EntryKind::Other => {}
3766        }
3767    }
3768
3769    fn move_serving_file_partition(
3770        &mut self,
3771        path: &Path,
3772        id: EntryId,
3773        previous_ignored: bool,
3774        current_ignored: bool,
3775    ) {
3776        if previous_ignored == current_ignored
3777            || self.serving.is_none()
3778            || self.entry(id).kind != EntryKind::File
3779        {
3780            return;
3781        }
3782        let name = self.classify(path).file_type.as_str().to_string();
3783        let exact_name = path.file_name();
3784        let attrs = self.entry(id).attrs;
3785        let parent = self.entry(id).parent;
3786        let arena = &self.arena;
3787        let serving = self.serving.as_mut().expect("checked above");
3788        let semantic = *serving
3789            .semantic_ids
3790            .get(&name)
3791            .expect("every served file has an interned semantic type");
3792        let mut ancestor = parent;
3793        while let Some(directory) = ancestor {
3794            let partition = serving
3795                .semantic_by_directory
3796                .get_mut(&directory)
3797                .expect("every served file contributes to every ancestor");
3798            if current_ignored {
3799                unmerge_semantic(&mut partition.unignored, semantic, attrs);
3800            } else {
3801                merge_semantic(&mut partition.unignored, semantic, attrs);
3802            }
3803            ancestor = retained_parent(arena, directory);
3804        }
3805        if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
3806            let mut ancestor = parent;
3807            while let Some(directory) = ancestor {
3808                let partition = serving
3809                    .exact_name_by_directory
3810                    .get_mut(&directory)
3811                    .expect("every declared exact-name file contributes to every ancestor");
3812                if current_ignored {
3813                    unmerge_semantic(&mut partition.unignored, exact_name, attrs);
3814                } else {
3815                    merge_semantic(&mut partition.unignored, exact_name, attrs);
3816                }
3817                ancestor = retained_parent(arena, directory);
3818            }
3819        }
3820    }
3821
3822    /// Attributes for any entry, by relative path.
3823    pub fn attrs(&self, path: &Path) -> Option<&Attrs> {
3824        Some(&self.entry(self.lookup(path)?).attrs)
3825    }
3826
3827    /// Kind of an entry, by relative path.
3828    pub fn kind(&self, path: &Path) -> Option<EntryKind> {
3829        Some(self.entry(self.lookup(path)?).kind)
3830    }
3831
3832    /// Effective fixed-control classification for one retained entry.
3833    ///
3834    /// `Ok(Some(ignored))` for a retained entry and `Ok(None)` for a path the index does
3835    /// not hold.
3836    ///
3837    /// # Errors
3838    ///
3839    /// [`crate::Error::ControlStateNotObserved`] when the index was built without
3840    /// observing control state, whatever the path. Every entry of such an index carries
3841    /// "not ignored" only because no rule was read, so that answer would be silently
3842    /// wrong for a tree that has a `.gitignore`.
3843    pub fn is_ignored(&self, path: &Path) -> crate::Result<Option<bool>> {
3844        self.require_observed_controls()?;
3845        Ok(self.lookup(path).map(|id| self.entry(id).ignored))
3846    }
3847
3848    /// Borrow direct children of a directory as `(name, id)` pairs in name order.
3849    ///
3850    /// The iterator borrows this owned index and allocates nothing.
3851    pub fn children(
3852        &self,
3853        path: &Path,
3854    ) -> Option<impl DoubleEndedIterator<Item = (&OsStr, EntryId)> + ExactSizeIterator + '_> {
3855        let id = self.lookup(path)?;
3856        let entry = self.entry(id);
3857        entry.kind.is_dir().then(|| IndexChildren::new(self, entry))
3858    }
3859
3860    /// Borrow direct children of an entry id as `(name, id)` pairs in name order.
3861    ///
3862    /// Returns `None` for a stale handle. A live non-directory returns an empty iterator.
3863    pub fn children_of(
3864        &self,
3865        id: EntryId,
3866    ) -> Option<impl DoubleEndedIterator<Item = (&OsStr, EntryId)> + ExactSizeIterator + '_> {
3867        Some(IndexChildren::new(self, self.try_entry(id)?))
3868    }
3869
3870    /// Reconstruct an entry's path relative to the root by walking parent pointers.
3871    pub fn path_of(&self, id: EntryId) -> Option<PathBuf> {
3872        let mut parts = Vec::new();
3873        let mut current = Some(id);
3874        while let Some(node) = current {
3875            let entry = self.try_entry(node)?;
3876            if entry.parent.is_some() {
3877                parts.push(entry.name.as_os_str());
3878            }
3879            current = entry.parent;
3880        }
3881        parts.reverse();
3882        Some(parts.iter().collect())
3883    }
3884
3885    /// Owned, self-describing roll-up state for an entry id, if it is a directory.
3886    pub fn rollup_of(&self, id: EntryId) -> Option<RollUp> {
3887        let entry = self.try_entry(id)?;
3888        entry.kind.is_dir().then(|| self.named_rollup(&entry.rollup().all))
3889    }
3890
3891    /// Map-free totals of a directory's `all` and `unignored` partitions, in that order,
3892    /// for reporting paths that derive an ignored share.
3893    ///
3894    /// No observation check: in an index that observed no control state the two are equal,
3895    /// so a caller that has not checked [`Self::observes_controls`] derives a zero share
3896    /// rather than an error, and must not present it as one.
3897    pub(crate) fn partition_scalars_of(
3898        &self,
3899        id: EntryId,
3900    ) -> Option<(RollUpScalars, RollUpScalars)> {
3901        let entry = self.try_entry(id)?;
3902        entry.kind.is_dir().then(|| {
3903            let rollup = entry.rollup();
3904            (RollUpScalars::from(&rollup.all), RollUpScalars::from(&rollup.unignored))
3905        })
3906    }
3907
3908    /// The retained ignore bit of a live entry, without the observation check
3909    /// [`Self::is_ignored`] makes, or `None` for a stale handle.
3910    ///
3911    /// `false` throughout an index that observed no control state, for the reason
3912    /// [`Self::partition_scalars_of`] gives.
3913    pub(crate) fn ignored_bit_of(&self, id: EntryId) -> Option<bool> {
3914        Some(self.try_entry(id)?.ignored)
3915    }
3916
3917    /// Whether a live directory's in-scope child set is authoritative: the id form of
3918    /// [`Self::directory_complete`], for a reader that already holds the id. `None` for a
3919    /// stale handle or an entry that is not a directory.
3920    pub(crate) fn directory_complete_of(&self, id: EntryId) -> Option<bool> {
3921        let entry = self.try_entry(id)?;
3922        (entry.kind == EntryKind::Dir).then(|| entry.directory().children_complete)
3923    }
3924
3925    /// Attributes for an entry id, or `None` when the handle is stale.
3926    pub fn attrs_of(&self, id: EntryId) -> Option<&Attrs> {
3927        Some(&self.try_entry(id)?.attrs)
3928    }
3929
3930    /// Kind for an entry id, or `None` when the handle is stale.
3931    pub fn kind_of(&self, id: EntryId) -> Option<EntryKind> {
3932        Some(self.try_entry(id)?.kind)
3933    }
3934
3935    /// Name for an entry id. The root's name is empty; stale handles return `None`.
3936    pub fn name_of(&self, id: EntryId) -> Option<&OsStr> {
3937        Some(&self.try_entry(id)?.name)
3938    }
3939
3940    /// Sparse content tier, when analysis has been enabled.
3941    pub fn content(&self) -> Option<&ContentIndex> {
3942        self.content.as_deref()
3943    }
3944
3945    /// Precomputed content rollup for one relative directory.
3946    pub fn content_rollup(&self, path: &Path) -> Option<&ContentRollUp> {
3947        self.content()?.rollup(path)
3948    }
3949
3950    /// The analyzer set this index's content tier holds records for, or
3951    /// [`AnalysisSet::NONE`] when it holds no content tier.
3952    pub fn content_set(&self) -> AnalysisSet {
3953        self.content().and_then(ContentIndex::profile).unwrap_or(AnalysisSet::NONE)
3954    }
3955
3956    /// Whether the retained content tier lacks a record for any current regular file.
3957    ///
3958    /// Every requested analyzer records a coverage outcome, including binary, invalid
3959    /// UTF-8, and unsupported files. A count mismatch therefore means analysis is still
3960    /// pending, while deleting a file removes both its entry and its content record.
3961    pub(crate) fn content_has_pending(&self, profile: AnalysisSet) -> bool {
3962        if !profile.is_enabled() {
3963            return false;
3964        }
3965        let wanted = self.content_identity(profile);
3966        let Some(content) = self.content().and_then(|content| content.admit(&wanted)) else {
3967            return true;
3968        };
3969        u64::try_from(content.len()).unwrap_or(u64::MAX) < self.entry(EntryId::ROOT).rollup().files
3970    }
3971
3972    /// The content tier identity this index gives records of `analysis`: its own entry tier,
3973    /// which holds its type rules, the analyzer set, and the analyzers' versions and options.
3974    pub fn content_identity(&self, analysis: AnalysisSet) -> crate::ContentTierIdentity {
3975        crate::ContentTierIdentity::for_request(
3976            crate::EntryTierIdentity::of_scope(self.scope),
3977            analysis,
3978        )
3979    }
3980
3981    /// Prepare the content tier to hold records of `request`'s identity, clearing it when
3982    /// it holds any other.
3983    pub(crate) fn prepare_content_analysis(&mut self, request: crate::content::AnalysisRequest) {
3984        if !request.profile.is_enabled() {
3985            return;
3986        }
3987        let identity = self.content_identity(request.profile);
3988        self.content.get_or_insert_with(|| Box::new(ContentIndex::default())).prepare(identity);
3989    }
3990
3991    pub(crate) fn set_content_tier_state(
3992        &mut self,
3993        source: Source,
3994        freshness: Freshness,
3995        observed_at_ns: Option<i64>,
3996    ) {
3997        if let Some(content) = self.content.as_deref_mut() {
3998            content.set_state(crate::content::ContentTierState {
3999                source,
4000                freshness,
4001                observed_at_ns,
4002            });
4003        }
4004    }
4005
4006    /// Capture every regular-file analysis candidate without retaining a lock or entry
4007    /// borrow across filesystem I/O.
4008    ///
4009    /// Crate-private until the request model (P1.3) decides whether an out-of-crate
4010    /// analyzer is a supported surface (`fdu-5upj`). A caller outside the crate cannot
4011    /// prepare the content tier, so every result it produced would commit as
4012    /// [`AnalysisApplyOutcome::Stale`]; [`analyze_index`] is the entry that works.
4013    ///
4014    /// [`analyze_index`]: crate::content::analyze_index
4015    pub(crate) fn analysis_candidates(&self, profile: AnalysisSet) -> Vec<AnalysisCandidate> {
4016        let root_files = self.entry(EntryId::ROOT).rollup().files;
4017        let mut candidates = Vec::with_capacity(usize::try_from(root_files).unwrap_or(0));
4018        self.for_each_analysis_file(profile, |id, revision, attrs, relative_path| {
4019            candidates.push(AnalysisCandidate {
4020                entry_id: id,
4021                revision,
4022                absolute_path: self.root_path.join(&relative_path),
4023                classification: self.classify(&relative_path),
4024                relative_path,
4025                attrs,
4026            });
4027        });
4028        candidates
4029    }
4030
4031    /// File identities restore matches against sidecar records, without classifying.
4032    ///
4033    /// The `HashMap` is keyed by relative path because load looks up each decoded record
4034    /// that way. Classification is omitted: cache-only restore commits the sidecar's
4035    /// stored classification, and the apply-path self-check cannot change that answer.
4036    pub(crate) fn restore_analysis_candidates(
4037        &self,
4038        profile: AnalysisSet,
4039    ) -> (HashMap<PathBuf, RestoreCandidate>, u64) {
4040        let root_files = self.entry(EntryId::ROOT).rollup().files;
4041        let mut candidates = HashMap::with_capacity(usize::try_from(root_files).unwrap_or(0));
4042        let mut visited = 0_u64;
4043        self.for_each_analysis_file(profile, |id, revision, attrs, relative_path| {
4044            visited = visited.saturating_add(1);
4045            candidates.insert(
4046                relative_path.clone(),
4047                RestoreCandidate { entry_id: id, revision, relative_path, attrs },
4048            );
4049        });
4050        (candidates, visited)
4051    }
4052
4053    fn for_each_analysis_file(
4054        &self,
4055        profile: AnalysisSet,
4056        mut visit: impl FnMut(EntryId, u64, Attrs, PathBuf),
4057    ) {
4058        if !profile.is_enabled() {
4059            return;
4060        }
4061        // Join the parent path this walk already holds. `path_of` would walk
4062        // ancestors per file for the same bytes.
4063        let mut stack = vec![(EntryId::ROOT, PathBuf::new())];
4064        while let Some((parent, parent_path)) = stack.pop() {
4065            for (name, id) in self.children_of(parent).into_iter().flatten() {
4066                let entry = self.entry(id);
4067                if entry.kind == EntryKind::Dir {
4068                    stack.push((id, parent_path.join(name)));
4069                    continue;
4070                }
4071                if entry.kind != EntryKind::File {
4072                    continue;
4073                }
4074                let revision = entry.revision;
4075                let attrs = entry.attrs;
4076                visit(id, revision, attrs, parent_path.join(name));
4077            }
4078        }
4079    }
4080
4081    /// The candidates `request` still has to read: every one, unless the content tier
4082    /// holds exactly `request`'s identity, and then those without a record whose
4083    /// fingerprint matches.
4084    pub(crate) fn pending_analysis_candidates(
4085        &self,
4086        request: crate::content::AnalysisRequest,
4087    ) -> Vec<AnalysisCandidate> {
4088        let wanted = self.content_identity(request.profile);
4089        // The tier refuses a record of any identity but its own, so one comparison here
4090        // decides for every record it holds.
4091        let held = self.content().and_then(|content| content.admit(&wanted));
4092        self.analysis_candidates(request.profile)
4093            .into_iter()
4094            .filter(|candidate| {
4095                held.and_then(|content| content.file(&candidate.relative_path)).is_none_or(
4096                    |record| {
4097                        record.fingerprint != candidate.attrs.fingerprint() || !record.is_reusable()
4098                    },
4099                )
4100            })
4101            .collect()
4102    }
4103
4104    /// Conditionally commit a worker result if its entry and metadata expectation still
4105    /// match, and the content tier was prepared for the identity the result was produced
4106    /// under.
4107    ///
4108    /// A result of another identity is [`AnalysisApplyOutcome::Stale`]: it answers another
4109    /// request than the one the tier holds, so committing it would mix records of two
4110    /// identities in one tier.
4111    ///
4112    /// Crate-private with [`Index::analysis_candidates`], and for the same reason.
4113    pub(crate) fn apply_analysis(
4114        &mut self,
4115        observation: AnalysisObservation,
4116    ) -> AnalysisApplyOutcome {
4117        self.apply_analysis_record(observation)
4118    }
4119
4120    /// Restore-path apply: insert the record and leave roll-ups for one rebuild.
4121    ///
4122    /// The caller must [`Self::rebuild_content_rollups`] before any query reads a
4123    /// directory total; sidecar load does that after the apply loop.
4124    pub(crate) fn apply_restored_analysis(
4125        &mut self,
4126        candidate: RestoreCandidate,
4127        analysis: crate::stored_state::AdmittedRecord<'_>,
4128    ) -> AnalysisApplyOutcome {
4129        let Some(entry) = self.try_entry(candidate.entry_id) else {
4130            return AnalysisApplyOutcome::Stale;
4131        };
4132        if entry.kind != EntryKind::File
4133            || entry.revision != candidate.revision
4134            || entry.attrs.fingerprint() != candidate.attrs.fingerprint()
4135        {
4136            return AnalysisApplyOutcome::Stale;
4137        }
4138        let Some(content) = self.content.as_mut() else {
4139            return AnalysisApplyOutcome::Stale;
4140        };
4141        if content.commit_without_rollup(candidate.relative_path, analysis) {
4142            AnalysisApplyOutcome::Applied
4143        } else {
4144            AnalysisApplyOutcome::Stale
4145        }
4146    }
4147
4148    pub(crate) fn rebuild_content_rollups(&mut self) {
4149        if let Some(content) = self.content.as_mut() {
4150            content.rebuild_rollups();
4151        }
4152    }
4153
4154    fn apply_analysis_record(&mut self, observation: AnalysisObservation) -> AnalysisApplyOutcome {
4155        let candidate = &observation.candidate;
4156        let Some(entry) = self.try_entry(candidate.entry_id) else {
4157            return AnalysisApplyOutcome::Stale;
4158        };
4159        if entry.kind != EntryKind::File
4160            || entry.revision != candidate.revision
4161            || entry.attrs.fingerprint() != candidate.attrs.fingerprint()
4162            || self.classify(&candidate.relative_path) != candidate.classification
4163        {
4164            return AnalysisApplyOutcome::Stale;
4165        }
4166        let Some(content) = self.content.as_mut() else {
4167            return AnalysisApplyOutcome::Stale;
4168        };
4169        if content.commit(
4170            candidate.relative_path.clone(),
4171            observation.profile,
4172            &observation.provenance,
4173            observation.analysis,
4174        ) {
4175            AnalysisApplyOutcome::Applied
4176        } else {
4177            AnalysisApplyOutcome::Stale
4178        }
4179    }
4180
4181    /// Drop all derived content while preserving metadata and snapshot compatibility.
4182    pub fn clear_content(&mut self) {
4183        self.content = None;
4184    }
4185
4186    // ---- internals ----
4187
4188    fn try_entry(&self, id: EntryId) -> Option<&Entry> {
4189        match self.arena.get(id.idx())? {
4190            Slot::Occupied { generation, entry } if *generation == id.generation => Some(entry),
4191            Slot::Occupied { .. } | Slot::Free { .. } => None,
4192        }
4193    }
4194
4195    fn expectation_matches(&self, op: &Op, expected: PathExpectation) -> bool {
4196        let current = self.path_state(op.path());
4197        // An operation whose target the index already holds changes nothing, whatever
4198        // happened to its baseline: another producer verified the same fact first and
4199        // there is no older state left to overwrite. Refusing it as stale cost the
4200        // observation handoff a full-root walk per convergent refresh, and three in a
4201        // row failed the root, for commits that would have applied as unchanged.
4202        if self.holds_target(op, current) {
4203            return true;
4204        }
4205        if current != expected.state {
4206            return false;
4207        }
4208
4209        let require_structure = match (op, expected.state) {
4210            (Op::Remove { .. }, _) => true,
4211            (Op::Upsert { kind, .. }, PathState::Present { kind: baseline, .. }) => {
4212                *kind != baseline
4213            }
4214            (
4215                Op::Upsert { .. }
4216                | Op::ControlUpsert { .. }
4217                | Op::ControlRemove { .. }
4218                | Op::InvalidateSubtree { .. },
4219                _,
4220            ) => false,
4221        };
4222        if !same_target(self.entry_identity(op.path()), expected.entry(), require_structure) {
4223            return false;
4224        }
4225
4226        match expected.absence_guard() {
4227            Some(expected) => self
4228                .absence_guard_identity(op.path())
4229                .is_some_and(|current| current.same_absence_guard(expected)),
4230            None => true,
4231        }
4232    }
4233
4234    /// Whether the index already holds what `op` would leave behind at `current`, the state
4235    /// of its path.
4236    ///
4237    /// An entry operation's target is a path state. A control operation's is the table:
4238    /// exactly its source retained at its path, or nothing retained there. The walk pushes
4239    /// a control file's entry and rules on one baseline, so both must converge together or
4240    /// the pair is refused for the rules alone. An invalidation always commits a change, so
4241    /// it is arbitrated on its baseline.
4242    fn holds_target(&self, op: &Op, current: PathState) -> bool {
4243        match op {
4244            Op::Upsert { kind, attrs, .. } => {
4245                current == PathState::Present { kind: *kind, attrs: *attrs }
4246            }
4247            Op::Remove { .. } => current == PathState::Absent,
4248            Op::ControlUpsert { path, source } => self.controls.source_is(path, source),
4249            Op::ControlRemove { path } => !self.controls.contains(path),
4250            Op::InvalidateSubtree { .. } => false,
4251        }
4252    }
4253
4254    fn absence_guard_identity(&self, path: &Path) -> Option<EntryIdentity> {
4255        let parts = normalize(path)?;
4256        let (_, ancestors) = parts.split_last()?;
4257        let mut current = EntryId::ROOT;
4258        for part in ancestors {
4259            let Some(child) = self.child(current, part) else {
4260                break;
4261            };
4262            current = child;
4263        }
4264        Some(self.identity(current))
4265    }
4266
4267    /// Prove that every accepted live upsert has a verified parent chain.
4268    ///
4269    /// The overlay follows batch order without touching the real index. That admits
4270    /// parent-first discovery batches and rejects a child whose missing or non-directory
4271    /// ancestry would otherwise be filled with guessed metadata.
4272    fn validate_known_ancestry(
4273        &self,
4274        ops: &[ObservationOp],
4275        accepted: &[bool],
4276    ) -> crate::Result<()> {
4277        if let Some((path, reconcile_from)) =
4278            self.unknown_ancestry(ops, accepted).into_iter().next()
4279        {
4280            return Err(crate::Error::UnknownAncestry { path, reconcile_from });
4281        }
4282        Ok(())
4283    }
4284
4285    fn accepted_operations(&self, ops: &[ObservationOp]) -> Vec<bool> {
4286        ops.iter()
4287            .map(|observed| match observed.expectation {
4288                Expectation::Any => true,
4289                Expectation::State(expected) => self.expectation_matches(&observed.op, expected),
4290            })
4291            .collect()
4292    }
4293
4294    /// Consume a walker-owned batch and prove every parent before mutation begins.
4295    ///
4296    /// Scanner batches contain only unconditional discoveries. The walker publishes a
4297    /// directory before any worker may enumerate it, so almost every parent resolves to
4298    /// an existing id. Serial batches may still contain a parent-first directory and its
4299    /// children together; those children retain the earlier operation index instead.
4300    /// The proof owns no duplicate paths and application performs no second path-tree
4301    /// search.
4302    ///
4303    /// A discovery can also find an entry whose kind the index no longer agrees with: a
4304    /// concurrent refresh may have replaced it after its directory was listed. Replacing a
4305    /// kind drops a subtree, so the parent ids proved here would not survive the batch.
4306    /// That rare batch is prepared for the general lane instead, which proves ancestry in
4307    /// operation order and replaces the entry as it would for any verified observation;
4308    /// the next observation of the path repairs a stale one.
4309    fn prepare_scanner_batch(
4310        &self,
4311        batch: crate::scan::ScannerBatch,
4312    ) -> crate::Result<PreparedObservation> {
4313        let ops = batch.into_ops();
4314        let mut parents = Vec::with_capacity(ops.len());
4315        let mut last_parent: Option<(&Path, ResolvedParent)> = None;
4316        let mut has_batch_parents = false;
4317        let mut path_comparisons = 0_u64;
4318        let mut replaces_kind = false;
4319
4320        for (op_index, observed) in ops.iter().enumerate() {
4321            if !matches!(observed.expectation, Expectation::Any) {
4322                return Err(crate::Error::UnsupportedScanConfig(
4323                    "scanner batches contain unconditional discoveries only",
4324                ));
4325            }
4326            let op = &observed.op;
4327            let path = op.path();
4328            if path.as_os_str().is_empty() {
4329                return Err(crate::Error::UnsupportedScanConfig(
4330                    "scanner batches cannot mutate the index root",
4331                ));
4332            }
4333            for component in path.components() {
4334                match component {
4335                    Component::Normal(_) => {}
4336                    Component::CurDir => {
4337                        return Err(crate::Error::UnsupportedScanConfig(
4338                            "scanner batches require canonical relative paths",
4339                        ));
4340                    }
4341                    Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
4342                        return Err(crate::Error::PathEscapesRoot(path.to_path_buf()));
4343                    }
4344                }
4345            }
4346            match op {
4347                Op::Upsert { .. } => {}
4348                Op::ControlUpsert { .. } | Op::ControlRemove { .. }
4349                    if crate::control::is_control_file(path) => {}
4350                Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {
4351                    return Err(crate::Error::InvalidControlPath(path.to_path_buf()));
4352                }
4353                Op::Remove { .. } | Op::InvalidateSubtree { .. } => {
4354                    return Err(crate::Error::UnsupportedScanConfig(
4355                        "scanner batches contain discoveries only",
4356                    ));
4357                }
4358            }
4359            if replaces_kind {
4360                // The general lane proves every remaining parent. Only the scanner input
4361                // contract above still applies to the rest of the batch.
4362                continue;
4363            }
4364
4365            let parent_path = path.parent().expect("a non-root relative path has a parent");
4366            if last_parent.is_some() {
4367                path_comparisons = path_comparisons.saturating_add(1);
4368            }
4369            let same_parent = last_parent
4370                .filter(|(previous, _)| *previous == parent_path)
4371                .map(|(_, parent)| parent);
4372            let parent = if let Some(parent) = same_parent {
4373                parent
4374            } else {
4375                self.lookup(parent_path)
4376                    .filter(|id| self.entry(*id).kind.is_dir())
4377                    .map(ResolvedParent::Existing)
4378                    .or_else(|| Self::earlier_scanner_parent(&ops, op_index, parent_path))
4379                    .ok_or_else(|| crate::Error::UnknownAncestry {
4380                        path: path.to_path_buf(),
4381                        reconcile_from: PathBuf::new(),
4382                    })?
4383            };
4384            if let (Op::Upsert { kind, .. }, ResolvedParent::Existing(parent)) = (op, parent) {
4385                if path.file_name().is_some_and(|name| {
4386                    self.child(parent, name).is_some_and(|child| self.entry(child).kind != *kind)
4387                }) {
4388                    replaces_kind = true;
4389                    continue;
4390                }
4391            }
4392            has_batch_parents |= matches!(parent, ResolvedParent::Earlier(_));
4393            parents.push(parent);
4394            last_parent = Some((parent_path, parent));
4395        }
4396
4397        if replaces_kind {
4398            return prepare_observation(&Observation::from_ops(ops));
4399        }
4400
4401        crate::counters::bump(|counts| {
4402            counts.ancestry_path_comparisons =
4403                counts.ancestry_path_comparisons.saturating_add(path_comparisons);
4404            counts.ancestry_parent_proofs = counts
4405                .ancestry_parent_proofs
4406                .saturating_add(u64::try_from(ops.len()).unwrap_or(u64::MAX));
4407        });
4408        Ok(PreparedObservation {
4409            ops,
4410            ancestry: PreparedAncestry::Scanner { parents, has_batch_parents },
4411            #[cfg(test)]
4412            reject_before_apply: false,
4413        })
4414    }
4415
4416    /// Resolve a parent produced earlier in the same scanner batch.
4417    fn earlier_scanner_parent(
4418        ops: &[ObservationOp],
4419        before: usize,
4420        parent_path: &Path,
4421    ) -> Option<ResolvedParent> {
4422        let (op_index, op) = ops[..before].iter().enumerate().rev().find(
4423            |(_, observed)| matches!(&observed.op, Op::Upsert { path, .. } if path == parent_path),
4424        )?;
4425        let Op::Upsert { kind, .. } = &op.op else {
4426            unreachable!("the search selected an upsert");
4427        };
4428        kind.is_dir().then_some(ResolvedParent::Earlier(op_index))
4429    }
4430
4431    /// Evaluate the complete resulting control table before any fact or reducer moves.
4432    ///
4433    /// Only a malformed control path fails here; a source the bounds cannot admit is
4434    /// refused inside the projection. Building the whole table first keeps a failing
4435    /// observation fault-atomic even when the same batch also moves ordinary entries.
4436    fn projected_controls(
4437        &self,
4438        ops: &[ObservationOp],
4439        accepted: &[bool],
4440    ) -> crate::Result<Option<crate::control::ControlTable>> {
4441        self.projected_controls_from(
4442            ops.iter()
4443                .zip(accepted)
4444                .filter_map(|(observed, accepted)| accepted.then_some(&observed.op)),
4445        )
4446    }
4447
4448    /// The table this batch would leave behind, or `None` when it leaves the current one.
4449    fn projected_controls_from<'a>(
4450        &self,
4451        ops: impl Iterator<Item = &'a Op> + Clone,
4452    ) -> crate::Result<Option<crate::control::ControlTable>> {
4453        if self.controls_unchanged_by(ops.clone()) {
4454            return Ok(None);
4455        }
4456        let mut projected = self.controls.clone();
4457        #[cfg(test)]
4458        CONTROL_PROJECTION_CLONES.with(|clones| clones.set(clones.get() + 1));
4459        let mut structure = StructuralOverlay::default();
4460        for op in ops {
4461            match op {
4462                Op::Upsert { path, kind, .. } => {
4463                    if crate::control::is_control_file(path) && *kind != EntryKind::File {
4464                        projected.remove(path)?;
4465                    }
4466                    if structure.kind(self, path) == Some(EntryKind::Dir) && !kind.is_dir() {
4467                        projected.remove_subtree(path);
4468                    }
4469                    structure.upsert(self, path, *kind);
4470                }
4471                Op::Remove { path } => {
4472                    if crate::control::is_control_file(path) {
4473                        projected.remove(path)?;
4474                    }
4475                    projected.remove_subtree(path);
4476                    structure.remove(self, path);
4477                }
4478                Op::ControlUpsert { path, source } => {
4479                    projected.upsert(path, source.clone())?;
4480                }
4481                Op::ControlRemove { path } => {
4482                    projected.remove(path)?;
4483                }
4484                Op::InvalidateSubtree { .. } => {}
4485            }
4486        }
4487        Ok(Some(projected))
4488    }
4489
4490    /// Whether no operation in the batch can change the retained control table.
4491    ///
4492    /// Only control ops write the table, and only a structural removal prunes it, so a
4493    /// batch whose control ops are all inert against this table and whose structural ops
4494    /// touch nothing it records leaves it exactly as it is. Each op is decided against the
4495    /// current table rather than against the projection, which is the same thing: an inert
4496    /// op leaves the state the next one is decided against unchanged.
4497    ///
4498    /// This is what keeps a warm revalidate of a tree past its budget from cloning the
4499    /// whole table for every batch of re-read refusals (fdu-hzm5), and a cold scan of a
4500    /// tree with no control files from projecting an empty table onto an empty one
4501    /// (fdu-pro1). A malformed control path is never inert, so the projection still
4502    /// reports it.
4503    fn controls_unchanged_by<'a>(&self, ops: impl Iterator<Item = &'a Op>) -> bool {
4504        // A vacant table decides every op from its kind alone, which is what the cold
4505        // no-controls lane costs per entry: there is nothing for a structural op to drop or
4506        // prune, and no source is inert against it, since `Unchanged` needs a retained
4507        // source and `Refuse` a matching refusal. A removal still asks, so a malformed
4508        // control path stays non-inert and the projection reports it.
4509        if self.controls.is_vacant() {
4510            return ops.into_iter().all(|op| match op {
4511                Op::ControlUpsert { .. } => false,
4512                Op::ControlRemove { path } => self.controls.remove_is_inert(path),
4513                Op::Upsert { .. } | Op::Remove { .. } | Op::InvalidateSubtree { .. } => true,
4514            });
4515        }
4516
4517        ops.into_iter().all(|op| match op {
4518            Op::ControlUpsert { path, source } => self.controls.upsert_is_inert(path, source),
4519            Op::ControlRemove { path } => self.controls.remove_is_inert(path),
4520            Op::Upsert { path, kind, .. } => {
4521                // The kind first: it is one discriminant test, where naming a control file
4522                // parses the path's last component.
4523                let drops_control = *kind != EntryKind::File
4524                    && crate::control::is_control_file(path)
4525                    && self.controls.contains(path);
4526                let prunes_subtree = !kind.is_dir() && self.controls.has_record_at_or_below(path);
4527                !drops_control && !prunes_subtree
4528            }
4529            Op::Remove { path } => {
4530                let drops_control =
4531                    crate::control::is_control_file(path) && self.controls.contains(path);
4532                !drops_control && !self.controls.has_record_at_or_below(path)
4533            }
4534            Op::InvalidateSubtree { .. } => true,
4535        })
4536    }
4537
4538    fn apply_control_transition<C: ConsequenceSink>(
4539        &mut self,
4540        projected: Option<crate::control::ControlTable>,
4541        stats: &mut ApplyStats,
4542        effects: &mut C,
4543    ) {
4544        let Some(projected) = projected else {
4545            return;
4546        };
4547        let changes = projected.changes_from(&self.controls);
4548        let refusals = projected.refusal_changes_from(&self.controls);
4549        if changes.is_empty() && refusals.is_empty() {
4550            return;
4551        }
4552        let affected: Vec<PathBuf> = changes
4553            .iter()
4554            .filter_map(|(path, _, _)| crate::control::ControlTable::affected_subtree(path).ok())
4555            .collect();
4556        self.controls = projected;
4557        stats.controls = u64::try_from(changes.len() + refusals.len()).unwrap_or(u64::MAX);
4558        for (path, previous, current) in changes {
4559            effects.change(|| EffectiveChange::ControlUpdated { path, previous, current });
4560        }
4561        // A refusal changes what classification covers, not what it says, so it moves no
4562        // entry by itself; the source it may have dropped arrived as a change above.
4563        for (path, previous, current) in refusals {
4564            effects.change(|| EffectiveChange::ControlRefusalUpdated { path, previous, current });
4565        }
4566        self.reclassify_controlled_subtrees(&affected, stats, effects);
4567    }
4568
4569    /// Re-evaluate only subtrees governed by changed controls, then rebuild the fixed
4570    /// unignored reducer from the resulting facts.
4571    fn reclassify_controlled_subtrees<C: ConsequenceSink>(
4572        &mut self,
4573        affected: &[PathBuf],
4574        stats: &mut ApplyStats,
4575        effects: &mut C,
4576    ) {
4577        let mut roots: Vec<PathBuf> = affected.to_vec();
4578        roots.sort();
4579        roots.dedup();
4580        let mut collapsed = Vec::new();
4581        for root in roots {
4582            if collapsed.iter().any(|ancestor: &PathBuf| root.starts_with(ancestor)) {
4583                continue;
4584            }
4585            collapsed.push(root);
4586        }
4587
4588        let mut moved = false;
4589        for root in collapsed {
4590            let Some(root_id) = self.lookup(&root) else {
4591                continue;
4592            };
4593            let children: Vec<(PathBuf, EntryId)> = self
4594                .children_of(root_id)
4595                .expect("controlled subtree root is live")
4596                .map(|(name, id)| (root.join(name), id))
4597                .collect();
4598            let mut queue = VecDeque::from(children);
4599            while let Some((path, id)) = queue.pop_front() {
4600                #[cfg(test)]
4601                RECLASSIFY_VISITS.with(|visits| visits.set(visits.get() + 1));
4602                let entry = self.entry(id);
4603                let parent_ignored = entry.parent.is_some_and(|parent| self.entry(parent).ignored);
4604                let current = entry.ignored;
4605                let next = parent_ignored
4606                    || self.controls.matcher_for(&path).is_ignored(entry.kind.is_dir());
4607                let descendants: Vec<(PathBuf, EntryId)> = self
4608                    .children_of(id)
4609                    .expect("controlled subtree entry is live")
4610                    .map(|(name, child)| (path.join(name), child))
4611                    .collect();
4612                if current != next {
4613                    self.move_serving_file_partition(&path, id, current, next);
4614                    self.entry_mut(id).ignored = next;
4615                    stats.reclassified += 1;
4616                    effects.change(|| EffectiveChange::Reclassified {
4617                        path: path.clone(),
4618                        previous_ignored: current,
4619                        current_ignored: next,
4620                    });
4621                    moved = true;
4622                }
4623                queue.extend(descendants);
4624            }
4625        }
4626        if moved {
4627            self.rebuild_unignored_rollups();
4628        }
4629    }
4630
4631    fn rebuild_unignored_rollups(&mut self) {
4632        let mut order = Vec::with_capacity(usize::try_from(self.live).unwrap_or(0));
4633        let mut stack = vec![EntryId::ROOT];
4634        while let Some(id) = stack.pop() {
4635            order.push(id);
4636            if self.entry(id).kind.is_dir() {
4637                stack.extend(self.child_ids(id));
4638            }
4639            if self.entry(id).kind.is_dir() {
4640                self.entry_mut(id).rollup_mut().unignored = InternedRollUp::default();
4641            }
4642        }
4643        for id in order.into_iter().rev() {
4644            let Some(parent) = self.entry(id).parent else {
4645                continue;
4646            };
4647            let contribution = self.contribution(id).unignored;
4648            self.entry_mut(parent).rollup_mut().unignored.merge(&contribution);
4649        }
4650    }
4651
4652    fn unknown_ancestry(
4653        &self,
4654        ops: &[ObservationOp],
4655        accepted: &[bool],
4656    ) -> Vec<(PathBuf, PathBuf)> {
4657        let mut structure = StructuralOverlay::default();
4658        let mut unknown = Vec::new();
4659        let mut overlay_inserts = 0_u64;
4660        let mut path_comparisons = 0_u64;
4661        let mut parent_proofs = 0_u64;
4662        let count_preflight = crate::counters::enabled();
4663        // The last directory this pass proved, with every ancestor of it. A producer
4664        // emits a directory's children together, so consecutive ops overwhelmingly
4665        // share a parent, and re-proving the same chain per op was the largest single
4666        // allocation cost of a cold scan (fdu-pro1): one component vector plus one
4667        // ancestor path rebuilt push-by-push, per entry, for an answer that had not
4668        // changed since the previous entry. The memo is invalidated wherever this loop
4669        // learns something that could change an answer -- a non-directory upsert or a
4670        // removal -- exactly like `ParentMemo` in the apply loop below.
4671        let mut proven_dir: Option<PathBuf> = None;
4672        let mut ancestor = PathBuf::new();
4673        for (observed, accepted) in ops.iter().zip(accepted) {
4674            if !accepted {
4675                continue;
4676            }
4677            match &observed.op {
4678                Op::Upsert { path, .. } | Op::ControlUpsert { path, .. }
4679                    if !path.as_os_str().is_empty() =>
4680                {
4681                    if count_preflight && proven_dir.is_some() {
4682                        path_comparisons = path_comparisons.saturating_add(1);
4683                    }
4684                    let same_proven_parent = matches!(
4685                        (path.parent(), proven_dir.as_deref()),
4686                        (Some(parent), Some(proven)) if parent == proven
4687                    );
4688                    if same_proven_parent {
4689                        if count_preflight {
4690                            parent_proofs = parent_proofs.saturating_add(1);
4691                        }
4692                    } else {
4693                        let mut reconcile_from = PathBuf::new();
4694                        let mut ancestry_known = true;
4695                        let parts = normalize(path).expect("prepared paths are canonical");
4696                        let (_, ancestors) = parts.split_last().expect("non-root path has a name");
4697                        ancestor.clear();
4698                        for part in ancestors {
4699                            ancestor.push(part);
4700                            if structure.kind(self, &ancestor) != Some(EntryKind::Dir) {
4701                                unknown.push((path.clone(), reconcile_from));
4702                                ancestry_known = false;
4703                                break;
4704                            }
4705                            reconcile_from.clone_from(&ancestor);
4706                        }
4707                        if !ancestry_known {
4708                            proven_dir = None;
4709                            continue;
4710                        }
4711                        if count_preflight {
4712                            parent_proofs = parent_proofs.saturating_add(1);
4713                        }
4714                        match &mut proven_dir {
4715                            Some(proven) => {
4716                                proven.clear();
4717                                path.parent().unwrap_or(Path::new("")).clone_into(proven);
4718                            }
4719                            None => {
4720                                proven_dir =
4721                                    Some(path.parent().unwrap_or(Path::new("")).to_path_buf());
4722                            }
4723                        }
4724                    }
4725                    if let Op::Upsert { kind, .. } = &observed.op {
4726                        structure.upsert(self, path, *kind);
4727                        if count_preflight {
4728                            overlay_inserts = overlay_inserts.saturating_add(1);
4729                        }
4730                        if !kind.is_dir() {
4731                            // This path may itself have been somebody's proven ancestor
4732                            // only if it was a directory before; the overlay knows, but
4733                            // the memo does not, so it forgets rather than reasons.
4734                            if proven_dir.as_deref().is_some_and(|proven| proven.starts_with(path))
4735                            {
4736                                proven_dir = None;
4737                            }
4738                        }
4739                    }
4740                }
4741                Op::Remove { path } if !path.as_os_str().is_empty() => {
4742                    structure.remove(self, path);
4743                    if proven_dir.as_deref().is_some_and(|proven| proven.starts_with(path)) {
4744                        proven_dir = None;
4745                    }
4746                }
4747                Op::Upsert { .. }
4748                | Op::Remove { .. }
4749                | Op::ControlUpsert { .. }
4750                | Op::ControlRemove { .. }
4751                | Op::InvalidateSubtree { .. } => {}
4752            }
4753        }
4754        if count_preflight {
4755            crate::counters::bump(|counts| {
4756                counts.ancestry_overlay_inserts =
4757                    counts.ancestry_overlay_inserts.saturating_add(overlay_inserts);
4758                counts.ancestry_path_comparisons =
4759                    counts.ancestry_path_comparisons.saturating_add(path_comparisons);
4760                counts.ancestry_parent_proofs =
4761                    counts.ancestry_parent_proofs.saturating_add(parent_proofs);
4762            });
4763        }
4764        unknown
4765    }
4766
4767    fn entry_identity(&self, path: &Path) -> Option<EntryIdentity> {
4768        Some(self.identity(self.lookup(path)?))
4769    }
4770
4771    fn identity(&self, id: EntryId) -> EntryIdentity {
4772        let entry = self.entry(id);
4773        EntryIdentity::new(
4774            id.slot,
4775            id.generation,
4776            entry.revision,
4777            entry.directory.as_deref().map_or(0, |directory| directory.children_revision),
4778            entry.kind.is_dir(),
4779        )
4780    }
4781
4782    fn bump_revision(entry: &mut Entry) {
4783        entry.revision = entry.revision.checked_add(1).expect("entry revision exhausted");
4784    }
4785
4786    fn bump_children_revision(entry: &mut Entry) {
4787        let directory = entry.directory_mut();
4788        directory.children_revision =
4789            directory.children_revision.checked_add(1).expect("entry children revision exhausted");
4790    }
4791
4792    fn child(&self, parent: EntryId, name: &OsStr) -> Option<EntryId> {
4793        let children = &self.entry(parent).directory.as_deref()?.children;
4794        match children {
4795            DirectoryChildren::Sorted(ids) => ids
4796                .binary_search_by(|id| self.entry(*id).name.as_os_str().cmp(name))
4797                .ok()
4798                .map(|position| ids[position]),
4799            DirectoryChildren::Mutable(children) => children.get(name).copied(),
4800        }
4801    }
4802
4803    fn child_ids(&self, parent: EntryId) -> ChildIds<'_> {
4804        self.entry(parent).directory().children.ids()
4805    }
4806
4807    /// Promote one compact, completed directory when its first mutation arrives.
4808    ///
4809    /// Detached indexes keep each name only on its child entry. Arbitrary public
4810    /// mutation needs keyed insertion and removal, so the touched parent pays the
4811    /// name clones once; untouched one-shot topology stays compact.
4812    fn promote_children(&mut self, parent: EntryId) {
4813        let ids = match &mut self.entry_mut(parent).directory_mut().children {
4814            DirectoryChildren::Sorted(ids) => std::mem::take(ids),
4815            DirectoryChildren::Mutable(_) => return,
4816        };
4817        let expected = ids.len();
4818        let children =
4819            ids.into_iter().map(|id| (self.entry(id).name.clone(), id)).collect::<BTreeMap<_, _>>();
4820        assert_eq!(
4821            children.len(),
4822            expected,
4823            "compact child names must remain unique before promotion"
4824        );
4825        self.entry_mut(parent).directory_mut().children = DirectoryChildren::Mutable(children);
4826    }
4827
4828    fn insert_child(&mut self, parent: EntryId, name: OsString, child: EntryId) {
4829        self.promote_children(parent);
4830        let entry = self.entry_mut(parent);
4831        let DirectoryChildren::Mutable(children) = &mut entry.directory_mut().children else {
4832            unreachable!("child promotion produces mutable storage")
4833        };
4834        children.insert(name, child);
4835        Self::bump_children_revision(entry);
4836    }
4837
4838    fn reserve_detached_children(&mut self, parent: EntryId, additional: usize) {
4839        let entry = self.entry_mut(parent);
4840        let DirectoryChildren::Sorted(children) = &mut entry.directory_mut().children else {
4841            unreachable!("detached directories retain sorted child storage")
4842        };
4843        children.reserve(additional);
4844    }
4845
4846    fn push_detached_child(&mut self, parent: EntryId, child: EntryId) {
4847        let entry = self.entry_mut(parent);
4848        let DirectoryChildren::Sorted(children) = &mut entry.directory_mut().children else {
4849            unreachable!("detached directories retain sorted child storage")
4850        };
4851        children.push(child);
4852        Self::bump_children_revision(entry);
4853    }
4854
4855    /// Merge a completed detached directory without cloning its retained roll-up.
4856    fn merge_detached_descendants(&mut self, parent: EntryId, child: EntryId) {
4857        debug_assert!(parent.idx() < child.idx(), "cold parents must precede descendants");
4858        let (parents, children) = self.arena.split_at_mut(child.idx());
4859        let child_rollup = match &children[0] {
4860            Slot::Occupied { generation, entry } if *generation == child.generation => {
4861                entry.rollup()
4862            }
4863            Slot::Occupied { .. } | Slot::Free { .. } => {
4864                panic!("detached child handle must be live: {child:?}")
4865            }
4866        };
4867        let parent_entry = match &mut parents[parent.idx()] {
4868            Slot::Occupied { generation, entry } if *generation == parent.generation => entry,
4869            Slot::Occupied { .. } | Slot::Free { .. } => {
4870                panic!("detached parent handle must be live: {parent:?}")
4871            }
4872        };
4873        parent_entry.rollup_mut().merge(child_rollup);
4874    }
4875
4876    fn remove_child(&mut self, parent: EntryId, name: &OsStr) {
4877        self.promote_children(parent);
4878        let entry = self.entry_mut(parent);
4879        let DirectoryChildren::Mutable(children) = &mut entry.directory_mut().children else {
4880            unreachable!("child promotion produces mutable storage")
4881        };
4882        if children.remove(name).is_some() {
4883            Self::bump_children_revision(entry);
4884        }
4885    }
4886
4887    fn entry(&self, id: EntryId) -> &Entry {
4888        self.try_entry(id).expect("internal entry handle must be live")
4889    }
4890
4891    fn entry_mut(&mut self, id: EntryId) -> &mut Entry {
4892        match self.arena.get_mut(id.idx()) {
4893            Some(Slot::Occupied { generation, entry }) if *generation == id.generation => entry,
4894            Some(Slot::Occupied { .. } | Slot::Free { .. }) | None => {
4895                panic!("internal entry handle must be live: {id:?}")
4896            }
4897        }
4898    }
4899
4900    fn alloc(&mut self, entry: Entry) -> EntryId {
4901        crate::counters::bump(|c| c.entries_allocated += 1);
4902        self.live += 1;
4903        if let Some(free_slot) = self.free_head {
4904            let free_idx = free_slot as usize;
4905            let (generation, next) = match &self.arena[free_idx] {
4906                Slot::Free { generation, next_free } => (*generation, *next_free),
4907                Slot::Occupied { .. } => unreachable!("free list pointed at a live slot"),
4908            };
4909            self.free_head = next;
4910            self.arena[free_idx] = Slot::Occupied { generation, entry };
4911            return EntryId { slot: free_slot, generation };
4912        }
4913        let slot = u32::try_from(self.arena.len()).expect("index arena exceeded u32 capacity");
4914        let id = EntryId { slot, generation: 0 };
4915        self.arena.push(Slot::Occupied { generation: 0, entry });
4916        id
4917    }
4918
4919    fn free(&mut self, id: EntryId) {
4920        let next_generation = match &self.arena[id.idx()] {
4921            Slot::Occupied { generation, .. } if *generation == id.generation => {
4922                generation.checked_add(1).expect("entry generation exhausted")
4923            }
4924            Slot::Occupied { .. } | Slot::Free { .. } => {
4925                panic!("internal entry handle must be live: {id:?}")
4926            }
4927        };
4928        self.arena[id.idx()] =
4929            Slot::Free { generation: next_generation, next_free: self.free_head };
4930        self.free_head = Some(id.slot);
4931        self.live -= 1;
4932    }
4933
4934    /// Wall-clock now, in nanoseconds since the epoch, or zero if the clock is before
4935    /// it. Provenance timestamps are for display, so a nonsensical clock reads as
4936    /// "unknown" rather than propagating an error through every constructor.
4937    fn now_unix_nanos() -> i64 {
4938        std::time::SystemTime::now()
4939            .duration_since(std::time::UNIX_EPOCH)
4940            .ok()
4941            .and_then(|since| i64::try_from(since.as_nanos()).ok())
4942            .unwrap_or(0)
4943    }
4944
4945    /// Provenance of one path: where its value came from, when, and how settled.
4946    ///
4947    /// Built on demand from the entry's stored source and the index's timestamps
4948    /// rather than read from a field, because the timestamps are shared by nearly
4949    /// every entry and storing them per entry would cost far more than the
4950    /// information is worth.
4951    ///
4952    /// # Two limitations, both tracked
4953    ///
4954    /// **This reports the entry's own provenance, not its subtree's.** A directory
4955    /// whose descendants are less trustworthy than itself will still report its own
4956    /// source, so a `Complete`/`Revalidated` directory can contain `Cached` children.
4957    /// Composition belongs in the roll-up, where it costs one merge rather than an
4958    /// O(subtree) walk per query, and it is not implemented yet (`fdu-fka6`,
4959    /// `fdu-b1ts`). Do not read a directory's provenance as a subtree guarantee.
4960    ///
4961    /// A completed reconciliation records one clocked [`StateTransition::Verified`]
4962    /// for its subtree, including when every entry was unchanged. Consumers of exact
4963    /// commits therefore observe the same provenance movement as readers of this view.
4964    pub fn provenance(&self, path: &Path) -> Option<Provenance> {
4965        let id = self.lookup(path)?;
4966        Some(self.provenance_of(id))
4967    }
4968
4969    fn provenance_of(&self, id: EntryId) -> Provenance {
4970        let entry = self.entry(id);
4971        let status = self.status_of(id);
4972        // A completed sweep over an ancestor verified this entry even if no delta ever
4973        // named it, so an interval beats the entry's own stamp.
4974        //
4975        // Only while the index still considers the path fresh, though. An
4976        // `InvalidateSubtree` marks paths `Stale` and a running sweep marks them
4977        // `Reconciling`; in both cases trust has been withdrawn since the interval was
4978        // recorded, and promoting anyway would produce the self-contradicting answer
4979        // "partial, and verified".
4980        //
4981        // This applies to entries a delta *did* name, too, not only the ones it
4982        // skipped. Those were stamped `Revalidated` by the sweep, but their timestamp
4983        // would otherwise come from `observed_at`, which dates `Revalidated` to when
4984        // the index was constructed. One sweep would then report two different "as of"
4985        // times for equally verified paths — the elided siblings dated correctly to the
4986        // sweep, the touched entries dated to construction — and a consumer comparing
4987        // two rows could not tell which discipline it was reading.
4988        //
4989        // `Scanned` is excluded because it is *stronger* than `Revalidated`: a path
4990        // walked fresh this session is not improved by a sweep having covered it, and
4991        // its own scan time is already the right answer.
4992        if entry.source >= Source::Revalidated {
4993            if let Some(path) = self.path_of(id) {
4994                if self.freshness_at(&path) == Freshness::Fresh {
4995                    if let Some(verified_at) = self.verified_at(&path) {
4996                        return Provenance {
4997                            source: Source::Revalidated,
4998                            observed_at_ns: verified_at,
4999                            status,
5000                        };
5001                    }
5002                }
5003            }
5004        }
5005        Provenance { source: entry.source, observed_at_ns: self.observed_at(entry.source), status }
5006    }
5007
5008    /// Whether this path's totals account for everything beneath it.
5009    ///
5010    /// Derived from the freshness marks rather than stored, and answering the coverage
5011    /// question only. `Reconciling` and `Stale` describe values whose *trust* is in
5012    /// doubt while their coverage is not: a cached subtree still accounts for every
5013    /// entry it knows about, and saying otherwise would report a complete cached
5014    /// baseline as if it were half-built. That distinction is [`Source`]'s job, and
5015    /// collapsing the two axes is what let a value that may shrink advertise itself as
5016    /// a lower bound that can only grow.
5017    ///
5018    /// Only [`Freshness::Partial`] — reconciliation errors left some of the subtree
5019    /// unread — is genuinely missing coverage.
5020    fn status_of(&self, id: EntryId) -> Status {
5021        let Some(path) = self.path_of(id) else {
5022            return Status::Complete;
5023        };
5024        match self.freshness_at(&path) {
5025            Freshness::Fresh | Freshness::Reconciling | Freshness::Stale => Status::Complete,
5026            Freshness::Partial => Status::Partial,
5027        }
5028    }
5029
5030    /// When an entry with this source was observed.
5031    const fn observed_at(&self, source: Source) -> i64 {
5032        match source {
5033            Source::Cached | Source::JournalScoped => self.captured_at_ns,
5034            Source::Scanned | Source::Revalidated => self.scanned_at_ns,
5035        }
5036    }
5037
5038    /// The start of the pass a snapshot of this index records as the one that wrote its
5039    /// image.
5040    pub(crate) const fn writing_pass_started_at_ns(&self) -> i64 {
5041        self.writing_pass_started_at_ns
5042    }
5043
5044    /// Record the pass start a loaded snapshot carried for the facts it restored.
5045    pub(crate) fn set_writing_pass_started_at_ns(&mut self, writing_pass_started_at_ns: i64) {
5046        self.writing_pass_started_at_ns = writing_pass_started_at_ns;
5047    }
5048
5049    /// Whether this index holds entry-tier facts no completed metadata write has recorded.
5050    pub(crate) const fn persistence_owed(&self) -> bool {
5051        self.persistence_owed
5052    }
5053
5054    /// Record that a metadata write of this index completed, or that a pass mutated it
5055    /// since the last one did.
5056    pub(crate) fn set_persistence_owed(&mut self, owed: bool) {
5057        self.persistence_owed = owed;
5058    }
5059
5060    /// Stamp deltas applied from here on with `source`, restoring the previous value
5061    /// when the returned guard value is passed back.
5062    ///
5063    /// Used by snapshot loading, which is replaying observations that describe a tree
5064    /// as it was, not as this process has seen it.
5065    pub(crate) fn set_applying_source(&mut self, source: Source, captured_at_ns: i64) -> Source {
5066        let previous = self.applying_source;
5067        self.applying_source = source;
5068        if source == Source::Cached {
5069            self.state.source = Source::Cached;
5070        }
5071        if captured_at_ns != 0 {
5072            self.captured_at_ns = captured_at_ns;
5073        }
5074        previous
5075    }
5076
5077    /// Intern an extension name and retain one file's reference to it.
5078    ///
5079    /// Every call must be matched by a [`Self::release_ext`] when that file leaves the
5080    /// index, which is what keeps the interner proportional to the extensions the tree
5081    /// currently holds rather than to every extension it has ever held.
5082    fn intern_ext(&mut self, name: &str) -> ExtId {
5083        if let Some(&id) = self.ext_ids.get(name) {
5084            let refcount =
5085                self.ext_refcounts.get_mut(id as usize).expect("a live id has a refcount");
5086            *refcount = refcount.checked_add(1).expect("extension refcount exhausted");
5087            return id;
5088        }
5089        let id = if let Some(id) = self.free_ext_ids.pop() {
5090            let slot = id as usize;
5091            self.ext_names[slot] = Some(name.to_string());
5092            self.ext_refcounts[slot] = 1;
5093            id
5094        } else {
5095            let id = ExtId::try_from(self.ext_names.len()).expect("extension interner exhausted");
5096            self.ext_names.push(Some(name.to_string()));
5097            self.ext_refcounts.push(1);
5098            id
5099        };
5100        self.ext_ids.insert(name.to_string(), id);
5101        id
5102    }
5103
5104    /// Drop one file's reference, freeing the id and its name after the last one.
5105    fn release_ext(&mut self, id: ExtId) {
5106        let slot = id as usize;
5107        let refcount = self.ext_refcounts.get_mut(slot).expect("a live id has a refcount");
5108        debug_assert!(*refcount > 0, "extension reference released twice");
5109        *refcount -= 1;
5110        if *refcount != 0 {
5111            return;
5112        }
5113        let name = self.ext_names[slot].take().expect("a live id has a name");
5114        let removed = self.ext_ids.remove(&name);
5115        debug_assert_eq!(removed, Some(id), "the interner's two maps disagreed");
5116        self.free_ext_ids.push(id);
5117    }
5118
5119    /// Resolve hot-path integer keys exactly once at a public query boundary.
5120    fn named_rollup(&self, rollup: &InternedRollUp) -> RollUp {
5121        let by_ext = rollup
5122            .by_ext
5123            .iter()
5124            .map(|(id, tally)| {
5125                let name = self
5126                    .ext_names
5127                    .get(*id as usize)
5128                    .and_then(Option::as_ref)
5129                    .expect("a live roll-up's extension id has a name");
5130                (name.clone(), *tally)
5131            })
5132            .collect();
5133        RollUp {
5134            files: rollup.files,
5135            dirs: rollup.dirs,
5136            bytes: rollup.bytes,
5137            allocated: rollup.allocated,
5138            newest_mtime_ns: rollup.newest_mtime_ns,
5139            by_ext,
5140        }
5141    }
5142
5143    fn named_partitions(&self, rollup: &InternedPartitionRollUp) -> PartitionRollUp {
5144        PartitionRollUp {
5145            all: self.named_rollup(&rollup.all),
5146            unignored: self.named_rollup(&rollup.unignored),
5147        }
5148    }
5149
5150    /// What an entry contributes to each of its ancestors.
5151    fn contribution(&self, id: EntryId) -> InternedPartitionRollUp {
5152        let entry = self.entry(id);
5153        match entry.kind {
5154            EntryKind::Dir => {
5155                let mut all = entry.rollup().all.clone();
5156                all.dirs += 1;
5157                let mut unignored = InternedRollUp::default();
5158                if !entry.ignored {
5159                    unignored = entry.rollup().unignored.clone();
5160                    unignored.dirs += 1;
5161                }
5162                InternedPartitionRollUp { all, unignored }
5163            }
5164            EntryKind::File => {
5165                let mut all = InternedRollUp {
5166                    files: 1,
5167                    dirs: 0,
5168                    bytes: entry.attrs.size,
5169                    allocated: entry.attrs.allocated,
5170                    newest_mtime_ns: entry.attrs.mtime_ns,
5171                    by_ext: BTreeMap::new(),
5172                };
5173                if let Some(ext_id) = entry.ext_id {
5174                    all.by_ext.insert(
5175                        ext_id,
5176                        ExtTally {
5177                            files: 1,
5178                            bytes: entry.attrs.size,
5179                            allocated: entry.attrs.allocated,
5180                        },
5181                    );
5182                }
5183                let unignored = if entry.ignored { InternedRollUp::default() } else { all.clone() };
5184                InternedPartitionRollUp { all, unignored }
5185            }
5186            EntryKind::Symlink | EntryKind::Other => InternedPartitionRollUp::default(),
5187        }
5188    }
5189
5190    fn merge_upward(
5191        &mut self,
5192        from_parent: Option<EntryId>,
5193        contribution: &InternedPartitionRollUp,
5194    ) {
5195        let mut current = from_parent;
5196        while let Some(id) = current {
5197            // Counted per level rather than per call: the O(depth) shape is the thing
5198            // worth seeing, and it is what S4's bottom-up pass would collapse.
5199            crate::counters::bump(|c| c.rollup_merges += 1);
5200            let entry = self.entry_mut(id);
5201            entry.rollup_mut().merge(contribution);
5202            current = entry.parent;
5203        }
5204    }
5205
5206    fn unmerge_upward(
5207        &mut self,
5208        from_parent: Option<EntryId>,
5209        contribution: &InternedPartitionRollUp,
5210    ) {
5211        let mut current = from_parent;
5212        while let Some(id) = current {
5213            let entry = self.entry_mut(id);
5214            entry.rollup_mut().unmerge(contribution);
5215            current = entry.parent;
5216        }
5217    }
5218
5219    /// Rebuild `newest_mtime_ns` from direct children, walking to the root.
5220    ///
5221    /// Every ancestor must be visited even when the nearest directory is already
5222    /// correct. Differential unmerge/re-merge can repair a single-child directory as
5223    /// it goes while leaving an ancestor with other contributors holding the removed
5224    /// maximum. Stopping at the first unchanged directory therefore strands a stale
5225    /// value higher in the tree.
5226    fn recompute_newest_upward(&mut self, from: Option<EntryId>) {
5227        let mut current = from;
5228        while let Some(id) = current {
5229            let mut newest: Option<i64> = None;
5230            for child in self.child_ids(id) {
5231                let child_entry = self.entry(child);
5232                let candidate = match child_entry.kind {
5233                    EntryKind::Dir => (child_entry.rollup().files > 0)
5234                        .then_some(child_entry.rollup().newest_mtime_ns),
5235                    EntryKind::File => Some(child_entry.attrs.mtime_ns),
5236                    EntryKind::Symlink | EntryKind::Other => None,
5237                };
5238                if let Some(candidate) = candidate {
5239                    newest = Some(newest.map_or(candidate, |current| current.max(candidate)));
5240                }
5241            }
5242            let newest = newest.unwrap_or(0);
5243            self.entry_mut(id).rollup_mut().all.newest_mtime_ns = newest;
5244
5245            let mut newest_unignored: Option<i64> = None;
5246            for child in self.child_ids(id) {
5247                let child_entry = self.entry(child);
5248                let candidate = match child_entry.kind {
5249                    EntryKind::Dir => (!child_entry.ignored
5250                        && child_entry.rollup().unignored.files > 0)
5251                        .then_some(child_entry.rollup().unignored.newest_mtime_ns),
5252                    EntryKind::File => (!child_entry.ignored).then_some(child_entry.attrs.mtime_ns),
5253                    EntryKind::Symlink | EntryKind::Other => None,
5254                };
5255                if let Some(candidate) = candidate {
5256                    newest_unignored =
5257                        Some(newest_unignored.map_or(candidate, |current| current.max(candidate)));
5258                }
5259            }
5260            let entry = self.entry_mut(id);
5261            entry.rollup_mut().unignored.newest_mtime_ns = newest_unignored.unwrap_or(0);
5262            current = entry.parent;
5263        }
5264    }
5265
5266    /// Resolve a parent chain already proved by [`Self::validate_known_ancestry`].
5267    fn resolve_dir_chain(&self, parts: &[&OsStr]) -> EntryId {
5268        let mut current = EntryId::ROOT;
5269        for part in parts {
5270            current = self
5271                .child(current, part)
5272                .expect("validated ancestry remains present under the writer lock");
5273            debug_assert!(self.entry(current).kind.is_dir());
5274        }
5275        current
5276    }
5277
5278    fn apply_upsert<C: ConsequenceSink>(
5279        &mut self,
5280        path: &Path,
5281        kind: EntryKind,
5282        attrs: Attrs,
5283        stats: &mut ApplyStats,
5284        effects: &mut C,
5285        parent_memo: &mut ParentMemo,
5286    ) -> bool {
5287        // A walker reports a directory's children consecutively, because that is the
5288        // order one `getdents64` batch hands them over, so the parent resolved for the
5289        // previous entry is almost always the parent of this one. Checking that first
5290        // turns the common case into a single path comparison and skips both the
5291        // component vector and the descent below.
5292        crate::counters::bump(|c| c.upserts += 1);
5293        if let (Some(dir), Some(name)) = (path.parent(), path.file_name()) {
5294            if let Some(parent) = parent_memo.get(dir) {
5295                crate::counters::bump(|c| c.parent_memo_hits += 1);
5296                return self.upsert_beneath(parent, name, path, kind, attrs, stats, effects);
5297            }
5298        }
5299        crate::counters::bump(|c| c.parent_resolutions += 1);
5300
5301        let Some(parts) = normalize(path) else {
5302            return false;
5303        };
5304        let source = self.applying_source;
5305
5306        let Some((name, ancestors)) = parts.split_last() else {
5307            // The root itself: only its own attributes can change. Its source is
5308            // stamped on both paths for the same reason every other entry's is — a
5309            // producer just looked at it — and the root is the entry where getting this
5310            // wrong costs the most, because the whole-tree totals hang off it and a
5311            // consumer reads its provenance to label the headline number.
5312            if self.entry(EntryId::ROOT).attrs == attrs {
5313                self.entry_mut(EntryId::ROOT).source = source;
5314                stats.unchanged += 1;
5315                return false;
5316            }
5317            let root = self.entry_mut(EntryId::ROOT);
5318            let previous = root.attrs;
5319            root.attrs = attrs;
5320            root.source = source;
5321            Self::bump_revision(root);
5322            stats.updated += 1;
5323            effects.change(|| EffectiveChange::Updated {
5324                path: PathBuf::new(),
5325                kind: EntryKind::Dir,
5326                previous,
5327                current: attrs,
5328            });
5329            return true;
5330        };
5331        let parent = self.resolve_dir_chain(ancestors);
5332        if let Some(dir) = path.parent() {
5333            parent_memo.set(dir, parent);
5334        }
5335        self.upsert_beneath(parent, name, path, kind, attrs, stats, effects)
5336    }
5337
5338    /// Apply one upsert beneath a parent whose id is already resolved.
5339    ///
5340    /// This is the whole of [`apply_upsert`] except for finding the parent, split out so
5341    /// that the memoized and the resolved paths share one body rather than two copies of
5342    /// the arbitration rules.  Every guard the delta contract requires still runs here:
5343    /// the caller has supplied a parent, not a decision.
5344    #[allow(clippy::too_many_arguments)]
5345    fn upsert_beneath<C: ConsequenceSink>(
5346        &mut self,
5347        parent: EntryId,
5348        name: &OsStr,
5349        path: &Path,
5350        kind: EntryKind,
5351        attrs: Attrs,
5352        stats: &mut ApplyStats,
5353        effects: &mut C,
5354    ) -> bool {
5355        let source = self.applying_source;
5356        let existing = self.child(parent, name);
5357
5358        if let Some(id) = existing {
5359            let entry = self.entry(id);
5360            if entry.kind == kind {
5361                if entry.attrs == attrs {
5362                    // Nothing about the value changed, but a producer just looked at
5363                    // it, and that is exactly what provenance records. Without this an
5364                    // entry verified by a revalidation sweep keeps reporting the source
5365                    // it was loaded with, and a consumer could never clear a
5366                    // stale-value indicator no matter how much checking happened.
5367                    self.entry_mut(id).source = source;
5368                    stats.unchanged += 1;
5369                    return false;
5370                }
5371                if kind.is_dir() {
5372                    // A directory's own attributes do not reach its ancestors' roll-ups,
5373                    // so there is nothing to re-merge.
5374                    let entry = self.entry_mut(id);
5375                    let previous = entry.attrs;
5376                    entry.attrs = attrs;
5377                    entry.source = source;
5378                    Self::bump_revision(entry);
5379                    stats.updated += 1;
5380                    effects.change(|| EffectiveChange::Updated {
5381                        path: path.to_path_buf(),
5382                        kind,
5383                        previous,
5384                        current: attrs,
5385                    });
5386                    return true;
5387                }
5388                let previous_attrs = entry.attrs;
5389                self.invalidate_content(path);
5390                if kind == EntryKind::File {
5391                    self.remove_serving_file_semantics(path, id, previous_attrs);
5392                }
5393                self.remove_serving_entry(path, kind, previous_attrs, id);
5394                let old = self.contribution(id);
5395                self.unmerge_upward(Some(parent), &old);
5396                let entry = self.entry_mut(id);
5397                let previous = entry.attrs;
5398                entry.attrs = attrs;
5399                entry.source = source;
5400                Self::bump_revision(entry);
5401                let new = self.contribution(id);
5402                self.merge_upward(Some(parent), &new);
5403                self.insert_serving_entry(path, kind, attrs, id);
5404                if new.newest_mtime_ns < old.newest_mtime_ns {
5405                    self.recompute_newest_upward(Some(parent));
5406                }
5407                stats.updated += 1;
5408                effects.change(|| EffectiveChange::Updated {
5409                    path: path.to_path_buf(),
5410                    kind,
5411                    previous,
5412                    current: attrs,
5413                });
5414                return true;
5415            }
5416            // The kind changed (a file became a directory, say). Remove and re-insert
5417            // rather than trying to mutate one shape into the other.
5418            //
5419            // This drops a subtree but cannot invalidate the memo: the memo holds this
5420            // entry's *parent*, and the subtree removed is rooted at the entry itself.
5421            // Clearing here would be untestable defensive code, which reads as a hazard
5422            // that does not exist.
5423            self.remove_entry(id, stats, effects);
5424        }
5425
5426        let ext_id =
5427            (kind == EntryKind::File).then(|| self.intern_ext(&crate::classify::ext_bucket(name)));
5428        let ignored =
5429            self.entry(parent).ignored || self.controls.matcher_for(path).is_ignored(kind.is_dir());
5430        let id = self.alloc(Entry::new(
5431            NewEntry {
5432                parent: Some(parent),
5433                name: name.to_os_string(),
5434                ext_id,
5435                ignored,
5436                source,
5437                kind,
5438                attrs,
5439            },
5440            false,
5441        ));
5442        self.insert_child(parent, name.to_os_string(), id);
5443        let contribution = self.contribution(id);
5444        self.merge_upward(Some(parent), &contribution);
5445        stats.inserted += 1;
5446        effects.change(|| EffectiveChange::Inserted { path: path.to_path_buf(), kind, attrs });
5447        self.insert_serving_entry(path, kind, attrs, id);
5448        true
5449    }
5450
5451    /// Insert one snapshot record beneath a parent whose id the caller already holds.
5452    ///
5453    /// The snapshot loader is not a producer.  It restores state that the delta contract
5454    /// already arbitrated and serialized, in the order it was written, with parents
5455    /// always preceding their children — so every fact [`apply_upsert`] rediscovers by
5456    /// resolving a path is a fact the loader was handed.  Routing it through the
5457    /// observation path made the loader pay, per record, a `PathBuf` join, an
5458    /// `Observation` vector, a `normalize` vector, and a descent from the root through
5459    /// one `BTreeMap` lookup per level, to arrive at a parent it had in a local variable.
5460    /// A callgrind profile of a 450k-entry load put the allocator at about 27% of the
5461    /// work and path-component iteration at about 15%; this removes both.
5462    ///
5463    /// It stays `pub(crate)` and takes an `EntryId` rather than a path precisely so it
5464    /// cannot become a second mutation surface: no external producer can reach it, and
5465    /// the guarantee that a loaded index equals the saved one is enforced by round-trip
5466    /// tests rather than by making deserialization impersonate a producer.
5467    ///
5468    /// Returns `None` when the parent is not a live directory or already holds `name`,
5469    /// which is how a corrupt snapshot fails closed.
5470    pub(crate) fn insert_loaded_child(
5471        &mut self,
5472        parent: EntryId,
5473        name: OsString,
5474        kind: EntryKind,
5475        attrs: Attrs,
5476    ) -> Option<EntryId> {
5477        let parent_entry = self.try_entry(parent)?;
5478        if parent_entry.kind != EntryKind::Dir || self.child(parent, &name).is_some() {
5479            return None;
5480        }
5481        let source = self.applying_source;
5482        let ext_id =
5483            (kind == EntryKind::File).then(|| self.intern_ext(&crate::classify::ext_bucket(&name)));
5484        let id = self.alloc(Entry::new(
5485            NewEntry {
5486                parent: Some(parent),
5487                name: name.clone(),
5488                ext_id,
5489                ignored: false,
5490                source,
5491                kind,
5492                attrs,
5493            },
5494            true,
5495        ));
5496        self.insert_child(parent, name, id);
5497        // Roll-ups stay eager. The same profile put `merge_upward` at about 3.5%, so
5498        // deferring it to a bottom-up pass would buy little and would introduce a window
5499        // in which the index is structurally complete but numerically wrong.
5500        let contribution = self.contribution(id);
5501        self.merge_upward(Some(parent), &contribution);
5502        // One-shot snapshot load constructs the index with serving off.
5503        // `insert_serving_entry` would discard a reconstructed path.
5504        if self.serving.is_some() {
5505            let path = self.path_of(id).expect("a newly loaded entry has a path");
5506            self.insert_serving_entry(&path, kind, attrs, id);
5507        }
5508        Some(id)
5509    }
5510
5511    fn apply_remove<C: ConsequenceSink>(
5512        &mut self,
5513        path: &Path,
5514        stats: &mut ApplyStats,
5515        effects: &mut C,
5516    ) -> bool {
5517        let Some(id) = self.lookup(path) else {
5518            stats.unchanged += 1;
5519            return false;
5520        };
5521        if id == EntryId::ROOT {
5522            stats.unchanged += 1;
5523            return false;
5524        }
5525        self.remove_entry(id, stats, effects);
5526        true
5527    }
5528
5529    fn remove_entry<C: ConsequenceSink>(
5530        &mut self,
5531        id: EntryId,
5532        stats: &mut ApplyStats,
5533        effects: &mut C,
5534    ) {
5535        let removed_root = self.path_of(id).expect("a live entry has a path");
5536        self.invalidate_content(&removed_root);
5537        self.remove_serving_subtree_semantics(id, &removed_root);
5538        let parent = self.entry(id).parent;
5539        let name = self.entry(id).name.clone();
5540        let contribution = self.contribution(id);
5541
5542        self.unmerge_upward(parent, &contribution);
5543        if let Some(parent) = parent {
5544            self.remove_child(parent, &name);
5545        }
5546
5547        // Free the subtree iteratively; a recursive drop would blow the stack on deep
5548        // trees, which is exactly the shape this engine is built for.
5549        let mut queue = VecDeque::from([(id, removed_root)]);
5550        while let Some((node, path)) = queue.pop_front() {
5551            let entry = self.entry(node);
5552            let kind = entry.kind;
5553            let attrs = entry.attrs;
5554            let children: Vec<(OsString, EntryId)> = self
5555                .children_of(node)
5556                .expect("removed subtree entry is live")
5557                .map(|(name, child)| (name.to_os_string(), child))
5558                .collect();
5559            let ext_id = entry.ext_id;
5560            for (name, child) in children {
5561                queue.push_back((child, path.join(name)));
5562            }
5563            self.remove_serving_entry(&path, kind, attrs, node);
5564            effects.change(|| EffectiveChange::Removed { path, kind, attrs });
5565            // Give the extension back before the entry itself goes, so the interner
5566            // holds only what the tree still contains.
5567            if let Some(ext_id) = ext_id {
5568                self.release_ext(ext_id);
5569            }
5570            self.free(node);
5571            stats.removed += 1;
5572        }
5573
5574        // The max may have lived in what was just removed.
5575        self.recompute_newest_upward(parent);
5576    }
5577
5578    fn invalidate_content(&mut self, path: &Path) {
5579        if let Some(content) = self.content.as_mut() {
5580            content.invalidate(path);
5581        }
5582    }
5583}
5584
5585/// Capture every child's expectation directly off its live entry, with no path work.
5586///
5587/// Both reconcile targets use this. The exclusive path once had a twin in `scan.rs`
5588/// that re-derived each expectation by joining a `PathBuf` and descending from the
5589/// root — two full descents and ~13 allocations per child to recover an `EntryId`
5590/// the iterator already held. The equivalence test below is what lets the twin stay
5591/// deleted.
5592pub(crate) fn collect_child_expectations(
5593    index: &Index,
5594    path: &Path,
5595) -> BTreeMap<OsString, PathExpectation> {
5596    index.children(path).map_or_else(BTreeMap::new, |children| {
5597        children
5598            .map(|(name, id)| {
5599                let entry = index.entry(id);
5600                let expectation = PathExpectation::new(
5601                    PathState::Present { kind: entry.kind, attrs: entry.attrs },
5602                    Some(index.identity(id)),
5603                    None,
5604                );
5605                (name.to_os_string(), expectation)
5606            })
5607            .collect()
5608    })
5609}
5610
5611/// Split a relative path into its normal components, rejecting anything that escapes.
5612///
5613/// Returns `None` for paths containing `..`, a root, or a prefix — an index keyed by
5614/// relative path has no way to represent those, and silently normalizing them away would
5615/// let a delta write outside the tree it claims to describe.
5616/// The components are borrowed from `path`, not copied out of it.
5617///
5618/// Owning them cost an allocation per component, and this runs twice for every
5619/// operation in every batch — once to validate the path and once to apply it. On a
5620/// tree averaging eight levels deep that was on the order of eighteen allocations per
5621/// entry, all of them holding bytes that the caller's `PathBuf` already owned and
5622/// outlives. Only the returned `Vec` allocates now, and only where a slice is
5623/// genuinely needed.
5624/// The parent directory resolved for the previous upsert in a batch.
5625///
5626/// A walker reports a directory's children consecutively, so resolving the parent path
5627/// once per directory rather than once per entry removes the dominant cost of applying a
5628/// cold scan: a callgrind profile attributed about 25 path-component comparisons per
5629/// entry to the descent, and the component vector `normalize` builds is an allocation
5630/// per entry on top of that.
5631///
5632/// It is a single slot rather than a map on purpose.  A map would keep entries alive
5633/// across structural changes and turn every miss into a hash, where consecutive runs are
5634/// what the walker actually produces; one slot captures those and costs a path
5635/// comparison when it misses.  The slot holds an id, so it must be cleared whenever a
5636/// removal could unmake it — [`Index::apply_remove`], an invalidation, and the
5637/// kind-change removal inside an upsert all do.
5638#[derive(Default)]
5639struct ParentMemo {
5640    entry: Option<(PathBuf, EntryId)>,
5641}
5642
5643impl ParentMemo {
5644    /// The id remembered for `dir`, if the last resolved parent was that directory.
5645    fn get(&self, dir: &Path) -> Option<EntryId> {
5646        self.entry.as_ref().filter(|(cached, _)| cached == dir).map(|&(_, id)| id)
5647    }
5648
5649    fn set(&mut self, dir: &Path, id: EntryId) {
5650        match &mut self.entry {
5651            // Overwriting in place keeps this to one allocation per directory rather
5652            // than one per run, which matters because a wide tree alternates often.
5653            Some((cached, cached_id)) => {
5654                cached.clear();
5655                cached.push(dir);
5656                *cached_id = id;
5657            }
5658            slot => *slot = Some((dir.to_path_buf(), id)),
5659        }
5660    }
5661
5662    fn clear(&mut self) {
5663        self.entry = None;
5664    }
5665}
5666
5667/// Structural effects of accepted operations evaluated before the real mutation.
5668#[derive(Default)]
5669struct StructuralOverlay {
5670    // Only point lookup and subtree retention use these keys; no iteration order is
5671    // observed. Ordering every path on lookup and insert dominated public preflight.
5672    entries: HashMap<PathBuf, EntryKind>,
5673    removed_roots: Vec<PathBuf>,
5674}
5675
5676impl StructuralOverlay {
5677    fn kind(&self, index: &Index, path: &Path) -> Option<EntryKind> {
5678        self.entries.get(path).copied().or_else(|| {
5679            (!self.removed_roots.iter().any(|removed| path.starts_with(removed)))
5680                .then(|| index.kind(path))
5681                .flatten()
5682        })
5683    }
5684
5685    fn upsert(&mut self, index: &Index, path: &Path, kind: EntryKind) {
5686        if self.kind(index, path).is_some_and(|current| current != kind) {
5687            self.remove(index, path);
5688        }
5689        self.entries.insert(path.to_path_buf(), kind);
5690    }
5691
5692    fn remove(&mut self, index: &Index, path: &Path) {
5693        if self.kind(index, path).is_none() {
5694            return;
5695        }
5696        self.entries.retain(|candidate, _| !candidate.starts_with(path));
5697        self.removed_roots.retain(|candidate| !candidate.starts_with(path));
5698        if !self.removed_roots.iter().any(|removed| path.starts_with(removed)) {
5699            self.removed_roots.push(path.to_path_buf());
5700        }
5701    }
5702}
5703
5704fn normalize(path: &Path) -> Option<Vec<&OsStr>> {
5705    let mut parts = Vec::new();
5706    for component in path.components() {
5707        match component {
5708            Component::Normal(part) => parts.push(part),
5709            Component::CurDir => {}
5710            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
5711        }
5712    }
5713    Some(parts)
5714}
5715
5716fn prepare_observation(observation: &Observation) -> crate::Result<PreparedObservation> {
5717    let mut ops = Vec::with_capacity(observation.len());
5718    for observed in &observation.ops {
5719        let path = canonical_relative_path(observed.op.path())?;
5720        let op = match &observed.op {
5721            Op::Upsert { kind, attrs, .. } => Op::Upsert { path, kind: *kind, attrs: *attrs },
5722            Op::Remove { .. } => Op::Remove { path },
5723            Op::ControlUpsert { source, .. } => {
5724                if !crate::control::is_control_file(&path) {
5725                    return Err(crate::Error::InvalidControlPath(path));
5726                }
5727                Op::ControlUpsert { path, source: source.clone() }
5728            }
5729            Op::ControlRemove { .. } => {
5730                if !crate::control::is_control_file(&path) {
5731                    return Err(crate::Error::InvalidControlPath(path));
5732                }
5733                Op::ControlRemove { path }
5734            }
5735            Op::InvalidateSubtree { reason, .. } => Op::InvalidateSubtree { path, reason: *reason },
5736        };
5737        ops.push(ObservationOp { op, expectation: observed.expectation });
5738    }
5739    Ok(PreparedObservation {
5740        ops,
5741        ancestry: PreparedAncestry::General,
5742        #[cfg(test)]
5743        reject_before_apply: false,
5744    })
5745}
5746
5747fn canonical_relative_path(path: &Path) -> crate::Result<PathBuf> {
5748    let mut canonical = PathBuf::with_capacity(path.as_os_str().as_encoded_bytes().len());
5749    for component in path.components() {
5750        match component {
5751            Component::Normal(part) => canonical.push(part),
5752            Component::CurDir => {}
5753            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
5754                return Err(crate::Error::PathEscapesRoot(path.to_path_buf()));
5755            }
5756        }
5757    }
5758    Ok(canonical)
5759}
5760
5761fn derive_impact(changes: &[EffectiveChange], state: &[StateTransition]) -> Impact {
5762    let mut domains = BTreeSet::new();
5763    let mut paths = BTreeSet::new();
5764    let mut all_dirty = false;
5765    let mut ancestor_visits = 0_u64;
5766    let count_impact = crate::counters::enabled();
5767
5768    for change in changes {
5769        match change {
5770            EffectiveChange::Inserted { .. } | EffectiveChange::Removed { .. } => {
5771                domains.extend([
5772                    ImpactDomain::Topology,
5773                    ImpactDomain::Metadata,
5774                    ImpactDomain::Classification,
5775                    ImpactDomain::Aggregates,
5776                    ImpactDomain::Content,
5777                ]);
5778            }
5779            EffectiveChange::Updated { .. } => {
5780                domains.extend([
5781                    ImpactDomain::Metadata,
5782                    ImpactDomain::Aggregates,
5783                    ImpactDomain::Content,
5784                ]);
5785            }
5786            EffectiveChange::ControlUpdated { .. } | EffectiveChange::Reclassified { .. } => {
5787                domains.extend([ImpactDomain::Classification, ImpactDomain::Aggregates]);
5788            }
5789            EffectiveChange::ControlRefusalUpdated { .. } => {
5790                domains.insert(ImpactDomain::Classification);
5791            }
5792            EffectiveChange::Invalidated { .. } => {
5793                domains.insert(ImpactDomain::State);
5794            }
5795        }
5796        insert_dirty_ancestors(
5797            change.path(),
5798            &mut paths,
5799            &mut all_dirty,
5800            count_impact,
5801            &mut ancestor_visits,
5802        );
5803    }
5804    for transition in state {
5805        domains.insert(ImpactDomain::State);
5806        insert_dirty_ancestors(
5807            transition.path(),
5808            &mut paths,
5809            &mut all_dirty,
5810            count_impact,
5811            &mut ancestor_visits,
5812        );
5813    }
5814
5815    if count_impact {
5816        let candidates =
5817            u64::try_from(changes.len().saturating_add(state.len())).unwrap_or(u64::MAX);
5818        let retained_dirty_paths = u64::try_from(paths.len()).unwrap_or(u64::MAX);
5819        crate::counters::bump(|counts| {
5820            counts.impact_candidates = counts.impact_candidates.saturating_add(candidates);
5821            counts.impact_ancestor_visits =
5822                counts.impact_ancestor_visits.saturating_add(ancestor_visits);
5823            counts.impact_retained_dirty_paths =
5824                counts.impact_retained_dirty_paths.saturating_add(retained_dirty_paths);
5825            counts.impact_all_dirty = counts.impact_all_dirty.saturating_add(u64::from(all_dirty));
5826        });
5827    }
5828
5829    Impact {
5830        domains: domains.into_iter().collect(),
5831        dirty_paths: if all_dirty { Vec::new() } else { paths.into_iter().collect() },
5832        all_dirty,
5833    }
5834}
5835
5836fn commit_work(observations: u64, stats: ApplyStats) -> Work {
5837    Work {
5838        observations,
5839        unchanged: stats.unchanged,
5840        stale: stats.stale,
5841        resource_refused: stats.resource_refused,
5842        ..Work::default()
5843    }
5844}
5845
5846fn insert_dirty_ancestors(
5847    path: &Path,
5848    paths: &mut BTreeSet<PathBuf>,
5849    all_dirty: &mut bool,
5850    count_impact: bool,
5851    ancestor_visits: &mut u64,
5852) {
5853    if *all_dirty {
5854        return;
5855    }
5856    for ancestor in path.ancestors() {
5857        if count_impact {
5858            *ancestor_visits = ancestor_visits.saturating_add(1);
5859        }
5860        paths.insert(ancestor.to_path_buf());
5861        if paths.len() > MAX_DIRTY_PATHS {
5862            paths.clear();
5863            *all_dirty = true;
5864            return;
5865        }
5866    }
5867}
5868
5869fn same_target(
5870    current: Option<EntryIdentity>,
5871    expected: Option<EntryIdentity>,
5872    require_structure: bool,
5873) -> bool {
5874    match (current, expected) {
5875        (Some(current), Some(expected)) => current.same_target(expected, require_structure),
5876        (None, None) => true,
5877        (Some(_), None) | (None, Some(_)) => false,
5878    }
5879}
5880
5881#[cfg(test)]
5882mod tests {
5883    use super::*;
5884    use crate::engine_contract::ObservationOp;
5885    use std::sync::{Arc, Barrier};
5886
5887    #[test]
5888    fn reusable_entry_keeps_directory_state_out_of_line() {
5889        let entry_bytes = std::mem::size_of::<Entry>();
5890        let slot_bytes = std::mem::size_of::<Slot>();
5891
5892        assert!(
5893            entry_bytes <= 136,
5894            "common entry storage must not inline directory-only maps and roll-ups: {entry_bytes} bytes"
5895        );
5896        assert!(
5897            slot_bytes <= entry_bytes + 16,
5898            "the arena slot must not add a second per-entry allocation: entry={entry_bytes}, slot={slot_bytes}"
5899        );
5900    }
5901
5902    #[test]
5903    fn detached_children_store_each_name_once_and_promote_on_mutation() {
5904        let mut builder = DetachedIndexBuilder::new(
5905            "/root",
5906            ScanScope::default(),
5907            crate::classify::TypeRegistry::compiled_shared(),
5908        );
5909        builder
5910            .push_directory(crate::scan::DetachedDirectory {
5911                path: PathBuf::new(),
5912                children: vec![
5913                    crate::scan::DetachedChild {
5914                        name: OsString::from("dir"),
5915                        kind: EntryKind::Dir,
5916                        attrs: Attrs::default(),
5917                        position: 0,
5918                    },
5919                    crate::scan::DetachedChild {
5920                        name: OsString::from("z.txt"),
5921                        kind: EntryKind::File,
5922                        attrs: file_attrs(1, 1),
5923                        position: 1,
5924                    },
5925                ],
5926                control: None,
5927            })
5928            .expect("detached root listing");
5929        builder
5930            .push_directory(crate::scan::DetachedDirectory {
5931                path: PathBuf::from("dir"),
5932                children: vec![
5933                    crate::scan::DetachedChild {
5934                        name: OsString::from("z.txt"),
5935                        kind: EntryKind::File,
5936                        attrs: file_attrs(2, 2),
5937                        position: 0,
5938                    },
5939                    crate::scan::DetachedChild {
5940                        name: OsString::from("a.txt"),
5941                        kind: EntryKind::File,
5942                        attrs: file_attrs(3, 3),
5943                        position: 1,
5944                    },
5945                ],
5946                control: None,
5947            })
5948            .expect("detached child listing");
5949        let mut index = builder.finish();
5950        let directory = index.lookup(Path::new("dir")).expect("detached directory");
5951
5952        assert!(index.entry(EntryId::ROOT).directory().children.is_sorted());
5953        assert!(index.entry(directory).directory().children.is_sorted());
5954        assert_eq!(
5955            index
5956                .children(Path::new("dir"))
5957                .expect("directory children")
5958                .map(|(name, _)| name.to_os_string())
5959                .collect::<Vec<_>>(),
5960            [OsString::from("a.txt"), OsString::from("z.txt")]
5961        );
5962
5963        index.apply_ok(&Observation::new(vec![upsert(
5964            "dir/m.txt",
5965            EntryKind::File,
5966            file_attrs(4, 4),
5967        )]));
5968
5969        assert!(index.entry(EntryId::ROOT).directory().children.is_sorted());
5970        assert!(index.entry(directory).directory().children.is_mutable());
5971        assert_eq!(
5972            index
5973                .children(Path::new("dir"))
5974                .expect("directory children")
5975                .map(|(name, _)| name.to_os_string())
5976                .collect::<Vec<_>>(),
5977            [OsString::from("a.txt"), OsString::from("m.txt"), OsString::from("z.txt")]
5978        );
5979    }
5980
5981    #[test]
5982    fn detached_builder_tolerates_a_duplicate_readdir_name() {
5983        let mut builder = DetachedIndexBuilder::new(
5984            "/root",
5985            ScanScope::default(),
5986            crate::classify::TypeRegistry::compiled_shared(),
5987        );
5988        let twice = |position, mtime_ns| crate::scan::DetachedChild {
5989            name: OsString::from("twice.txt"),
5990            kind: EntryKind::File,
5991            attrs: file_attrs(1, mtime_ns),
5992            position,
5993        };
5994        let result = builder.push_directory(crate::scan::DetachedDirectory {
5995            path: PathBuf::new(),
5996            children: vec![twice(0, 1), twice(1, 2)],
5997            control: None,
5998        });
5999        assert!(result.is_ok(), "a duplicate listing name must not fail the scan: {result:?}");
6000        let detached = builder.finish();
6001        assert_eq!(detached.total().files, 1);
6002        assert_eq!(detached.attrs(Path::new("twice.txt")), Some(&file_attrs(1, 2)));
6003
6004        // The streaming reducer tolerates the same input, and keeps the same observation.
6005        let mut streaming = Index::new("/root");
6006        streaming
6007            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
6008                upsert("twice.txt", EntryKind::File, file_attrs(1, 1)),
6009                upsert("twice.txt", EntryKind::File, file_attrs(1, 2)),
6010            ]))
6011            .expect("streaming tolerates a re-upsert");
6012        assert_eq!(streaming.total(), detached.total());
6013        assert_eq!(streaming.attrs(Path::new("twice.txt")), detached.attrs(Path::new("twice.txt")));
6014    }
6015
6016    #[test]
6017    fn detached_builder_accepts_the_repeated_walk_of_a_duplicated_directory() {
6018        let child = |name: &str, kind, attrs, position| crate::scan::DetachedChild {
6019            name: OsString::from(name),
6020            kind,
6021            attrs,
6022            position,
6023        };
6024        let listing = |path: &str, children| crate::scan::DetachedDirectory {
6025            path: PathBuf::from(path),
6026            children,
6027            control: None,
6028        };
6029        let mut builder = DetachedIndexBuilder::new(
6030            "/root",
6031            ScanScope::default(),
6032            crate::classify::TypeRegistry::compiled_shared(),
6033        );
6034        // The enumerator returned `dir` twice, and `swapped` first as a directory and then
6035        // as the file that replaced it.
6036        builder
6037            .push_directory(listing(
6038                "",
6039                vec![
6040                    child("dir", EntryKind::Dir, file_attrs(0, 1), 0),
6041                    child("swapped", EntryKind::Dir, file_attrs(0, 1), 1),
6042                    child("dir", EntryKind::Dir, file_attrs(0, 2), 2),
6043                    child("swapped", EntryKind::File, file_attrs(5, 2), 3),
6044                ],
6045            ))
6046            .expect("root listing with repeated names");
6047        // The walker lists `dir`, and everything below it, once per observation.
6048        for _ in 0..2 {
6049            builder
6050                .push_directory(listing(
6051                    "dir",
6052                    vec![child("nested", EntryKind::Dir, file_attrs(0, 3), 0)],
6053                ))
6054                .expect("each walk of the repeated directory");
6055            builder
6056                .push_directory(listing(
6057                    "dir/nested",
6058                    vec![child("file.txt", EntryKind::File, file_attrs(4, 4), 0)],
6059                ))
6060                .expect("each walk below the repeated directory");
6061        }
6062        // It also lists the directory observation that the file superseded.
6063        builder
6064            .push_directory(listing(
6065                "swapped",
6066                vec![child("stale.txt", EntryKind::File, file_attrs(6, 5), 0)],
6067            ))
6068            .expect("the superseded directory's walk");
6069        // A listing that no repeated name explains is still an ancestry failure.
6070        let error = builder
6071            .push_directory(listing("elsewhere", Vec::new()))
6072            .expect_err("a listing whose parent was never listed");
6073        assert!(matches!(
6074            error,
6075            crate::Error::UnknownAncestry { path, .. } if path == Path::new("elsewhere")
6076        ));
6077
6078        let index = builder.finish();
6079        assert_eq!(index.attrs(Path::new("dir")), Some(&file_attrs(0, 2)));
6080        assert_eq!(index.kind(Path::new("swapped")), Some(EntryKind::File));
6081        assert!(index.lookup(Path::new("swapped/stale.txt")).is_none());
6082        let total = index.total();
6083        assert_eq!((total.files, total.dirs, total.bytes), (2, 2, 9));
6084    }
6085
6086    fn file_attrs(size: u64, mtime_ns: i64) -> Attrs {
6087        Attrs {
6088            size,
6089            allocated: size.div_ceil(512) * 512,
6090            mtime_ns,
6091            ctime_ns: mtime_ns,
6092            inode: size.wrapping_mul(31).wrapping_add(mtime_ns.unsigned_abs()),
6093            dev: 1,
6094        }
6095    }
6096
6097    fn upsert(path: &str, kind: EntryKind, attrs: Attrs) -> Op {
6098        Op::Upsert { path: PathBuf::from(path), kind, attrs }
6099    }
6100
6101    fn assert_serving_indexes(index: &Index) {
6102        let serving = index.serving.as_ref().expect("test index has serving state");
6103        let mut entries = BTreeMap::new();
6104        let mut children = BTreeMap::<PathBuf, PortableChildren>::new();
6105        let mut recent_files = BTreeSet::new();
6106        let mut semantic_by_directory =
6107            BTreeMap::<EntryId, (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>)>::new();
6108        let declared_exact_names: BTreeSet<_> =
6109            index.types.exact_filenames().map(str::to_ascii_lowercase).collect();
6110        let mut exact_name_by_directory =
6111            BTreeMap::<EntryId, (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>)>::new();
6112        let mut semantic_refcounts = BTreeMap::<String, u64>::new();
6113        let mut pending = vec![(EntryId::ROOT, PathBuf::new(), vec![EntryId::ROOT])];
6114        while let Some((parent_id, parent_path, ancestors)) = pending.pop() {
6115            let facts: Vec<_> = index
6116                .children_of(parent_id)
6117                .expect("live directory")
6118                .map(|(name, id)| (name.to_os_string(), id))
6119                .collect();
6120            for (name, id) in facts {
6121                let path = parent_path.join(&name);
6122                let kind = index.kind_of(id).expect("live child");
6123                let portable = crate::opened::read::portable_path(&path);
6124                entries.insert(portable.clone(), id);
6125                if kind == EntryKind::File {
6126                    recent_files.insert(RecentKey {
6127                        mtime_ns: index.attrs(&path).expect("live child has attributes").mtime_ns,
6128                        portable_path: portable,
6129                        id,
6130                    });
6131                }
6132                if kind == EntryKind::File {
6133                    let semantic = index.classify(&path).file_type.as_str().to_string();
6134                    *semantic_refcounts.entry(semantic.clone()).or_default() += 1;
6135                    let attrs = *index.attrs(&path).expect("live child has attributes");
6136                    let ignored = index.entry(id).ignored;
6137                    for ancestor in &ancestors {
6138                        let partition = semantic_by_directory.entry(*ancestor).or_default();
6139                        let all = partition.0.entry(semantic.clone()).or_default();
6140                        all.files += 1;
6141                        all.bytes += attrs.size;
6142                        all.allocated += attrs.allocated;
6143                        if !ignored {
6144                            let unignored = partition.1.entry(semantic.clone()).or_default();
6145                            unignored.files += 1;
6146                            unignored.bytes += attrs.size;
6147                            unignored.allocated += attrs.allocated;
6148                        }
6149                    }
6150                    if let Some(exact_name) = name
6151                        .to_str()
6152                        .map(str::to_ascii_lowercase)
6153                        .filter(|name| declared_exact_names.contains(name))
6154                    {
6155                        for ancestor in &ancestors {
6156                            let partition = exact_name_by_directory.entry(*ancestor).or_default();
6157                            let all = partition.0.entry(exact_name.clone()).or_default();
6158                            all.files += 1;
6159                            all.bytes += attrs.size;
6160                            all.allocated += attrs.allocated;
6161                            if !ignored {
6162                                let unignored = partition.1.entry(exact_name.clone()).or_default();
6163                                unignored.files += 1;
6164                                unignored.bytes += attrs.size;
6165                                unignored.allocated += attrs.allocated;
6166                            }
6167                        }
6168                    }
6169                }
6170                let partition = children.entry(parent_path.clone()).or_default();
6171                let portable_name = crate::opened::read::portable_component(&name);
6172                if kind.is_dir() {
6173                    partition.directories.insert(portable_name, id);
6174                } else {
6175                    partition.nondirectories.insert(portable_name, id);
6176                }
6177                if kind.is_dir() {
6178                    let mut child_ancestors = ancestors.clone();
6179                    child_ancestors.insert(0, id);
6180                    pending.push((id, path, child_ancestors));
6181                }
6182            }
6183        }
6184
6185        assert_eq!(serving.portable_entries, entries);
6186        assert_eq!(serving.recent_files, recent_files);
6187        let actual_semantics: BTreeMap<_, _> = serving
6188            .semantic_by_directory
6189            .iter()
6190            .map(|(directory, partitions)| {
6191                let named = |source: &BTreeMap<u32, ExtTally>| {
6192                    source
6193                        .iter()
6194                        .map(|(semantic, tally)| {
6195                            let name = serving.semantic_names[*semantic as usize]
6196                                .as_ref()
6197                                .expect("live semantic has a name")
6198                                .clone();
6199                            (name, *tally)
6200                        })
6201                        .collect()
6202                };
6203                (*directory, (named(&partitions.all), named(&partitions.unignored)))
6204            })
6205            .collect();
6206        assert_eq!(actual_semantics, semantic_by_directory);
6207        let actual_exact_names: BTreeMap<_, _> = serving
6208            .exact_name_by_directory
6209            .iter()
6210            .map(|(directory, partitions)| {
6211                let named = |source: &BTreeMap<u32, ExtTally>| {
6212                    source
6213                        .iter()
6214                        .map(|(exact_name, tally)| {
6215                            (serving.exact_names[*exact_name as usize].clone(), *tally)
6216                        })
6217                        .collect()
6218                };
6219                (*directory, (named(&partitions.all), named(&partitions.unignored)))
6220            })
6221            .collect();
6222        assert_eq!(actual_exact_names, exact_name_by_directory);
6223        assert_eq!(
6224            serving.exact_names.iter().cloned().collect::<BTreeSet<_>>(),
6225            declared_exact_names
6226        );
6227        assert_eq!(
6228            serving.exact_name_ids,
6229            serving
6230                .exact_names
6231                .iter()
6232                .enumerate()
6233                .map(|(position, name)| {
6234                    (
6235                        name.clone(),
6236                        u32::try_from(position).expect("the exact-name vocabulary fits u32"),
6237                    )
6238                })
6239                .collect()
6240        );
6241        assert!(serving.exact_name_by_directory.len() <= index.arena.len());
6242        assert!(serving.exact_name_by_directory.values().all(|partitions| {
6243            partitions.all.len() <= serving.exact_names.len()
6244                && partitions.unignored.len() <= serving.exact_names.len()
6245                && partitions
6246                    .all
6247                    .keys()
6248                    .chain(partitions.unignored.keys())
6249                    .all(|name| (*name as usize) < serving.exact_names.len())
6250        }));
6251        let actual_refcounts: BTreeMap<_, _> = serving
6252            .semantic_ids
6253            .iter()
6254            .map(|(name, semantic)| (name.clone(), serving.semantic_refcounts[*semantic as usize]))
6255            .collect();
6256        assert_eq!(actual_refcounts, semantic_refcounts);
6257        assert_eq!(serving.portable_children, children);
6258
6259        // Every retained entry has a portable name, and the names are unique. The second
6260        // half is what the escaping has to earn: `%` is escaped in every name precisely so
6261        // a file called `x%FF` and one whose bytes are `x\xff` cannot collide here.
6262        assert_eq!(
6263            u64::try_from(serving.portable_entries.len()).expect("entry count fits u64"),
6264            index.len().saturating_sub(1),
6265            "every retained non-root entry has exactly one portable name"
6266        );
6267    }
6268
6269    #[test]
6270    fn portable_indexes_conserve_insert_kind_change_and_subtree_removal() {
6271        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6272            "/root",
6273            ScanScope::default(),
6274            crate::classify::TypeRegistry::compiled_shared(),
6275            DEFAULT_JOURNAL_CAPACITY_BYTES,
6276        );
6277        index.apply_ok(&Observation::new(vec![
6278            upsert("dir", EntryKind::Dir, Attrs::default()),
6279            upsert("dir/a", EntryKind::File, file_attrs(1, 1)),
6280            upsert("replace", EntryKind::File, file_attrs(2, 2)),
6281        ]));
6282        assert_serving_indexes(&index);
6283
6284        index.apply_ok(&Observation::new(vec![
6285            upsert("replace", EntryKind::Dir, Attrs::default()),
6286            upsert("replace/child", EntryKind::File, file_attrs(3, 3)),
6287        ]));
6288        assert_serving_indexes(&index);
6289
6290        index.apply_ok(&Observation::new(vec![upsert("dir/a", EntryKind::File, file_attrs(4, 9))]));
6291        assert_serving_indexes(&index);
6292        assert_eq!(
6293            index
6294                .serving
6295                .as_ref()
6296                .expect("opened test index")
6297                .recent_files
6298                .iter()
6299                .map(|entry| entry.portable_path.as_str())
6300                .collect::<Vec<_>>(),
6301            vec!["dir/a", "replace/child"]
6302        );
6303
6304        // Same-kind attribute updates of entries that hold no semantic tally: a symlink
6305        // re-created in place (`ln -sfn`) and a special entry replaced by another. Every
6306        // file here is extensionless, so each non-file shares its classification with a
6307        // real file, and a non-file update that touched semantics would move that file's
6308        // tally rather than fail loudly.
6309        index.apply_ok(&Observation::new(vec![
6310            upsert("dir/current", EntryKind::Symlink, file_attrs(5, 5)),
6311            upsert("dir/pipe", EntryKind::Other, file_attrs(6, 6)),
6312        ]));
6313        assert_serving_indexes(&index);
6314        index.apply_ok(&Observation::new(vec![
6315            upsert("dir/current", EntryKind::Symlink, file_attrs(7, 7)),
6316            upsert("dir/pipe", EntryKind::Other, file_attrs(8, 8)),
6317        ]));
6318        assert_serving_indexes(&index);
6319
6320        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("replace") }]));
6321        assert_serving_indexes(&index);
6322        assert_eq!(
6323            index.portable_entries().keys().map(crate::PortablePath::as_str).collect::<Vec<_>>(),
6324            vec!["dir", "dir/a", "dir/current", "dir/pipe"]
6325        );
6326    }
6327
6328    /// A symlink or special entry holds no semantic tally, so updating one must neither
6329    /// panic looking for a tally it never had nor subtract from a real file's.
6330    ///
6331    /// Both failures were reachable from ordinary filesystem churn on an opened root. With
6332    /// no file of the same classification, the update panicked inside the commit while the
6333    /// index write guard was held, poisoning the root. With one, it silently subtracted the
6334    /// link's attributes from that file's tally and released the file's interned type, so
6335    /// the file's own later removal panicked instead.
6336    #[test]
6337    fn non_file_attrs_updates_leave_file_semantics_untouched() {
6338        for kind in [EntryKind::Symlink, EntryKind::Other] {
6339            let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6340                "/root",
6341                ScanScope::default(),
6342                crate::classify::TypeRegistry::compiled_shared(),
6343                DEFAULT_JOURNAL_CAPACITY_BYTES,
6344            );
6345            index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(1, 1))]));
6346            assert_serving_indexes(&index);
6347            index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(1, 2))]));
6348            assert_serving_indexes(&index);
6349
6350            index.apply_ok(&Observation::new(vec![upsert(
6351                "notes",
6352                EntryKind::File,
6353                file_attrs(5, 3),
6354            )]));
6355            assert_serving_indexes(&index);
6356            index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(4, 4))]));
6357            assert_serving_indexes(&index);
6358
6359            index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("notes") }]));
6360            assert_serving_indexes(&index);
6361        }
6362    }
6363
6364    /// Escaping touches exactly two things and leaves everything else byte-identical.
6365    ///
6366    /// The rule is narrow on purpose: a byte that is not valid UTF-8, and `%` itself.
6367    /// Everything else — spaces, non-ASCII scalars, punctuation — passes through, because
6368    /// this produces a JSON string rather than a URL and mangling readable names would be
6369    /// a cost with no benefit.
6370    ///
6371    /// This test used to assert the opposite property, that the derived name could be
6372    /// turned back into a filesystem path with `PathBuf::from`. That held only while the
6373    /// derivation was the identity, and it is now unsound: `100%.txt` derives to
6374    /// `100%25.txt`, which names no file. The conversion was deleted rather than kept
6375    /// working, and callers ask the arena for a native path instead.
6376    #[test]
6377    fn escaping_touches_only_invalid_bytes_and_percent() {
6378        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6379            "/root",
6380            ScanScope::default(),
6381            crate::classify::TypeRegistry::compiled_shared(),
6382            DEFAULT_JOURNAL_CAPACITY_BYTES,
6383        );
6384        index.apply_ok(&Observation::new(vec![
6385            upsert("dir", EntryKind::Dir, Attrs::default()),
6386            upsert("dir/plain.txt", EntryKind::File, file_attrs(1, 1)),
6387            upsert("café", EntryKind::Dir, Attrs::default()),
6388            upsert("café/naïve.txt", EntryKind::File, file_attrs(2, 2)),
6389            upsert("日本語.md", EntryKind::File, file_attrs(3, 3)),
6390            upsert("a b", EntryKind::Dir, Attrs::default()),
6391            upsert("a b/c d.txt", EntryKind::File, file_attrs(5, 5)),
6392            upsert("100%.txt", EntryKind::File, file_attrs(4, 4)),
6393        ]));
6394
6395        let names: Vec<_> =
6396            index.portable_entries().keys().map(crate::PortablePath::as_str).collect();
6397        assert_eq!(
6398            names,
6399            vec![
6400                "100%25.txt",
6401                "a b",
6402                "a b/c d.txt",
6403                "café",
6404                "café/naïve.txt",
6405                "dir",
6406                "dir/plain.txt",
6407                "日本語.md",
6408            ],
6409            "only the literal percent is rewritten; separators, spaces and non-ASCII are not"
6410        );
6411    }
6412
6413    #[test]
6414    fn declared_exact_names_roll_up_by_ancestor_and_partition() {
6415        let types = Arc::new(
6416            crate::classify::TypeRegistry::from_manifest(
6417                "[[kind]]\nid = \"make\"\nfamily = \"code\"\nfilenames = [\"Makefile\"]\n",
6418            )
6419            .expect("custom registry"),
6420        );
6421        let scope =
6422            ScanScope { type_rules_fingerprint: types.fingerprint(), ..ScanScope::default() };
6423        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6424            "/root",
6425            scope,
6426            types,
6427            DEFAULT_JOURNAL_CAPACITY_BYTES,
6428        );
6429        index.apply_ok(&Observation::new(vec![
6430            upsert("Makefile", EntryKind::File, file_attrs(2, 1)),
6431            upsert("dir", EntryKind::Dir, Attrs::default()),
6432            upsert("dir/makefile", EntryKind::File, file_attrs(3, 2)),
6433            upsert("dir/notes", EntryKind::File, file_attrs(5, 3)),
6434        ]));
6435
6436        let serving = index.serving.as_ref().expect("opened test index");
6437        let exact_name = serving.exact_name_ids["makefile"];
6438        let root = &serving.exact_name_by_directory[&EntryId::ROOT];
6439        assert_eq!(root.all[&exact_name], ExtTally { files: 2, bytes: 5, allocated: 1_024 });
6440        assert_eq!(root.unignored, root.all);
6441
6442        let directory = index.lookup(Path::new("dir")).expect("directory");
6443        let nested = &serving.exact_name_by_directory[&directory];
6444        assert_eq!(nested.all[&exact_name], ExtTally { files: 1, bytes: 3, allocated: 512 });
6445        assert_eq!(nested.unignored, nested.all);
6446    }
6447
6448    #[test]
6449    #[ignore = "manual opened-root commit-cost evidence"]
6450    fn measure_opened_serving_commit_cost() {
6451        const DIRECTORY_COUNT: usize = 100;
6452        const FILES_PER_DIRECTORY: usize = 100;
6453        const SAMPLE_COUNT: usize = 7;
6454
6455        let mut operations = Vec::with_capacity(
6456            DIRECTORY_COUNT.saturating_mul(FILES_PER_DIRECTORY.saturating_add(1)),
6457        );
6458        for directory in 0..DIRECTORY_COUNT {
6459            let parent = format!("d{directory:03}");
6460            operations.push(upsert(&parent, EntryKind::Dir, Attrs::default()));
6461            for file in 0..FILES_PER_DIRECTORY {
6462                let size = u64::try_from(file).expect("the probe file count fits u64") + 1;
6463                let mtime = i64::try_from(file).expect("the probe file count fits i64");
6464                let name = if file == 0 {
6465                    format!("{parent}/Makefile")
6466                } else {
6467                    format!("{parent}/f{file:03}.rs")
6468                };
6469                operations.push(upsert(&name, EntryKind::File, file_attrs(size, mtime)));
6470            }
6471        }
6472        let observation = Observation::new(operations);
6473        let types = crate::classify::TypeRegistry::compiled_shared();
6474        let scope =
6475            ScanScope { type_rules_fingerprint: types.fingerprint(), ..ScanScope::default() };
6476        let measure = |opened: bool| {
6477            let mut index = if opened {
6478                Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6479                    "/root",
6480                    scope,
6481                    Arc::clone(&types),
6482                    DEFAULT_JOURNAL_CAPACITY_BYTES,
6483                )
6484            } else {
6485                Index::new_with_scope_types_and_journal_capacity_bytes(
6486                    "/root",
6487                    scope,
6488                    Arc::clone(&types),
6489                    DEFAULT_JOURNAL_CAPACITY_BYTES,
6490                )
6491            };
6492            let started = std::time::Instant::now();
6493            index.apply_ok(&observation);
6494            let elapsed = started.elapsed();
6495            std::hint::black_box(index.len());
6496            (elapsed, index)
6497        };
6498
6499        let _ = measure(false);
6500        let _ = measure(true);
6501        let mut detached = Vec::with_capacity(SAMPLE_COUNT);
6502        let mut opened = Vec::with_capacity(SAMPLE_COUNT);
6503        let mut last_opened = None;
6504        for sample in 0..SAMPLE_COUNT {
6505            if sample % 2 == 0 {
6506                detached.push(measure(false).0);
6507                let (duration, index) = measure(true);
6508                opened.push(duration);
6509                last_opened = Some(index);
6510            } else {
6511                let (duration, index) = measure(true);
6512                opened.push(duration);
6513                last_opened = Some(index);
6514                detached.push(measure(false).0);
6515            }
6516        }
6517        detached.sort_unstable();
6518        opened.sort_unstable();
6519        let detached_median = detached[SAMPLE_COUNT / 2];
6520        let opened_median = opened[SAMPLE_COUNT / 2];
6521        let ratio = opened_median.as_secs_f64() / detached_median.as_secs_f64();
6522
6523        let index = last_opened.expect("an opened sample ran");
6524        let serving = index.serving.as_ref().expect("opened sample has serving indexes");
6525        let semantic_rows: usize = serving
6526            .semantic_by_directory
6527            .values()
6528            .map(|partitions| partitions.all.len() + partitions.unignored.len())
6529            .sum();
6530        let exact_name_rows: usize = serving
6531            .exact_name_by_directory
6532            .values()
6533            .map(|partitions| partitions.all.len() + partitions.unignored.len())
6534            .sum();
6535        eprintln!(
6536            "entries={} detached_median_us={} opened_median_us={} ratio={ratio:.3} \
6537             portable_rows={} child_rows={} recent_rows={} semantic_rows={} exact_name_rows={} \
6538             exact_name_vocabulary={}",
6539            index.len(),
6540            detached_median.as_micros(),
6541            opened_median.as_micros(),
6542            serving.portable_entries.len(),
6543            serving
6544                .portable_children
6545                .values()
6546                .map(|children| children.directories.len() + children.nondirectories.len())
6547                .sum::<usize>(),
6548            serving.recent_files.len(),
6549            semantic_rows,
6550            exact_name_rows,
6551            serving.exact_names.len(),
6552        );
6553    }
6554
6555    #[test]
6556    fn detached_indexes_never_allocate_or_populate_serving_state() {
6557        let mut index = Index::new("/root");
6558        index.apply_ok(&Observation::new(vec![
6559            upsert("dir", EntryKind::Dir, Attrs::default()),
6560            upsert("dir/a", EntryKind::File, file_attrs(1, 1)),
6561        ]));
6562
6563        assert!(index.serving.is_none());
6564        assert!(index.portable_children(Path::new("dir")).is_none());
6565    }
6566
6567    #[test]
6568    fn insert_loaded_child_skips_serving_path_when_serving_is_off() {
6569        let mut index = Index::new("/root");
6570        let id = index
6571            .insert_loaded_child(
6572                EntryId::ROOT,
6573                OsString::from("a.rs"),
6574                EntryKind::File,
6575                file_attrs(4, 1),
6576            )
6577            .expect("parent is a live directory");
6578        assert!(!index.serving_indexes_enabled());
6579        assert_eq!(index.path_of(id), Some(PathBuf::from("a.rs")));
6580        assert_eq!(index.lookup(Path::new("a.rs")), Some(id));
6581        assert_eq!(index.total().files, 1);
6582    }
6583
6584    #[test]
6585    fn insert_loaded_child_fills_serving_when_enabled() {
6586        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6587            "/root",
6588            ScanScope::default(),
6589            crate::classify::TypeRegistry::compiled_shared(),
6590            DEFAULT_JOURNAL_CAPACITY_BYTES,
6591        );
6592        let id = index
6593            .insert_loaded_child(
6594                EntryId::ROOT,
6595                OsString::from("a.rs"),
6596                EntryKind::File,
6597                file_attrs(4, 1),
6598            )
6599            .expect("parent is a live directory");
6600        assert!(index.serving_indexes_enabled());
6601        assert_eq!(index.path_of(id), Some(PathBuf::from("a.rs")));
6602        let serving = index.serving.as_ref().expect("opened test index");
6603        assert!(serving.portable_entries.keys().any(|path| path.as_str() == "a.rs"));
6604    }
6605
6606    #[test]
6607    fn opened_entry_values_project_name_identity_without_retaining_it_on_detached_entries() {
6608        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6609            "/root",
6610            ScanScope::default(),
6611            crate::classify::TypeRegistry::compiled_shared(),
6612            DEFAULT_JOURNAL_CAPACITY_BYTES,
6613        );
6614        index.apply_ok(&Observation::new(vec![upsert(
6615            "bundle.umd.min.js",
6616            EntryKind::File,
6617            file_attrs(7, 1),
6618        )]));
6619
6620        let row = index.entry_value(Path::new("bundle.umd.min.js")).expect("entry value");
6621        let identity = row.classification.expect("regular files carry name identity");
6622        assert_eq!(identity.logical_extension(), Some(".min.js"));
6623        assert_eq!(identity.canonical_extension(), Some(".js"));
6624        assert_eq!(identity.kind_id(), Some("javascript"));
6625        assert_eq!(identity.content_family(), crate::classify::ContentFamily::Code);
6626    }
6627
6628    #[test]
6629    fn a_shared_snapshot_drops_opened_root_serving_state() {
6630        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6631            "/root",
6632            ScanScope::default(),
6633            crate::classify::TypeRegistry::compiled_shared(),
6634            DEFAULT_JOURNAL_CAPACITY_BYTES,
6635        );
6636        index.apply_ok(&Observation::new(vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))]));
6637        assert!(index.serving_indexes_enabled());
6638
6639        let snapshot = IndexHandle::new(index).snapshot().expect("detached snapshot");
6640
6641        assert!(!snapshot.serving_indexes_enabled());
6642        assert_eq!(snapshot.total().files, 1);
6643    }
6644
6645    #[test]
6646    #[should_panic(expected = "an index's registry must match its semantic scope")]
6647    fn an_index_cannot_claim_a_registry_different_from_its_scope() {
6648        let types = Arc::new(
6649            crate::classify::TypeRegistry::from_manifest(
6650                "[[kind]]\nid = \"notes\"\nfamily = \"prose\"\nextensions = [\"rs\"]\n",
6651            )
6652            .expect("custom registry"),
6653        );
6654
6655        let _ = Index::new_with_scope_and_types("/root", ScanScope::default(), types);
6656    }
6657
6658    /// The parent memo skips resolving a path when consecutive upserts share a parent,
6659    /// so every test below puts the op that could invalidate it *between* two upserts
6660    /// into the same directory — the arrangement where a stale hit would be believed.
6661    /// A memo that never cleared would still pass an ordinary scan-shaped workload,
6662    /// which is why these are written as batches rather than as separate applies: one
6663    /// `apply_validated` call is the memo's whole lifetime.
6664    #[test]
6665    fn parent_memo_does_not_survive_removing_the_directory_it_remembers() {
6666        let mut index = Index::new(PathBuf::from("/root"));
6667        index.apply_ok(&Observation::new(vec![
6668            upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
6669            upsert("dir/a.txt", EntryKind::File, file_attrs(10, 1)),
6670        ]));
6671
6672        // Rebuild the directory explicitly after the removal. The following child
6673        // must resolve through that new entry rather than a memoized id for the entry
6674        // that was just removed.
6675        index.apply_ok(&Observation::new(vec![
6676            upsert("dir/b.txt", EntryKind::File, file_attrs(20, 1)),
6677            Op::Remove { path: PathBuf::from("dir") },
6678            upsert("dir", EntryKind::Dir, file_attrs(0, 2)),
6679            upsert("dir/c.txt", EntryKind::File, file_attrs(30, 2)),
6680        ]));
6681
6682        let children = index.children(Path::new("dir")).expect("dir survives");
6683        let names: Vec<_> = children.map(|(name, _)| name.to_os_string()).collect();
6684        assert_eq!(names, vec![OsString::from("c.txt")], "only the re-added child remains");
6685        assert_eq!(index.total().bytes, 30, "totals match the surviving child");
6686    }
6687
6688    #[test]
6689    fn a_kind_change_mid_batch_leaves_the_memo_usable_for_the_next_sibling() {
6690        let mut index = Index::new(PathBuf::from("/root"));
6691        index.apply_ok(&Observation::new(vec![
6692            upsert("swap", EntryKind::Dir, file_attrs(0, 1)),
6693            upsert("swap/inner", EntryKind::Dir, file_attrs(0, 1)),
6694            upsert("swap/inner/deep.txt", EntryKind::File, file_attrs(40, 1)),
6695        ]));
6696
6697        // A kind change drops a subtree, which looks like it should invalidate the memo
6698        // and does not: the memo holds the changed entry's parent, and the subtree
6699        // removed is rooted at the entry itself. This pins that reasoning, so that if
6700        // the removal ever widens to touch the parent the failure lands here rather
6701        // than as a dangling id in a scan.
6702        index.apply_ok(&Observation::new(vec![
6703            upsert("swap/inner/other.txt", EntryKind::File, file_attrs(50, 1)),
6704            upsert("swap/inner", EntryKind::File, file_attrs(60, 2)),
6705            upsert("swap/sibling.txt", EntryKind::File, file_attrs(70, 2)),
6706        ]));
6707
6708        let children = index.children(Path::new("swap")).expect("swap survives");
6709        let names: Vec<_> = children.map(|(name, _)| name.to_os_string()).collect();
6710        assert_eq!(names, vec![OsString::from("inner"), OsString::from("sibling.txt")]);
6711        assert_eq!(index.total().files, 2, "inner counts once, as a file");
6712        assert_eq!(index.total().bytes, 130, "the dropped subtree's bytes are gone");
6713    }
6714
6715    #[test]
6716    fn parent_memo_distinguishes_directories_that_share_a_name_prefix() {
6717        let mut index = Index::new(PathBuf::from("/root"));
6718        // `src` and `src2` differ only after the memo's stored bytes end, which is the
6719        // comparison a prefix check rather than an equality check would get wrong.
6720        index.apply_ok(&Observation::new(vec![
6721            upsert("src", EntryKind::Dir, file_attrs(0, 1)),
6722            upsert("src2", EntryKind::Dir, file_attrs(0, 1)),
6723            upsert("src/one.txt", EntryKind::File, file_attrs(11, 1)),
6724            upsert("src2/two.txt", EntryKind::File, file_attrs(22, 1)),
6725            upsert("src/three.txt", EntryKind::File, file_attrs(33, 1)),
6726        ]));
6727
6728        let in_src: Vec<_> = index
6729            .children(Path::new("src"))
6730            .expect("src")
6731            .map(|(name, _)| name.to_os_string())
6732            .collect();
6733        let in_src2: Vec<_> = index
6734            .children(Path::new("src2"))
6735            .expect("src2")
6736            .map(|(name, _)| name.to_os_string())
6737            .collect();
6738        assert_eq!(in_src, vec![OsString::from("one.txt"), OsString::from("three.txt")]);
6739        assert_eq!(in_src2, vec![OsString::from("two.txt")]);
6740    }
6741
6742    #[test]
6743    fn parent_memo_leaves_root_level_entries_alone() {
6744        // A root-level path has `Some("")` as its parent, which must not be confused
6745        // with the root entry itself or with a sibling's empty-parent lookup.
6746        let mut index = Index::new(PathBuf::from("/root"));
6747        index.apply_ok(&Observation::new(vec![
6748            upsert("a.txt", EntryKind::File, file_attrs(5, 1)),
6749            upsert("b.txt", EntryKind::File, file_attrs(6, 1)),
6750            upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
6751            upsert("dir/c.txt", EntryKind::File, file_attrs(7, 1)),
6752            upsert("d.txt", EntryKind::File, file_attrs(8, 1)),
6753        ]));
6754
6755        let top: Vec<_> = index
6756            .children(Path::new(""))
6757            .expect("root children")
6758            .map(|(name, _)| name.to_os_string())
6759            .collect();
6760        assert_eq!(
6761            top,
6762            vec![
6763                OsString::from("a.txt"),
6764                OsString::from("b.txt"),
6765                OsString::from("d.txt"),
6766                OsString::from("dir"),
6767            ]
6768        );
6769        assert_eq!(index.total().files, 4);
6770        assert_eq!(index.total().bytes, 26);
6771    }
6772
6773    #[test]
6774    fn shared_queries_return_owned_values_and_release_the_lock() {
6775        let handle = IndexHandle::new(index_with_sample_tree());
6776        let retained_total = handle.total().expect("total");
6777        let retained_history = handle.since(Clock::ZERO).expect("history");
6778        let retained_children = handle.children(Path::new("src")).expect("children");
6779        let retained_snapshot = handle.snapshot().expect("snapshot");
6780
6781        let writer = handle.clone();
6782        let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
6783        let thread = std::thread::spawn(move || {
6784            let result = writer.apply(&Observation::new(vec![upsert(
6785                "concurrent.txt",
6786                EntryKind::File,
6787                file_attrs(7, 30),
6788            )]));
6789            done_tx.send(result).expect("report writer result");
6790        });
6791
6792        let outcome = done_rx
6793            .recv_timeout(std::time::Duration::from_secs(5))
6794            .expect("owned query results must not retain the read lock")
6795            .expect("writer apply");
6796        thread.join().expect("writer thread");
6797
6798        assert_eq!(outcome.inserted, 1);
6799        assert_eq!(retained_total.files, 3);
6800        assert!(!retained_history.commits.is_empty());
6801        assert_eq!(retained_children.expect("src directory").len(), 2);
6802        assert!(retained_snapshot.lookup(Path::new("concurrent.txt")).is_none());
6803        assert!(handle.kind(Path::new("concurrent.txt")).expect("query").is_some());
6804    }
6805
6806    #[test]
6807    fn cloned_indexes_are_independent_detached_images() {
6808        let original = index_with_sample_tree();
6809        let original_clock = original.clock();
6810        let mut detached = original.clone();
6811
6812        detached.apply_ok(&Observation::new(vec![upsert(
6813            "detached-only.txt",
6814            EntryKind::File,
6815            file_attrs(7, 30),
6816        )]));
6817
6818        assert_eq!(original.clock(), original_clock);
6819        assert!(original.lookup(Path::new("detached-only.txt")).is_none());
6820        assert!(detached.lookup(Path::new("detached-only.txt")).is_some());
6821        assert_eq!(detached.total().files, original.total().files + 1);
6822    }
6823
6824    #[test]
6825    fn captured_child_expectations_match_individual_path_lookups() {
6826        let index = index_with_sample_tree();
6827        let captured = collect_child_expectations(&index, Path::new("src"));
6828
6829        assert!(!captured.is_empty());
6830        for (name, expectation) in captured {
6831            assert_eq!(expectation, index.expectation(&Path::new("src").join(name)));
6832        }
6833    }
6834
6835    #[test]
6836    fn index_and_shared_handle_are_send_and_sync() {
6837        fn assert_send_sync<T: Send + Sync>() {}
6838
6839        assert_send_sync::<Index>();
6840        assert_send_sync::<IndexHandle>();
6841    }
6842
6843    #[test]
6844    fn simultaneous_writers_commit_unique_contiguous_clocks_in_journal_order() {
6845        let writer_count: usize = 8;
6846        let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
6847        let barrier: Arc<Barrier> = Arc::new(Barrier::new(writer_count));
6848        let (result_tx, result_rx) = std::sync::mpsc::sync_channel(writer_count);
6849
6850        std::thread::scope(|scope| {
6851            for worker_id in 0..writer_count {
6852                let worker: IndexHandle = handle.clone();
6853                let start: Arc<Barrier> = Arc::clone(&barrier);
6854                let results = result_tx.clone();
6855                scope.spawn(move || {
6856                    let ordinal: u64 = u64::try_from(worker_id + 1).expect("small worker count");
6857                    let path: String = format!("writer-{ordinal}.txt");
6858                    start.wait();
6859                    let outcome: crate::Result<ApplyOutcome> =
6860                        worker.apply(&Observation::new(vec![upsert(
6861                            &path,
6862                            EntryKind::File,
6863                            file_attrs(ordinal, i64::try_from(ordinal).expect("small ordinal")),
6864                        )]));
6865                    results.send((path, outcome)).expect("report writer result");
6866                });
6867            }
6868        });
6869        drop(result_tx);
6870
6871        let mut committed_clocks: Vec<u64> = Vec::with_capacity(writer_count);
6872        for (path, outcome) in result_rx {
6873            let commit = outcome.expect("writer apply").commit.expect("unique upsert must commit");
6874            committed_clocks.push(commit.clock.0);
6875            assert!(handle.kind(Path::new(&path)).expect("query committed path").is_some());
6876        }
6877        committed_clocks.sort_unstable();
6878
6879        let last_clock: u64 = u64::try_from(writer_count).expect("small writer count");
6880        let expected_clocks: Vec<u64> = (1..=last_clock).collect();
6881        assert_eq!(committed_clocks, expected_clocks);
6882        assert_eq!(handle.clock().expect("clock"), Clock(last_clock));
6883
6884        let journal_clocks: Vec<u64> = handle
6885            .since(Clock::ZERO)
6886            .expect("journal")
6887            .commits
6888            .iter()
6889            .map(|commit| commit.clock.0)
6890            .collect();
6891        assert_eq!(journal_clocks, expected_clocks);
6892    }
6893
6894    #[test]
6895    fn readers_observe_only_complete_states_around_a_large_batch() {
6896        let file_count: u64 = 2_048;
6897        let expected_bytes: u64 = file_count * (file_count + 1) / 2;
6898        let operations: Vec<Op> =
6899            std::iter::once(upsert("batch", EntryKind::Dir, file_attrs(0, 1)))
6900                .chain((1..=file_count).map(|ordinal| {
6901                    upsert(
6902                        &format!("batch/file-{ordinal}.bin"),
6903                        EntryKind::File,
6904                        file_attrs(ordinal, i64::try_from(ordinal).expect("small ordinal")),
6905                    )
6906                }))
6907                .collect();
6908        let observation: Observation = Observation::new(operations);
6909        let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
6910        let before: Index = handle.snapshot().expect("before snapshot");
6911        assert_eq!(before.total().files, 0);
6912
6913        let barrier: Arc<Barrier> = Arc::new(Barrier::new(2));
6914        let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
6915        std::thread::scope(|scope| {
6916            let writer: IndexHandle = handle.clone();
6917            let writer_start: Arc<Barrier> = Arc::clone(&barrier);
6918            scope.spawn(move || {
6919                writer_start.wait();
6920                let result: crate::Result<ApplyOutcome> = writer.apply(&observation);
6921                done_tx.send(result).expect("report batch result");
6922            });
6923
6924            let reader: IndexHandle = handle.clone();
6925            let reader_start: Arc<Barrier> = Arc::clone(&barrier);
6926            scope.spawn(move || {
6927                reader_start.wait();
6928                let deadline: std::time::Instant =
6929                    std::time::Instant::now() + std::time::Duration::from_secs(10);
6930                loop {
6931                    assert!(
6932                        std::time::Instant::now() < deadline,
6933                        "reader did not observe batch completion before the deadline"
6934                    );
6935                    let image: Index = reader.snapshot().expect("coherent reader snapshot");
6936                    let total = image.total();
6937                    match total.files {
6938                        0 => {
6939                            assert_eq!(total.bytes, 0);
6940                            assert_eq!(image.len(), 1);
6941                        }
6942                        count if count == file_count => {
6943                            assert_eq!(total.bytes, expected_bytes);
6944                            assert_eq!(total.dirs, 1);
6945                            assert_eq!(image.len(), file_count + 2);
6946                        }
6947                        partial => panic!("reader observed partial batch with {partial} files"),
6948                    }
6949
6950                    match done_rx.try_recv() {
6951                        Ok(result) => {
6952                            assert_eq!(result.expect("batch apply").inserted, file_count + 1);
6953                            break;
6954                        }
6955                        Err(std::sync::mpsc::TryRecvError::Empty) => {}
6956                        Err(std::sync::mpsc::TryRecvError::Disconnected) => {
6957                            panic!("batch writer disconnected")
6958                        }
6959                    }
6960                }
6961            });
6962        });
6963
6964        let after: Index = handle.snapshot().expect("after snapshot");
6965        assert_eq!(after.total().files, file_count);
6966        assert_eq!(after.total().bytes, expected_bytes);
6967        assert_eq!(after.len(), file_count + 2);
6968    }
6969
6970    #[test]
6971    fn poisoned_shared_lock_returns_typed_errors() {
6972        let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
6973        let poisoner: IndexHandle = handle.clone();
6974        let panic_result: std::thread::Result<()> = std::thread::spawn(move || {
6975            let _poison_guard = poisoner.write_index().expect("initial write lock");
6976            panic!("intentional lock poison");
6977        })
6978        .join();
6979        assert!(panic_result.is_err());
6980
6981        assert!(matches!(handle.total(), Err(crate::Error::IndexLockPoisoned)));
6982        assert!(matches!(
6983            handle.apply(&Observation::new(vec![upsert(
6984                "never-applied.txt",
6985                EntryKind::File,
6986                file_attrs(1, 1),
6987            )])),
6988            Err(crate::Error::IndexLockPoisoned)
6989        ));
6990    }
6991
6992    #[test]
6993    fn clock_exhaustion_rejects_before_any_mutation() {
6994        let mut index = index_with_sample_tree();
6995        index.clock = Clock(u64::MAX);
6996        let before_total = index.total();
6997        let before_len = index.len();
6998
6999        let error = index
7000            .apply(&Observation::new(vec![upsert(
7001                "too-late.txt",
7002                EntryKind::File,
7003                file_attrs(1, 1),
7004            )]))
7005            .expect_err("clock exhaustion must be typed");
7006
7007        assert!(matches!(error, crate::Error::ClockExhausted));
7008        assert_eq!(index.clock(), Clock(u64::MAX));
7009        assert_eq!(index.len(), before_len);
7010        assert_eq!(index.total(), before_total);
7011        assert!(index.lookup(Path::new("too-late.txt")).is_none());
7012    }
7013
7014    #[test]
7015    fn terminal_clock_still_accepts_no_op_and_stale_observations() {
7016        let mut index = index_with_sample_tree();
7017        let current = *index.attrs(Path::new("src/main.rs")).expect("sample attributes");
7018        let stale_baseline = index.expectation(Path::new("src/main.rs"));
7019        index.apply_ok(&Observation::new(vec![upsert(
7020            "src/main.rs",
7021            EntryKind::File,
7022            file_attrs(99, 99),
7023        )]));
7024        index.clock = Clock(u64::MAX);
7025        let before_total = index.total();
7026        let before_len = index.len();
7027        let before_journal = index.journal.clone();
7028
7029        let no_op = index
7030            .apply(&Observation::new(vec![upsert(
7031                "src/main.rs",
7032                EntryKind::File,
7033                file_attrs(99, 99),
7034            )]))
7035            .expect("a no-op needs no new clock");
7036        let stale = index
7037            .apply(&Observation::from_ops(vec![ObservationOp::if_state(
7038                upsert("src/main.rs", EntryKind::File, current),
7039                stale_baseline,
7040            )]))
7041            .expect("a rejected stale observation needs no new clock");
7042
7043        assert_eq!(no_op.unchanged, 1);
7044        assert!(no_op.commit.is_none());
7045        assert_eq!(stale.stale, 1);
7046        assert!(stale.commit.is_none());
7047        assert_eq!(index.clock(), Clock(u64::MAX));
7048        assert_eq!(index.len(), before_len);
7049        assert_eq!(index.total(), before_total);
7050        assert_eq!(index.journal, before_journal);
7051    }
7052
7053    #[test]
7054    fn delayed_conditional_observation_cannot_overwrite_newer_state() {
7055        let mut index = Index::new("/root");
7056        index.apply_ok(&Observation::new(vec![upsert(
7057            "file.txt",
7058            EntryKind::File,
7059            file_attrs(10, 1),
7060        )]));
7061
7062        let baseline = index.expectation(Path::new("file.txt"));
7063        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7064            upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
7065            baseline,
7066        )]);
7067
7068        index.apply_ok(&Observation::new(vec![upsert(
7069            "file.txt",
7070            EntryKind::File,
7071            file_attrs(30, 3),
7072        )]));
7073        let outcome = index.apply_ok(&delayed);
7074
7075        assert_eq!(outcome.stats.stale, 1);
7076        assert!(outcome.commit.is_none());
7077        assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 30);
7078    }
7079
7080    #[test]
7081    fn delayed_absent_child_cannot_replace_a_newer_parent_file() {
7082        let mut index = Index::new("/root");
7083        index.apply_ok(&Observation::new(vec![upsert("parent", EntryKind::Dir, file_attrs(0, 1))]));
7084        let child_baseline = index.expectation(Path::new("parent/child.txt"));
7085        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7086            upsert("parent/child.txt", EntryKind::File, file_attrs(10, 2)),
7087            child_baseline,
7088        )]);
7089
7090        index.apply_ok(&Observation::new(vec![upsert(
7091            "parent",
7092            EntryKind::File,
7093            file_attrs(20, 3),
7094        )]));
7095        let outcome = index.apply_ok(&delayed);
7096
7097        assert_eq!(outcome.stats.stale, 1);
7098        assert!(outcome.commit.is_none());
7099        assert_eq!(index.kind(Path::new("parent")), Some(EntryKind::File));
7100        assert!(index.lookup(Path::new("parent/child.txt")).is_none());
7101    }
7102
7103    #[test]
7104    fn conditional_observation_rejects_present_state_aba() {
7105        let mut index = Index::new("/root");
7106        index.apply_ok(&Observation::new(vec![upsert(
7107            "file.txt",
7108            EntryKind::File,
7109            file_attrs(10, 1),
7110        )]));
7111        let baseline = index.expectation(Path::new("file.txt"));
7112        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7113            upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
7114            baseline,
7115        )]);
7116
7117        index.apply_ok(&Observation::new(vec![upsert(
7118            "file.txt",
7119            EntryKind::File,
7120            file_attrs(30, 3),
7121        )]));
7122        index.apply_ok(&Observation::new(vec![upsert(
7123            "file.txt",
7124            EntryKind::File,
7125            file_attrs(10, 1),
7126        )]));
7127        let outcome = index.apply_ok(&delayed);
7128
7129        assert_eq!(outcome.stats.stale, 1);
7130        assert!(outcome.commit.is_none());
7131        assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 10);
7132    }
7133
7134    #[test]
7135    fn conditional_observation_rejects_absent_state_aba() {
7136        let mut index = Index::new("/root");
7137        let baseline = index.expectation(Path::new("file.txt"));
7138        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7139            upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
7140            baseline,
7141        )]);
7142
7143        index.apply_ok(&Observation::new(vec![upsert(
7144            "file.txt",
7145            EntryKind::File,
7146            file_attrs(30, 3),
7147        )]));
7148        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("file.txt") }]));
7149        let outcome = index.apply_ok(&delayed);
7150
7151        assert_eq!(outcome.stats.stale, 1);
7152        assert!(outcome.commit.is_none());
7153        assert!(index.lookup(Path::new("file.txt")).is_none());
7154    }
7155
7156    /// A delayed conditional upsert whose baseline moved to exactly its target is no
7157    /// conflict: the other producer verified the same fact first. It applies as unchanged,
7158    /// not stale, so a refresh that converges with the observation handoff does not send
7159    /// the handoff back for another full-root walk.
7160    #[test]
7161    fn convergent_conditional_upsert_applies_as_unchanged_not_stale() {
7162        let mut index = Index::new("/root");
7163        index.apply_ok(&Observation::new(vec![upsert(
7164            "file.txt",
7165            EntryKind::File,
7166            file_attrs(10, 1),
7167        )]));
7168        let baseline = index.expectation(Path::new("file.txt"));
7169        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7170            upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
7171            baseline,
7172        )]);
7173
7174        index.apply_ok(&Observation::new(vec![upsert(
7175            "file.txt",
7176            EntryKind::File,
7177            file_attrs(20, 2),
7178        )]));
7179        let outcome = index.apply_ok(&delayed);
7180
7181        assert_eq!(outcome.stats.stale, 0);
7182        assert_eq!(outcome.stats.unchanged, 1);
7183        assert!(outcome.commit.is_none());
7184        assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 20);
7185    }
7186
7187    #[test]
7188    fn convergent_conditional_remove_applies_as_unchanged_not_stale() {
7189        let mut index = Index::new("/root");
7190        index.apply_ok(&Observation::new(vec![
7191            upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
7192            upsert("dir/file.txt", EntryKind::File, file_attrs(10, 1)),
7193        ]));
7194        let baseline = index.expectation(Path::new("dir/file.txt"));
7195        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7196            Op::Remove { path: PathBuf::from("dir/file.txt") },
7197            baseline,
7198        )]);
7199
7200        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("dir/file.txt") }]));
7201        let outcome = index.apply_ok(&delayed);
7202
7203        assert_eq!(outcome.stats.stale, 0);
7204        assert_eq!(outcome.stats.unchanged, 1);
7205        assert!(outcome.commit.is_none());
7206        assert!(index.lookup(Path::new("dir/file.txt")).is_none());
7207    }
7208
7209    /// A control file's entry and rules are pushed on one baseline, so a refresh that
7210    /// verified both first leaves nothing for either to change. Both apply as unchanged; a
7211    /// control op whose rules the table does not hold is still refused on the moved
7212    /// baseline.
7213    #[test]
7214    fn convergent_conditional_control_ops_apply_as_unchanged_not_stale() {
7215        let path = PathBuf::from(".gitignore");
7216        let rules =
7217            |source: &[u8]| Op::ControlUpsert { path: path.clone(), source: source.to_vec() };
7218        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
7219        index.apply_ok(&Observation::new(vec![
7220            upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
7221            rules(b"before"),
7222        ]));
7223        let baseline = index.expectation(&path);
7224        index.apply_ok(&Observation::new(vec![
7225            upsert(".gitignore", EntryKind::File, file_attrs(7, 2)),
7226            rules(b"changed"),
7227        ]));
7228
7229        let outcome = index.apply_ok(&Observation::from_ops(vec![
7230            ObservationOp::if_state(
7231                upsert(".gitignore", EntryKind::File, file_attrs(7, 2)),
7232                baseline,
7233            ),
7234            ObservationOp::if_state(rules(b"changed"), baseline),
7235        ]));
7236        assert_eq!(outcome.stats.stale, 0);
7237        assert!(outcome.commit.is_none());
7238        let diverged = index.apply_ok(&Observation::from_ops(vec![ObservationOp::if_state(
7239            rules(b"other"),
7240            baseline,
7241        )]));
7242        assert_eq!(diverged.stats.stale, 1);
7243        assert!(index.controls().expect("control state observed").source_is(&path, b"changed"));
7244
7245        let baseline = index.expectation(&path);
7246        index.apply_ok(&Observation::new(vec![Op::Remove { path: path.clone() }]));
7247        let outcome = index.apply_ok(&Observation::from_ops(vec![
7248            ObservationOp::if_state(Op::Remove { path: path.clone() }, baseline),
7249            ObservationOp::if_state(Op::ControlRemove { path: path.clone() }, baseline),
7250        ]));
7251        assert_eq!(outcome.stats.stale, 0);
7252        assert!(outcome.commit.is_none());
7253        assert!(!index.controls().expect("control state observed").contains(&path));
7254    }
7255
7256    #[test]
7257    fn unrelated_mutation_does_not_stale_an_absent_path() {
7258        let mut index = Index::new("/root");
7259        index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
7260        let baseline = index.expectation(Path::new("dir/new.txt"));
7261        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7262            upsert("dir/new.txt", EntryKind::File, file_attrs(20, 2)),
7263            baseline,
7264        )]);
7265
7266        index.apply_ok(&Observation::new(vec![upsert(
7267            "other.txt",
7268            EntryKind::File,
7269            file_attrs(30, 3),
7270        )]));
7271        let outcome = index.apply_ok(&delayed);
7272
7273        assert_eq!(outcome.stats.stale, 0);
7274        assert_eq!(outcome.stats.inserted, 1);
7275        assert_eq!(index.attrs(Path::new("dir/new.txt")).expect("file").size, 20);
7276    }
7277
7278    #[test]
7279    fn directory_metadata_change_does_not_stale_an_absent_child() {
7280        let mut index = Index::new("/root");
7281        index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
7282        let baseline = index.expectation(Path::new("dir/new.txt"));
7283        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7284            upsert("dir/new.txt", EntryKind::File, file_attrs(20, 2)),
7285            baseline,
7286        )]);
7287
7288        index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 3))]));
7289        let outcome = index.apply_ok(&delayed);
7290
7291        assert_eq!(outcome.stats.stale, 0);
7292        assert_eq!(outcome.stats.inserted, 1);
7293    }
7294
7295    #[test]
7296    fn file_parent_metadata_change_stales_an_absent_child() {
7297        let mut index = Index::new("/root");
7298        index.apply_ok(&Observation::new(vec![upsert(
7299            "parent",
7300            EntryKind::File,
7301            file_attrs(10, 1),
7302        )]));
7303        let baseline = index.expectation(Path::new("parent/child.txt"));
7304        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7305            upsert("parent/child.txt", EntryKind::File, file_attrs(20, 2)),
7306            baseline,
7307        )]);
7308
7309        index.apply_ok(&Observation::new(vec![upsert(
7310            "parent",
7311            EntryKind::File,
7312            file_attrs(30, 3),
7313        )]));
7314        let outcome = index.apply_ok(&delayed);
7315
7316        assert_eq!(outcome.stats.stale, 1);
7317        assert_eq!(index.kind(Path::new("parent")), Some(EntryKind::File));
7318        assert!(index.lookup(Path::new("parent/child.txt")).is_none());
7319    }
7320
7321    #[test]
7322    fn delayed_directory_remove_cannot_delete_a_newer_child() {
7323        let mut index = Index::new("/root");
7324        index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
7325        let baseline = index.expectation(Path::new("dir"));
7326        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7327            Op::Remove { path: PathBuf::from("dir") },
7328            baseline,
7329        )]);
7330
7331        index.apply_ok(&Observation::new(vec![upsert(
7332            "dir/new.txt",
7333            EntryKind::File,
7334            file_attrs(20, 2),
7335        )]));
7336        let outcome = index.apply_ok(&delayed);
7337
7338        assert_eq!(outcome.stats.stale, 1);
7339        assert!(index.lookup(Path::new("dir/new.txt")).is_some());
7340    }
7341
7342    #[test]
7343    fn conditional_batch_is_validated_at_one_boundary() {
7344        let mut index = Index::new("/root");
7345        let first = index.expectation(Path::new("first.txt"));
7346        let second = index.expectation(Path::new("second.txt"));
7347
7348        let outcome = index.apply_ok(&Observation::from_ops(vec![
7349            ObservationOp::if_state(upsert("first.txt", EntryKind::File, file_attrs(10, 1)), first),
7350            ObservationOp::if_state(
7351                upsert("second.txt", EntryKind::File, file_attrs(20, 2)),
7352                second,
7353            ),
7354        ]));
7355
7356        assert_eq!(outcome.stats.inserted, 2);
7357        assert_eq!(outcome.stats.stale, 0);
7358    }
7359
7360    #[test]
7361    fn public_observation_outputs_use_canonical_encoded_paths() {
7362        let separator = std::path::MAIN_SEPARATOR;
7363        let dotted = PathBuf::from(format!("dotted{separator}.{separator}file.txt"));
7364        let dotted_canonical = PathBuf::from(format!("dotted{separator}file.txt"));
7365        let repeated = PathBuf::from(format!("repeated{separator}{separator}file.txt"));
7366        let repeated_canonical = PathBuf::from(format!("repeated{separator}file.txt"));
7367        let mut index = Index::new("/root");
7368
7369        let outcome = index.apply_ok(&Observation::new(vec![
7370            upsert("dotted", EntryKind::Dir, file_attrs(0, 1)),
7371            Op::Upsert { path: dotted, kind: EntryKind::File, attrs: file_attrs(10, 2) },
7372            upsert("repeated", EntryKind::Dir, file_attrs(0, 3)),
7373            Op::Upsert { path: repeated, kind: EntryKind::File, attrs: file_attrs(20, 4) },
7374        ]));
7375        let commit = outcome.commit.as_ref().expect("one exact commit");
7376
7377        for (actual, canonical) in [
7378            (commit.changes[1].path(), dotted_canonical.as_path()),
7379            (commit.changes[3].path(), repeated_canonical.as_path()),
7380        ] {
7381            assert_eq!(
7382                actual.as_os_str().as_encoded_bytes(),
7383                canonical.as_os_str().as_encoded_bytes()
7384            );
7385        }
7386
7387        for canonical in [&dotted_canonical, &repeated_canonical] {
7388            let dirty = commit
7389                .impact
7390                .dirty_paths
7391                .iter()
7392                .find(|candidate| candidate.as_path() == canonical)
7393                .expect("changed path is dirty");
7394            assert_eq!(
7395                dirty.as_os_str().as_encoded_bytes(),
7396                canonical.as_os_str().as_encoded_bytes()
7397            );
7398        }
7399    }
7400
7401    /// The trailing spellings `Path::components` hides reach public values canonically.
7402    ///
7403    /// `a/b/` and `a/b/.` compare equal to `a/b` component by component, so lookups never
7404    /// notice a preserved spelling; only a value that carries the bytes out does. The
7405    /// reproduction this pins is an unknown-ancestry error that named `a/b/` (PR #51
7406    /// review COMMIT-4).
7407    #[test]
7408    fn trailing_path_spellings_leave_errors_and_changes_canonical() {
7409        let separator = std::path::MAIN_SEPARATOR;
7410        let canonical = format!("a{separator}b");
7411        for spelling in [format!("a{separator}b{separator}"), format!("a{separator}b{separator}.")]
7412        {
7413            let file = |mtime_ns| Op::Upsert {
7414                path: PathBuf::from(&spelling),
7415                kind: EntryKind::File,
7416                attrs: file_attrs(1, mtime_ns),
7417            };
7418            let mut index = Index::new("/root");
7419
7420            let error = index
7421                .apply(&Observation::new(vec![file(1)]))
7422                .expect_err("a child of an unknown directory is refused");
7423            let crate::Error::UnknownAncestry { path, .. } = error else {
7424                panic!("expected unknown ancestry for {spelling:?}, got {error}");
7425            };
7426            assert_eq!(path.as_os_str().as_encoded_bytes(), canonical.as_bytes(), "{spelling:?}");
7427
7428            let outcome = index.apply_ok(&Observation::new(vec![
7429                upsert("a", EntryKind::Dir, file_attrs(0, 1)),
7430                file(2),
7431            ]));
7432            let commit = outcome.commit.as_ref().expect("one exact commit");
7433            assert_eq!(
7434                commit.changes[1].path().as_os_str().as_encoded_bytes(),
7435                canonical.as_bytes(),
7436                "{spelling:?}"
7437            );
7438        }
7439    }
7440
7441    #[test]
7442    fn malformed_batch_is_rejected_before_any_index_mutation() {
7443        let invalid_paths = [
7444            PathBuf::from("../escape"),
7445            PathBuf::from(format!("{}absolute", std::path::MAIN_SEPARATOR)),
7446        ];
7447
7448        for invalid_path in invalid_paths {
7449            for invalid_first in [false, true] {
7450                let mut index = index_with_sample_tree();
7451                let before_clock = index.clock;
7452                let before_live = index.live;
7453                let before_total = index.total();
7454                let before_journal = index.journal.clone();
7455                let before_journal_cost = index.journal_cost;
7456                let before_journal_floor = index.journal_floor;
7457                let before_invalidations = index.pending_invalidations.clone();
7458                let before_freshness_epoch = index.freshness_epoch;
7459                let before_freshness = index.freshness();
7460                let valid = upsert("new.txt", EntryKind::File, file_attrs(99, 99));
7461                let invalid = Op::InvalidateSubtree {
7462                    path: invalid_path.clone(),
7463                    reason: InvalidateReason::Requested,
7464                };
7465                let ops = if invalid_first { vec![invalid, valid] } else { vec![valid, invalid] };
7466
7467                let error = index.apply(&Observation::new(ops)).expect_err("malformed batch");
7468
7469                assert!(
7470                    matches!(error, crate::Error::PathEscapesRoot(path) if path == invalid_path)
7471                );
7472                assert_eq!(index.clock, before_clock);
7473                assert_eq!(index.live, before_live);
7474                assert_eq!(index.total(), before_total);
7475                assert_eq!(index.journal, before_journal);
7476                assert_eq!(index.journal_cost, before_journal_cost);
7477                assert_eq!(index.journal_floor, before_journal_floor);
7478                assert_eq!(index.pending_invalidations, before_invalidations);
7479                assert_eq!(index.freshness_epoch, before_freshness_epoch);
7480                assert_eq!(index.freshness(), before_freshness);
7481                assert!(index.lookup(Path::new("new.txt")).is_none());
7482            }
7483        }
7484    }
7485
7486    #[cfg(windows)]
7487    #[test]
7488    fn windows_prefix_is_rejected_before_mutation() {
7489        let mut index = index_with_sample_tree();
7490        let before_clock = index.clock();
7491
7492        let error = index
7493            .apply(&Observation::new(vec![Op::Remove { path: PathBuf::from(r"C:\escape") }]))
7494            .expect_err("prefixed path");
7495
7496        assert!(
7497            matches!(error, crate::Error::PathEscapesRoot(path) if path == Path::new(r"C:\escape"))
7498        );
7499        assert_eq!(index.clock(), before_clock);
7500    }
7501
7502    fn index_with_sample_tree() -> Index {
7503        let mut index = Index::new("/root");
7504        index.apply_ok(&Observation::new(vec![
7505            upsert("src", EntryKind::Dir, Attrs::default()),
7506            upsert("src/main.rs", EntryKind::File, file_attrs(100, 10)),
7507            upsert("src/lib.rs", EntryKind::File, file_attrs(200, 20)),
7508            upsert("docs", EntryKind::Dir, Attrs::default()),
7509            upsert("docs/guide.md", EntryKind::File, file_attrs(300, 30)),
7510        ]));
7511        index
7512    }
7513
7514    #[test]
7515    fn the_extension_interner_reclaims_ids_after_churn() {
7516        // The long-lived case: a watched tree that keeps creating and deleting files
7517        // with distinct extensions. Without reclamation both interner maps grow for the
7518        // life of the process, which for `fdu --watch` means forever.
7519        let mut index = Index::new("/root");
7520        for sequence in 0..128 {
7521            let path = PathBuf::from(format!("build.out-{sequence}"));
7522            index.apply_ok(&Observation::new(vec![Op::Upsert {
7523                path: path.clone(),
7524                kind: EntryKind::File,
7525                attrs: file_attrs(1, sequence),
7526            }]));
7527            index.apply_ok(&Observation::new(vec![Op::Remove { path }]));
7528        }
7529
7530        assert!(index.ext_ids.is_empty(), "no extension survives the file that named it");
7531        assert_eq!(index.ext_names.len(), 1, "128 dead extensions reuse one interner slot");
7532        assert!(index.total().by_ext.is_empty());
7533    }
7534
7535    #[test]
7536    fn a_reclaimed_extension_id_does_not_alias_a_live_tally() {
7537        // Reissuing a slot is only safe if nothing still points at it. Keep one file on
7538        // the recycled extension while another one comes and goes.
7539        let mut index = Index::new("/root");
7540        index.apply_ok(&Observation::new(vec![
7541            upsert("keep.rs", EntryKind::File, file_attrs(10, 1)),
7542            upsert("drop.tmp", EntryKind::File, file_attrs(20, 2)),
7543        ]));
7544        let retained = index.total();
7545        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("drop.tmp") }]));
7546        index.apply_ok(&Observation::new(vec![upsert(
7547            "next.bak",
7548            EntryKind::File,
7549            file_attrs(30, 3),
7550        )]));
7551
7552        let tallies = index.total().by_ext;
7553        assert_eq!(tallies[".rs"], ExtTally { files: 1, bytes: 10, allocated: 512 });
7554        assert_eq!(tallies[".bak"], ExtTally { files: 1, bytes: 30, allocated: 512 });
7555        assert!(!tallies.contains_key(".tmp"), "the removed extension is gone");
7556        assert_eq!(
7557            retained.by_ext[".tmp"],
7558            ExtTally { files: 1, bytes: 20, allocated: 512 },
7559            "an owned roll-up stays self-describing after its interner slot is reused"
7560        );
7561        assert!(!retained.by_ext.contains_key(".bak"));
7562    }
7563
7564    #[test]
7565    fn rollups_aggregate_up_the_tree() {
7566        let index = index_with_sample_tree();
7567
7568        let total = index.total();
7569        assert_eq!(total.files, 3);
7570        assert_eq!(total.dirs, 2);
7571        assert_eq!(total.bytes, 600);
7572        assert_eq!(total.newest_mtime_ns, 30);
7573
7574        let src = index.rollup(Path::new("src")).expect("src is a directory");
7575        assert_eq!(src.files, 2);
7576        assert_eq!(src.dirs, 0);
7577        assert_eq!(src.bytes, 300);
7578        assert_eq!(src.newest_mtime_ns, 20);
7579    }
7580
7581    #[test]
7582    fn symlinks_and_special_nodes_do_not_contribute_regular_file_tallies() {
7583        let mut index = Index::new("/root");
7584        index.apply_ok(&Observation::new(vec![
7585            upsert("regular.txt", EntryKind::File, file_attrs(10, 10)),
7586            upsert("link.rs", EntryKind::Symlink, file_attrs(99, 99)),
7587            upsert("socket.md", EntryKind::Other, file_attrs(88, 88)),
7588        ]));
7589
7590        let total = index.total();
7591        assert_eq!(total.files, 1);
7592        assert_eq!(total.bytes, 10);
7593        assert_eq!(total.allocated, 512);
7594        assert_eq!(total.newest_mtime_ns, 10);
7595        assert_eq!(total.by_ext[".txt"], ExtTally { files: 1, bytes: 10, allocated: 512 });
7596        assert!(!total.by_ext.contains_key(".rs"));
7597        assert!(!total.by_ext.contains_key(".md"));
7598
7599        index.apply_ok(&Observation::new(vec![upsert(
7600            "link.rs",
7601            EntryKind::File,
7602            file_attrs(99, 99),
7603        )]));
7604        assert_eq!(index.total().files, 2);
7605        assert_eq!(index.total().bytes, 109);
7606
7607        index.apply_ok(&Observation::new(vec![upsert(
7608            "link.rs",
7609            EntryKind::Symlink,
7610            file_attrs(99, 99),
7611        )]));
7612        assert_eq!(index.total().files, 1);
7613        assert_eq!(index.total().bytes, 10);
7614    }
7615
7616    #[test]
7617    fn per_extension_tallies_roll_up_hierarchically() {
7618        let index = index_with_sample_tree();
7619
7620        let total = index.total();
7621        assert_eq!(total.by_ext[".rs"], ExtTally { files: 2, bytes: 300, allocated: 1024 });
7622        assert_eq!(total.by_ext[".md"], ExtTally { files: 1, bytes: 300, allocated: 512 });
7623
7624        // Per-directory breakdown, which no surveyed tool provides.
7625        let src = index.rollup(Path::new("src")).expect("src is a directory");
7626        assert_eq!(src.by_ext[".rs"], ExtTally { files: 2, bytes: 300, allocated: 1024 });
7627        assert!(!src.by_ext.contains_key(".md"));
7628    }
7629
7630    #[test]
7631    fn per_extension_allocated_tracks_apparent_bytes_separately() {
7632        // Both size metrics ride in the same tally, so a report asked for allocated bytes
7633        // keeps its per-type breakdown instead of silently answering in apparent bytes.
7634        let mut index = Index::new("/root");
7635        index.apply_ok(&Observation::new(vec![
7636            // Two small files: apparent bytes are tiny, allocation rounds each to a block.
7637            upsert("a.rs", EntryKind::File, file_attrs(1, 10)),
7638            upsert("b.rs", EntryKind::File, file_attrs(2, 20)),
7639        ]));
7640
7641        let rs = index.total().by_ext[".rs"];
7642        assert_eq!((rs.files, rs.bytes), (2, 3));
7643        assert_eq!(rs.allocated, 1024, "each file occupies one 512-byte block");
7644
7645        // Removing one file withdraws its allocation from the tally, not just its bytes.
7646        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("b.rs") }]));
7647        let rs = index.total().by_ext[".rs"];
7648        assert_eq!((rs.files, rs.bytes, rs.allocated), (1, 1, 512));
7649    }
7650
7651    #[test]
7652    fn upsert_with_matching_fingerprint_is_a_no_op() {
7653        let mut index = index_with_sample_tree();
7654        let before = index.total();
7655        let mark = index.clock();
7656
7657        let stats = index.apply_ok(&Observation::new(vec![upsert(
7658            "src/main.rs",
7659            EntryKind::File,
7660            file_attrs(100, 10),
7661        )]));
7662
7663        assert_eq!(stats.unchanged, 1);
7664        assert_eq!(stats.updated, 0);
7665        assert_eq!(index.total(), before);
7666        assert_eq!(index.clock(), mark);
7667        assert!(index.since(mark).commits.is_empty());
7668    }
7669
7670    #[test]
7671    fn commit_contains_only_effective_mutations() {
7672        let mut index = index_with_sample_tree();
7673        let outcome = index.apply_ok(&Observation::new(vec![
7674            upsert("src/main.rs", EntryKind::File, file_attrs(100, 10)),
7675            upsert("new.txt", EntryKind::File, file_attrs(4, 4)),
7676            Op::Remove { path: PathBuf::from("missing.txt") },
7677        ]));
7678
7679        assert_eq!(outcome.stats.unchanged, 2);
7680        let commit = outcome.commit.expect("one effective insert");
7681        assert_eq!(commit.changes.len(), 1);
7682        assert_eq!(commit.changes[0].path(), Path::new("new.txt"));
7683    }
7684
7685    #[test]
7686    fn mutation_counters_match_exact_batch_and_consequence_totals() {
7687        struct DisableCounters;
7688
7689        impl Drop for DisableCounters {
7690            fn drop(&mut self) {
7691                crate::counters::enable(false);
7692                crate::counters::test_thread_reset();
7693            }
7694        }
7695
7696        let _serial = crate::counters::test_serial();
7697        crate::counters::enable(true);
7698        let _disable = DisableCounters;
7699        let observation = Observation::new(vec![
7700            upsert("a", EntryKind::Dir, Attrs::default()),
7701            upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
7702            upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
7703        ]);
7704
7705        crate::counters::test_thread_reset();
7706        let mut baseline = Index::new("/root");
7707        baseline.apply_baseline_ok(&observation);
7708        let counts = crate::counters::test_thread_snapshot();
7709        assert_eq!(counts.baseline_batches, 1);
7710        assert_eq!(counts.baseline_accepted_ops, 3);
7711        assert_eq!(counts.opened_batches, 0);
7712        assert_eq!(counts.public_batches, 0);
7713        assert_eq!(counts.ancestry_overlay_inserts, 3);
7714        assert_eq!(counts.ancestry_path_comparisons, 2);
7715        assert_eq!(counts.ancestry_parent_proofs, 3);
7716        assert_eq!(counts.effect_paths, 0);
7717        assert_eq!(counts.effect_path_bytes, 0);
7718        assert_eq!(counts.impact_candidates, 0);
7719        assert_eq!(counts.impact_ancestor_visits, 0);
7720        assert_eq!(counts.impact_retained_dirty_paths, 0);
7721        assert_eq!(counts.impact_all_dirty, 0);
7722        assert_eq!(counts.journal_cloned_commits, 0);
7723        assert_eq!(counts.journal_retained_commits, 0);
7724
7725        crate::counters::test_thread_reset();
7726        let mut public = Index::new("/root");
7727        public.apply_ok(&observation);
7728        let counts = crate::counters::test_thread_snapshot();
7729        assert_eq!(counts.baseline_batches, 0);
7730        assert_eq!(counts.opened_batches, 0);
7731        assert_eq!(counts.public_batches, 1);
7732        assert_eq!(counts.public_accepted_ops, 3);
7733        assert_eq!(counts.effect_paths, 3);
7734        assert_eq!(counts.journal_cloned_commits, 1);
7735        assert_eq!(counts.journal_retained_commits, 1);
7736        assert_eq!(counts.journal_oversized_commits, 0);
7737        assert_eq!(counts.journal_dropped_commits, 0);
7738
7739        crate::counters::test_thread_reset();
7740        let opened = IndexHandle::new(Index::new("/root"));
7741        opened
7742            .apply_discovery(&observation, DiscoveryCommit::default())
7743            .expect("opened discovery batch");
7744        let counts = crate::counters::test_thread_snapshot();
7745        assert_eq!(counts.baseline_batches, 0);
7746        assert_eq!(counts.opened_batches, 1);
7747        assert_eq!(counts.opened_accepted_ops, 3);
7748        assert_eq!(counts.public_batches, 0);
7749
7750        crate::counters::test_thread_reset();
7751        let mut scanner = Index::new("/root");
7752        scanner
7753            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
7754                upsert("a", EntryKind::Dir, Attrs::default()),
7755                upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
7756                upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
7757            ]))
7758            .expect("private scanner batch");
7759        let counts = crate::counters::test_thread_snapshot();
7760        assert_eq!(counts.baseline_batches, 1);
7761        assert_eq!(counts.baseline_accepted_ops, 3);
7762        assert_eq!(counts.ancestry_overlay_inserts, 0);
7763        assert_eq!(counts.ancestry_path_comparisons, 2);
7764        assert_eq!(counts.ancestry_parent_proofs, 3);
7765        assert_eq!(counts.parent_resolutions, 0);
7766        assert_eq!(counts.parent_memo_hits, 0);
7767        assert_eq!(scanner.total().dirs, 1);
7768        assert_eq!(scanner.total().files, 2);
7769
7770        crate::counters::test_thread_reset();
7771        let opened_scanner = IndexHandle::new(Index::new("/root"));
7772        opened_scanner
7773            .apply_scanner_discovery_bounded(
7774                crate::scan::ScannerBatch::from_ops(vec![
7775                    upsert("a", EntryKind::Dir, Attrs::default()),
7776                    upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
7777                    upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
7778                ]),
7779                DiscoveryCommit::default(),
7780                None,
7781            )
7782            .expect("opened scanner batch");
7783        let counts = crate::counters::test_thread_snapshot();
7784        assert_eq!(counts.opened_batches, 1);
7785        assert_eq!(counts.opened_accepted_ops, 3);
7786        assert_eq!(counts.ancestry_overlay_inserts, 0);
7787        assert_eq!(counts.effect_paths, 3);
7788        assert_eq!(counts.journal_retained_commits, 1);
7789
7790        // Room for exactly two one-file commits; measured before the counters reset so the
7791        // probe's own journal work is not counted.
7792        let two_commits = 2 * commit_cost(vec![upsert("one", EntryKind::File, file_attrs(1, 1))]);
7793        crate::counters::test_thread_reset();
7794        let mut bounded = Index::new("/root");
7795        bounded.journal_capacity_bytes = two_commits;
7796        bounded.apply_ok(&Observation::new(vec![upsert("one", EntryKind::File, file_attrs(1, 1))]));
7797        bounded.apply_ok(&Observation::new(vec![upsert("two", EntryKind::File, file_attrs(2, 2))]));
7798        bounded.journal_capacity_bytes = 1;
7799        bounded.apply_ok(&Observation::new(vec![upsert(
7800            "three",
7801            EntryKind::File,
7802            file_attrs(3, 3),
7803        )]));
7804        let counts = crate::counters::test_thread_snapshot();
7805        assert_eq!(counts.journal_cloned_commits, 2);
7806        assert_eq!(counts.journal_retained_commits, 2);
7807        assert_eq!(counts.journal_oversized_commits, 1);
7808        assert_eq!(counts.journal_dropped_commits, 2);
7809    }
7810
7811    #[test]
7812    fn detached_and_exact_reducers_produce_the_same_facts_and_stats() {
7813        fn fact_image(index: &Index) -> Vec<(PathBuf, EntryKind, Attrs, bool, Source, bool)> {
7814            let mut image = Vec::new();
7815            let mut frontier = VecDeque::from([EntryId::ROOT]);
7816            while let Some(id) = frontier.pop_front() {
7817                let entry = index.entry(id);
7818                if entry.kind.is_dir() {
7819                    frontier.extend(index.child_ids(id));
7820                }
7821                image.push((
7822                    index.path_of(id).expect("live entry path"),
7823                    entry.kind,
7824                    entry.attrs,
7825                    entry.ignored,
7826                    entry.source,
7827                    entry.directory.as_deref().is_none_or(|directory| directory.children_complete),
7828                ));
7829            }
7830            image.sort_by(|left, right| left.0.cmp(&right.0));
7831            image
7832        }
7833
7834        fn assert_same_facts(detached: &Index, exact: &Index) {
7835            assert_eq!(fact_image(detached), fact_image(exact));
7836            assert_eq!(detached.total(), exact.total());
7837            let partitions =
7838                |index: &Index| index.named_partitions(index.entry(EntryId::ROOT).rollup());
7839            assert_eq!(partitions(detached), partitions(exact));
7840            let controls = |index: &Index| {
7841                index
7842                    .controls
7843                    .sources()
7844                    .map(|(path, source)| (path, source.to_vec()))
7845                    .collect::<Vec<_>>()
7846            };
7847            assert_eq!(controls(detached), controls(exact));
7848            assert_eq!(detached.state, exact.state);
7849            assert_eq!(detached.issues, exact.issues);
7850            let freshness = |index: &Index| {
7851                index
7852                    .freshness_marks
7853                    .iter()
7854                    .map(|(path, mark)| (path.clone(), mark.state, mark.epoch))
7855                    .collect::<Vec<_>>()
7856            };
7857            assert_eq!(freshness(detached), freshness(exact));
7858            assert_eq!(detached.verified, exact.verified);
7859            assert_eq!(detached.pending_invalidations, exact.pending_invalidations);
7860        }
7861
7862        let mut detached = Index::new("/root");
7863        let mut exact = detached.clone();
7864        let batches = [
7865            Observation::new(vec![
7866                upsert("a", EntryKind::Dir, file_attrs(0, 1)),
7867                upsert("a/one.rs", EntryKind::File, file_attrs(10, 2)),
7868                upsert("a/two.txt", EntryKind::File, file_attrs(20, 3)),
7869            ]),
7870            Observation::new(vec![
7871                upsert("a/one.rs", EntryKind::File, file_attrs(30, 4)),
7872                Op::Remove { path: PathBuf::from("a/two.txt") },
7873                Op::InvalidateSubtree {
7874                    path: PathBuf::from("a"),
7875                    reason: InvalidateReason::VerificationFailed,
7876                },
7877            ]),
7878        ];
7879
7880        for batch in batches {
7881            let detached_stats = detached.apply_baseline(&batch).expect("detached batch");
7882            let exact_outcome = exact.apply(&batch).expect("exact batch");
7883            assert_eq!(detached_stats, exact_outcome.stats);
7884            exact.establish_baseline();
7885            assert_same_facts(&detached, &exact);
7886        }
7887    }
7888
7889    #[test]
7890    fn exact_commit_records_verified_ancestry_and_kind_replacement() {
7891        let mut index = Index::new("/root");
7892        let inserted = index.apply_ok(&Observation::new(vec![
7893            upsert("unknown", EntryKind::Dir, file_attrs(0, 1)),
7894            upsert("unknown/deep", EntryKind::Dir, file_attrs(0, 2)),
7895            upsert("unknown/deep/file.txt", EntryKind::File, file_attrs(10, 3)),
7896        ]));
7897        let inserted = inserted.commit.expect("ancestry commit");
7898        assert_eq!(
7899            inserted.changes.iter().map(EffectiveChange::path).collect::<Vec<_>>(),
7900            [Path::new("unknown"), Path::new("unknown/deep"), Path::new("unknown/deep/file.txt")]
7901        );
7902        assert!(
7903            inserted
7904                .changes
7905                .iter()
7906                .all(|change| matches!(change, EffectiveChange::Inserted { .. }))
7907        );
7908
7909        let replaced = index.apply_ok(&Observation::new(vec![upsert(
7910            "unknown",
7911            EntryKind::File,
7912            file_attrs(20, 2),
7913        )]));
7914        let replaced = replaced.commit.expect("replacement commit");
7915        assert_eq!(
7916            replaced.changes,
7917            vec![
7918                EffectiveChange::Removed {
7919                    path: "unknown".into(),
7920                    kind: EntryKind::Dir,
7921                    attrs: file_attrs(0, 1),
7922                },
7923                EffectiveChange::Removed {
7924                    path: "unknown/deep".into(),
7925                    kind: EntryKind::Dir,
7926                    attrs: file_attrs(0, 2),
7927                },
7928                EffectiveChange::Removed {
7929                    path: "unknown/deep/file.txt".into(),
7930                    kind: EntryKind::File,
7931                    attrs: file_attrs(10, 3),
7932                },
7933                EffectiveChange::Inserted {
7934                    path: "unknown".into(),
7935                    kind: EntryKind::File,
7936                    attrs: file_attrs(20, 2),
7937                },
7938            ]
7939        );
7940        assert_eq!(
7941            replaced.impact.domains,
7942            vec![
7943                ImpactDomain::Topology,
7944                ImpactDomain::Metadata,
7945                ImpactDomain::Classification,
7946                ImpactDomain::Aggregates,
7947                ImpactDomain::Content,
7948            ]
7949        );
7950        assert_eq!(
7951            replaced.impact.dirty_paths,
7952            vec![
7953                PathBuf::new(),
7954                "unknown".into(),
7955                "unknown/deep".into(),
7956                "unknown/deep/file.txt".into(),
7957            ]
7958        );
7959    }
7960
7961    #[test]
7962    fn rejected_prepared_commit_is_fault_atomic() {
7963        let mut index = index_with_sample_tree();
7964        let before_clock = index.clock();
7965        let before_total = index.total();
7966        let before_len = index.len();
7967        let before_history = index.since(Clock::ZERO);
7968        let mut prepared = prepare_observation(&Observation::new(vec![upsert(
7969            "new/deep.txt",
7970            EntryKind::File,
7971            file_attrs(99, 99),
7972        )]))
7973        .expect("valid preparation");
7974        prepared.reject_before_apply = true;
7975
7976        let error = index.commit_prepared(prepared, true).expect_err("injected preflight");
7977
7978        assert!(matches!(error, crate::Error::CommitRejected("injected reducer preflight")));
7979        assert_eq!(index.clock(), before_clock);
7980        assert_eq!(index.total(), before_total);
7981        assert_eq!(index.len(), before_len);
7982        assert_eq!(index.since(Clock::ZERO), before_history);
7983        assert!(index.lookup(Path::new("new")).is_none());
7984    }
7985
7986    #[test]
7987    fn reconciliation_state_moves_through_exact_commits() {
7988        let mut index = Index::new("/root");
7989        let (started, start) = index.begin_reconcile(Path::new("src")).expect("begin");
7990        let start = start.expect("start commit");
7991        assert!(start.changes.is_empty());
7992        assert_eq!(
7993            start.state,
7994            vec![
7995                StateTransition::Freshness {
7996                    path: "src".into(),
7997                    previous: Freshness::Fresh,
7998                    current: Freshness::Reconciling,
7999                },
8000                StateTransition::IndexState {
8001                    previous: IndexState::default(),
8002                    current: IndexState {
8003                        freshness: Freshness::Reconciling,
8004                        ..IndexState::default()
8005                    },
8006                },
8007            ]
8008        );
8009
8010        let finish = index
8011            .finish_reconcile(
8012                Path::new("src"),
8013                started,
8014                true,
8015                &[],
8016                &[],
8017                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
8018            )
8019            .expect("finish")
8020            .commit
8021            .expect("finish commit");
8022        assert!(finish.changes.is_empty());
8023        assert_eq!(
8024            finish.state,
8025            vec![
8026                StateTransition::Verified { path: "src".into() },
8027                StateTransition::Freshness {
8028                    path: "src".into(),
8029                    previous: Freshness::Reconciling,
8030                    current: Freshness::Fresh,
8031                },
8032                StateTransition::IndexState {
8033                    previous: IndexState {
8034                        freshness: Freshness::Reconciling,
8035                        ..IndexState::default()
8036                    },
8037                    current: IndexState::default(),
8038                },
8039            ]
8040        );
8041    }
8042
8043    #[cfg(feature = "watch")]
8044    #[test]
8045    fn observation_failure_cannot_overwrite_a_terminal_resource_stop() {
8046        let handle = IndexHandle::new(Index::new("/root"));
8047        handle
8048            .transition_discovery(DiscoveryTransition::BudgetRefused(Issue::resource_budget(0)))
8049            .expect("stop for budget");
8050        let stopped = handle.state().expect("stopped state");
8051        let clock = handle.clock().expect("stopped clock");
8052
8053        let outcome = handle
8054            .transition_observation(ObservationTransition::Failed(Issue::from_error(
8055                &crate::Error::WatchStopped,
8056            )))
8057            .expect("late observer failure is ignored");
8058
8059        assert_eq!(outcome.commit, None);
8060        assert_eq!(handle.state().expect("terminal state"), stopped);
8061        assert_eq!(handle.clock().expect("terminal clock"), clock);
8062    }
8063
8064    /// Once a root has stopped or failed, discovery can neither expand it nor reopen it.
8065    #[test]
8066    fn a_terminal_root_refuses_every_discovery_commit() {
8067        let terminals = [
8068            DiscoveryTransition::BudgetRefused(Issue::resource_budget(1)),
8069            DiscoveryTransition::Failed(Issue::from_error(&crate::Error::OpenedIndexClosed)),
8070        ];
8071        for terminal in terminals {
8072            let handle = IndexHandle::new(Index::new("/root"));
8073            handle.transition_discovery(DiscoveryTransition::Begin).expect("begin");
8074            handle
8075                .apply_discovery(
8076                    &Observation::new(vec![upsert("dir", EntryKind::Dir, Attrs::default())]),
8077                    DiscoveryCommit::default(),
8078                )
8079                .expect("listing before the stop");
8080            handle.transition_discovery(terminal.clone()).expect("terminal transition");
8081            let state = handle.state().expect("terminal state");
8082            let clock = handle.clock().expect("terminal clock");
8083
8084            let late = [
8085                (
8086                    vec![upsert("dir/late.txt", EntryKind::File, file_attrs(1, 1))],
8087                    DiscoveryCommit {
8088                        directory_complete: Some(PathBuf::from("dir")),
8089                        transition: None,
8090                    },
8091                ),
8092                (
8093                    Vec::new(),
8094                    DiscoveryCommit {
8095                        directory_complete: None,
8096                        transition: Some(DiscoveryTransition::Finish),
8097                    },
8098                ),
8099                (
8100                    Vec::new(),
8101                    DiscoveryCommit {
8102                        directory_complete: None,
8103                        transition: Some(DiscoveryTransition::Inaccessible {
8104                            issues: vec![Issue::resource_budget(2)],
8105                            omitted: 0,
8106                        }),
8107                    },
8108                ),
8109            ];
8110            for (ops, discovery) in late {
8111                assert!(
8112                    matches!(
8113                        handle.apply_discovery(&Observation::new(ops), discovery.clone()),
8114                        Err(crate::Error::OpenedIndexStopped)
8115                    ),
8116                    "after {terminal:?}, {discovery:?} was accepted"
8117                );
8118            }
8119            assert_eq!(handle.state().expect("state"), state, "after {terminal:?}");
8120            assert_eq!(handle.clock().expect("clock"), clock, "after {terminal:?}");
8121            assert_eq!(handle.kind(Path::new("dir/late.txt")).expect("lookup"), None);
8122        }
8123    }
8124
8125    #[test]
8126    fn replaying_the_same_delta_twice_changes_nothing() {
8127        let mut index = Index::new("/root");
8128        let delta = Observation::new(vec![
8129            upsert("a", EntryKind::Dir, Attrs::default()),
8130            upsert("a/f.txt", EntryKind::File, file_attrs(10, 1)),
8131        ]);
8132        index.apply_ok(&delta);
8133        let after_first = index.total();
8134        let stats = index.apply_ok(&delta);
8135
8136        assert_eq!(stats.unchanged, 2);
8137        assert_eq!(index.total(), after_first);
8138        assert_eq!(index.len(), 3);
8139    }
8140
8141    #[test]
8142    fn changed_size_updates_every_ancestor() {
8143        let mut index = index_with_sample_tree();
8144        index.apply_ok(&Observation::new(vec![upsert(
8145            "src/main.rs",
8146            EntryKind::File,
8147            file_attrs(150, 11),
8148        )]));
8149
8150        assert_eq!(index.rollup(Path::new("src")).expect("dir").bytes, 350);
8151        assert_eq!(index.total().bytes, 650);
8152        assert_eq!(index.total().by_ext[".rs"], ExtTally { files: 2, bytes: 350, allocated: 1024 });
8153    }
8154
8155    #[test]
8156    fn allocated_size_change_updates_rollups_even_when_fingerprint_matches() {
8157        let mut index = Index::new("/root");
8158        let original = Attrs { allocated: 512, ..file_attrs(100, 10) };
8159        index.apply_ok(&Observation::new(vec![upsert("file.bin", EntryKind::File, original)]));
8160
8161        let repacked = Attrs { allocated: 4096, ..original };
8162        let outcome =
8163            index.apply_ok(&Observation::new(vec![upsert("file.bin", EntryKind::File, repacked)]));
8164
8165        assert_eq!(outcome.stats.updated, 1);
8166        assert_eq!(outcome.stats.unchanged, 0);
8167        assert_eq!(index.total().allocated, 4096);
8168        assert_eq!(index.attrs(Path::new("file.bin")), Some(&repacked));
8169    }
8170
8171    #[test]
8172    fn newest_mtime_preserves_pre_epoch_values_through_updates_and_removals() {
8173        let mut index = Index::new("/root");
8174        index.apply_ok(&Observation::new(vec![
8175            upsert("newer.txt", EntryKind::File, file_attrs(10, -10)),
8176            upsert("older.txt", EntryKind::File, file_attrs(20, -20)),
8177        ]));
8178
8179        assert_eq!(index.total().newest_mtime_ns, -10);
8180
8181        index.apply_ok(&Observation::new(vec![upsert(
8182            "newer.txt",
8183            EntryKind::File,
8184            file_attrs(10, -30),
8185        )]));
8186        assert_eq!(index.total().newest_mtime_ns, -20);
8187
8188        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("older.txt") }]));
8189        assert_eq!(index.total().newest_mtime_ns, -30);
8190    }
8191
8192    #[test]
8193    fn removing_a_file_corrects_sums_and_rebuilds_the_max() {
8194        let mut index = index_with_sample_tree();
8195        // guide.md holds the newest mtime for the whole tree.
8196        let stats = index
8197            .apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("docs/guide.md") }]));
8198
8199        assert_eq!(stats.removed, 1);
8200        let total = index.total();
8201        assert_eq!(total.files, 2);
8202        assert_eq!(total.bytes, 300);
8203        assert_eq!(total.newest_mtime_ns, 20, "max must fall back to src/lib.rs");
8204        assert!(!total.by_ext.contains_key(".md"), "emptied tallies are dropped");
8205    }
8206
8207    #[test]
8208    fn removing_a_directory_cascades_to_descendants() {
8209        let mut index = index_with_sample_tree();
8210        let stats = index
8211            .apply(&Observation::new(vec![Op::Remove { path: PathBuf::from("src") }]))
8212            .expect("valid observation");
8213
8214        assert_eq!(stats.removed, 3, "the directory and both files");
8215        let total = index.total();
8216        assert_eq!(total.files, 1);
8217        assert_eq!(total.dirs, 1);
8218        assert_eq!(total.bytes, 300);
8219        assert!(index.lookup(Path::new("src/main.rs")).is_none());
8220        assert!(!total.by_ext.contains_key(".rs"));
8221    }
8222
8223    #[test]
8224    fn freed_slots_are_reused() {
8225        let mut index = index_with_sample_tree();
8226        let before = index.len();
8227        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("src") }]));
8228        index.apply_ok(&Observation::new(vec![
8229            upsert("other", EntryKind::Dir, Attrs::default()),
8230            upsert("other/x.rs", EntryKind::File, file_attrs(1, 1)),
8231            upsert("other/y.rs", EntryKind::File, file_attrs(1, 1)),
8232        ]));
8233        assert_eq!(index.len(), before, "three freed slots, three new entries");
8234    }
8235
8236    #[test]
8237    fn stale_entry_handle_does_not_alias_a_reused_slot() {
8238        let mut index = Index::new("/root");
8239        index.apply_ok(&Observation::new(vec![upsert(
8240            "first.txt",
8241            EntryKind::File,
8242            file_attrs(1, 1),
8243        )]));
8244        let stale = index.lookup(Path::new("first.txt")).expect("first id");
8245        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("first.txt") }]));
8246        index.apply_ok(&Observation::new(vec![upsert(
8247            "second.txt",
8248            EntryKind::File,
8249            file_attrs(2, 2),
8250        )]));
8251        let current = index.lookup(Path::new("second.txt")).expect("second id");
8252
8253        assert_ne!(stale, current, "generation participates in handle identity");
8254        assert!(index.attrs_of(stale).is_none());
8255        assert!(index.kind_of(stale).is_none());
8256        assert!(index.name_of(stale).is_none());
8257        assert!(index.path_of(stale).is_none());
8258        assert!(index.children_of(stale).is_none());
8259        assert!(index.rollup_of(stale).is_none());
8260        assert_eq!(index.attrs_of(current).map(|attrs| attrs.size), Some(2));
8261    }
8262
8263    #[test]
8264    fn parent_first_batch_establishes_exact_ancestry() {
8265        let mut index = Index::new("/root");
8266        let deep = file_attrs(0, 1);
8267        let nested = file_attrs(0, 2);
8268        let tree = file_attrs(0, 3);
8269        index.apply_ok(&Observation::new(vec![
8270            upsert("deep", EntryKind::Dir, deep),
8271            upsert("deep/nested", EntryKind::Dir, nested),
8272            upsert("deep/nested/tree", EntryKind::Dir, tree),
8273            upsert("deep/nested/tree/file.txt", EntryKind::File, file_attrs(42, 7)),
8274        ]));
8275
8276        assert_eq!(index.total().files, 1);
8277        assert_eq!(index.total().dirs, 3);
8278        assert_eq!(index.total().bytes, 42);
8279        assert_eq!(index.rollup(Path::new("deep/nested")).expect("created").files, 1);
8280        assert_eq!(index.attrs(Path::new("deep")), Some(&deep));
8281        assert_eq!(index.attrs(Path::new("deep/nested")), Some(&nested));
8282        assert_eq!(index.attrs(Path::new("deep/nested/tree")), Some(&tree));
8283    }
8284
8285    #[test]
8286    fn scanner_parent_proof_matches_public_parent_first_application() {
8287        let ops = vec![
8288            upsert("deep", EntryKind::Dir, file_attrs(0, 1)),
8289            upsert("deep/nested", EntryKind::Dir, file_attrs(0, 2)),
8290            upsert("deep/nested/file.txt", EntryKind::File, file_attrs(42, 3)),
8291        ];
8292        let mut scanner = Index::new("/root");
8293        let scanner_stats = scanner
8294            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(ops.clone()))
8295            .expect("scanner proof");
8296        let mut public = Index::new("/root");
8297        let public_stats = public.apply(&Observation::new(ops)).expect("public proof").stats;
8298
8299        assert_eq!(scanner_stats, public_stats);
8300        assert_eq!(scanner.total(), public.total());
8301        assert_eq!(scanner.len(), public.len());
8302        for path in ["deep", "deep/nested", "deep/nested/file.txt"] {
8303            assert_eq!(scanner.kind(Path::new(path)), public.kind(Path::new(path)));
8304            assert_eq!(scanner.attrs(Path::new(path)), public.attrs(Path::new(path)));
8305        }
8306    }
8307
8308    #[test]
8309    fn scanner_parent_proof_rejects_unknown_ancestry_before_mutation() {
8310        let mut index = Index::new("/root");
8311        let before = index.total();
8312        let error = index
8313            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![upsert(
8314                "missing/child.txt",
8315                EntryKind::File,
8316                file_attrs(1, 1),
8317            )]))
8318            .expect_err("scanner proof must reject an unknown parent");
8319
8320        assert!(matches!(
8321            error,
8322            crate::Error::UnknownAncestry { path, .. }
8323                if path == Path::new("missing/child.txt")
8324        ));
8325        assert_eq!(index.total(), before);
8326        assert_eq!(index.len(), 1);
8327        assert_eq!(index.clock(), Clock::ZERO);
8328    }
8329
8330    #[test]
8331    fn scanner_kind_replacement_is_proved_by_the_general_lane() {
8332        let mut index = Index::new("/root");
8333        index.apply_ok(&Observation::new(vec![
8334            upsert("a", EntryKind::Dir, file_attrs(0, 1)),
8335            upsert("a/old.txt", EntryKind::File, file_attrs(7, 1)),
8336        ]));
8337        let before = index.total();
8338        let before_clock = index.clock();
8339
8340        // Once `a` is a file nothing can attach below it, and the batch is refused before
8341        // any fact moves, exactly as a public observation would be.
8342        let error = index
8343            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
8344                upsert("a", EntryKind::File, file_attrs(2, 2)),
8345                upsert("a/new.txt", EntryKind::File, file_attrs(3, 2)),
8346            ]))
8347            .expect_err("a child cannot attach after its parent became a file");
8348
8349        assert!(matches!(
8350            error,
8351            crate::Error::UnknownAncestry { path, .. } if path == Path::new("a/new.txt")
8352        ));
8353        assert_eq!(index.total(), before);
8354        assert_eq!(index.clock(), before_clock);
8355        assert_eq!(index.kind(Path::new("a")), Some(EntryKind::Dir));
8356        assert!(index.lookup(Path::new("a/old.txt")).is_some());
8357        assert!(index.lookup(Path::new("a/new.txt")).is_none());
8358
8359        // The replacement on its own is an ordinary verified observation.
8360        index
8361            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![upsert(
8362                "a",
8363                EntryKind::File,
8364                file_attrs(2, 2),
8365            )]))
8366            .expect("a scanner batch can replace an entry's kind");
8367        assert_eq!(index.kind(Path::new("a")), Some(EntryKind::File));
8368        assert!(index.lookup(Path::new("a/old.txt")).is_none());
8369        let total = index.total();
8370        assert_eq!((total.files, total.dirs, total.bytes), (1, 0, 2));
8371    }
8372
8373    #[test]
8374    fn scanner_discovery_survives_a_kind_changed_by_a_concurrent_refresh() {
8375        let handle = IndexHandle::new(Index::new("/root"));
8376        handle
8377            .apply_scanner_discovery_bounded(
8378                crate::scan::ScannerBatch::from_ops(vec![upsert(
8379                    "p",
8380                    EntryKind::Dir,
8381                    Attrs::default(),
8382                )]),
8383                DiscoveryCommit::default(),
8384                None,
8385            )
8386            .expect("root listing");
8387        // A refresh, on the general lane, saw that p/d is now a file on disk.
8388        handle
8389            .apply(&Observation::new(vec![upsert("p/d", EntryKind::File, file_attrs(1, 1))]))
8390            .expect("refresh insert");
8391        // Discovery's pending batch still carries the directory observation it listed.
8392        let outcome = handle.apply_scanner_discovery_bounded(
8393            crate::scan::ScannerBatch::from_ops(vec![upsert(
8394                "p/d",
8395                EntryKind::Dir,
8396                Attrs::default(),
8397            )]),
8398            DiscoveryCommit { directory_complete: Some(PathBuf::from("p")), transition: None },
8399            None,
8400        );
8401        assert!(outcome.is_ok(), "discovery must not die on a kind race: {outcome:?}");
8402        // The listed observation replaces the entry, as any verified observation does, and
8403        // the directory it came from is complete.
8404        assert_eq!(handle.kind(Path::new("p/d")).expect("kind read"), Some(EntryKind::Dir));
8405        assert_eq!(handle.directory_complete(Path::new("p")).expect("read"), Some(true));
8406    }
8407
8408    #[test]
8409    fn live_upsert_refuses_unknown_ancestry_without_mutation() {
8410        let mut index = Index::new("/root");
8411        let before = index.clock();
8412
8413        let error = index
8414            .apply(&Observation::new(vec![upsert(
8415                "unknown/deep/file.txt",
8416                EntryKind::File,
8417                file_attrs(10, 1),
8418            )]))
8419            .expect_err("live input must not invent parent metadata");
8420
8421        assert!(matches!(
8422            error,
8423            crate::Error::UnknownAncestry { path, reconcile_from }
8424                if path == Path::new("unknown/deep/file.txt")
8425                    && reconcile_from.as_os_str().is_empty()
8426        ));
8427        assert_eq!(index.clock(), before);
8428        assert_eq!(index.len(), 1);
8429        assert!(index.since(before).commits.is_empty());
8430    }
8431
8432    #[test]
8433    fn explicit_kind_replacement_precedes_attaching_a_child() {
8434        let mut index = Index::new("/root");
8435        index.apply_ok(&Observation::new(vec![upsert(
8436            "conflict",
8437            EntryKind::File,
8438            file_attrs(9, 1),
8439        )]));
8440
8441        let outcome = index.apply_ok(&Observation::new(vec![
8442            upsert("conflict", EntryKind::Dir, file_attrs(0, 2)),
8443            upsert("conflict/child.txt", EntryKind::File, file_attrs(4, 2)),
8444        ]));
8445
8446        assert_eq!(index.kind(Path::new("conflict")), Some(EntryKind::Dir));
8447        assert!(index.lookup(Path::new("conflict/child.txt")).is_some());
8448        assert_eq!(index.total().files, 1);
8449        assert_eq!(index.total().dirs, 1);
8450        assert_eq!(index.total().bytes, 4);
8451        assert_eq!(outcome.removed, 1);
8452    }
8453
8454    #[test]
8455    fn kind_change_replaces_the_entry() {
8456        let mut index = Index::new("/root");
8457        index.apply_ok(&Observation::new(vec![upsert(
8458            "thing",
8459            EntryKind::File,
8460            file_attrs(50, 5),
8461        )]));
8462        assert_eq!(index.total().files, 1);
8463
8464        index.apply_ok(&Observation::new(vec![upsert("thing", EntryKind::Dir, Attrs::default())]));
8465        let total = index.total();
8466        assert_eq!(total.files, 0);
8467        assert_eq!(total.dirs, 1);
8468        assert_eq!(total.bytes, 0);
8469    }
8470
8471    #[test]
8472    fn paths_are_reconstructed_from_parent_pointers() {
8473        let index = index_with_sample_tree();
8474        let id = index.lookup(Path::new("src/main.rs")).expect("present");
8475        assert_eq!(index.path_of(id), Some(PathBuf::from("src/main.rs")));
8476        assert_eq!(index.path_of(EntryId::ROOT), Some(PathBuf::new()));
8477    }
8478
8479    #[test]
8480    fn since_returns_commits_after_a_clock() {
8481        let mut index = Index::new("/root");
8482        index.apply_ok(&Observation::new(vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))]));
8483        let mark = index.clock();
8484        index.apply_ok(&Observation::new(vec![upsert("b.txt", EntryKind::File, file_attrs(2, 2))]));
8485
8486        let since = index.since(mark);
8487        assert!(!since.truncated);
8488        assert_eq!(since.commits.len(), 1);
8489        assert_eq!(since.commits[0].changes[0].path(), Path::new("b.txt"));
8490
8491        assert_eq!(index.since(index.clock()).commits.len(), 0);
8492    }
8493
8494    /// The bytes one batch is charged when it commits against an empty tree.
8495    fn commit_cost(ops: Vec<Op>) -> usize {
8496        let mut probe = Index::new("/root");
8497        probe.apply_ok(&Observation::new(ops)).commit.expect("effective commit").retained_cost()
8498    }
8499
8500    #[test]
8501    fn oversized_single_batch_is_not_retained() {
8502        let batch = || {
8503            vec![
8504                upsert("a.txt", EntryKind::File, file_attrs(1, 1)),
8505                upsert("b.txt", EntryKind::File, file_attrs(2, 2)),
8506                upsert("c.txt", EntryKind::File, file_attrs(3, 3)),
8507            ]
8508        };
8509        let mut index = Index::with_journal_capacity_bytes("/root", commit_cost(batch()) - 1);
8510        let outcome = index.apply_ok(&Observation::new(batch()));
8511
8512        assert_eq!(outcome.commit.as_ref().expect("committed").changes.len(), 3);
8513        let since = index.since(Clock::ZERO);
8514        assert!(since.truncated);
8515        assert!(since.commits.is_empty());
8516    }
8517
8518    #[test]
8519    fn journal_eviction_charges_the_complete_retained_payload() {
8520        let first = || {
8521            vec![
8522                upsert("a.txt", EntryKind::File, file_attrs(1, 1)),
8523                upsert("b.txt", EntryKind::File, file_attrs(2, 2)),
8524            ]
8525        };
8526        let second = || {
8527            vec![
8528                upsert("c.txt", EntryKind::File, file_attrs(3, 3)),
8529                upsert("d.txt", EntryKind::File, file_attrs(4, 4)),
8530            ]
8531        };
8532        // Room for the second commit and all but one byte of the first.
8533        let capacity = commit_cost(first()) + commit_cost(second()) - 1;
8534        let mut index = Index::with_journal_capacity_bytes("/root", capacity);
8535        index.apply_ok(&Observation::new(first()));
8536        index.apply_ok(&Observation::new(second()));
8537
8538        let since = index.since(Clock::ZERO);
8539        assert!(since.truncated);
8540        assert_eq!(since.commits.len(), 1);
8541        assert_eq!(since.commits[0].changes.len(), 2);
8542        assert_eq!(since.commits[0].changes[0].path(), Path::new("c.txt"));
8543    }
8544
8545    /// Two commits of one inserted file each: the same item count, but the second names a
8546    /// path whose bytes alone dwarf the first commit. A budget counted in items held both
8547    /// and let a long-path tree retain tens of mebibytes under a 64 Ki budget; a budget in
8548    /// bytes evicts the first.
8549    #[test]
8550    fn journal_eviction_is_charged_in_path_bytes() {
8551        let long_name = format!("{}.txt", "n".repeat(4096));
8552        let short = || vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))];
8553        let long = || vec![upsert(&long_name, EntryKind::File, file_attrs(2, 2))];
8554        let short_cost = commit_cost(short());
8555        let long_cost = commit_cost(long());
8556        assert!(
8557            long_cost > short_cost + 4096,
8558            "path bytes must be charged: short {short_cost}, long {long_cost}"
8559        );
8560
8561        let mut index = Index::with_journal_capacity_bytes("/root", short_cost + long_cost - 1);
8562        index.apply_ok(&Observation::new(short()));
8563        index.apply_ok(&Observation::new(long()));
8564
8565        let since = index.since(Clock::ZERO);
8566        assert!(since.truncated, "an item budget kept both commits; a byte budget cannot");
8567        assert_eq!(since.commits.len(), 1);
8568        assert_eq!(since.commits[0].changes[0].path(), Path::new(&long_name));
8569    }
8570
8571    #[test]
8572    fn impact_drops_an_overflowing_path_set_instead_of_truncating_it() {
8573        let mut index = Index::new("/root");
8574        let ops = (0..=MAX_DIRTY_PATHS)
8575            .map(|which| {
8576                upsert(
8577                    &format!("file-{which}.txt"),
8578                    EntryKind::File,
8579                    file_attrs(u64::try_from(which).expect("bounded"), 1),
8580                )
8581            })
8582            .collect();
8583
8584        let commit =
8585            index.apply_ok(&Observation::new(ops)).commit.expect("overflowing impact commit");
8586
8587        assert!(commit.impact.all_dirty);
8588        assert!(commit.impact.dirty_paths.is_empty(), "a partial path list must not escape");
8589        assert_eq!(commit.changes.len(), MAX_DIRTY_PATHS + 1);
8590    }
8591
8592    #[test]
8593    fn invalidations_are_queued_for_the_scan_layer() {
8594        let mut index = Index::new("/root");
8595        let stats = index.apply_ok(&Observation::new(vec![Op::InvalidateSubtree {
8596            path: PathBuf::from("src"),
8597            reason: InvalidateReason::WatchOverflow,
8598        }]));
8599
8600        assert_eq!(stats.invalidated, 1);
8601        let pending = index.take_pending_invalidations();
8602        assert_eq!(pending.len(), 1);
8603        assert_eq!(pending[0].0, PathBuf::from("src"));
8604        assert_eq!(pending[0].1, InvalidateReason::WatchOverflow);
8605        assert!(index.take_pending_invalidations().is_empty(), "drained once");
8606    }
8607
8608    #[test]
8609    fn paths_escaping_the_root_are_rejected() {
8610        assert!(normalize(Path::new("../escape")).is_none());
8611        assert!(normalize(Path::new("/absolute")).is_none());
8612        assert_eq!(
8613            normalize(Path::new("./a/b")).expect("relative"),
8614            vec![OsString::from("a"), OsString::from("b")]
8615        );
8616
8617        let mut index = Index::new("/root");
8618        let upsert_error = index
8619            .apply(&Observation::new(vec![upsert("../escape", EntryKind::File, file_attrs(1, 1))]))
8620            .expect_err("escaping upsert");
8621        assert!(matches!(upsert_error, crate::Error::PathEscapesRoot(_)));
8622        assert_eq!(index.total().files, 0);
8623
8624        let invalidation_error = index
8625            .apply(&Observation::new(vec![Op::InvalidateSubtree {
8626                path: PathBuf::from("../outside"),
8627                reason: InvalidateReason::Requested,
8628            }]))
8629            .expect_err("escaping invalidation");
8630        assert!(matches!(invalidation_error, crate::Error::PathEscapesRoot(_)));
8631        assert!(index.take_pending_invalidations().is_empty());
8632        assert_eq!(index.freshness(), Freshness::Fresh);
8633    }
8634
8635    #[cfg(unix)]
8636    #[test]
8637    fn distinct_non_utf8_names_have_distinct_identity() {
8638        use std::ffi::OsString;
8639        use std::os::unix::ffi::OsStringExt;
8640
8641        let first = PathBuf::from(OsString::from_vec(vec![b'n', 0x80]));
8642        let second = PathBuf::from(OsString::from_vec(vec![b'n', 0x81]));
8643        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
8644            "/root",
8645            ScanScope::default(),
8646            crate::classify::TypeRegistry::compiled_shared(),
8647            DEFAULT_JOURNAL_CAPACITY_BYTES,
8648        );
8649        index.apply_ok(&Observation::new(vec![
8650            Op::Upsert { path: first.clone(), kind: EntryKind::File, attrs: file_attrs(10, 1) },
8651            Op::Upsert { path: second.clone(), kind: EntryKind::File, attrs: file_attrs(20, 2) },
8652        ]));
8653
8654        assert_eq!(index.total().files, 2);
8655        assert_eq!(index.total().bytes, 30);
8656        assert!(index.lookup(&first).is_some());
8657        assert!(index.lookup(&second).is_some());
8658        assert_serving_indexes(&index);
8659    }
8660
8661    #[cfg(unix)]
8662    #[test]
8663    fn a_non_utf8_parent_still_lists_its_children() {
8664        use std::ffi::OsString;
8665        use std::os::unix::ffi::OsStringExt;
8666
8667        let directory = PathBuf::from(OsString::from_vec(vec![b'd', 0x80]));
8668        let child = directory.join("child");
8669        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
8670            "/root",
8671            ScanScope::default(),
8672            crate::classify::TypeRegistry::compiled_shared(),
8673            DEFAULT_JOURNAL_CAPACITY_BYTES,
8674        );
8675        index.apply_ok(&Observation::new(vec![
8676            Op::Upsert { path: directory.clone(), kind: EntryKind::Dir, attrs: Attrs::default() },
8677            Op::Upsert { path: child, kind: EntryKind::File, attrs: file_attrs(1, 1) },
8678        ]));
8679
8680        assert_serving_indexes(&index);
8681        // The directory's own name escapes to `d%80`, and its child is reachable beneath
8682        // it. While the encoding was partial this directory had no portable name, so its
8683        // whole subtree was unlistable and the assertion here counted the loss instead.
8684        assert_eq!(
8685            index.portable_children(&directory).map(|children| children.nondirectories.len()),
8686            Some(1)
8687        );
8688        assert!(
8689            index.portable_entries().keys().any(|portable| portable.as_str() == "d%80/child"),
8690            "a child under a non-utf8 directory is listed at its escaped path"
8691        );
8692    }
8693
8694    #[cfg(unix)]
8695    #[test]
8696    fn non_utf8_stem_keeps_ascii_extension_tally() {
8697        use std::ffi::OsString;
8698        use std::os::unix::ffi::OsStringExt;
8699
8700        let path = PathBuf::from(OsString::from_vec(vec![b'n', 0x80, b'.', b'R', b'S']));
8701        let mut index = Index::new("/root");
8702        index.apply_ok(&Observation::new(vec![Op::Upsert {
8703            path,
8704            kind: EntryKind::File,
8705            attrs: file_attrs(10, 1),
8706        }]));
8707
8708        let tallies = index.total().by_ext;
8709        assert_eq!(
8710            tallies.get(".rs"),
8711            Some(&ExtTally { files: 1, bytes: 10, allocated: file_attrs(10, 1).allocated })
8712        );
8713    }
8714
8715    #[test]
8716    fn restore_candidates_count_visited_files_not_pathbuf_aliases() {
8717        use crate::content::AnalysisSet;
8718
8719        let mut index = Index::new("/root");
8720        let attrs = file_attrs(10, 1);
8721        assert!(
8722            index
8723                .insert_loaded_child(EntryId::ROOT, OsString::from("a"), EntryKind::File, attrs)
8724                .is_some()
8725        );
8726        assert!(
8727            index
8728                .insert_loaded_child(EntryId::ROOT, OsString::from("a/"), EntryKind::File, attrs)
8729                .is_some()
8730        );
8731        let (candidates, visited) =
8732            index.restore_analysis_candidates(AnalysisSet::NONE.with_lines());
8733        assert_eq!(visited, 2, "each visited regular file is a completeness slot");
8734        assert_eq!(candidates.len(), 1, "PathBuf keys merge trailing-separator aliases");
8735    }
8736
8737    #[test]
8738    fn restore_candidates_match_analysis_file_identities() {
8739        use crate::content::AnalysisSet;
8740
8741        let mut index = Index::new("/root");
8742        index.apply_ok(&Observation::new(vec![
8743            upsert("src", EntryKind::Dir, file_attrs(0, 1)),
8744            upsert("src/nested", EntryKind::Dir, file_attrs(0, 2)),
8745            upsert("src/nested/lib.rs", EntryKind::File, file_attrs(10, 1)),
8746            upsert("README.md", EntryKind::File, file_attrs(20, 2)),
8747        ]));
8748        let profile = AnalysisSet::NONE.with_lines();
8749        let live = index.analysis_candidates(profile);
8750        let (restore, visited) = index.restore_analysis_candidates(profile);
8751        assert_eq!(restore.len(), live.len());
8752        assert_eq!(visited, u64::try_from(live.len()).expect("candidate count fits u64"));
8753        assert_eq!(restore.len(), 2);
8754        for candidate in &live {
8755            assert_eq!(
8756                index.path_of(candidate.entry_id).as_deref(),
8757                Some(candidate.relative_path.as_path())
8758            );
8759            let restored = restore.get(&candidate.relative_path).expect("same relative path");
8760            assert_eq!(restored.entry_id, candidate.entry_id);
8761            assert_eq!(restored.revision, candidate.revision);
8762            assert_eq!(restored.attrs.fingerprint(), candidate.attrs.fingerprint());
8763        }
8764    }
8765
8766    #[test]
8767    fn content_results_commit_conditionally_and_metadata_changes_invalidate_them() {
8768        use crate::content::{
8769            AnalysisApplyOutcome, AnalysisRequest, AnalysisSet, AnalyzerOutcome, BasicMetrics,
8770            ContentProvenance, FileAnalysis,
8771        };
8772
8773        let mut index = Index::new("/root");
8774        index.apply_ok(&Observation::new(vec![
8775            upsert("src", EntryKind::Dir, file_attrs(0, 1)),
8776            upsert("src/lib.rs", EntryKind::File, file_attrs(10, 1)),
8777        ]));
8778        let profile = AnalysisSet::NONE.with_lines();
8779        let candidate =
8780            index.analysis_candidates(profile).into_iter().next().expect("file candidate");
8781        let analysis = FileAnalysis {
8782            fingerprint: candidate.attrs.fingerprint(),
8783            bytes: candidate.attrs.size,
8784            detection: candidate.classification.clone().into(),
8785            lines: AnalyzerOutcome::analyzed(BasicMetrics {
8786                physical_lines: 2,
8787                nonblank_lines: 2,
8788                ..BasicMetrics::default()
8789            }),
8790            code: None,
8791            words: None,
8792            error: None,
8793        };
8794        let provenance = ContentProvenance::for_request(
8795            AnalysisRequest { profile, ..AnalysisRequest::default() },
8796            crate::classify::type_rule_fingerprint(),
8797        );
8798        let observation = AnalysisObservation {
8799            candidate: candidate.clone(),
8800            profile,
8801            provenance: provenance.clone(),
8802            analysis: analysis.clone(),
8803        };
8804        assert_eq!(
8805            index.apply_analysis(observation.clone()),
8806            AnalysisApplyOutcome::Stale,
8807            "an index prepared for no content identity holds no record"
8808        );
8809        assert!(index.content().is_none());
8810
8811        index.prepare_content_analysis(AnalysisRequest { profile, ..AnalysisRequest::default() });
8812        assert_eq!(index.content_set(), profile);
8813        assert_eq!(index.apply_analysis(observation), AnalysisApplyOutcome::Applied);
8814        assert_eq!(
8815            index
8816                .content_rollup(Path::new(""))
8817                .expect("content root")
8818                .total
8819                .lines
8820                .metrics
8821                .physical_lines,
8822            2
8823        );
8824
8825        index.apply_ok(&Observation::new(vec![upsert(
8826            "src/lib.rs",
8827            EntryKind::File,
8828            file_attrs(20, 2),
8829        )]));
8830        assert!(index.content_rollup(Path::new("")).is_none());
8831        assert_eq!(
8832            index.apply_analysis(AnalysisObservation { candidate, profile, provenance, analysis }),
8833            AnalysisApplyOutcome::Stale
8834        );
8835    }
8836
8837    #[test]
8838    fn operational_content_failures_are_retained_but_retried_until_recovery() {
8839        use crate::content::{
8840            AnalysisApplyOutcome, AnalysisRequest, AnalysisSet, AnalyzerOutcome, BasicMetrics,
8841            ContentProvenance, CoverageReason, FileAnalysis,
8842        };
8843
8844        let mut index = Index::new("/root");
8845        index.apply_ok(&Observation::new(vec![
8846            upsert("src", EntryKind::Dir, file_attrs(0, 1)),
8847            upsert("src/lib.rs", EntryKind::File, file_attrs(10, 1)),
8848        ]));
8849        let profile = AnalysisSet::LINES_ONLY;
8850        let request = AnalysisRequest { profile, ..AnalysisRequest::default() };
8851        index.prepare_content_analysis(request);
8852        let candidate = index.pending_analysis_candidates(request).pop().expect("candidate");
8853        let provenance =
8854            ContentProvenance::for_request(request, crate::classify::type_rule_fingerprint());
8855        let record = |reason, error: &str| FileAnalysis {
8856            fingerprint: candidate.attrs.fingerprint(),
8857            bytes: candidate.attrs.size,
8858            detection: candidate.classification.clone().into(),
8859            lines: AnalyzerOutcome::unavailable(reason),
8860            code: None,
8861            words: None,
8862            error: Some(error.to_owned()),
8863        };
8864
8865        for (reason, error) in [
8866            (CoverageReason::IoError, "read failed"),
8867            (CoverageReason::ChangedDuringRead, "changed during read"),
8868        ] {
8869            assert_eq!(
8870                index.apply_analysis(AnalysisObservation {
8871                    candidate: candidate.clone(),
8872                    profile,
8873                    provenance: provenance.clone(),
8874                    analysis: record(reason, error),
8875                }),
8876                AnalysisApplyOutcome::Applied
8877            );
8878            let retained = index.content().expect("content").file(Path::new("src/lib.rs"));
8879            assert_eq!(retained.and_then(FileAnalysis::operational_failure), Some(reason));
8880            assert_eq!(
8881                index.pending_analysis_candidates(request).len(),
8882                1,
8883                "an operational failure must remain pending"
8884            );
8885        }
8886
8887        let recovered = FileAnalysis {
8888            fingerprint: candidate.attrs.fingerprint(),
8889            bytes: candidate.attrs.size,
8890            detection: candidate.classification.clone().into(),
8891            lines: AnalyzerOutcome::analyzed(BasicMetrics {
8892                physical_lines: 1,
8893                nonblank_lines: 1,
8894                raw_words: 1,
8895                ..BasicMetrics::default()
8896            }),
8897            code: None,
8898            words: None,
8899            error: None,
8900        };
8901        assert_eq!(
8902            index.apply_analysis(AnalysisObservation {
8903                candidate,
8904                profile,
8905                provenance,
8906                analysis: recovered,
8907            }),
8908            AnalysisApplyOutcome::Applied
8909        );
8910        assert!(index.pending_analysis_candidates(request).is_empty());
8911        assert_eq!(
8912            index
8913                .content()
8914                .expect("content")
8915                .file(Path::new("src/lib.rs"))
8916                .and_then(FileAnalysis::operational_failure),
8917            None
8918        );
8919    }
8920
8921    /// An index that read no control file cannot say what is ignored, so it says that,
8922    /// rather than calling every entry unignored.
8923    #[test]
8924    fn an_index_that_did_not_observe_controls_refuses_ignore_questions() {
8925        let mut index =
8926            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
8927        assert!(!index.observes_controls());
8928        index.apply_ok(&Observation::new(vec![upsert(
8929            "debug.log",
8930            EntryKind::File,
8931            file_attrs(10, 1),
8932        )]));
8933        for path in ["debug.log", "absent.log"] {
8934            assert!(
8935                matches!(
8936                    index.is_ignored(Path::new(path)),
8937                    Err(crate::Error::ControlStateNotObserved)
8938                ),
8939                "{path}"
8940            );
8941        }
8942        assert!(matches!(index.controls(), Err(crate::Error::ControlStateNotObserved)));
8943
8944        let mut observed =
8945            Index::new_with_scope("/root", crate::test_support::observing_controls());
8946        assert!(observed.observes_controls());
8947        observed.apply_ok(&Observation::new(vec![upsert(
8948            "debug.log",
8949            EntryKind::File,
8950            file_attrs(10, 1),
8951        )]));
8952        assert_eq!(observed.is_ignored(Path::new("debug.log")).ok(), Some(Some(false)));
8953        assert_eq!(observed.is_ignored(Path::new("absent.log")).ok(), Some(None));
8954        assert!(observed.controls().is_ok_and(crate::control::ControlTable::is_empty));
8955    }
8956
8957    /// Control input to an index that observes no control state is refused, typed, and
8958    /// changes nothing. Accepted, it installed a table and reclassified entries under a
8959    /// scope that says no rule was read, so `is_ignored` refused over classification the
8960    /// index held and a snapshot saved from it loaded into an open that turned observation
8961    /// off as an exact match (`fdu-agb6`). A stale conditional control op is refused as
8962    /// well: the refusal is about the index's scope, not its state.
8963    #[test]
8964    fn an_index_that_does_not_observe_controls_refuses_control_input() {
8965        let mut index =
8966            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
8967        index.apply_ok(&Observation::new(vec![
8968            upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
8969            upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
8970        ]));
8971        let stale_baseline = index.expectation(Path::new(".gitignore"));
8972        index.apply_ok(&Observation::new(vec![upsert(
8973            ".gitignore",
8974            EntryKind::File,
8975            file_attrs(7, 3),
8976        )]));
8977        let clock = index.clock();
8978        let total = index.total();
8979        let controls = || {
8980            [
8981                Op::ControlUpsert {
8982                    path: PathBuf::from(".gitignore"),
8983                    source: b"*.log\n".to_vec(),
8984                },
8985                Op::ControlRemove { path: PathBuf::from(".gitignore") },
8986            ]
8987        };
8988
8989        for control in controls() {
8990            let batch = Observation::new(vec![
8991                upsert("new.txt", EntryKind::File, file_attrs(1, 4)),
8992                control.clone(),
8993            ]);
8994            assert!(
8995                matches!(index.apply(&batch), Err(crate::Error::ControlStateNotObserved)),
8996                "{control:?}"
8997            );
8998            assert!(
8999                matches!(index.apply_baseline(&batch), Err(crate::Error::ControlStateNotObserved)),
9000                "{control:?}"
9001            );
9002            let stale = Observation::from_ops(vec![ObservationOp::if_state(
9003                control.clone(),
9004                stale_baseline,
9005            )]);
9006            assert!(
9007                matches!(index.apply(&stale), Err(crate::Error::ControlStateNotObserved)),
9008                "stale {control:?}"
9009            );
9010        }
9011        assert_eq!(index.clock(), clock, "a refused batch commits nothing");
9012        assert_eq!(index.total(), total);
9013        assert!(index.lookup(Path::new("new.txt")).is_none());
9014        assert!(index.control_table().is_empty());
9015
9016        let mut table = crate::control::ControlTable::default();
9017        table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control source");
9018        assert!(matches!(
9019            index.install_controls(table),
9020            Err(crate::Error::ControlStateNotObserved)
9021        ));
9022        assert!(index.control_table().is_empty());
9023
9024        let mut observed =
9025            Index::new_with_scope("/root", crate::test_support::observing_controls());
9026        for control in controls() {
9027            observed
9028                .apply(&Observation::new(vec![
9029                    upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9030                    control,
9031                ]))
9032                .expect("an observing index accepts control input");
9033        }
9034    }
9035
9036    /// The unignored partition and a shared child's ignore bit are ignore facts too. On an
9037    /// index that read no rule the partition equals `all` and every bit reads "not
9038    /// ignored" only because nobody looked, so they refuse the way `is_ignored` does,
9039    /// while the `all` roll-up and the children themselves still answer (`fdu-agb6`).
9040    #[test]
9041    fn an_index_that_did_not_observe_controls_states_no_partition_or_child_ignore_fact() {
9042        let tree = Observation::new(vec![
9043            upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
9044            upsert("dir/debug.log", EntryKind::File, file_attrs(10, 2)),
9045        ]);
9046        let mut unobserved =
9047            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
9048        unobserved.apply_ok(&tree);
9049        assert!(matches!(unobserved.partition_total(), Err(crate::Error::ControlStateNotObserved)));
9050        for path in ["", "dir", "dir/debug.log", "absent"] {
9051            assert!(
9052                matches!(
9053                    unobserved.partition_rollup(Path::new(path)),
9054                    Err(crate::Error::ControlStateNotObserved)
9055                ),
9056                "{path}"
9057            );
9058            assert!(
9059                matches!(
9060                    unobserved.partition_rollup_summary(Path::new(path)),
9061                    Err(crate::Error::ControlStateNotObserved)
9062                ),
9063                "{path}"
9064            );
9065        }
9066        assert_eq!(unobserved.total().files, 1, "the all partition still answers");
9067        let children = IndexHandle::new(unobserved)
9068            .children(Path::new(""))
9069            .expect("children read")
9070            .expect("root directory");
9071        assert_eq!(children.len(), 1);
9072        assert_eq!(children[0].ignored, None);
9073        assert_eq!(children[0].partitions, None);
9074        assert_eq!(children[0].rollup.as_ref().map(|rollup| rollup.files), Some(1));
9075
9076        let mut observed =
9077            Index::new_with_scope("/root", crate::test_support::observing_controls());
9078        observed.apply_ok(&tree);
9079        assert_eq!(observed.partition_total().expect("control state observed").unignored.files, 1);
9080        assert!(
9081            observed.partition_rollup(Path::new("dir")).expect("control state observed").is_some()
9082        );
9083        assert_eq!(
9084            observed
9085                .partition_rollup_summary(Path::new("dir/debug.log"))
9086                .expect("control state observed"),
9087            None,
9088            "a file has no partitions"
9089        );
9090        let children = IndexHandle::new(observed)
9091            .children(Path::new(""))
9092            .expect("children read")
9093            .expect("root directory");
9094        assert_eq!(children[0].ignored, Some(false));
9095        assert_eq!(
9096            children[0].partitions.as_ref().map(|partitions| partitions.unignored.files),
9097            Some(1)
9098        );
9099    }
9100
9101    #[test]
9102    fn control_changes_atomically_move_fixed_partitions_without_changing_all() {
9103        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9104        index.apply_ok(&Observation::new(vec![
9105            upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9106            upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
9107            upsert("keep.rs", EntryKind::File, file_attrs(20, 3)),
9108            upsert("docs", EntryKind::Dir, file_attrs(0, 4)),
9109            upsert("docs/other.log", EntryKind::File, file_attrs(30, 5)),
9110            upsert("docs/keep.log", EntryKind::File, file_attrs(40, 6)),
9111        ]));
9112        let before = index.partition_total().expect("control state observed");
9113
9114        let outcome = index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
9115            path: PathBuf::from(".gitignore"),
9116            source: b"*.log\n".to_vec(),
9117        }]));
9118        let partitions = index.partition_total().expect("control state observed");
9119
9120        assert_eq!(partitions.all, before.all, "classification never changes all facts");
9121        assert_eq!(partitions.all.files, 5);
9122        assert_eq!(partitions.unignored.files, 2);
9123        assert_eq!(partitions.unignored.bytes, 26);
9124        assert_eq!(outcome.controls, 1);
9125        assert_eq!(outcome.reclassified, 3);
9126        assert_eq!(
9127            index.is_ignored(Path::new("debug.log")).expect("control state observed"),
9128            Some(true)
9129        );
9130        assert_eq!(
9131            index.is_ignored(Path::new("keep.rs")).expect("control state observed"),
9132            Some(false)
9133        );
9134
9135        let commit = outcome.commit.expect("control and classification commit together");
9136        assert!(matches!(
9137            commit.changes.first(),
9138            Some(EffectiveChange::ControlUpdated { path, previous: None, current: Some(_) })
9139                if path == Path::new(".gitignore")
9140        ));
9141        assert_eq!(
9142            commit
9143                .changes
9144                .iter()
9145                .filter(|change| matches!(change, EffectiveChange::Reclassified { .. }))
9146                .count(),
9147            3
9148        );
9149    }
9150
9151    #[test]
9152    fn serving_semantics_follow_ignore_reclassification_exactly() {
9153        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
9154            "/root",
9155            crate::test_support::observing_controls(),
9156            crate::classify::TypeRegistry::compiled_shared(),
9157            DEFAULT_JOURNAL_CAPACITY_BYTES,
9158        );
9159        index.apply_ok(&Observation::new(vec![
9160            upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9161            upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
9162            upsert("keep.rs", EntryKind::File, file_attrs(20, 3)),
9163            upsert("Makefile", EntryKind::File, file_attrs(30, 4)),
9164        ]));
9165        assert_serving_indexes(&index);
9166
9167        index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
9168            path: PathBuf::from(".gitignore"),
9169            source: b"*.log\nMakefile\n".to_vec(),
9170        }]));
9171        assert_serving_indexes(&index);
9172
9173        index.apply_ok(&Observation::new(vec![Op::ControlRemove {
9174            path: PathBuf::from(".gitignore"),
9175        }]));
9176        assert_serving_indexes(&index);
9177    }
9178
9179    #[test]
9180    fn nested_negation_edit_and_last_control_deletion_reclassify_exactly() {
9181        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9182        index.apply_ok(&Observation::new(vec![
9183            upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9184            upsert("docs", EntryKind::Dir, file_attrs(0, 2)),
9185            upsert("docs/.gitignore", EntryKind::File, file_attrs(10, 3)),
9186            upsert("docs/keep.log", EntryKind::File, file_attrs(40, 4)),
9187            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
9188            Op::ControlUpsert {
9189                path: PathBuf::from("docs/.gitignore"),
9190                source: b"!keep.log\n".to_vec(),
9191            },
9192        ]));
9193        assert_eq!(
9194            index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
9195            Some(false)
9196        );
9197
9198        let edited = index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
9199            path: PathBuf::from("docs/.gitignore"),
9200            source: b"# no exception\n".to_vec(),
9201        }]));
9202        assert_eq!(edited.reclassified, 1);
9203        assert_eq!(
9204            index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
9205            Some(true)
9206        );
9207
9208        let removed = index
9209            .apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from(".gitignore") }]));
9210        assert_eq!(removed.controls, 1, "removing the retained row removes its control state");
9211        assert_eq!(removed.reclassified, 1);
9212        assert_eq!(
9213            index.controls().expect("control state observed").len(),
9214            1,
9215            "the nested control remains"
9216        );
9217        assert_eq!(
9218            index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
9219            Some(false)
9220        );
9221
9222        index.apply_ok(&Observation::new(vec![Op::Remove {
9223            path: PathBuf::from("docs/.gitignore"),
9224        }]));
9225        assert!(index.controls().expect("control state observed").is_empty());
9226        assert_eq!(
9227            index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
9228            Some(false)
9229        );
9230    }
9231
9232    #[test]
9233    fn replacing_batch_ancestors_prunes_retained_and_transient_controls() {
9234        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9235        index
9236            .apply(&Observation::new(vec![
9237                upsert("docs", EntryKind::Dir, file_attrs(0, 1)),
9238                upsert("docs/.gitignore", EntryKind::File, file_attrs(6, 2)),
9239                Op::ControlUpsert { path: "docs/.gitignore".into(), source: b"*.log\n".to_vec() },
9240            ]))
9241            .expect("retained control");
9242
9243        let outcome = index
9244            .apply(&Observation::new(vec![
9245                upsert("scratch", EntryKind::Dir, file_attrs(0, 3)),
9246                upsert("scratch/.gitignore", EntryKind::File, file_attrs(6, 4)),
9247                Op::ControlUpsert {
9248                    path: "scratch/.gitignore".into(),
9249                    source: b"*.log\n".to_vec(),
9250                },
9251                upsert("scratch", EntryKind::File, file_attrs(1, 5)),
9252                upsert("scratch", EntryKind::Dir, file_attrs(0, 6)),
9253                upsert("scratch/new.log", EntryKind::File, file_attrs(7, 7)),
9254                Op::Remove { path: "docs".into() },
9255                upsert("docs", EntryKind::Dir, file_attrs(0, 8)),
9256                upsert("docs/new.log", EntryKind::File, file_attrs(9, 9)),
9257            ]))
9258            .expect("mixed control and structural batch");
9259
9260        assert!(index.controls().expect("control state observed").is_empty());
9261        assert_eq!(
9262            index.is_ignored(Path::new("scratch/new.log")).expect("control state observed"),
9263            Some(false)
9264        );
9265        assert_eq!(
9266            index.is_ignored(Path::new("docs/new.log")).expect("control state observed"),
9267            Some(false)
9268        );
9269        assert_eq!(outcome.stats.controls, 1, "only the retained control has a net change");
9270        let controls = outcome
9271            .commit
9272            .as_ref()
9273            .expect("one exact commit")
9274            .changes
9275            .iter()
9276            .filter(|change| matches!(change, EffectiveChange::ControlUpdated { .. }))
9277            .collect::<Vec<_>>();
9278        assert!(matches!(
9279            controls.as_slice(),
9280            [EffectiveChange::ControlUpdated { path, previous: Some(_), current: None }]
9281                if path == Path::new("docs/.gitignore")
9282        ));
9283    }
9284
9285    /// A control the budget cannot admit is refused inside the commit that carried it: the
9286    /// batch's ordinary entries land, the refusal is a change of its own, and a source it
9287    /// replaces is dropped and its entries reclassified.
9288    #[test]
9289    fn an_over_budget_control_is_refused_while_its_batch_commits() {
9290        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
9291            "/root",
9292            crate::test_support::observing_controls(),
9293            crate::classify::TypeRegistry::compiled_shared(),
9294            DEFAULT_JOURNAL_CAPACITY_BYTES,
9295        );
9296        index.apply_ok(&Observation::new(vec![
9297            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
9298            upsert("debug.log", EntryKind::File, file_attrs(10, 1)),
9299        ]));
9300        assert_eq!(index.is_ignored(Path::new("debug.log")).expect("observed"), Some(true));
9301        let mut oversized = crate::control::source_at_test_limit();
9302        oversized.push(b'a');
9303
9304        let outcome = index.apply_ok(&Observation::new(vec![
9305            upsert("ordinary.txt", EntryKind::File, file_attrs(1, 2)),
9306            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: oversized },
9307        ]));
9308
9309        assert!(index.lookup(Path::new("ordinary.txt")).is_some(), "ordinary work commits");
9310        assert_eq!(index.total().files, 2);
9311        assert_eq!(
9312            index.is_ignored(Path::new("debug.log")).expect("observed"),
9313            Some(false),
9314            "the rules the refused source replaced no longer apply"
9315        );
9316        let changes = &outcome.commit.as_ref().expect("one commit").changes;
9317        let refused = Some(crate::control::ControlRefusalReason::Budget);
9318        assert!(changes.iter().any(|change| matches!(
9319            change,
9320            EffectiveChange::ControlUpdated { previous: Some(_), current: None, .. }
9321        )));
9322        assert!(changes.iter().any(|change| matches!(
9323            change,
9324            EffectiveChange::ControlRefusalUpdated { previous: None, current, .. }
9325                if *current == refused
9326        )));
9327        let crate::control::ControlCoverage::Observed(coverage) = index.control_coverage() else {
9328            panic!("an observing index reports observed coverage");
9329        };
9330        assert_eq!((coverage.applied, coverage.refused), (0, 1));
9331        assert_eq!(coverage.refusals[0].path, Path::new(".gitignore"));
9332
9333        // Removing the refused file lifts the refusal in a commit of its own.
9334        let lifted = index.apply_ok(&Observation::new(vec![Op::ControlRemove {
9335            path: PathBuf::from(".gitignore"),
9336        }]));
9337        assert!(matches!(
9338            lifted.commit.as_ref().expect("lifting a refusal commits").changes.as_slice(),
9339            [EffectiveChange::ControlRefusalUpdated { previous, current: None, .. }]
9340                if *previous == refused
9341        ));
9342        assert_eq!(
9343            index.control_coverage(),
9344            crate::control::ControlCoverage::Observed(crate::control::ControlObservation {
9345                limits: crate::control::ControlLimits::default(),
9346                applied: 0,
9347                refused: 0,
9348                refusals: Vec::new(),
9349            })
9350        );
9351    }
9352
9353    /// A batch that cannot change the control table does not copy it.
9354    ///
9355    /// Every warm revalidate re-reads each refused `.gitignore`, because nothing is
9356    /// retained where one was refused, and re-upserts it; projecting each such batch copied
9357    /// the whole table, once per batch, to arrive at the table it started from (fdu-hzm5).
9358    /// A batch that does change it still projects.
9359    #[test]
9360    fn a_batch_that_cannot_change_the_control_table_does_not_copy_it() {
9361        let projection_clones = |index: &mut Index, ops: Vec<Op>| {
9362            CONTROL_PROJECTION_CLONES.with(|clones| clones.set(0));
9363            let outcome = index.apply_ok(&Observation::new(ops));
9364            (CONTROL_PROJECTION_CLONES.with(std::cell::Cell::get), outcome)
9365        };
9366        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9367
9368        // The cold lane first: against a table that records nothing, a batch of ordinary
9369        // entries has nothing to drop or prune, so it never projects (fdu-pro1).
9370        let (clones, _) = projection_clones(
9371            &mut index,
9372            vec![
9373                upsert("cold", EntryKind::Dir, file_attrs(0, 1)),
9374                upsert("cold/file.txt", EntryKind::File, file_attrs(1, 1)),
9375            ],
9376        );
9377        assert_eq!(clones, 0, "an empty table has nothing a structural batch can change");
9378
9379        let mut over_budget = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
9380        over_budget.push(b'\n');
9381        index.apply_ok(&Observation::new(vec![
9382            upsert("keep", EntryKind::Dir, file_attrs(0, 1)),
9383            upsert("keep/file.txt", EntryKind::File, file_attrs(3, 1)),
9384            upsert("vendor", EntryKind::Dir, file_attrs(0, 1)),
9385            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
9386            Op::ControlUpsert {
9387                path: PathBuf::from("vendor/.gitignore"),
9388                source: over_budget.clone(),
9389            },
9390        ]));
9391        let coverage = index.control_coverage();
9392
9393        // A warm revalidate's shape: the refused source re-read, the retained one re-read
9394        // unchanged, and ordinary entries beside them.
9395        let (clones, outcome) = projection_clones(
9396            &mut index,
9397            vec![
9398                upsert("keep/file.txt", EntryKind::File, file_attrs(4, 1)),
9399                Op::ControlUpsert {
9400                    path: PathBuf::from(".gitignore"),
9401                    source: b"*.log\n".to_vec(),
9402                },
9403                Op::ControlUpsert { path: PathBuf::from("vendor/.gitignore"), source: over_budget },
9404            ],
9405        );
9406        assert_eq!(clones, 0, "no control op changes anything");
9407        assert_eq!(index.control_coverage(), coverage);
9408        assert!(!outcome.commit.expect("the file's size changed").changes.iter().any(
9409            |change| matches!(
9410                change,
9411                EffectiveChange::ControlUpdated { .. }
9412                    | EffectiveChange::ControlRefusalUpdated { .. }
9413            )
9414        ));
9415
9416        // Removing the refused file is a change, so this batch projects.
9417        let (clones, _) = projection_clones(
9418            &mut index,
9419            vec![Op::ControlRemove { path: PathBuf::from("vendor/.gitignore") }],
9420        );
9421        assert_eq!(clones, 1);
9422        assert_eq!(index.controls().expect("observed").refused_len(), 0);
9423    }
9424
9425    /// A structural removal takes the refusals under it along, even when no rule is retained.
9426    #[test]
9427    fn removing_a_subtree_lifts_the_refusals_beneath_it() {
9428        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9429        let mut line = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
9430        line.push(b'\n');
9431        index.apply_ok(&Observation::new(vec![
9432            upsert("vendor", EntryKind::Dir, file_attrs(0, 1)),
9433            upsert("vendor/.gitignore", EntryKind::File, file_attrs(16_386, 1)),
9434            Op::ControlUpsert { path: PathBuf::from("vendor/.gitignore"), source: line },
9435        ]));
9436        assert_eq!(index.controls().expect("observed").refused_len(), 1);
9437
9438        let outcome =
9439            index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("vendor") }]));
9440
9441        assert_eq!(index.controls().expect("observed").refused_len(), 0);
9442        assert!(outcome.commit.expect("commit").changes.iter().any(|change| matches!(
9443            change,
9444            EffectiveChange::ControlRefusalUpdated { current: None, .. }
9445        )));
9446    }
9447
9448    #[test]
9449    fn one_sweep_reports_one_as_of_time_for_everything_it_verified() {
9450        // Found by reviewing the PR #6 provenance work against the composable-CLI
9451        // merge. A revalidation sweep elides entries whose attributes did not change,
9452        // so within one pass some paths are named by a delta and some are not. Both
9453        // were verified at the same moment and must say so identically: if the
9454        // delta-touched entry dates itself to index construction while its untouched
9455        // sibling dates itself to the sweep, a consumer sorting rows by age is
9456        // comparing two different clocks and cannot tell.
9457        let mut index = Index::new("/root");
9458        index.set_applying_source(Source::Cached, 1_000);
9459        index.apply_ok(&Observation::new(vec![
9460            Op::Upsert { path: "a".into(), kind: EntryKind::Dir, attrs: file_attrs(0, 1) },
9461            Op::Upsert {
9462                path: "a/kept.txt".into(),
9463                kind: EntryKind::File,
9464                attrs: file_attrs(1, 1),
9465            },
9466            Op::Upsert {
9467                path: "a/changed.txt".into(),
9468                kind: EntryKind::File,
9469                attrs: file_attrs(2, 2),
9470            },
9471        ]));
9472
9473        // A sweep re-observes both: one is unchanged and elided, one is updated.
9474        index.set_applying_source(Source::Revalidated, 2_000);
9475        index.begin_reconcile(Path::new("")).expect("begin reconciliation");
9476        index.apply_ok(&Observation::new(vec![
9477            Op::Upsert {
9478                path: "a/kept.txt".into(),
9479                kind: EntryKind::File,
9480                attrs: file_attrs(1, 1),
9481            },
9482            Op::Upsert {
9483                path: "a/changed.txt".into(),
9484                kind: EntryKind::File,
9485                attrs: file_attrs(9, 2),
9486            },
9487        ]));
9488        index
9489            .finish_reconcile(
9490                Path::new(""),
9491                0,
9492                true,
9493                &[],
9494                &[],
9495                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9496            )
9497            .expect("finish reconciliation");
9498
9499        let kept = index.provenance(Path::new("a/kept.txt")).expect("present");
9500        let changed = index.provenance(Path::new("a/changed.txt")).expect("present");
9501        assert!(kept.is_verified() && changed.is_verified(), "the sweep covered both");
9502        assert_eq!(
9503            kept.observed_at_ns, changed.observed_at_ns,
9504            "one sweep, one as-of time: {kept:?} vs {changed:?}"
9505        );
9506    }
9507
9508    #[test]
9509    fn withdrawn_trust_beats_a_verification_interval() {
9510        // A verification interval records that a sweep once covered a path. If the
9511        // index has since withdrawn trust — an InvalidateSubtree marking it Stale, or
9512        // a sweep in progress marking it Reconciling — the interval must not promote
9513        // it, or provenance answers "partial, and verified" in one breath.
9514        let mut index = Index::new("/root");
9515        // The entry arrives the way a snapshot load delivers it: unverified.
9516        index.set_applying_source(Source::Cached, 1_000);
9517        index.apply_baseline_ok(&Observation::new(vec![
9518            Op::Upsert { path: PathBuf::from("a"), kind: EntryKind::Dir, attrs: Attrs::default() },
9519            Op::Upsert {
9520                path: PathBuf::from("a/file.txt"),
9521                kind: EntryKind::File,
9522                attrs: Attrs { size: 1, ..Attrs::default() },
9523            },
9524        ]));
9525        assert_eq!(
9526            index.provenance(Path::new("a/file.txt")).expect("present").source,
9527            Source::Cached,
9528            "nothing has checked it yet"
9529        );
9530        // A completed sweep then covers the whole tree.
9531        index
9532            .finish_reconcile(
9533                Path::new(""),
9534                0,
9535                true,
9536                &[],
9537                &[],
9538                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9539            )
9540            .expect("finish reconciliation");
9541        let path = Path::new("a/file.txt");
9542        assert_eq!(
9543            index.provenance(path).expect("present").source,
9544            Source::Revalidated,
9545            "a completed sweep covers this path"
9546        );
9547
9548        // Now withdraw trust over the subtree.
9549        index.mark_unfresh(Path::new("a"), Freshness::Stale);
9550        let provenance = index.provenance(path).expect("present");
9551        assert!(
9552            !provenance.is_verified(),
9553            "an invalidated path must not read as verified: {provenance:?}"
9554        );
9555        assert_eq!(
9556            provenance.status,
9557            Status::Complete,
9558            "withdrawing trust changes how far to believe the value, not how much of \
9559             the subtree it covers: the cached total still accounts for every entry \
9560             beneath this path, and reporting it as Partial would tell a consumer the \
9561             number is still being built when it is merely unverified"
9562        );
9563    }
9564
9565    #[test]
9566    fn verification_intervals_stay_bounded() {
9567        // Repeated scoped sweeps of sibling subtrees must not grow without bound;
9568        // dropping the oldest only ever under-claims trust.
9569        let mut index = Index::new("/root");
9570        for which in 0..(MAX_VERIFIED_INTERVALS * 2) {
9571            index
9572                .finish_reconcile(
9573                    &PathBuf::from(format!("dir-{which}")),
9574                    0,
9575                    true,
9576                    &[],
9577                    &[],
9578                    ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9579                )
9580                .expect("finish reconciliation");
9581        }
9582        assert!(
9583            index.verified.len() <= MAX_VERIFIED_INTERVALS,
9584            "interval list grew to {}",
9585            index.verified.len()
9586        );
9587    }
9588
9589    #[test]
9590    fn retained_walk_issues_are_the_same_first_paths_for_every_arrival_order() {
9591        let make = |order: Vec<usize>| {
9592            order
9593                .into_iter()
9594                .map(|number| {
9595                    crate::Error::io(
9596                        PathBuf::from(format!("/root/file-{number:02}")),
9597                        std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
9598                    )
9599                })
9600                .collect::<Vec<_>>()
9601        };
9602        let mut reverse_order: Vec<_> = (0..66).rev().collect();
9603        reverse_order.push(65);
9604        let mut shuffled_order: Vec<_> = (0..66).step_by(2).collect();
9605        shuffled_order.extend((0..66).skip(1).step_by(2));
9606        shuffled_order.push(65);
9607
9608        let mut reverse = Index::new("/root");
9609        let mut reverse_errors = make(reverse_order);
9610        reverse.record_walk_errors(&mut reverse_errors);
9611        let mut shuffled = Index::new("/root");
9612        let mut shuffled_errors = make(shuffled_order);
9613        shuffled.record_walk_errors(&mut shuffled_errors);
9614
9615        let reverse_paths: Vec<_> =
9616            reverse.issues().iter().map(|issue| issue.path.clone().expect("path")).collect();
9617        let shuffled_paths: Vec<_> =
9618            shuffled.issues().iter().map(|issue| issue.path.clone().expect("path")).collect();
9619        assert_eq!(reverse_paths, shuffled_paths);
9620        assert_eq!(reverse_paths.len(), MAX_RETAINED_ISSUES);
9621        assert_eq!(reverse_paths.first().map(PathBuf::as_path), Some(Path::new("file-00")));
9622        assert_eq!(reverse_paths.last().map(PathBuf::as_path), Some(Path::new("file-63")));
9623        assert_eq!(reverse.state().issues.omitted, 2);
9624        assert_eq!(shuffled.state().issues.omitted, 2);
9625        assert_eq!(reverse.issue_epochs.len(), reverse.issues.len());
9626        assert_eq!(shuffled.issue_epochs.len(), shuffled.issues.len());
9627    }
9628
9629    #[test]
9630    fn repeated_partial_root_pass_replaces_bounded_issues_and_omitted_count() {
9631        let root = Path::new("/root");
9632        let mut index = Index::new(root);
9633        let run = |index: &mut Index, range: std::ops::Range<usize>| {
9634            let errors: Vec<_> = range
9635                .clone()
9636                .map(|number| {
9637                    crate::Error::io(
9638                        root.join(format!("file-{number:02}")),
9639                        std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
9640                    )
9641                })
9642                .collect();
9643            let failed: Vec<_> =
9644                range.map(|number| PathBuf::from(format!("file-{number:02}"))).collect();
9645            let (started, _) = index.begin_reconcile(Path::new("")).expect("begin");
9646            index
9647                .finish_reconcile(
9648                    Path::new(""),
9649                    started,
9650                    false,
9651                    &[],
9652                    &failed,
9653                    ReconcileErrors { errors: &errors, terminal: None, disproves_old: true },
9654                )
9655                .expect("finish");
9656        };
9657
9658        run(&mut index, 0..66);
9659        assert_eq!(index.state().issues.omitted, 2);
9660        run(&mut index, 10..76);
9661
9662        assert_eq!(index.state().issues.omitted, 2, "a retry replaces the old omission count");
9663        assert_eq!(index.omitted_issue_epochs.len(), 1, "sequential failures use one bucket");
9664        assert_eq!(index.issues().len(), crate::MAX_RETAINED_ISSUES);
9665        assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("file-10")));
9666        assert_eq!(index.issues()[63].path.as_deref(), Some(Path::new("file-73")));
9667    }
9668
9669    #[test]
9670    fn partial_pass_preserves_an_issue_published_after_it_began() {
9671        let root = Path::new("/root");
9672        let mut index = Index::new(root);
9673        let (started, _) = index.begin_reconcile(Path::new("")).expect("begin");
9674        index.mark_unfresh(Path::new("concurrent"), Freshness::Stale);
9675        index.retain_issue(Issue::from_error_under(
9676            root,
9677            &crate::Error::io(
9678                root.join("concurrent"),
9679                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "concurrent"),
9680            ),
9681        ));
9682        let pass_error = crate::Error::io(
9683            root.join("pass"),
9684            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "pass"),
9685        );
9686
9687        index
9688            .finish_reconcile(
9689                Path::new(""),
9690                started,
9691                false,
9692                &[],
9693                &[PathBuf::from("pass")],
9694                ReconcileErrors { errors: &[pass_error], terminal: None, disproves_old: true },
9695            )
9696            .expect("finish");
9697
9698        let paths: Vec<_> =
9699            index.issues().iter().filter_map(|issue| issue.path.as_deref()).collect();
9700        assert_eq!(paths, [Path::new("concurrent"), Path::new("pass")]);
9701    }
9702
9703    #[test]
9704    fn older_root_closer_preserves_newer_pass_omissions_and_partial_coverage() {
9705        let root = Path::new("/root");
9706        let mut index = Index::new(root);
9707        for number in 0..66 {
9708            let path = PathBuf::from(format!("a-{number:02}"));
9709            index.retain_issue(Issue::observation_gap(
9710                &path,
9711                crate::InvalidateReason::WatchOverflow,
9712            ));
9713        }
9714        index.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
9715        assert_eq!(index.state.issues.omitted, 2);
9716
9717        let (older, _) = index.begin_reconcile(Path::new("")).expect("begin older pass");
9718        let (newer, _) = index.begin_reconcile(Path::new("")).expect("begin newer pass");
9719        let newer_errors = ["z-one", "z-two"].map(|path| {
9720            crate::Error::io(
9721                root.join(path),
9722                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
9723            )
9724        });
9725        index
9726            .finish_reconcile(
9727                Path::new(""),
9728                newer,
9729                false,
9730                &[],
9731                &[PathBuf::from("z-one"), PathBuf::from("z-two")],
9732                ReconcileErrors { errors: &newer_errors, terminal: None, disproves_old: true },
9733            )
9734            .expect("finish newer pass");
9735        assert_eq!(index.state.issues.omitted, 2);
9736
9737        index
9738            .finish_reconcile(
9739                Path::new(""),
9740                older,
9741                true,
9742                &[],
9743                &[],
9744                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9745            )
9746            .expect("finish older pass");
9747
9748        assert_eq!(index.state.issues.omitted, 2, "the older closer cannot erase newer omissions");
9749        assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
9750    }
9751
9752    #[test]
9753    fn an_unvisited_aborted_scope_does_not_disprove_its_old_issue() {
9754        let root = Path::new("/root");
9755        let mut index = Index::new(root);
9756        index.retain_issue(Issue::from_error_under(
9757            root,
9758            &crate::Error::io(
9759                root.join("later/blocked"),
9760                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "old failure"),
9761            ),
9762        ));
9763        let (started, _) = index.begin_reconcile(Path::new("later")).expect("begin later scope");
9764
9765        index
9766            .finish_reconcile(
9767                Path::new("later"),
9768                started,
9769                false,
9770                &[],
9771                &[],
9772                ReconcileErrors { errors: &[], terminal: None, disproves_old: false },
9773            )
9774            .expect("close skipped scope");
9775
9776        assert_eq!(index.issues().len(), 1);
9777        assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("later/blocked")));
9778    }
9779    #[test]
9780    fn older_pass_cannot_publish_errors_after_newer_clean_verification() {
9781        let root = Path::new("/root");
9782        let mut index = Index::new(root);
9783        let (older, _) = index.begin_reconcile(Path::new("")).expect("begin older pass");
9784        let (newer, _) = index.begin_reconcile(Path::new("")).expect("begin newer pass");
9785        index
9786            .finish_reconcile(
9787                Path::new(""),
9788                newer,
9789                true,
9790                &[],
9791                &[],
9792                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9793            )
9794            .expect("finish newer pass");
9795        let stale_error = crate::Error::io(
9796            root.join("stale"),
9797            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "older failure"),
9798        );
9799
9800        index
9801            .finish_reconcile(
9802                Path::new(""),
9803                older,
9804                false,
9805                &[],
9806                &[PathBuf::from("stale")],
9807                ReconcileErrors { errors: &[stale_error], terminal: None, disproves_old: true },
9808            )
9809            .expect("finish superseded older pass");
9810
9811        assert!(index.issues().is_empty());
9812        assert_eq!(index.state.coverage, Coverage::Complete);
9813        assert_eq!(index.state.freshness, Freshness::Fresh);
9814    }
9815
9816    #[test]
9817    fn newer_unchanged_verification_refuses_an_older_conditional_fact() {
9818        let mut index = Index::new("/root");
9819        index
9820            .apply(&Observation::new(vec![Op::Upsert {
9821                path: PathBuf::from("same"),
9822                kind: EntryKind::File,
9823                attrs: file_attrs(1, 1),
9824            }]))
9825            .expect("fixture");
9826        let handle = IndexHandle::new(index);
9827        let baseline = handle.expectation(Path::new("same")).expect("baseline");
9828        let (older, _) = handle.begin_reconcile(Path::new("")).expect("begin older pass");
9829        let (newer, _) = handle.begin_reconcile(Path::new("")).expect("begin newer pass");
9830        handle
9831            .finish_reconcile(
9832                Path::new(""),
9833                newer,
9834                true,
9835                &[],
9836                &[],
9837                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9838            )
9839            .expect("finish unchanged newer pass");
9840
9841        let stale = handle
9842            .apply_reconcile(
9843                older,
9844                &Observation::from_ops(vec![ObservationOp::if_state(
9845                    Op::Remove { path: PathBuf::from("same") },
9846                    baseline,
9847                )]),
9848            )
9849            .expect("arbitrate older fact");
9850
9851        assert_eq!(stale.stats.stale, 1);
9852        assert!(stale.commit.is_none());
9853        assert!(handle.attrs(Path::new("same")).expect("attrs").is_some());
9854    }
9855
9856    #[test]
9857    fn newer_disjoint_verification_does_not_refuse_an_older_fact() {
9858        let mut index = Index::new("/root");
9859        index
9860            .apply(&Observation::new(vec![
9861                Op::Upsert {
9862                    path: PathBuf::from("a"),
9863                    kind: EntryKind::File,
9864                    attrs: file_attrs(1, 1),
9865                },
9866                Op::Upsert {
9867                    path: PathBuf::from("b"),
9868                    kind: EntryKind::File,
9869                    attrs: file_attrs(1, 1),
9870                },
9871            ]))
9872            .expect("fixture");
9873        let handle = IndexHandle::new(index);
9874        let baseline = handle.expectation(Path::new("a")).expect("baseline");
9875        let (older, _) = handle.begin_reconcile(Path::new("a")).expect("begin a");
9876        let (newer, _) = handle.begin_reconcile(Path::new("b")).expect("begin b");
9877        handle
9878            .finish_reconcile(
9879                Path::new("b"),
9880                newer,
9881                true,
9882                &[],
9883                &[],
9884                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9885            )
9886            .expect("finish b");
9887
9888        let applied = handle
9889            .apply_reconcile(
9890                older,
9891                &Observation::from_ops(vec![ObservationOp::if_state(
9892                    Op::Remove { path: PathBuf::from("a") },
9893                    baseline,
9894                )]),
9895            )
9896            .expect("apply disjoint a fact");
9897
9898        assert_eq!(applied.stats.removed, 1);
9899        assert_eq!(applied.stats.stale, 0);
9900        assert!(handle.attrs(Path::new("a")).expect("attrs").is_none());
9901    }
9902    #[test]
9903    fn newer_child_verification_preserves_older_sibling_failure() {
9904        let root = Path::new("/root");
9905        let mut index = Index::new(root);
9906        index.apply_ok(&Observation::new(
9907            ["a", "a/old", "b", "b/blocked", "healthy"]
9908                .map(|path| Op::Upsert {
9909                    path: PathBuf::from(path),
9910                    kind: EntryKind::Dir,
9911                    attrs: Attrs::default(),
9912                })
9913                .to_vec(),
9914        ));
9915        index.set_initial_scan_freshness(&[]);
9916        let (older, _) = index.begin_reconcile(Path::new("")).expect("older root");
9917        let (newer, _) = index.begin_reconcile(Path::new("a")).expect("newer child");
9918        index
9919            .finish_reconcile(
9920                Path::new("a"),
9921                newer,
9922                true,
9923                &[],
9924                &[],
9925                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9926            )
9927            .expect("verify a");
9928        let errors = ["a/old", "b/blocked"].map(|path| {
9929            crate::Error::io(
9930                root.join(path),
9931                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed read"),
9932            )
9933        });
9934        index
9935            .finish_reconcile(
9936                Path::new(""),
9937                older,
9938                false,
9939                &[],
9940                &[PathBuf::from("a/old"), PathBuf::from("b/blocked")],
9941                ReconcileErrors { errors: &errors, terminal: None, disproves_old: true },
9942            )
9943            .expect("finish older root");
9944        for path in ["", "a", "a/old", "b", "healthy"] {
9945            assert_eq!(index.directory_complete(Path::new(path)), Some(true), "{path}");
9946        }
9947        assert_eq!(index.directory_complete(Path::new("b/blocked")), Some(false));
9948        assert_eq!(index.issues().len(), 1);
9949        assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("b/blocked")));
9950        assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
9951        assert_eq!(index.freshness_at(Path::new("a")), Freshness::Fresh);
9952        assert_eq!(index.freshness_at(Path::new("b/blocked")), Freshness::Partial);
9953    }
9954    #[test]
9955    fn failure_published_after_a_newer_pass_began_keeps_its_issue_with_its_mark() {
9956        // A root pass begins, a child pass begins under it, and the root pass fails on
9957        // that child and closes first. Its `Partial` mark is minted after the child pass
9958        // began, so the child's clean finish leaves the mark in place; the issue that
9959        // explains the mark must follow the same rule, or the root reports a partial with
9960        // no explanation until another root pass runs.
9961        let root = Path::new("/root");
9962        let mut index = Index::new(root);
9963        index.apply_ok(&Observation::new(
9964            ["x", "x/deep", "healthy"]
9965                .map(|path| Op::Upsert {
9966                    path: PathBuf::from(path),
9967                    kind: EntryKind::Dir,
9968                    attrs: Attrs::default(),
9969                })
9970                .to_vec(),
9971        ));
9972        index.set_initial_scan_freshness(&[]);
9973        let (older_root, _) = index.begin_reconcile(Path::new("")).expect("older root");
9974        let (newer_child, _) = index.begin_reconcile(Path::new("x")).expect("newer child");
9975        let error = crate::Error::io(
9976            root.join("x"),
9977            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed read"),
9978        );
9979        index
9980            .finish_reconcile(
9981                Path::new(""),
9982                older_root,
9983                false,
9984                &[],
9985                &[PathBuf::from("x")],
9986                ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
9987            )
9988            .expect("older root fails on x");
9989        assert_eq!(index.freshness_at(Path::new("x")), Freshness::Partial);
9990        assert_eq!(index.issues().len(), 1);
9991
9992        index
9993            .finish_reconcile(
9994                Path::new("x"),
9995                newer_child,
9996                true,
9997                &[],
9998                &[],
9999                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10000            )
10001            .expect("newer child verifies clean");
10002
10003        let explained = !index.issues().is_empty();
10004        let partial = index.freshness_at(Path::new("x")) == Freshness::Partial;
10005        assert_eq!(
10006            partial,
10007            explained,
10008            "a surviving partial mark and its issue must be kept or dropped together: \
10009             partial={partial}, issues={:?}",
10010            index.issues()
10011        );
10012        assert!(partial, "the mark minted after the child pass began is the newer claim");
10013        assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("x")));
10014        assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10015        assert_eq!(index.state.freshness, Freshness::Partial);
10016        let request = crate::query::Request::new(
10017            crate::query::Basis::held_by(&index),
10018            crate::query::Query::default(),
10019            std::time::UNIX_EPOCH,
10020        );
10021        let status = crate::query::TreeStatus::of(&index, &request);
10022        assert!(!status.complete);
10023        assert_eq!(status.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10024        assert_eq!(status.errors.len(), 1, "an incomplete status names its cause");
10025    }
10026    #[test]
10027    fn cold_scan_failure_does_not_verify_unknown_descendants_or_unscoped_work() {
10028        let mut index = Index::new("/root");
10029        index.apply_ok(&Observation::new(
10030            ["blocked", "blocked/nested", "healthy"]
10031                .map(|path| Op::Upsert {
10032                    path: PathBuf::from(path),
10033                    kind: EntryKind::Dir,
10034                    attrs: Attrs::default(),
10035                })
10036                .to_vec(),
10037        ));
10038        let error = crate::Error::io(
10039            PathBuf::from("/root/blocked"),
10040            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed listing"),
10041        );
10042        index.set_initial_scan_freshness(&[error]);
10043        assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
10044        assert_eq!(index.directory_complete(Path::new("")), Some(true));
10045        assert_eq!(index.freshness_at(Path::new("")), Freshness::Partial);
10046        for path in ["blocked", "blocked/nested"] {
10047            assert_eq!(index.directory_complete(Path::new(path)), Some(false), "{path}");
10048            assert_eq!(index.freshness_at(Path::new(path)), Freshness::Partial, "{path}");
10049        }
10050        index.set_initial_scan_freshness(&[crate::Error::Snapshot("unscoped failure".into())]);
10051        assert_eq!(index.directory_complete(Path::new("healthy")), Some(false));
10052        assert_eq!(index.freshness_at(Path::new("healthy")), Freshness::Partial);
10053    }
10054
10055    #[test]
10056    fn unscoped_failure_publishes_listing_withdrawal_when_root_state_is_unchanged() {
10057        let mut index = Index::new("/root");
10058        index.apply_ok(&Observation::new(vec![Op::Upsert {
10059            path: PathBuf::from("healthy"),
10060            kind: EntryKind::Dir,
10061            attrs: Attrs::default(),
10062        }]));
10063        index.set_initial_scan_freshness(&[]);
10064        index.mark_unfresh(Path::new("elsewhere"), Freshness::Partial);
10065        index.state.freshness = Freshness::Partial;
10066        index.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
10067        let error = crate::Error::Snapshot("unscoped failure".into());
10068        index.retain_issue(Issue::from_error_under(&index.root_path, &error));
10069        let progress = index.state.progress;
10070        let (epoch, _) = index.begin_reconcile(Path::new("")).expect("begin partial root");
10071        let before = index.state;
10072        let clock = index.clock;
10073        let finished = index
10074            .finish_reconcile(
10075                Path::new(""),
10076                epoch,
10077                false,
10078                &[],
10079                &[],
10080                ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
10081            )
10082            .expect("finish failure");
10083        assert_eq!(index.state, before, "aggregate root state remains identical");
10084        assert_eq!(index.state.progress, progress, "discovery progress is cumulative");
10085        assert_eq!(index.directory_complete(Path::new("healthy")), Some(false));
10086        let commit = finished.commit.expect("withdrawal must publish even without another effect");
10087        assert!(index.clock > clock);
10088        assert_eq!(commit.clock, index.clock);
10089        assert!(
10090            commit
10091                .state
10092                .iter()
10093                .all(|effect| matches!(effect, StateTransition::DirectoryIncomplete { .. }))
10094        );
10095        assert!(
10096            commit
10097                .state
10098                .contains(&StateTransition::DirectoryIncomplete { path: PathBuf::from("healthy") })
10099        );
10100        assert!(commit.impact.dirty_paths.contains(&PathBuf::from("healthy")));
10101    }
10102
10103    #[test]
10104    fn omitted_failed_entry_withdraws_parent_listing_without_tainting_siblings() {
10105        let mut index = Index::new("/root");
10106        index.apply_ok(&Observation::new(vec![Op::Upsert {
10107            path: PathBuf::from("healthy"),
10108            kind: EntryKind::Dir,
10109            attrs: Attrs::default(),
10110        }]));
10111        let error = crate::Error::io(
10112            PathBuf::from("/root/missing"),
10113            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "metadata failed"),
10114        );
10115        index.set_initial_scan_freshness(&[error]);
10116        assert_eq!(index.directory_complete(Path::new("")), Some(false));
10117        assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
10118        index.set_initial_scan_freshness(&[]);
10119        let (epoch, _) = index.begin_reconcile(Path::new("")).expect("begin root");
10120        let error = crate::Error::io(
10121            PathBuf::from("/root/missing"),
10122            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "metadata failed"),
10123        );
10124        index
10125            .finish_reconcile(
10126                Path::new(""),
10127                epoch,
10128                false,
10129                &[],
10130                &[PathBuf::from("missing")],
10131                ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
10132            )
10133            .expect("partial root");
10134        assert_eq!(index.directory_complete(Path::new("")), Some(false));
10135        assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
10136    }
10137
10138    #[test]
10139    fn complete_older_root_does_not_verify_a_newer_failed_child() {
10140        let root = Path::new("/root");
10141        let mut index = Index::new(root);
10142        let (older, _) = index.begin_reconcile(Path::new("")).expect("older root");
10143        let (newer, _) = index.begin_reconcile(Path::new("child")).expect("newer child");
10144        let error = crate::Error::io(
10145            root.join("child"),
10146            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "new failure"),
10147        );
10148        index
10149            .finish_reconcile(
10150                Path::new("child"),
10151                newer,
10152                false,
10153                &[],
10154                &[PathBuf::from("child")],
10155                ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
10156            )
10157            .expect("failed child");
10158        let finish = index
10159            .finish_reconcile(
10160                Path::new(""),
10161                older,
10162                true,
10163                &[],
10164                &[],
10165                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10166            )
10167            .expect("complete older walk");
10168        assert_eq!(index.issues().len(), 1);
10169        assert_eq!(index.freshness_at(Path::new("child")), Freshness::Partial);
10170        assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10171        assert!(!finish.commit.iter().flat_map(|commit| commit.state.iter()).any(|state| {
10172            matches!(state, StateTransition::Verified { path } if path.as_os_str().is_empty())
10173        }));
10174    }
10175
10176    #[test]
10177    fn reconciliation_scope_budget_preserves_issues_and_newer_facts() {
10178        let mut index = Index::new("/root");
10179        index.apply_ok(&Observation::new(vec![Op::Upsert {
10180            path: PathBuf::from("kept"),
10181            kind: EntryKind::File,
10182            attrs: file_attrs(1, 1),
10183        }]));
10184        index.retain_issue(Issue::provider_failure(
10185            Some(Path::new("unvisited")),
10186            "earlier failure".into(),
10187        ));
10188        let (older, _) = index.begin_reconcile(Path::new("")).expect("older pass");
10189        let budget = index.active_reconciles[&older].scope_budget;
10190        assert_eq!(budget, 2, "root and kept file define the evidence budget");
10191        for number in 0..100 {
10192            let path = PathBuf::from(format!("missing-{number}"));
10193            let (newer, _) = index.begin_reconcile(&path).expect("newer pass");
10194            index
10195                .finish_reconcile(
10196                    &path,
10197                    newer,
10198                    true,
10199                    &[],
10200                    &[],
10201                    ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10202                )
10203                .expect("newer verification");
10204            if let ReconcileEvidence::Scopes(scopes) = &index.active_reconciles[&older].evidence {
10205                assert!(scopes.len() <= budget);
10206            }
10207        }
10208        assert!(matches!(index.active_reconciles[&older].evidence, ReconcileEvidence::Retry));
10209        index.apply_ok(&Observation::new(vec![Op::Upsert {
10210            path: PathBuf::from("kept"),
10211            kind: EntryKind::File,
10212            attrs: file_attrs(9, 2),
10213        }]));
10214        let finished = index
10215            .finish_reconcile(
10216                Path::new(""),
10217                older,
10218                true,
10219                &[],
10220                &[],
10221                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10222            )
10223            .expect("close interrupted pass");
10224        assert!(finished.retry);
10225        assert!(finished.commit.is_some());
10226        assert_eq!(index.total_scalars().bytes, 9);
10227        assert!(
10228            index
10229                .issues()
10230                .iter()
10231                .any(|issue| issue.path.as_deref() == Some(Path::new("unvisited")))
10232        );
10233        assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10234        assert!(index.active_reconciles.is_empty(), "closed passes retain no shadow history");
10235    }
10236}