Skip to main content

fdu_core/
index.rs

1//! The in-memory hierarchical index.
2//!
3//! The index is a parent-pointer tree in a flat arena. Entries store their **name only**
4//! and paths are reconstructed by walking parents, so a path like
5//! `srv/data/project/src/lib/utils.rs` costs six name strings across six entries with no
6//! duplication — the fsearch/ncdu layout, deliberately not dut's full-path-per-entry.
7//!
8//! Every directory carries pre-computed roll-up state for its whole subtree, so a query
9//! reads a field and never traverses. Applying an [`Observation`] re-merges that state up the
10//! ancestor chain only. Producers submit observations; only effective, arbitrated fact
11//! or state changes become exact clocked commits.
12//!
13//! Reducers split into two classes and the split is visible in the code, because it
14//! decides the cost of an update:
15//!
16//! - **Invertible** (counts, byte sums, per-extension tallies) apply differentially in
17//!   O(depth): add the new contribution, subtract the old one.
18//! - **Non-invertible** ([`RollUp::newest_mtime_ns`]) absorb *additions* in O(depth) by
19//!   taking a max, but a *removal* may need the directory's value rebuilt from its direct
20//!   children — standard incremental-view-maintenance behaviour. Metabrowser's
21//!   per-parent newest-mtime heaps are exactly this workaround, hand-written for one
22//!   metric.
23//!
24//! # Concurrency
25//!
26//! This type is a single-writer structure. The intended deployment is one writer
27//! applying commits behind a `RwLock` with readers taking the read side: writes are short
28//! (O(depth) applies) and reads are field lookups rather than queries that walk. The
29//! delta contract being the only mutation path means escalating later to epoch or
30//! arc-swap snapshots stays contained rather than becoming a rewrite.
31
32use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
33use std::ffi::{OsStr, OsString};
34use std::path::{Component, Path, PathBuf};
35use std::sync::{Arc, RwLock};
36
37use crate::content::{
38    AnalysisApplyOutcome, AnalysisCandidate, AnalysisObservation, AnalysisSet, ContentIndex,
39    ContentRollUp, RestoreCandidate,
40};
41use crate::engine_contract::{
42    Attrs, Clock, Commit, Coverage, CoverageReason, DiscoveryProgress, EffectiveChange,
43    EntryIdentity, EntryKind, Expectation, Freshness, Impact, ImpactDomain, IndexState,
44    InvalidateReason, Issue, LifecyclePhase, MAX_DIRTY_PATHS, MAX_RETAINED_ISSUES, Observation,
45    ObservationOp, Op, PathExpectation, PathState, Provenance, ScanScope, Source, StateTransition,
46    Status, Work,
47};
48
49/// Verification intervals kept before the oldest are dropped.
50///
51/// Bounds the memory a long-lived session can accumulate through repeated scoped
52/// reconciliation. Dropping an interval only ever moves a path back to reporting
53/// `Cached`, so the bound costs precision, never correctness.
54const MAX_VERIFIED_INTERVALS: usize = 256;
55
56fn same_issue_cause(left: &Issue, right: &Issue) -> bool {
57    left.kind == right.kind && left.path == right.path && (right.path.is_some() || left == right)
58}
59
60fn compare_issues(left: &Issue, right: &Issue) -> std::cmp::Ordering {
61    left.path
62        .cmp(&right.path)
63        .then_with(|| issue_kind_rank(left.kind).cmp(&issue_kind_rank(right.kind)))
64        .then_with(|| left.message.cmp(&right.message))
65        .then_with(|| left.os_error.cmp(&right.os_error))
66}
67
68const fn issue_kind_rank(kind: crate::IssueKind) -> u8 {
69    match kind {
70        crate::IssueKind::Permission => 0,
71        crate::IssueKind::Disappeared => 1,
72        crate::IssueKind::InvalidMetadata => 2,
73        crate::IssueKind::ResourceBudget => 3,
74        crate::IssueKind::ObservationGap => 4,
75        crate::IssueKind::ProviderFailure => 5,
76    }
77}
78
79#[cfg(test)]
80std::thread_local! {
81    /// Entries the control reclassification walk has visited on this thread.
82    ///
83    /// The walk changes nothing when no bit moves, so a test cannot see it through the
84    /// index. Per thread, because tests run in parallel and a load runs on its caller's.
85    pub(crate) static RECLASSIFY_VISITS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
86
87    /// Control tables copied to project a batch, on this thread.
88    ///
89    /// A projection that changes nothing is indistinguishable from one that was never
90    /// made, through the index; this is how a test sees which one happened.
91    pub(crate) static CONTROL_PROJECTION_CLONES: std::cell::Cell<u64> =
92        const { std::cell::Cell::new(0) };
93}
94
95/// Approximate bytes the exact commit history used by [`Index::since`] may retain.
96///
97/// Bounded on purpose: an unbounded journal is a memory leak in a long-lived server. A
98/// consumer that falls further behind than this is told so ([`Since::truncated`]) and is
99/// expected to re-read state rather than silently miss changes. The bound is stated in
100/// bytes, as [`Commit::retained_cost`] estimates them, because the question it answers is
101/// how much memory history may hold, and a budget counted in items would let long paths
102/// hold many times as much. An opened root lifts it through `journal_capacity_bytes`;
103/// there is no unbounded setting, since truncation is always announced and a journal that
104/// never truncates would grow for the life of the session.
105pub const DEFAULT_JOURNAL_CAPACITY_BYTES: usize = 8 * 1024 * 1024;
106
107/// Identifier for an entry within an [`Index`] arena.
108#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
109pub struct EntryId {
110    slot: u32,
111    generation: u64,
112}
113
114impl EntryId {
115    /// The root entry. Always present, never removed.
116    pub const ROOT: EntryId = EntryId { slot: 0, generation: 0 };
117
118    #[inline]
119    const fn idx(self) -> usize {
120        self.slot as usize
121    }
122}
123
124/// Index-private extension identity.
125type ExtId = u32;
126
127/// Per-extension tally within a roll-up.
128#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
129pub struct ExtTally {
130    /// Files with this extension.
131    pub files: u64,
132    /// Apparent bytes across those files.
133    pub bytes: u64,
134    /// Allocated bytes across those files.
135    ///
136    /// Carried alongside `bytes` so a per-type report can answer in either metric. A
137    /// tally that tracked only apparent size would force a report asked for allocated
138    /// bytes to either switch metrics silently or drop the breakdown.
139    pub allocated: u64,
140}
141
142/// Pre-computed aggregate state for one directory's entire subtree.
143///
144/// # What is counted
145///
146/// `bytes` and `allocated` sum **files only**. Directories contribute their own subtree
147/// plus one to `dirs`, but their own inode block usage is not added — unlike `du`, which
148/// counts directory blocks. The difference is small and constant per directory, and
149/// making it configurable is deferred rather than guessed at.
150///
151/// `newest_mtime_ns` is the newest modification time among descendant **files**.
152/// Directory mtimes are excluded because they change on every child add or remove, which
153/// makes "what changed recently" answer with directories instead of the edits a user
154/// actually made.
155#[derive(Clone, PartialEq, Eq, Debug, Default)]
156pub struct RollUp {
157    /// Descendant files.
158    pub files: u64,
159    /// Descendant directories, not counting the directory that owns this roll-up.
160    pub dirs: u64,
161    /// Apparent bytes across descendant files.
162    pub bytes: u64,
163    /// Allocated bytes across descendant files.
164    pub allocated: u64,
165    /// Newest mtime among descendant files, or 0 when there are none.
166    pub newest_mtime_ns: i64,
167    /// Per-extension file and byte tallies across the subtree.
168    pub by_ext: BTreeMap<String, ExtTally>,
169}
170
171/// The two fixed aggregate partitions maintained for inventory reads.
172#[derive(Clone, PartialEq, Eq, Debug, Default)]
173pub struct PartitionRollUp {
174    /// Every retained descendant.
175    pub all: RollUp,
176    /// Retained descendants outside the effective ignored partition.
177    pub unignored: RollUp,
178}
179
180/// Constant-size directory totals suitable for bounded interactive rows.
181#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
182pub struct RollUpSummary {
183    /// Descendant regular files.
184    pub files: u64,
185    /// Descendant directories, excluding the directory that owns this summary.
186    pub dirs: u64,
187    /// Apparent bytes across descendant regular files.
188    pub bytes: u64,
189    /// Allocated bytes across descendant regular files.
190    pub allocated: u64,
191    /// Newest descendant-file modification time, or `None` for an empty subtree.
192    pub newest_mtime_ns: Option<i64>,
193}
194
195/// Constant-size totals for the fixed all and unignored partitions.
196#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
197pub struct PartitionRollUpSummary {
198    /// Every retained descendant.
199    pub all: RollUpSummary,
200    /// Retained descendants outside the effective ignored partition.
201    pub unignored: RollUpSummary,
202}
203
204/// Hot-path aggregate state owned by one index.
205///
206/// Integer extension keys make ancestor merges cheap, but they are meaningful only
207/// while held by the index that issued them. Public query methods convert this into a
208/// self-describing [`RollUp`] so a retained result cannot be relabelled when an interner
209/// slot is reused.
210#[derive(Clone, PartialEq, Eq, Debug, Default)]
211struct InternedRollUp {
212    files: u64,
213    dirs: u64,
214    bytes: u64,
215    allocated: u64,
216    newest_mtime_ns: i64,
217    by_ext: BTreeMap<ExtId, ExtTally>,
218}
219
220/// Hot-path form of the fixed `all` and `unignored` partitions.
221///
222/// Dereferencing yields `all`, keeping existing unrestricted query code direct while
223/// mutation helpers update both partitions explicitly.
224#[derive(Clone, PartialEq, Eq, Debug, Default)]
225struct InternedPartitionRollUp {
226    all: InternedRollUp,
227    unignored: InternedRollUp,
228}
229
230impl std::ops::Deref for InternedPartitionRollUp {
231    type Target = InternedRollUp;
232
233    fn deref(&self) -> &Self::Target {
234        &self.all
235    }
236}
237
238impl std::ops::DerefMut for InternedPartitionRollUp {
239    fn deref_mut(&mut self) -> &mut Self::Target {
240        &mut self.all
241    }
242}
243
244impl InternedPartitionRollUp {
245    fn merge(&mut self, other: &Self) {
246        self.all.merge(&other.all);
247        self.unignored.merge(&other.unignored);
248    }
249
250    fn unmerge(&mut self, other: &Self) {
251        self.all.unmerge(&other.all);
252        self.unignored.unmerge(&other.unignored);
253    }
254}
255
256fn rollup_summary(rollup: &InternedRollUp) -> RollUpSummary {
257    RollUpSummary {
258        files: rollup.files,
259        dirs: rollup.dirs,
260        bytes: rollup.bytes,
261        allocated: rollup.allocated,
262        newest_mtime_ns: (rollup.files > 0).then_some(rollup.newest_mtime_ns),
263    }
264}
265
266fn partition_summary(rollup: &InternedPartitionRollUp) -> PartitionRollUpSummary {
267    PartitionRollUpSummary {
268        all: rollup_summary(&rollup.all),
269        unignored: rollup_summary(&rollup.unignored),
270    }
271}
272
273/// Map-free roll-up fields for internal reports that do not need extension names.
274///
275/// Keeping this view separate avoids cloning every extension string for summary and
276/// tree queries while the public [`RollUp`] remains safe to retain independently.
277#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
278pub(crate) struct RollUpScalars {
279    pub(crate) files: u64,
280    pub(crate) dirs: u64,
281    pub(crate) bytes: u64,
282    pub(crate) allocated: u64,
283    pub(crate) newest_mtime_ns: i64,
284}
285
286impl From<&InternedRollUp> for RollUpScalars {
287    fn from(rollup: &InternedRollUp) -> Self {
288        Self {
289            files: rollup.files,
290            dirs: rollup.dirs,
291            bytes: rollup.bytes,
292            allocated: rollup.allocated,
293            newest_mtime_ns: rollup.newest_mtime_ns,
294        }
295    }
296}
297
298impl InternedRollUp {
299    /// Fold another roll-up into this one. Commutative and associative, which is what
300    /// lets the walk merge subtrees in whatever order threads finish them.
301    fn merge(&mut self, other: &InternedRollUp) {
302        let had_files = self.files > 0;
303        self.files += other.files;
304        self.dirs += other.dirs;
305        self.bytes += other.bytes;
306        self.allocated += other.allocated;
307        if other.files > 0 {
308            self.newest_mtime_ns = if had_files {
309                self.newest_mtime_ns.max(other.newest_mtime_ns)
310            } else {
311                other.newest_mtime_ns
312            };
313        }
314        for (ext, tally) in &other.by_ext {
315            let slot = self.by_ext.entry(*ext).or_default();
316            slot.files += tally.files;
317            slot.bytes += tally.bytes;
318            slot.allocated += tally.allocated;
319        }
320    }
321
322    /// Remove another roll-up's contribution from this one.
323    ///
324    /// Only the invertible reducers are corrected here. `newest_mtime_ns` is left stale
325    /// on purpose and repaired by [`Index::recompute_newest_upward`], because a max
326    /// cannot be un-merged without knowing what else contributed it.
327    fn unmerge(&mut self, other: &InternedRollUp) {
328        self.files = self.files.saturating_sub(other.files);
329        self.dirs = self.dirs.saturating_sub(other.dirs);
330        self.bytes = self.bytes.saturating_sub(other.bytes);
331        self.allocated = self.allocated.saturating_sub(other.allocated);
332        for (ext, tally) in &other.by_ext {
333            if let Some(slot) = self.by_ext.get_mut(ext) {
334                slot.files = slot.files.saturating_sub(tally.files);
335                slot.bytes = slot.bytes.saturating_sub(tally.bytes);
336                slot.allocated = slot.allocated.saturating_sub(tally.allocated);
337                if slot.files == 0 && slot.bytes == 0 && slot.allocated == 0 {
338                    self.by_ext.remove(ext);
339                }
340            }
341        }
342    }
343}
344
345#[derive(Clone, Debug)]
346enum DirectoryChildren {
347    /// Name order without a second copy of each retained name.
348    Sorted(Vec<EntryId>),
349    /// Incrementally mutable topology for opened and arbitrary public indexes.
350    Mutable(BTreeMap<OsString, EntryId>),
351}
352
353impl DirectoryChildren {
354    #[cfg(test)]
355    fn is_sorted(&self) -> bool {
356        matches!(self, Self::Sorted(_))
357    }
358
359    #[cfg(test)]
360    fn is_mutable(&self) -> bool {
361        matches!(self, Self::Mutable(_))
362    }
363
364    fn ids(&self) -> ChildIds<'_> {
365        match self {
366            Self::Sorted(ids) => ChildIds::Sorted(ids.iter()),
367            Self::Mutable(children) => ChildIds::Mutable(children.values()),
368        }
369    }
370}
371
372#[derive(Clone, Debug)]
373struct DirectoryEntry {
374    children: DirectoryChildren,
375    rollup: InternedPartitionRollUp,
376    children_revision: u64,
377    children_complete: bool,
378}
379
380impl DirectoryEntry {
381    fn new(children_complete: bool) -> Self {
382        Self {
383            children: DirectoryChildren::Mutable(BTreeMap::new()),
384            rollup: InternedPartitionRollUp::default(),
385            children_revision: 0,
386            children_complete,
387        }
388    }
389}
390
391#[derive(Clone, Debug)]
392struct Entry {
393    parent: Option<EntryId>,
394    name: OsString,
395    /// Interned extension, computed once at insert. Files only; `None` elsewhere and
396    /// for files without an extension. Precomputing it here is what lets
397    /// `contribution` run without a string allocation or an interner borrow.
398    ext_id: Option<ExtId>,
399    /// Effective fixed-control classification, including an ignored ancestor.
400    ignored: bool,
401    /// Where this entry's metadata came from.
402    ///
403    /// One byte, not a `Provenance` struct: the timestamps that complete the picture
404    /// are shared by nearly every entry in a tree, so they live once on the index
405    /// while only the source genuinely varies per entry. See `Index::provenance`.
406    source: Source,
407    kind: EntryKind,
408    attrs: Attrs,
409    /// Changes on direct metadata updates. Together with the arena generation this
410    /// detects present-state ABA races.
411    revision: u64,
412    /// Child topology, subtree roll-ups, and discovery state exist only for directories.
413    /// Keeping them behind one pointer prevents every file from paying for two roll-up
414    /// planes and an empty child map.
415    directory: Option<Box<DirectoryEntry>>,
416}
417
418struct NewEntry {
419    parent: Option<EntryId>,
420    name: OsString,
421    ext_id: Option<ExtId>,
422    ignored: bool,
423    source: Source,
424    kind: EntryKind,
425    attrs: Attrs,
426}
427
428impl Entry {
429    fn new(entry: NewEntry, children_complete: bool) -> Self {
430        let NewEntry { parent, name, ext_id, ignored, source, kind, attrs } = entry;
431        Self {
432            parent,
433            name,
434            ext_id,
435            ignored,
436            source,
437            kind,
438            attrs,
439            revision: 0,
440            directory: kind.is_dir().then(|| Box::new(DirectoryEntry::new(children_complete))),
441        }
442    }
443
444    fn new_detached(new_entry: NewEntry, children_complete: bool) -> Self {
445        let mut entry = Self::new(new_entry, children_complete);
446        if let Some(directory) = entry.directory.as_deref_mut() {
447            directory.children = DirectoryChildren::Sorted(Vec::new());
448        }
449        entry
450    }
451
452    fn directory(&self) -> &DirectoryEntry {
453        self.directory.as_deref().expect("directory entry must retain directory state")
454    }
455
456    fn directory_mut(&mut self) -> &mut DirectoryEntry {
457        self.directory.as_deref_mut().expect("directory entry must retain directory state")
458    }
459
460    fn rollup(&self) -> &InternedPartitionRollUp {
461        &self.directory().rollup
462    }
463
464    fn rollup_mut(&mut self) -> &mut InternedPartitionRollUp {
465        &mut self.directory_mut().rollup
466    }
467}
468
469/// Portable direct children retained in the order interactive tree pages emit them.
470#[derive(Clone, PartialEq, Eq, Debug, Default)]
471pub(crate) struct PortableChildren {
472    pub(crate) directories: BTreeMap<String, EntryId>,
473    pub(crate) nondirectories: BTreeMap<String, EntryId>,
474}
475
476/// Commit-maintained orders and diagnostics used only while serving an opened root.
477///
478/// A detached [`Index`] is the storage and one-shot execution shape used by the CLI,
479/// snapshots, and ordinary library callers. Keeping these maps behind one optional
480/// allocation makes interactive reads additive without charging those paths one copied
481/// portable string and child-map node per entry.
482#[derive(Clone, PartialEq, Eq, Debug, Default)]
483struct ServingIndexes {
484    portable_children: BTreeMap<PathBuf, PortableChildren>,
485    portable_entries: BTreeMap<crate::PortablePath, EntryId>,
486    recent_files: BTreeSet<RecentKey>,
487    semantic_names: Vec<Option<String>>,
488    semantic_ids: BTreeMap<String, u32>,
489    semantic_refcounts: Vec<u64>,
490    free_semantic_ids: Vec<u32>,
491    semantic_by_directory: BTreeMap<EntryId, InternedSemanticPartitions>,
492    exact_name_ids: BTreeMap<String, u32>,
493    exact_names: Vec<String>,
494    exact_name_by_directory: BTreeMap<EntryId, InternedSemanticPartitions>,
495}
496
497#[derive(Clone, PartialEq, Eq, Debug, Default)]
498struct InternedSemanticPartitions {
499    all: BTreeMap<u32, ExtTally>,
500    unignored: BTreeMap<u32, ExtTally>,
501}
502
503/// One regular file in global newest-first order.
504#[derive(Clone, PartialEq, Eq, Debug)]
505struct RecentKey {
506    mtime_ns: i64,
507    portable_path: crate::PortablePath,
508    id: EntryId,
509}
510
511impl PartialOrd for RecentKey {
512    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
513        Some(self.cmp(other))
514    }
515}
516
517impl Ord for RecentKey {
518    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
519        other
520            .mtime_ns
521            .cmp(&self.mtime_ns)
522            .then_with(|| self.portable_path.cmp(&other.portable_path))
523            .then_with(|| self.id.cmp(&other.id))
524    }
525}
526
527impl ServingIndexes {
528    fn for_types(types: &crate::classify::TypeRegistry) -> Self {
529        let exact_names: Vec<_> = types
530            .exact_filenames()
531            .map(str::to_ascii_lowercase)
532            .collect::<BTreeSet<_>>()
533            .into_iter()
534            .collect();
535        let exact_name_ids = exact_names
536            .iter()
537            .enumerate()
538            .map(|(index, name)| {
539                let id = u32::try_from(index)
540                    .expect("a registry declares fewer than four billion exact filenames");
541                (name.clone(), id)
542            })
543            .collect();
544        Self { exact_name_ids, exact_names, ..Self::default() }
545    }
546
547    fn exact_name_id(&self, name: &OsStr) -> Option<u32> {
548        let name = name.to_str()?;
549        if let Some(id) = self.exact_name_ids.get(name) {
550            return Some(*id);
551        }
552        name.bytes()
553            .any(|byte| byte.is_ascii_uppercase())
554            .then(|| name.to_ascii_lowercase())
555            .and_then(|name| self.exact_name_ids.get(&name).copied())
556    }
557
558    fn intern_semantic(&mut self, name: &str) -> u32 {
559        if let Some(id) = self.semantic_ids.get(name).copied() {
560            let refcount = self
561                .semantic_refcounts
562                .get_mut(id as usize)
563                .expect("a live semantic id has a refcount");
564            *refcount = refcount.checked_add(1).expect("semantic refcount exhausted");
565            return id;
566        }
567        let id = if let Some(id) = self.free_semantic_ids.pop() {
568            self.semantic_names[id as usize] = Some(name.to_string());
569            self.semantic_refcounts[id as usize] = 1;
570            id
571        } else {
572            let id = u32::try_from(self.semantic_names.len())
573                .expect("fewer than four billion semantic types are live");
574            self.semantic_names.push(Some(name.to_string()));
575            self.semantic_refcounts.push(1);
576            id
577        };
578        self.semantic_ids.insert(name.to_string(), id);
579        id
580    }
581
582    fn release_semantic(&mut self, id: u32, count: u64) {
583        let slot = self
584            .semantic_refcounts
585            .get_mut(id as usize)
586            .expect("a live semantic id has a refcount");
587        *slot = slot.checked_sub(count).expect("semantic reference released twice");
588        if *slot != 0 {
589            return;
590        }
591        let name =
592            self.semantic_names[id as usize].take().expect("a referenced semantic id has a name");
593        let removed = self.semantic_ids.remove(&name);
594        debug_assert_eq!(removed, Some(id), "the semantic interner's two maps disagreed");
595        self.free_semantic_ids.push(id);
596    }
597}
598
599fn merge_semantic(map: &mut BTreeMap<u32, ExtTally>, id: u32, attrs: Attrs) {
600    let tally = map.entry(id).or_default();
601    tally.files = tally.files.saturating_add(1);
602    tally.bytes = tally.bytes.saturating_add(attrs.size);
603    tally.allocated = tally.allocated.saturating_add(attrs.allocated);
604}
605
606fn unmerge_semantic(map: &mut BTreeMap<u32, ExtTally>, id: u32, attrs: Attrs) {
607    let tally = map.get_mut(&id).expect("a semantic contribution must exist before removal");
608    tally.files = tally.files.saturating_sub(1);
609    tally.bytes = tally.bytes.saturating_sub(attrs.size);
610    tally.allocated = tally.allocated.saturating_sub(attrs.allocated);
611    if tally.files == 0 && tally.bytes == 0 && tally.allocated == 0 {
612        map.remove(&id);
613    }
614}
615
616fn unmerge_semantic_map(
617    destination: &mut BTreeMap<u32, ExtTally>,
618    contribution: &BTreeMap<u32, ExtTally>,
619) {
620    for (id, removed) in contribution {
621        let tally = destination
622            .get_mut(id)
623            .expect("a semantic subtree contribution must exist before removal");
624        tally.files = tally.files.saturating_sub(removed.files);
625        tally.bytes = tally.bytes.saturating_sub(removed.bytes);
626        tally.allocated = tally.allocated.saturating_sub(removed.allocated);
627        if tally.files == 0 && tally.bytes == 0 && tally.allocated == 0 {
628            destination.remove(id);
629        }
630    }
631}
632
633#[derive(Clone, Debug)]
634enum Slot {
635    Occupied { generation: u64, entry: Entry },
636    Free { generation: u64, next_free: Option<u32> },
637}
638
639fn retained_parent(arena: &[Slot], id: EntryId) -> Option<EntryId> {
640    match arena.get(id.idx()) {
641        Some(Slot::Occupied { generation, entry }) if *generation == id.generation => entry.parent,
642        Some(Slot::Occupied { .. } | Slot::Free { .. }) | None => {
643            panic!("internal entry handle must be live: {id:?}")
644        }
645    }
646}
647
648/// Result of [`Index::since`].
649#[derive(Clone, PartialEq, Eq, Debug, Default)]
650#[must_use]
651pub struct Since {
652    /// Exact commits applied strictly after the requested clock, oldest first.
653    pub commits: Vec<Commit>,
654    /// Terminal clock captured under the same read boundary as `commits`.
655    pub clock: Clock,
656    /// Complete public state at `clock`.
657    pub state: IndexState,
658    /// True when the requested clock is older than the retained journal, meaning the
659    /// caller has missed commits and must re-read state rather than trust either view.
660    pub truncated: bool,
661}
662
663/// Summary of what one [`Index::apply`] call did.
664#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
665pub struct ApplyStats {
666    /// Entries created.
667    pub inserted: u64,
668    /// Entries whose attributes changed.
669    pub updated: u64,
670    /// Entries removed, including cascaded descendants.
671    pub removed: u64,
672    /// Operations whose complete observed state already matched, so nothing changed.
673    pub unchanged: u64,
674    /// Subtrees escalated for re-scan.
675    pub invalidated: u64,
676    /// Exact control sources inserted, replaced, or removed.
677    pub controls: u64,
678    /// Retained entries moved between ignored and unignored partitions.
679    pub reclassified: u64,
680    /// Conditional observations rejected because the indexed state changed after the
681    /// producer captured its baseline.
682    pub stale: u64,
683    /// File upserts refused because their exact effect would exceed an opened-root
684    /// resource budget.
685    pub resource_refused: u64,
686}
687
688impl ApplyStats {
689    /// True when any operation changed indexed state.
690    pub const fn mutated(&self) -> bool {
691        self.inserted > 0
692            || self.updated > 0
693            || self.removed > 0
694            || self.invalidated > 0
695            || self.controls > 0
696            || self.reclassified > 0
697    }
698}
699
700/// Result of arbitrating and applying one producer observation.
701#[derive(Clone, PartialEq, Eq, Debug, Default)]
702pub struct ApplyOutcome {
703    /// Per-operation arbitration and mutation counts.
704    pub stats: ApplyStats,
705    /// Present only when at least one exact fact or state transition was committed.
706    pub commit: Option<Commit>,
707}
708
709/// One direct child captured from a shared index at a single read boundary.
710///
711/// Every field is owned so retaining this value never retains an index lock. The
712/// optional roll-up is present for directories; non-directories carry only `attrs`.
713#[derive(Clone, PartialEq, Eq, Debug)]
714pub struct ChildSnapshot {
715    /// Generation-safe arena identity at the capture boundary.
716    pub id: EntryId,
717    /// Entry name relative to its direct parent.
718    pub name: OsString,
719    /// Filesystem entry kind.
720    pub kind: EntryKind,
721    /// Last observed metadata.
722    pub attrs: Attrs,
723    /// Effective fixed-control classification, or `None` when the index did not observe
724    /// control state ([`Index::observes_controls`]).
725    ///
726    /// Such an index read no rule, so `Some(false)` would claim the child is not ignored
727    /// when nobody looked.
728    pub ignored: Option<bool>,
729    /// Pre-computed subtree totals for a directory.
730    pub rollup: Option<RollUp>,
731    /// Both maintained aggregate partitions for a directory, or `None` for a
732    /// non-directory and for any child of an index that did not observe control state,
733    /// whose unignored partition would only repeat `rollup` as if no rule applied.
734    pub partitions: Option<PartitionRollUp>,
735}
736
737impl std::ops::Deref for ApplyOutcome {
738    type Target = ApplyStats;
739
740    fn deref(&self) -> &Self::Target {
741        &self.stats
742    }
743}
744
745impl ApplyOutcome {
746    fn from_commit(stats: ApplyStats, commit: Option<Commit>) -> Self {
747        Self { stats, commit }
748    }
749}
750
751#[derive(Clone, Copy)]
752enum BatchProvenance {
753    Baseline,
754    Opened,
755    Public,
756}
757
758fn record_batch(provenance: BatchProvenance, observed: usize, stats: ApplyStats) {
759    let observed = u64::try_from(observed).unwrap_or(u64::MAX);
760    let accepted = observed.saturating_sub(stats.stale);
761    crate::counters::bump(|counts| match provenance {
762        BatchProvenance::Baseline => {
763            counts.baseline_batches = counts.baseline_batches.saturating_add(1);
764            counts.baseline_accepted_ops = counts.baseline_accepted_ops.saturating_add(accepted);
765        }
766        BatchProvenance::Opened => {
767            counts.opened_batches = counts.opened_batches.saturating_add(1);
768            counts.opened_accepted_ops = counts.opened_accepted_ops.saturating_add(accepted);
769        }
770        BatchProvenance::Public => {
771            counts.public_batches = counts.public_batches.saturating_add(1);
772            counts.public_accepted_ops = counts.public_accepted_ops.saturating_add(accepted);
773        }
774    });
775}
776
777fn elapsed_micros(started: std::time::Instant) -> u64 {
778    u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX)
779}
780
781/// Validated, canonical producer input ready for arbitration under the write guard.
782#[derive(Clone, Debug)]
783struct PreparedObservation {
784    ops: Vec<ObservationOp>,
785    ancestry: PreparedAncestry,
786    #[cfg(test)]
787    reject_before_apply: bool,
788}
789
790#[derive(Clone, Debug)]
791enum PreparedAncestry {
792    General,
793    Scanner { parents: Vec<ResolvedParent>, has_batch_parents: bool },
794}
795
796/// Parent identity proved for one operation in a private scanner batch.
797#[derive(Clone, Copy, Debug)]
798enum ResolvedParent {
799    /// The parent is already live in the index at the preparation boundary.
800    Existing(EntryId),
801    /// The parent is the directory produced by this earlier operation in the batch.
802    Earlier(usize),
803}
804
805#[derive(Default)]
806struct ExactConsequences {
807    changes: Vec<EffectiveChange>,
808    state: Vec<StateTransition>,
809}
810
811impl ExactConsequences {
812    fn is_empty(&self) -> bool {
813        self.changes.is_empty() && self.state.is_empty()
814    }
815}
816
817#[derive(Default)]
818struct NoConsequences;
819
820/// Batch-selected destination for facts that escape the shared reducer.
821///
822/// The closure is intentional: `NoConsequences` never evaluates it, so path copies and
823/// effect construction compile out of detached baseline application rather than hiding
824/// behind a branch in the per-entry loop.
825trait ConsequenceSink {
826    fn change(&mut self, change: impl FnOnce() -> EffectiveChange);
827    fn state(&mut self, transition: impl FnOnce() -> StateTransition);
828}
829
830impl ConsequenceSink for ExactConsequences {
831    #[inline]
832    fn change(&mut self, change: impl FnOnce() -> EffectiveChange) {
833        self.changes.push(change());
834    }
835
836    #[inline]
837    fn state(&mut self, transition: impl FnOnce() -> StateTransition) {
838        self.state.push(transition());
839    }
840}
841
842impl ConsequenceSink for NoConsequences {
843    #[inline]
844    fn change(&mut self, _change: impl FnOnce() -> EffectiveChange) {}
845
846    #[inline]
847    fn state(&mut self, _transition: impl FnOnce() -> StateTransition) {}
848}
849
850/// The in-memory hierarchical index.
851#[derive(Clone, Debug)]
852pub struct Index {
853    root_path: PathBuf,
854    scope: ScanScope,
855    arena: Vec<Slot>,
856    free_head: Option<u32>,
857    live: u64,
858    clock: Clock,
859    journal: VecDeque<Commit>,
860    journal_cost: usize,
861    journal_capacity_bytes: usize,
862    /// Oldest clock still represented in `journal`.
863    journal_floor: Clock,
864    pending_invalidations: Vec<(PathBuf, InvalidateReason)>,
865    /// Source recorded on entries that incoming deltas create or update.
866    ///
867    /// Producers do not carry provenance in the delta itself — an observation says
868    /// what it saw, not how much to trust it — so the consumer stamps it, and a
869    /// caller loading a snapshot sets this to `Cached` for the duration.
870    applying_source: Source,
871    /// When this session observed the filesystem, in nanoseconds since the epoch.
872    scanned_at_ns: i64,
873    /// When the snapshot this index was loaded from captured the tree. Zero when the
874    /// index was never loaded from one.
875    captured_at_ns: i64,
876    /// The start of the pass a snapshot of this index records as the one that last wrote
877    /// its image: construction for an index built by a walk, which precedes the walk, and
878    /// the stamp a loaded snapshot carried for one loaded from a snapshot.
879    ///
880    /// A lower bound on when the facts were last verified, never later than the truth. A
881    /// later pass that verifies the same facts keeps the image on disk and its stamp, and a
882    /// reconciliation that verifies a loaded index again leaves this at the snapshot's
883    /// stamp, so the value can predate many verifying passes until P1.4.4 stamps completed
884    /// passes.
885    writing_pass_started_at_ns: i64,
886    /// Whether this index holds facts no completed metadata write has recorded.
887    ///
888    /// True from construction, because a walked index has been written nowhere; cleared
889    /// when the index is loaded from a snapshot or a metadata write of it completes; set
890    /// again by a pass that mutated the entry tier. A partial pass mutates without being
891    /// writable, so this is what carries its verified facts to the next complete write
892    /// rather than keying that write to the one pass that happened to change nothing.
893    persistence_owed: bool,
894    /// Wall-clock starts of in-flight full-root passes, keyed by their freshness epoch.
895    active_root_reconciles: BTreeMap<u64, i64>,
896    /// Scopes and newer verification evidence for filesystem passes still in flight.
897    active_reconciles: BTreeMap<u64, ActiveReconcile>,
898    /// Subtrees a completed reconciliation has verified, with when it finished.
899    ///
900    /// Kept as intervals rather than per-entry flags because a sweep verifies
901    /// everything beneath a path at once, including entries the producer elided as
902    /// no-ops, and because one record per sweep costs nothing against millions of
903    /// entries. Nested and repeated sweeps collapse: a new record replaces any it
904    /// covers.
905    verified: Vec<(PathBuf, i64)>,
906    /// Interner storage: id → live name. Ids are indexes into this vector, and a
907    /// vacant slot holds `None` until it is reissued.
908    ext_names: Vec<Option<String>>,
909    /// Interner lookup: name → id.
910    ext_ids: BTreeMap<String, ExtId>,
911    /// Live file entries holding each extension id, parallel to `ext_names`.
912    ///
913    /// Interning without a matching release is a leak in the case this engine is built
914    /// for: a watched tree that churns through editor temporaries, build outputs, and
915    /// content-hashed asset names keeps minting extensions the tree no longer contains,
916    /// and both maps grow for the life of the process.
917    ext_refcounts: Vec<u64>,
918    /// Slots whose last referencing file went away, available for reissue.
919    free_ext_ids: Vec<ExtId>,
920    /// Sparse derived-data tier, allocated only after analysis is enabled.
921    content: Option<Box<ContentIndex>>,
922    /// File-type rules this index classifies against.
923    ///
924    /// Held rather than reached for globally, because a caller may run two indexes under
925    /// different taxonomies in one process. It must agree with `scope`'s type-rule
926    /// fingerprint: an index that classified under one set of rules while claiming
927    /// another would serve a snapshot that is wrong in a way nothing checks.
928    types: std::sync::Arc<crate::classify::TypeRegistry>,
929    /// Exact fixed control sources and their derived matchers.
930    controls: crate::control::ControlTable,
931    /// Control files whose text could not be read in the latest owning pass.
932    /// Their governing descendants have unknown ignore classification.
933    unreadable_control_paths: BTreeSet<PathBuf>,
934    freshness_epoch: u64,
935    freshness_marks: BTreeMap<PathBuf, FreshnessMark>,
936    /// Coherent opened-root state. Detached indexes retain the settled default and do
937    /// not acquire live identity or worker ownership by carrying this value.
938    state: IndexState,
939    /// Bounded diagnostic details summarized by `state.issues`.
940    issues: Vec<Issue>,
941    /// The freshness epoch at which each retained issue was last observed, in step with
942    /// `issues`. A reconciliation only disproves an issue observed before it began.
943    issue_epochs: Vec<u64>,
944    /// Omitted issue counts grouped by the active reconciliation boundary that owns them.
945    /// At most one group exists before/between each active boundary, so this is bounded by
946    /// concurrent passes rather than by the number of failures.
947    omitted_issue_epochs: BTreeMap<u64, u64>,
948    /// Optional commit-maintained state for interactive opened-root projections.
949    ///
950    /// Detached indexes deliberately carry `None`, including the standalone CLI's
951    /// one-shot scan. Only [`crate::OpenedIndex`] enables this allocation.
952    serving: Option<Box<ServingIndexes>>,
953}
954
955#[derive(Clone, Copy)]
956pub(crate) struct ReconcileErrors<'a> {
957    pub(crate) errors: &'a [crate::Error],
958    pub(crate) terminal: Option<&'a crate::Error>,
959    pub(crate) disproves_old: bool,
960}
961
962#[derive(Clone, Debug)]
963struct ActiveReconcile {
964    path: PathBuf,
965    // One scope per entry present when this pass began. This bounds concurrent
966    // verification history by retained state, including on a root-only index.
967    scope_budget: usize,
968    evidence: ReconcileEvidence,
969}
970
971#[derive(Clone, Debug)]
972enum ReconcileEvidence {
973    Scopes(BTreeSet<PathBuf>),
974    Retry,
975}
976
977impl ActiveReconcile {
978    fn supersede(&mut self, path: &Path) {
979        if self.path.starts_with(path) {
980            self.evidence = ReconcileEvidence::Scopes(BTreeSet::from([path.to_path_buf()]));
981            return;
982        }
983        let ReconcileEvidence::Scopes(scopes) = &mut self.evidence else {
984            return;
985        };
986        if scopes.iter().any(|newer| path.starts_with(newer)) {
987            return;
988        }
989        scopes.retain(|newer| !newer.starts_with(path));
990        if scopes.len() == self.scope_budget {
991            // Discard proof, never widen it: the caller must retry this pass.
992            self.evidence = ReconcileEvidence::Retry;
993        } else {
994            scopes.insert(path.to_path_buf());
995        }
996    }
997
998    fn refuses(&self, observation: &Observation, index: &Index) -> bool {
999        match &self.evidence {
1000            ReconcileEvidence::Retry => true,
1001            ReconcileEvidence::Scopes(scopes) => observation.ops.iter().any(|op| {
1002                scopes
1003                    .iter()
1004                    .any(|path| op.op.path().starts_with(path) || path.starts_with(op.op.path()))
1005                    && !index.holds_target(&op.op, index.path_state(op.op.path()))
1006            }),
1007        }
1008    }
1009}
1010
1011pub(crate) struct ReconcileFinish {
1012    pub(crate) commit: Option<Commit>,
1013    pub(crate) retry: bool,
1014}
1015
1016enum ChildIds<'a> {
1017    Sorted(std::slice::Iter<'a, EntryId>),
1018    Mutable(std::collections::btree_map::Values<'a, OsString, EntryId>),
1019}
1020
1021impl Iterator for ChildIds<'_> {
1022    type Item = EntryId;
1023
1024    fn next(&mut self) -> Option<Self::Item> {
1025        match self {
1026            Self::Sorted(ids) => ids.next().copied(),
1027            Self::Mutable(ids) => ids.next().copied(),
1028        }
1029    }
1030
1031    fn size_hint(&self) -> (usize, Option<usize>) {
1032        let len = self.len();
1033        (len, Some(len))
1034    }
1035}
1036
1037impl DoubleEndedIterator for ChildIds<'_> {
1038    fn next_back(&mut self) -> Option<Self::Item> {
1039        match self {
1040            Self::Sorted(ids) => ids.next_back().copied(),
1041            Self::Mutable(ids) => ids.next_back().copied(),
1042        }
1043    }
1044}
1045
1046impl ExactSizeIterator for ChildIds<'_> {
1047    fn len(&self) -> usize {
1048        match self {
1049            Self::Sorted(ids) => ids.len(),
1050            Self::Mutable(ids) => ids.len(),
1051        }
1052    }
1053}
1054
1055enum IndexChildren<'a> {
1056    Empty,
1057    Sorted { index: &'a Index, ids: std::slice::Iter<'a, EntryId> },
1058    Mutable(std::collections::btree_map::Iter<'a, OsString, EntryId>),
1059}
1060
1061impl<'a> IndexChildren<'a> {
1062    fn new(index: &'a Index, entry: &'a Entry) -> Self {
1063        match entry.directory.as_deref().map(|directory| &directory.children) {
1064            None => Self::Empty,
1065            Some(DirectoryChildren::Sorted(ids)) => Self::Sorted { index, ids: ids.iter() },
1066            Some(DirectoryChildren::Mutable(children)) => Self::Mutable(children.iter()),
1067        }
1068    }
1069}
1070
1071impl<'a> Iterator for IndexChildren<'a> {
1072    type Item = (&'a OsStr, EntryId);
1073
1074    fn next(&mut self) -> Option<Self::Item> {
1075        match self {
1076            Self::Empty => None,
1077            Self::Sorted { index, ids } => {
1078                let id = *ids.next()?;
1079                Some((index.entry(id).name.as_os_str(), id))
1080            }
1081            Self::Mutable(children) => children.next().map(|(name, id)| (name.as_os_str(), *id)),
1082        }
1083    }
1084
1085    fn size_hint(&self) -> (usize, Option<usize>) {
1086        let len = self.len();
1087        (len, Some(len))
1088    }
1089}
1090
1091impl DoubleEndedIterator for IndexChildren<'_> {
1092    fn next_back(&mut self) -> Option<Self::Item> {
1093        match self {
1094            Self::Empty => None,
1095            Self::Sorted { index, ids } => {
1096                let id = *ids.next_back()?;
1097                Some((index.entry(id).name.as_os_str(), id))
1098            }
1099            Self::Mutable(children) => {
1100                children.next_back().map(|(name, id)| (name.as_os_str(), *id))
1101            }
1102        }
1103    }
1104}
1105
1106impl ExactSizeIterator for IndexChildren<'_> {
1107    fn len(&self) -> usize {
1108        match self {
1109            Self::Empty => 0,
1110            Self::Sorted { ids, .. } => ids.len(),
1111            Self::Mutable(children) => children.len(),
1112        }
1113    }
1114}
1115
1116#[derive(Clone, Copy, Debug)]
1117struct FreshnessMark {
1118    state: Freshness,
1119    epoch: u64,
1120}
1121
1122/// Shareable owner for serving readers while reconciliation applies short writes.
1123#[derive(Clone, Debug)]
1124pub struct IndexHandle {
1125    inner: Arc<RwLock<Index>>,
1126}
1127
1128/// Index-owned part of one progressive discovery commit.
1129///
1130/// The producer may combine one of these with entry observations; the opened commit
1131/// policy updates exact file progress and publishes one atomic fact-and-state commit.
1132#[derive(Clone, Debug, Default)]
1133pub(crate) struct DiscoveryCommit {
1134    pub(crate) directory_complete: Option<PathBuf>,
1135    pub(crate) transition: Option<DiscoveryTransition>,
1136}
1137
1138#[derive(Clone, Debug)]
1139pub(crate) enum DiscoveryTransition {
1140    Begin,
1141    Finish,
1142    BudgetRefused(Issue),
1143    Inaccessible { issues: Vec<Issue>, omitted: u64 },
1144    Cancelled,
1145    Failed(Issue),
1146}
1147
1148/// Index-owned lifecycle transitions for the optional observation producer.
1149#[derive(Clone, Debug)]
1150#[cfg_attr(not(feature = "watch"), allow(dead_code))]
1151pub(crate) enum ObservationTransition {
1152    /// Baseline discovery finished and the observer is closing its registration gap.
1153    Reconciling,
1154    /// The observer is active and its baseline handoff has been verified.
1155    ///
1156    /// Persistent inaccessible boundaries do not prevent observation of the readable
1157    /// scope, but they keep coverage partial and their causes remain inspectable.
1158    Watching { issues: Vec<Issue>, omitted: u64 },
1159    /// A reconciliation while watching could not read part of the scope.
1160    ///
1161    /// The subtree it covered is already partial and is not retried on every later event,
1162    /// so its causes are retained here, where partial freshness can be explained. Both
1163    /// watch drivers publish it: the opened root's observer and `Watcher::apply_next`.
1164    Unreadable { issues: Vec<Issue>, omitted: u64 },
1165    /// Observation could not establish or retain a trustworthy live boundary.
1166    Failed(Issue),
1167}
1168
1169impl IndexHandle {
1170    /// Wrap an owned index in the shared single-writer owner.
1171    pub fn new(index: Index) -> Self {
1172        Self { inner: Arc::new(RwLock::new(index)) }
1173    }
1174
1175    fn read_index(&self) -> crate::Result<std::sync::RwLockReadGuard<'_, Index>> {
1176        self.inner.read().map_err(|_| crate::Error::IndexLockPoisoned)
1177    }
1178
1179    fn write_index(&self) -> crate::Result<std::sync::RwLockWriteGuard<'_, Index>> {
1180        self.inner.write().map_err(|_| crate::Error::IndexLockPoisoned)
1181    }
1182
1183    /// Evaluate one owned result while holding exactly one coherent read boundary.
1184    pub(crate) fn read_with<T>(&self, read: impl FnOnce(&Index) -> T) -> crate::Result<T> {
1185        let index = self.read_index()?;
1186        Ok(read(&index))
1187    }
1188
1189    #[cfg(test)]
1190    pub(crate) fn poison_for_test(&self) {
1191        let handle = self.clone();
1192        std::thread::spawn(move || handle.panic_holding_the_write_lock_for_test())
1193            .join()
1194            .expect_err("injected index panic");
1195    }
1196
1197    /// Panic on this thread while holding the write lock, as a commit that panics does,
1198    /// leaving the lock poisoned.
1199    #[cfg(test)]
1200    pub(crate) fn panic_holding_the_write_lock_for_test(&self) -> ! {
1201        let _guard = self.inner.write().expect("test index write lock");
1202        panic!("inject index poison");
1203    }
1204
1205    /// Arbitrate and apply one observation under the single-writer lock.
1206    pub fn apply(&self, observation: &Observation) -> crate::Result<ApplyOutcome> {
1207        let prepared = prepare_observation(observation)?;
1208        let outcome = self.write_index()?.commit_prepared(prepared, true)?;
1209        record_batch(BatchProvenance::Public, observation.len(), outcome.stats);
1210        Ok(outcome)
1211    }
1212
1213    pub(crate) fn apply_discovery(
1214        &self,
1215        observation: &Observation,
1216        discovery: DiscoveryCommit,
1217    ) -> crate::Result<ApplyOutcome> {
1218        let prepared = prepare_observation(observation)?;
1219        let outcome = self.write_index()?.commit_prepared_with(
1220            prepared,
1221            true,
1222            Some(discovery),
1223            None,
1224            None,
1225            true,
1226        )?;
1227        record_batch(BatchProvenance::Opened, observation.len(), outcome.stats);
1228        Ok(outcome)
1229    }
1230
1231    #[cfg(feature = "watch")]
1232    pub(crate) fn apply_opened(
1233        &self,
1234        observation: &Observation,
1235        max_files: Option<u64>,
1236    ) -> crate::Result<ApplyOutcome> {
1237        let prepared = prepare_observation(observation)?;
1238        let outcome = self
1239            .write_index()?
1240            .commit_prepared_with(prepared, true, None, None, max_files, true)?;
1241        record_batch(BatchProvenance::Opened, observation.len(), outcome.stats);
1242        Ok(outcome)
1243    }
1244
1245    pub(crate) fn apply_scanner_discovery_bounded(
1246        &self,
1247        batch: crate::scan::ScannerBatch,
1248        discovery: DiscoveryCommit,
1249        max_files: Option<u64>,
1250    ) -> crate::Result<ApplyOutcome> {
1251        let observed = batch.len();
1252        let mut index = self.write_index()?;
1253        let prepared = index.prepare_scanner_batch(batch)?;
1254        let outcome =
1255            index.commit_prepared_with(prepared, true, Some(discovery), None, max_files, true)?;
1256        record_batch(BatchProvenance::Opened, observed, outcome.stats);
1257        Ok(outcome)
1258    }
1259
1260    pub(crate) fn transition_discovery(
1261        &self,
1262        transition: DiscoveryTransition,
1263    ) -> crate::Result<ApplyOutcome> {
1264        self.apply_discovery(
1265            &Observation::new(Vec::new()),
1266            DiscoveryCommit { directory_complete: None, transition: Some(transition) },
1267        )
1268    }
1269
1270    #[cfg(feature = "watch")]
1271    pub(crate) fn transition_observation(
1272        &self,
1273        transition: ObservationTransition,
1274    ) -> crate::Result<ApplyOutcome> {
1275        let prepared = prepare_observation(&Observation::default())?;
1276        let outcome = self.write_index()?.commit_prepared_with(
1277            prepared,
1278            true,
1279            None,
1280            Some(transition),
1281            None,
1282            true,
1283        )?;
1284        record_batch(BatchProvenance::Opened, 0, outcome.stats);
1285        Ok(outcome)
1286    }
1287
1288    /// Absolute filesystem root, copied without retaining the read lock.
1289    pub fn root_path(&self) -> crate::Result<PathBuf> {
1290        Ok(self.read_index()?.root_path().to_path_buf())
1291    }
1292
1293    /// Semantic scan scope represented by the shared index.
1294    pub fn scope(&self) -> crate::Result<ScanScope> {
1295        Ok(self.read_index()?.scope())
1296    }
1297
1298    /// Trust state for the whole index.
1299    pub fn freshness(&self) -> crate::Result<Freshness> {
1300        Ok(self.read_index()?.freshness())
1301    }
1302
1303    /// Trust state for one subtree.
1304    pub fn freshness_at(&self, path: &Path) -> crate::Result<Freshness> {
1305        Ok(self.read_index()?.freshness_at(path))
1306    }
1307
1308    /// Clock of the most recently committed delta.
1309    pub fn clock(&self) -> crate::Result<Clock> {
1310        Ok(self.read_index()?.clock())
1311    }
1312
1313    /// Number of live entries, including the root.
1314    pub fn len(&self) -> crate::Result<u64> {
1315        Ok(self.read_index()?.len())
1316    }
1317
1318    /// Whether the index contains only its root.
1319    pub fn is_empty(&self) -> crate::Result<bool> {
1320        Ok(self.read_index()?.is_empty())
1321    }
1322
1323    /// Owned roll-up totals for the whole tree.
1324    pub fn total(&self) -> crate::Result<RollUp> {
1325        Ok(self.read_index()?.total())
1326    }
1327
1328    /// Coherent opened-root state at the returned clock.
1329    pub(crate) fn state(&self) -> crate::Result<IndexState> {
1330        Ok(self.read_index()?.state())
1331    }
1332
1333    #[allow(dead_code)] // Consumed by the opened-root coherent read checkpoint.
1334    pub(crate) fn issues(&self) -> crate::Result<Vec<Issue>> {
1335        Ok(self.read_index()?.issues().to_vec())
1336    }
1337
1338    /// Whether a known directory has an authoritative in-scope child set.
1339    #[allow(dead_code)] // Consumed by the opened-root coherent read checkpoint.
1340    pub(crate) fn directory_complete(&self, path: &Path) -> crate::Result<Option<bool>> {
1341        Ok(self.read_index()?.directory_complete(path))
1342    }
1343
1344    /// Owned roll-up state for a relative directory path.
1345    pub fn rollup(&self, path: &Path) -> crate::Result<Option<RollUp>> {
1346        Ok(self.read_index()?.rollup(path))
1347    }
1348
1349    /// Owned metadata for a relative path.
1350    pub fn attrs(&self, path: &Path) -> crate::Result<Option<Attrs>> {
1351        Ok(self.read_index()?.attrs(path).copied())
1352    }
1353
1354    /// Entry kind for a relative path.
1355    pub fn kind(&self, path: &Path) -> crate::Result<Option<EntryKind>> {
1356        Ok(self.read_index()?.kind(path))
1357    }
1358
1359    /// Current visible state for a relative path.
1360    pub fn path_state(&self, path: &Path) -> crate::Result<PathState> {
1361        Ok(self.read_index()?.path_state(path))
1362    }
1363
1364    /// Conditional baseline for a producer operating on a shared index.
1365    pub fn expectation(&self, path: &Path) -> crate::Result<PathExpectation> {
1366        Ok(self.read_index()?.expectation(path))
1367    }
1368
1369    /// Owned exact commits after `clock`.
1370    pub fn since(&self, clock: Clock) -> crate::Result<Since> {
1371        Ok(self.read_index()?.since(clock))
1372    }
1373
1374    /// Direct children captured coherently at one read boundary.
1375    ///
1376    /// On an index that did not observe control state each child's
1377    /// [`ChildSnapshot::ignored`] and [`ChildSnapshot::partitions`] are `None`, the way
1378    /// [`Index::is_ignored`] refuses; the names, metadata, and roll-ups still answer.
1379    pub fn children(&self, path: &Path) -> crate::Result<Option<Vec<ChildSnapshot>>> {
1380        let index = self.read_index()?;
1381        let Some(children) = index.children(path) else {
1382            return Ok(None);
1383        };
1384        let observed = index.observes_controls();
1385        Ok(Some(
1386            children
1387                .map(|(name, id)| {
1388                    let entry = index.entry(id);
1389                    ChildSnapshot {
1390                        id,
1391                        name: name.to_os_string(),
1392                        kind: entry.kind,
1393                        attrs: entry.attrs,
1394                        ignored: observed.then_some(entry.ignored),
1395                        rollup: entry
1396                            .kind
1397                            .is_dir()
1398                            .then(|| index.named_rollup(&entry.rollup().all)),
1399                        partitions: (observed && entry.kind.is_dir())
1400                            .then(|| index.named_partitions(entry.rollup())),
1401                    }
1402                })
1403                .collect(),
1404        ))
1405    }
1406
1407    /// Capture one coherent owned index image, releasing the lock before callers do
1408    /// serialization, filesystem I/O, conversion, or other potentially blocking work.
1409    pub fn snapshot(&self) -> crate::Result<Index> {
1410        let mut snapshot = self.read_index()?.clone();
1411        snapshot.serving = None;
1412        Ok(snapshot)
1413    }
1414
1415    pub(crate) fn child_states(
1416        &self,
1417        path: &Path,
1418    ) -> crate::Result<BTreeMap<OsString, PathExpectation>> {
1419        let index = self.read_index()?;
1420        Ok(collect_child_expectations(&index, path))
1421    }
1422
1423    /// Child baselines for one opened-root listing, with whether the index does not yet
1424    /// hold that directory's child set as complete, read at one boundary.
1425    pub(crate) fn listing_baseline(
1426        &self,
1427        path: &Path,
1428    ) -> crate::Result<(BTreeMap<OsString, PathExpectation>, bool)> {
1429        let index = self.read_index()?;
1430        Ok((collect_child_expectations(&index, path), index.directory_complete(path) != Some(true)))
1431    }
1432
1433    pub(crate) fn has_control(&self, path: &Path) -> crate::Result<bool> {
1434        Ok(self.read_index()?.control_table().contains(path))
1435    }
1436
1437    pub(crate) fn take_pending_invalidations(
1438        &self,
1439    ) -> crate::Result<Vec<(PathBuf, InvalidateReason)>> {
1440        Ok(self.write_index()?.take_pending_invalidations())
1441    }
1442
1443    pub(crate) fn restore_pending_invalidations(
1444        &self,
1445        invalidations: Vec<(PathBuf, InvalidateReason)>,
1446    ) -> crate::Result<()> {
1447        self.write_index()?.restore_pending_invalidations(invalidations);
1448        Ok(())
1449    }
1450
1451    pub(crate) fn begin_reconcile(&self, path: &Path) -> crate::Result<(u64, Option<Commit>)> {
1452        self.write_index()?.begin_reconcile(path)
1453    }
1454
1455    pub(crate) fn finish_reconcile(
1456        &self,
1457        path: &Path,
1458        started_at: u64,
1459        complete: bool,
1460        listed_incomplete: &[PathBuf],
1461        failed_paths: &[PathBuf],
1462        errors: ReconcileErrors<'_>,
1463    ) -> crate::Result<ReconcileFinish> {
1464        self.write_index()?.finish_reconcile(
1465            path,
1466            started_at,
1467            complete,
1468            listed_incomplete,
1469            failed_paths,
1470            errors,
1471        )
1472    }
1473
1474    pub(crate) fn apply_reconcile(
1475        &self,
1476        started_at: u64,
1477        observation: &Observation,
1478    ) -> crate::Result<ApplyOutcome> {
1479        self.apply_reconcile_with(started_at, observation, None, BatchProvenance::Public)
1480    }
1481
1482    pub(crate) fn apply_opened_reconcile(
1483        &self,
1484        started_at: u64,
1485        observation: &Observation,
1486        max_files: Option<u64>,
1487    ) -> crate::Result<ApplyOutcome> {
1488        self.apply_reconcile_with(started_at, observation, max_files, BatchProvenance::Opened)
1489    }
1490
1491    fn apply_reconcile_with(
1492        &self,
1493        started_at: u64,
1494        observation: &Observation,
1495        max_files: Option<u64>,
1496        provenance: BatchProvenance,
1497    ) -> crate::Result<ApplyOutcome> {
1498        let prepared = prepare_observation(observation)?;
1499        let mut index = self.write_index()?;
1500        if index
1501            .active_reconciles
1502            .get(&started_at)
1503            .is_some_and(|active| active.refuses(observation, &index))
1504        {
1505            let stats = ApplyStats {
1506                stale: u64::try_from(observation.len()).unwrap_or(u64::MAX),
1507                ..ApplyStats::default()
1508            };
1509            record_batch(provenance, observation.len(), stats);
1510            return Ok(ApplyOutcome { stats, commit: None });
1511        }
1512        let outcome = index.commit_prepared_with(
1513            prepared,
1514            true,
1515            None,
1516            None,
1517            max_files,
1518            matches!(provenance, BatchProvenance::Opened),
1519        )?;
1520        record_batch(provenance, observation.len(), outcome.stats);
1521        Ok(outcome)
1522    }
1523
1524    #[cfg(feature = "watch")]
1525    pub(crate) fn apply_if_clock(
1526        &self,
1527        clock: Clock,
1528        observation: &Observation,
1529    ) -> crate::Result<Option<ApplyOutcome>> {
1530        let prepared = prepare_observation(observation)?;
1531        let mut index = self.write_index()?;
1532        if index.clock() != clock {
1533            return Ok(None);
1534        }
1535        index.commit_prepared(prepared, true).map(Some)
1536    }
1537
1538    #[cfg(feature = "watch")]
1539    pub(crate) fn apply_opened_if_clock(
1540        &self,
1541        clock: Clock,
1542        observation: &Observation,
1543        max_files: Option<u64>,
1544    ) -> crate::Result<Option<ApplyOutcome>> {
1545        let prepared = prepare_observation(observation)?;
1546        let mut index = self.write_index()?;
1547        if index.clock() != clock {
1548            return Ok(None);
1549        }
1550        index.commit_prepared_with(prepared, true, None, None, max_files, true).map(Some)
1551    }
1552
1553    #[cfg(feature = "watch")]
1554    pub(crate) fn unknown_ancestry(
1555        &self,
1556        observation: &Observation,
1557    ) -> crate::Result<Vec<(PathBuf, PathBuf)>> {
1558        let prepared = prepare_observation(observation)?;
1559        let index = self.read_index()?;
1560        let accepted = index.accepted_operations(&prepared.ops);
1561        Ok(index.unknown_ancestry(&prepared.ops, &accepted))
1562    }
1563
1564    #[cfg(feature = "watch")]
1565    pub(crate) fn watch_boundary(&self) -> crate::Result<(PathBuf, ScanScope, Clock)> {
1566        let index = self.read_index()?;
1567        Ok((index.root_path().to_path_buf(), index.scope(), index.clock()))
1568    }
1569
1570    #[cfg(feature = "watch")]
1571    pub(crate) fn invalidate_root(&self, reason: InvalidateReason) -> crate::Result<ApplyOutcome> {
1572        self.apply(&Observation::new(vec![Op::InvalidateSubtree { path: PathBuf::new(), reason }]))
1573    }
1574}
1575
1576/// Private parent-first builder for a cold index that is not yet externally visible.
1577///
1578/// Directory groups arrive while filesystem workers are still running. Applying each
1579/// group here overlaps structural construction with the walk without turning the cold
1580/// path back into public observations or manufacturing one full path per file.
1581pub(crate) struct DetachedIndexBuilder {
1582    index: Index,
1583    directory_ids: HashMap<PathBuf, EntryId>,
1584    /// Directories whose name one listing repeated. The walker lists each of them, and
1585    /// everything below it, once per observation.
1586    repeated_directories: Vec<PathBuf>,
1587    inserted: u64,
1588}
1589
1590impl DetachedIndexBuilder {
1591    pub(crate) fn new(
1592        root_path: impl Into<PathBuf>,
1593        scope: ScanScope,
1594        types: std::sync::Arc<crate::classify::TypeRegistry>,
1595    ) -> Self {
1596        let mut index = Index::new_with_scope_and_types(root_path, scope, types);
1597        index.entry_mut(EntryId::ROOT).directory_mut().children =
1598            DirectoryChildren::Sorted(Vec::new());
1599        Self {
1600            index,
1601            directory_ids: HashMap::from([(PathBuf::new(), EntryId::ROOT)]),
1602            repeated_directories: Vec::new(),
1603            inserted: 0,
1604        }
1605    }
1606
1607    /// Refuse control sources past either of `limits` while building.
1608    pub(crate) fn with_control_limits(mut self, limits: crate::control::ControlLimits) -> Self {
1609        self.index.set_control_limits(limits);
1610        self
1611    }
1612
1613    /// Consume one listing after its parent listing has already been consumed.
1614    ///
1615    /// An enumerator can repeat a name while its directory is modified, which the
1616    /// streaming reducer absorbs as a re-upsert. Here a listing keeps the last observation
1617    /// of each name. A directory observed twice is also listed twice, and so is everything
1618    /// below it: the first listing to arrive for each such directory builds it, and a
1619    /// repeat is accepted without being applied again. A filesystem race must not fail
1620    /// the scan.
1621    ///
1622    /// The listing is drained, not consumed: an applied listing is left with its path
1623    /// and an empty child buffer, so the caller can hand both back to the worker that
1624    /// allocated them (H159). A listing that is not applied keeps its children.
1625    pub(crate) fn push_directory(
1626        &mut self,
1627        directory: &mut crate::scan::DetachedDirectory,
1628    ) -> crate::Result<()> {
1629        let crate::scan::DetachedDirectory { path, children, control } = directory;
1630        // No descendant can become claimable until its parent's listing has been sent,
1631        // so the first listing of a directory finds its lookup entry. Retire the entry
1632        // now instead of retaining every walked directory path until the end of the scan.
1633        let Some(parent) = self.directory_ids.remove(path.as_path()) else {
1634            if self.repeated_directories.iter().any(|repeated| path.starts_with(repeated)) {
1635                return Ok(());
1636            }
1637            return Err(crate::Error::UnknownAncestry {
1638                path: path.clone(),
1639                reconcile_from: PathBuf::new(),
1640            });
1641        };
1642
1643        // The worker publishes this listing before descendants become claimable. Apply
1644        // its complete fixed-control state before classifying any sibling, and every
1645        // later child listing will therefore inherit all governing controls without a
1646        // post-build subtree reclassification pass.
1647        if let Some(control) = control.take() {
1648            match control {
1649                Op::ControlUpsert { path, source } => {
1650                    self.index.controls.upsert(&path, source)?;
1651                }
1652                Op::ControlRemove { path } => {
1653                    self.index.controls.remove(&path)?;
1654                }
1655                _ => unreachable!("detached directory retains only fixed-control operations"),
1656            }
1657            self.inserted = self.inserted.saturating_add(1);
1658        }
1659
1660        // Allocate in name order, the order the directory retains its children in, keeping
1661        // the last observation of a repeated name. The sort is unstable so that it needs
1662        // no scratch allocation; the enumeration position is what keeps "last" exact.
1663        children.sort_unstable_by(|left, right| {
1664            left.name.cmp(&right.name).then(left.position.cmp(&right.position))
1665        });
1666        let repeated_directories = &mut self.repeated_directories;
1667        children.dedup_by(|later, kept| {
1668            if later.name != kept.name {
1669                return false;
1670            }
1671            if later.kind.is_dir() || kept.kind.is_dir() {
1672                let repeated = path.join(&kept.name);
1673                if repeated_directories.last() != Some(&repeated) {
1674                    repeated_directories.push(repeated);
1675                }
1676            }
1677            std::mem::swap(later, kept);
1678            true
1679        });
1680
1681        let parent_ignored = self.index.entry(parent).ignored;
1682        // Every child shares this directory's governing controls, so they are resolved
1683        // once here rather than looked up per child (H163).
1684        let chain = (!parent_ignored && !self.index.controls.is_empty())
1685            .then(|| self.index.controls.chain_for(path));
1686        self.index.reserve_detached_children(parent, children.len());
1687        for child in children.drain(..) {
1688            let crate::scan::DetachedChild { name, kind, attrs, .. } = child;
1689            crate::counters::bump(|counts| counts.upserts += 1);
1690            let ext_id = (kind == EntryKind::File)
1691                .then(|| self.index.intern_ext(&crate::classify::ext_bucket(&name)));
1692            let ignored = match &chain {
1693                Some(chain) => chain.is_ignored(path, name.as_encoded_bytes(), kind.is_dir()),
1694                None => parent_ignored,
1695            };
1696            let child_path = kind.is_dir().then(|| path.join(&name));
1697            let child_id = self.index.alloc(Entry::new_detached(
1698                NewEntry {
1699                    parent: Some(parent),
1700                    name,
1701                    ext_id,
1702                    ignored,
1703                    source: Source::Scanned,
1704                    kind,
1705                    attrs,
1706                },
1707                false,
1708            ));
1709            self.index.push_detached_child(parent, child_id);
1710            // Fold the child's own direct contribution while filesystem work is still
1711            // in flight. Files are now complete; directories will add only their
1712            // descendant roll-up in the short bottom-up finish pass.
1713            let direct = self.index.contribution(child_id);
1714            crate::counters::bump(|counts| counts.rollup_merges += 1);
1715            self.index.entry_mut(parent).rollup_mut().merge(&direct);
1716            if let Some(child_path) = child_path {
1717                self.directory_ids.insert(child_path, child_id);
1718            }
1719            self.inserted = self.inserted.saturating_add(1);
1720        }
1721        Ok(())
1722    }
1723
1724    /// Complete the private baseline after every directory listing has arrived.
1725    pub(crate) fn finish(mut self) -> Index {
1726        // Parents are allocated before descendants, so reverse arena order is a valid
1727        // bottom-up traversal. Direct contributions were merged during the pipelined
1728        // build; only completed directory descendants remain to propagate here.
1729        for slot in (1..self.index.arena.len()).rev() {
1730            let id = EntryId {
1731                slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
1732                generation: 0,
1733            };
1734            if !self.index.entry(id).kind.is_dir() {
1735                continue;
1736            }
1737            let parent = self.index.entry(id).parent.expect("every non-root entry has a parent");
1738            // The directory's own count was merged when its parent listing arrived.
1739            crate::counters::bump(|counts| counts.rollup_merges += 1);
1740            self.index.merge_detached_descendants(parent, id);
1741        }
1742
1743        crate::counters::bump(|counts| {
1744            counts.baseline_batches = counts.baseline_batches.saturating_add(1);
1745            counts.baseline_accepted_ops =
1746                counts.baseline_accepted_ops.saturating_add(self.inserted);
1747        });
1748        self.index.establish_baseline();
1749        self.index
1750    }
1751}
1752
1753impl Index {
1754    /// Create an empty index rooted at `root_path`, under [`ScanScope::default`].
1755    ///
1756    /// That is the scope of [`ScanConfig::default`](crate::ScanConfig), which observes
1757    /// control state, so this index answers [`Self::is_ignored`], [`Self::controls`], and
1758    /// the partition accessors ([`Self::partition_total`], [`Self::partition_rollup`], and
1759    /// [`Self::partition_rollup_summary`]), and it accepts control input. Build any other
1760    /// scope, including one that turns control observation off, with
1761    /// [`Self::new_with_scope`].
1762    pub fn new(root_path: impl Into<PathBuf>) -> Self {
1763        Self::new_with_scope(root_path, ScanScope::default())
1764    }
1765
1766    /// Create an empty index with an explicit semantic scan scope.
1767    ///
1768    /// Its control table applies the default
1769    /// [`ControlLimits`](crate::control::ControlLimits), whatever limits the scope was
1770    /// taken under, so a snapshot of it saves only when those agree. Build an index for any
1771    /// other limits with [`Self::new_with_config`].
1772    pub fn new_with_scope(root_path: impl Into<PathBuf>, scope: ScanScope) -> Self {
1773        Self::new_with_scope_and_types(
1774            root_path,
1775            scope,
1776            crate::classify::TypeRegistry::compiled_shared(),
1777        )
1778    }
1779
1780    /// Create an empty index with the scope, file-type rules, and control limits of
1781    /// `config`, as the scans behind [`crate::open`] and [`crate::OpenedIndex`] do.
1782    ///
1783    /// The scope and the control table come from one configuration, so the table enforces
1784    /// exactly the limits the scope's ignore-rules identity claims.
1785    pub fn new_with_config(root_path: impl Into<PathBuf>, config: &crate::ScanConfig) -> Self {
1786        let mut index =
1787            Self::new_with_scope_and_types(root_path, config.scope(), config.types_shared());
1788        index.set_control_limits(config.control_limits);
1789        index
1790    }
1791
1792    /// Create an index whose registry is part of its validated semantic scope.
1793    pub(crate) fn new_with_scope_and_types(
1794        root_path: impl Into<PathBuf>,
1795        scope: ScanScope,
1796        types: std::sync::Arc<crate::classify::TypeRegistry>,
1797    ) -> Self {
1798        Self::new_with_scope_types_and_journal_capacity_bytes(
1799            root_path,
1800            scope,
1801            types,
1802            DEFAULT_JOURNAL_CAPACITY_BYTES,
1803        )
1804    }
1805
1806    pub(crate) fn new_with_scope_types_and_journal_capacity_bytes(
1807        root_path: impl Into<PathBuf>,
1808        scope: ScanScope,
1809        types: std::sync::Arc<crate::classify::TypeRegistry>,
1810        journal_capacity_bytes: usize,
1811    ) -> Self {
1812        assert_eq!(
1813            scope.type_rules_fingerprint,
1814            types.fingerprint(),
1815            "an index's registry must match its semantic scope"
1816        );
1817        Self::new_with_journal_capacity_bytes(root_path, scope, journal_capacity_bytes, types, None)
1818    }
1819
1820    /// Create the retained index behind an opened root, including its serving orders.
1821    pub(crate) fn new_opened_with_scope_types_and_journal_capacity_bytes(
1822        root_path: impl Into<PathBuf>,
1823        scope: ScanScope,
1824        types: std::sync::Arc<crate::classify::TypeRegistry>,
1825        journal_capacity_bytes: usize,
1826    ) -> Self {
1827        assert_eq!(
1828            scope.type_rules_fingerprint,
1829            types.fingerprint(),
1830            "an index's registry must match its semantic scope"
1831        );
1832        let serving = ServingIndexes::for_types(&types);
1833        Self::new_with_journal_capacity_bytes(
1834            root_path,
1835            scope,
1836            journal_capacity_bytes,
1837            types,
1838            Some(Box::new(serving)),
1839        )
1840    }
1841
1842    fn new_with_journal_capacity_bytes(
1843        root_path: impl Into<PathBuf>,
1844        scope: ScanScope,
1845        journal_capacity_bytes: usize,
1846        types: std::sync::Arc<crate::classify::TypeRegistry>,
1847        serving: Option<Box<ServingIndexes>>,
1848    ) -> Self {
1849        let root = Entry::new(
1850            NewEntry {
1851                parent: None,
1852                name: OsString::new(),
1853                ext_id: None,
1854                ignored: false,
1855                source: Source::Scanned,
1856                kind: EntryKind::Dir,
1857                attrs: Attrs::default(),
1858            },
1859            true,
1860        );
1861        let constructed_at_ns = Self::now_unix_nanos();
1862        Self {
1863            root_path: root_path.into(),
1864            scope,
1865            arena: vec![Slot::Occupied { generation: 0, entry: root }],
1866            free_head: None,
1867            live: 1,
1868            clock: Clock::ZERO,
1869            journal: VecDeque::new(),
1870            journal_cost: 0,
1871            journal_capacity_bytes,
1872            journal_floor: Clock::ZERO,
1873            pending_invalidations: Vec::new(),
1874            freshness_epoch: 0,
1875            freshness_marks: BTreeMap::new(),
1876            state: IndexState::default(),
1877            issues: Vec::new(),
1878            issue_epochs: Vec::new(),
1879            omitted_issue_epochs: BTreeMap::new(),
1880            serving,
1881            applying_source: Source::Scanned,
1882            scanned_at_ns: constructed_at_ns,
1883            captured_at_ns: 0,
1884            writing_pass_started_at_ns: constructed_at_ns,
1885            persistence_owed: true,
1886            active_root_reconciles: BTreeMap::new(),
1887            active_reconciles: BTreeMap::new(),
1888            verified: Vec::new(),
1889            ext_names: Vec::new(),
1890            ext_ids: BTreeMap::new(),
1891            ext_refcounts: Vec::new(),
1892            free_ext_ids: Vec::new(),
1893            content: None,
1894            types,
1895            controls: crate::control::ControlTable::default(),
1896            unreadable_control_paths: BTreeSet::new(),
1897        }
1898    }
1899
1900    /// The file-type rules this index classifies against.
1901    pub fn types(&self) -> &crate::classify::TypeRegistry {
1902        self.types.as_ref()
1903    }
1904
1905    /// Exact fixed control state retained by this detached index.
1906    ///
1907    /// # Errors
1908    ///
1909    /// [`crate::Error::ControlStateNotObserved`] when the index was built without
1910    /// observing control state ([`ScanScope::observes_controls`]). Its table is empty
1911    /// because nothing was read, and returning it would claim the tree has no control
1912    /// files.
1913    pub fn controls(&self) -> crate::Result<&crate::control::ControlTable> {
1914        self.require_observed_controls()?;
1915        Ok(&self.controls)
1916    }
1917
1918    /// Whether this index observed `.gitignore` control state, and so can answer
1919    /// [`Self::is_ignored`] and [`Self::controls`].
1920    pub const fn observes_controls(&self) -> bool {
1921        self.scope.observes_controls()
1922    }
1923
1924    /// Whether this index observed `.gitignore` control state, and under which limits.
1925    pub fn control_identity(&self) -> crate::ControlTierIdentity {
1926        if self.observes_controls() {
1927            crate::ControlTierIdentity::Observed { limits: self.controls.limits() }
1928        } else {
1929            crate::ControlTierIdentity::NotObserved
1930        }
1931    }
1932
1933    /// The identity of every tier a snapshot of this index holds.
1934    pub fn snapshot_identity(&self) -> crate::SnapshotIdentity {
1935        crate::SnapshotIdentity {
1936            entries: crate::EntryTierIdentity::of_scope(self.scope),
1937            controls: self.control_identity(),
1938        }
1939    }
1940
1941    fn require_observed_controls(&self) -> crate::Result<()> {
1942        if self.observes_controls() { Ok(()) } else { Err(crate::Error::ControlStateNotObserved) }
1943    }
1944
1945    /// Whether a batch carries control input this index must refuse because its scope
1946    /// observes no control state.
1947    ///
1948    /// Accepted, such input installed a table and reclassified entries under a scope that
1949    /// says no rule was read: `is_ignored` refused over classification the index held, and
1950    /// a snapshot saved from it loaded into an open that turned observation off as an exact
1951    /// scope match (`fdu-agb6`). Every operation counts, accepted or stale, so the refusal
1952    /// does not depend on the index's state.
1953    fn carries_unobserved_control_input(&self, ops: &[ObservationOp]) -> bool {
1954        !self.observes_controls()
1955            && ops.iter().any(|observed| {
1956                matches!(observed.op, Op::ControlUpsert { .. } | Op::ControlRemove { .. })
1957            })
1958    }
1959
1960    /// The retained control table whatever the scope: empty when nothing was observed.
1961    ///
1962    /// For the engine's own maintenance, which compares what it retains against what it
1963    /// reads and so needs no claim about coverage.
1964    pub(crate) fn control_table(&self) -> &crate::control::ControlTable {
1965        &self.controls
1966    }
1967
1968    /// Whether this index's ignore classification applies every control file in scope.
1969    ///
1970    /// [`crate::control::ControlCoverage::NotObserved`] when the index read no control
1971    /// file. Otherwise the limits, the applied and refused counts, and the first refused
1972    /// files. Sizes and counts are exact either way; only the ignored and unignored split
1973    /// below a refused file is not.
1974    pub fn control_coverage(&self) -> crate::control::ControlCoverage {
1975        if self.observes_controls() {
1976            crate::control::ControlCoverage::Observed(self.controls.observation())
1977        } else {
1978            crate::control::ControlCoverage::NotObserved
1979        }
1980    }
1981
1982    /// Refuse control sources past either of `limits`, as the scan configuration that
1983    /// builds this index asks. Set once, before any control input arrives: a table's
1984    /// refusals are only meaningful under the limits that made them.
1985    pub(crate) fn set_control_limits(&mut self, limits: crate::control::ControlLimits) {
1986        debug_assert!(self.controls.is_vacant(), "the control limits are set before any control");
1987        self.controls = crate::control::ControlTable::with_limits(limits);
1988    }
1989
1990    /// Refuse `limits` unless they are the ones this index's scope was taken under.
1991    ///
1992    /// A scope that observes control state names its limits in its ignore-rules identity,
1993    /// and an index's scope and its table must never disagree: a table refusing under other
1994    /// limits would be served, and saved, as if it applied the scope's. A scope that
1995    /// observes nothing retains no table, so its limits decide nothing.
1996    ///
1997    /// The guard is for [`crate::snapshot::save`], whose index may have been built with a
1998    /// scope and a table set apart (as [`Self::new_with_scope`] does), and for callers of
1999    /// [`Self::install_controls`] other than the loader. On the load path it cannot fail,
2000    /// because the loader builds the scope and the table from the same header limits.
2001    pub(crate) fn require_control_limits_in_scope(
2002        &self,
2003        limits: crate::control::ControlLimits,
2004    ) -> crate::Result<()> {
2005        if self.scope.observes_controls()
2006            && (crate::ControlTierIdentity::Observed { limits }).ignore_rules_fingerprint()
2007                != self.scope.ignore_rules_fingerprint
2008        {
2009            return Err(crate::Error::ControlLimitsOutsideScope { limits });
2010        }
2011        Ok(())
2012    }
2013
2014    /// Install a complete control table while restoring a detached snapshot.
2015    pub(crate) fn install_controls(
2016        &mut self,
2017        controls: crate::control::ControlTable,
2018    ) -> crate::Result<()> {
2019        self.require_control_limits_in_scope(controls.limits())?;
2020        // Every source a table retains was admitted under its own budget, and the charge
2021        // does not depend on admission order, so a larger total was not written by one.
2022        if controls.limits().budget.is_some_and(|budget| controls.retained_cost() > budget) {
2023            return Err(crate::Error::Snapshot(
2024                "a snapshot's control table exceeds its own control budget".into(),
2025            ));
2026        }
2027        // A scope that observed no control state retains no table and refuses nothing; a
2028        // snapshot carrying either under such a scope was not written by a scan that
2029        // honoured it.
2030        if !controls.is_vacant() {
2031            self.require_observed_controls()?;
2032        }
2033        // Every entry's ignored bit agrees with the table it replaces, so when neither table
2034        // governs anything no bit can move. Walking the tree to confirm it allocated a path
2035        // per entry on every snapshot load, including the common load with no controls.
2036        let unchanged = controls.is_empty() && self.controls.is_empty();
2037        self.controls = controls;
2038        if unchanged {
2039            return Ok(());
2040        }
2041        let mut stats = ApplyStats::default();
2042        let mut effects = NoConsequences;
2043        self.reclassify_controlled_subtrees(&[PathBuf::new()], &mut stats, &mut effects);
2044        Ok(())
2045    }
2046
2047    /// Share the registry with background analysis workers.
2048    pub(crate) fn types_shared(&self) -> std::sync::Arc<crate::classify::TypeRegistry> {
2049        std::sync::Arc::clone(&self.types)
2050    }
2051
2052    /// Classify one relative path under this index's rules, without opening the file.
2053    pub fn classify(&self, relative_path: &Path) -> crate::classify::Classification {
2054        crate::classify::classify_with(&self.types, relative_path, None)
2055    }
2056
2057    #[cfg(test)]
2058    fn with_journal_capacity_bytes(
2059        root_path: impl Into<PathBuf>,
2060        journal_capacity_bytes: usize,
2061    ) -> Self {
2062        Self::new_with_journal_capacity_bytes(
2063            root_path,
2064            ScanScope::default(),
2065            journal_capacity_bytes,
2066            crate::classify::TypeRegistry::compiled_shared(),
2067            None,
2068        )
2069    }
2070
2071    /// The absolute path this index is rooted at.
2072    pub fn root_path(&self) -> &Path {
2073        &self.root_path
2074    }
2075
2076    /// Semantic scope represented by this index and any snapshot written from it.
2077    pub const fn scope(&self) -> ScanScope {
2078        self.scope
2079    }
2080
2081    /// Trust state for the whole index.
2082    pub fn freshness(&self) -> Freshness {
2083        self.freshness_at(Path::new(""))
2084    }
2085
2086    /// The freshness the coherent [`IndexState`] publishes for the root.
2087    ///
2088    /// Subtree marks decide it, with one exception: while the observation handoff owns the
2089    /// root -- the `Reconciling` phase -- the root does not become `Fresh` until `Watching`
2090    /// says the handoff verified it. The handoff's own full pass clears the root's mark
2091    /// before the hints captured behind it are drained, and `Fresh` beside `Reconciling`
2092    /// promised a verified root the handoff had not delivered yet. Stale and partial marks
2093    /// still show through, since they say something the handoff has not yet disproved.
2094    fn published_freshness(&self) -> Freshness {
2095        let derived = self.freshness();
2096        if self.state.phase == LifecyclePhase::Reconciling && derived == Freshness::Fresh {
2097            Freshness::Reconciling
2098        } else {
2099            derived
2100        }
2101    }
2102
2103    /// Coherent state at the current clock.
2104    pub(crate) const fn state(&self) -> IndexState {
2105        self.state
2106    }
2107
2108    #[allow(dead_code)] // Consumed through `IndexHandle` by the next vertical slice.
2109    pub(crate) fn issues(&self) -> &[Issue] {
2110        &self.issues
2111    }
2112
2113    /// Whether the complete in-scope child set of a known directory is authoritative.
2114    #[allow(dead_code)] // Consumed through `IndexHandle` by the next vertical slice.
2115    pub(crate) fn directory_complete(&self, path: &Path) -> Option<bool> {
2116        let id = self.lookup(path)?;
2117        let entry = self.entry(id);
2118        (entry.kind == EntryKind::Dir).then(|| entry.directory().children_complete)
2119    }
2120
2121    /// Trust state for one subtree, including any stale descendant it contains.
2122    pub fn freshness_at(&self, path: &Path) -> Freshness {
2123        self.freshness_marks
2124            .iter()
2125            .filter(|(marked, _)| path.starts_with(marked) || marked.starts_with(path))
2126            .map(|(_, mark)| mark.state)
2127            .max_by_key(|state| state.rank())
2128            .unwrap_or(Freshness::Fresh)
2129    }
2130
2131    /// The clock of the most recently applied commit.
2132    pub fn clock(&self) -> Clock {
2133        self.clock
2134    }
2135
2136    /// Number of live entries, including the root.
2137    pub fn len(&self) -> u64 {
2138        self.live
2139    }
2140
2141    /// True when the index holds nothing but its root.
2142    pub fn is_empty(&self) -> bool {
2143        self.live <= 1
2144    }
2145
2146    /// Owned, self-describing roll-up state for the whole tree.
2147    pub fn total(&self) -> RollUp {
2148        self.named_rollup(&self.entry(EntryId::ROOT).rollup().all)
2149    }
2150
2151    /// Both fixed aggregate partitions for the complete tree.
2152    ///
2153    /// # Errors
2154    ///
2155    /// [`crate::Error::ControlStateNotObserved`] when the index did not observe control
2156    /// state: its unignored partition equals `all` only because no rule was read.
2157    /// [`Self::total`] answers the `all` partition for any index.
2158    pub fn partition_total(&self) -> crate::Result<PartitionRollUp> {
2159        self.require_observed_controls()?;
2160        Ok(self.named_partitions(self.entry(EntryId::ROOT).rollup()))
2161    }
2162
2163    /// Map-free whole-tree totals for in-crate reporting paths.
2164    pub(crate) fn total_scalars(&self) -> RollUpScalars {
2165        RollUpScalars::from(&self.entry(EntryId::ROOT).rollup().all)
2166    }
2167
2168    /// Arbitrate a producer observation and commit its effective mutations.
2169    ///
2170    /// Conditional operations are accepted only while their baseline still matches.
2171    /// No-ops and stale operations do not advance the clock or enter the journal.
2172    ///
2173    /// A control operation on an index that does not observe control state
2174    /// ([`Self::observes_controls`]) fails the whole batch with
2175    /// [`crate::Error::ControlStateNotObserved`], whatever its baseline.
2176    pub fn apply(&mut self, observation: &Observation) -> crate::Result<ApplyOutcome> {
2177        let prepared = prepare_observation(observation)?;
2178        let outcome = self.commit_prepared(prepared, true)?;
2179        record_batch(BatchProvenance::Public, observation.len(), outcome.stats);
2180        Ok(outcome)
2181    }
2182
2183    /// Arbitrate and atomically apply normalized producer input.
2184    fn commit_prepared(
2185        &mut self,
2186        prepared: PreparedObservation,
2187        journal: bool,
2188    ) -> crate::Result<ApplyOutcome> {
2189        self.commit_prepared_with(prepared, journal, None, None, None, false)
2190    }
2191
2192    fn commit_prepared_with(
2193        &mut self,
2194        prepared: PreparedObservation,
2195        journal: bool,
2196        discovery: Option<DiscoveryCommit>,
2197        observation: Option<ObservationTransition>,
2198        max_files: Option<u64>,
2199        track_file_progress: bool,
2200    ) -> crate::Result<ApplyOutcome> {
2201        if prepared.ops.is_empty()
2202            && discovery.as_ref().is_none_or(|discovery| {
2203                discovery.directory_complete.is_none() && discovery.transition.is_none()
2204            })
2205            && observation.is_none()
2206        {
2207            return Ok(ApplyOutcome::default());
2208        }
2209
2210        // A stopped or failed root is terminal for discovery. A listing that lands after
2211        // the stop -- a refresh can trip the shared budget while discovery is mid-walk --
2212        // may neither expand the retained set nor carry a transition that reopens the
2213        // phase: `Finish` would declare the root `Ready`, and an inaccessible boundary
2214        // would relabel why its coverage is partial.
2215        if discovery.is_some()
2216            && matches!(self.state.phase, LifecyclePhase::Stopped | LifecyclePhase::Failed)
2217        {
2218            return Err(crate::Error::OpenedIndexStopped);
2219        }
2220
2221        let mut discovery = discovery;
2222        if let Some(path) =
2223            discovery.as_mut().and_then(|discovery| discovery.directory_complete.as_mut())
2224        {
2225            // The transition this commit publishes carries the canonical relative path,
2226            // not the producer's spelling: a `DirectoryComplete` was only ever canonical
2227            // because discovery happened to build it that way.
2228            let canonical = canonical_relative_path(path)?;
2229            let Some(id) = self.lookup(&canonical) else {
2230                return Err(crate::Error::InvalidDirectoryCompletion(canonical));
2231            };
2232            if self.entry(id).kind != EntryKind::Dir {
2233                return Err(crate::Error::InvalidDirectoryCompletion(canonical));
2234            }
2235            *path = canonical;
2236        }
2237
2238        #[cfg(test)]
2239        if prepared.reject_before_apply {
2240            return Err(crate::Error::CommitRejected("injected reducer preflight"));
2241        }
2242
2243        let Some(next_clock) = self.clock.checked_next() else {
2244            // At the terminal clock, an all-no-op or all-stale batch is still a valid
2245            // observation. Probe on a detached clone to distinguish it from a real
2246            // change without touching the original index.
2247            let mut probe = self.clone();
2248            probe.clock = Clock(self.clock.0 - 1);
2249            let outcome = probe.commit_prepared_with(
2250                prepared,
2251                false,
2252                discovery,
2253                observation,
2254                max_files,
2255                track_file_progress,
2256            )?;
2257            return if outcome.commit.is_some() {
2258                Err(crate::Error::ClockExhausted)
2259            } else {
2260                Ok(outcome)
2261            };
2262        };
2263
2264        let observed = u64::try_from(prepared.ops.len()).unwrap_or(u64::MAX);
2265        let mut effects = ExactConsequences::default();
2266        let stats = self.reduce_prepared(
2267            &prepared,
2268            discovery,
2269            observation,
2270            max_files,
2271            track_file_progress,
2272            &mut effects,
2273        )?;
2274
2275        if effects.is_empty() {
2276            return Ok(ApplyOutcome::from_commit(stats, None));
2277        }
2278
2279        let commit =
2280            self.publish_effects(next_clock, effects, commit_work(observed, stats), journal);
2281        Ok(ApplyOutcome::from_commit(stats, Some(commit)))
2282    }
2283
2284    /// Apply one prepared batch through the shared fact and roll-up reducer.
2285    ///
2286    /// `C` is selected once by the caller. The exact instantiation retains closures as
2287    /// commits; the detached instantiation erases them, including their path copies.
2288    fn reduce_prepared<C: ConsequenceSink>(
2289        &mut self,
2290        prepared: &PreparedObservation,
2291        discovery: Option<DiscoveryCommit>,
2292        observation: Option<ObservationTransition>,
2293        max_files: Option<u64>,
2294        track_file_progress: bool,
2295        effects: &mut C,
2296    ) -> crate::Result<ApplyStats> {
2297        if self.carries_unobserved_control_input(&prepared.ops) {
2298            return Err(crate::Error::ControlStateNotObserved);
2299        }
2300        if matches!(prepared.ancestry, PreparedAncestry::Scanner { .. }) {
2301            debug_assert!(observation.is_none());
2302            return self.reduce_scanner_prepared(
2303                prepared,
2304                discovery,
2305                max_files,
2306                track_file_progress,
2307                effects,
2308            );
2309        }
2310        let mut stats = ApplyStats::default();
2311        let mut parent_memo = ParentMemo::default();
2312        let accepted = self.accepted_operations(&prepared.ops);
2313        stats.stale = u64::try_from(accepted.iter().filter(|accepted| !**accepted).count())
2314            .unwrap_or(u64::MAX);
2315        self.validate_known_ancestry(&prepared.ops, &accepted)?;
2316        let projected_controls = self.projected_controls(&prepared.ops, &accepted)?;
2317
2318        for (observed, accepted) in prepared.ops.iter().zip(accepted) {
2319            if !accepted {
2320                continue;
2321            }
2322            let op = &observed.op;
2323            if let (Some(max_files), Op::Upsert { path, kind, .. }) = (max_files, op) {
2324                if self.files_after_upsert(path, *kind) > max_files {
2325                    stats.resource_refused = stats.resource_refused.saturating_add(1);
2326                    continue;
2327                }
2328            }
2329            match op {
2330                Op::Upsert { path, kind, attrs } => {
2331                    self.apply_upsert(path, *kind, *attrs, &mut stats, effects, &mut parent_memo);
2332                }
2333                Op::Remove { path } => {
2334                    // A removal takes a subtree with it, so a remembered id inside that
2335                    // subtree would dangle. Both of the non-upsert arms drop the memo
2336                    // rather than reason about whether this particular path could be an
2337                    // ancestor of it: the memo is refilled by the next upsert, so the
2338                    // cost of being conservative is one path resolution.
2339                    parent_memo.clear();
2340                    self.apply_remove(path, &mut stats, effects);
2341                }
2342                Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {
2343                    // The complete table was already prepared above. It is installed
2344                    // once, after ordinary structural mutations, so classification and
2345                    // both reducer partitions become visible atomically.
2346                    parent_memo.clear();
2347                }
2348                Op::InvalidateSubtree { path, reason } => {
2349                    parent_memo.clear();
2350                    let previous_index_state = self.state;
2351                    let previous = self.freshness_at(path);
2352                    self.pending_invalidations.push((path.clone(), *reason));
2353                    self.mark_unfresh(path, Freshness::Stale);
2354                    let current = self.freshness_at(path);
2355                    self.state.freshness = self.published_freshness();
2356                    if matches!(
2357                        reason,
2358                        InvalidateReason::WatchOverflow
2359                            | InvalidateReason::UnpairedRename
2360                            | InvalidateReason::WatchSetupRace
2361                            | InvalidateReason::VerificationFailed
2362                            | InvalidateReason::UnknownAncestry
2363                            | InvalidateReason::WatchContention
2364                    ) {
2365                        self.retain_issue(Issue::observation_gap(path, *reason));
2366                    }
2367                    stats.invalidated += 1;
2368                    effects.change(|| EffectiveChange::Invalidated {
2369                        path: path.clone(),
2370                        reason: *reason,
2371                    });
2372                    if previous != current {
2373                        effects.state(|| StateTransition::Freshness {
2374                            path: path.clone(),
2375                            previous,
2376                            current,
2377                        });
2378                    }
2379                    if previous_index_state != self.state {
2380                        effects.state(|| StateTransition::IndexState {
2381                            previous: previous_index_state,
2382                            current: self.state,
2383                        });
2384                    }
2385                }
2386            }
2387        }
2388
2389        self.finish_reduction(
2390            projected_controls,
2391            &mut stats,
2392            discovery,
2393            observation,
2394            max_files,
2395            track_file_progress,
2396            effects,
2397        );
2398
2399        Ok(stats)
2400    }
2401
2402    /// Apply one scanner batch using only the parent identities proved above.
2403    fn reduce_scanner_prepared<C: ConsequenceSink>(
2404        &mut self,
2405        prepared: &PreparedObservation,
2406        discovery: Option<DiscoveryCommit>,
2407        max_files: Option<u64>,
2408        track_file_progress: bool,
2409        effects: &mut C,
2410    ) -> crate::Result<ApplyStats> {
2411        let PreparedAncestry::Scanner { parents, has_batch_parents } = &prepared.ancestry else {
2412            unreachable!("scanner reduction requires scanner ancestry");
2413        };
2414        debug_assert_eq!(prepared.ops.len(), parents.len());
2415        let projection_started = crate::counters::enabled().then(std::time::Instant::now);
2416        let projected_controls =
2417            self.projected_controls_from(prepared.ops.iter().map(|observed| &observed.op))?;
2418        if let Some(started) = projection_started {
2419            let elapsed = elapsed_micros(started);
2420            crate::counters::bump(|counts| {
2421                counts.scanner_control_projection_us =
2422                    counts.scanner_control_projection_us.saturating_add(elapsed);
2423            });
2424        }
2425        let mut stats = ApplyStats::default();
2426        let mut applied_ids = has_batch_parents.then(|| vec![None; prepared.ops.len()]);
2427
2428        for (op_index, (observed, parent)) in prepared.ops.iter().zip(parents.iter()).enumerate() {
2429            match &observed.op {
2430                Op::Upsert { path, kind, attrs } => {
2431                    if let Some(max_files) = max_files {
2432                        if self.files_after_upsert(path, *kind) > max_files {
2433                            stats.resource_refused = stats.resource_refused.saturating_add(1);
2434                            continue;
2435                        }
2436                    }
2437                    let parent = match parent {
2438                        ResolvedParent::Existing(parent) => *parent,
2439                        ResolvedParent::Earlier(parent_op) => applied_ids
2440                            .as_ref()
2441                            .and_then(|ids| ids.get(*parent_op))
2442                            .copied()
2443                            .flatten()
2444                            .expect("a proved parent directory was applied earlier"),
2445                    };
2446                    let name = path.file_name().expect("scanner upserts are not root mutations");
2447                    crate::counters::bump(|counts| counts.upserts += 1);
2448                    self.upsert_beneath(parent, name, path, *kind, *attrs, &mut stats, effects);
2449                    if kind.is_dir() {
2450                        if let Some(ids) = &mut applied_ids {
2451                            ids[op_index] = self.child(parent, name);
2452                        }
2453                    }
2454                }
2455                Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {}
2456                Op::Remove { .. } | Op::InvalidateSubtree { .. } => {
2457                    unreachable!("scanner preparation rejects non-discovery operations");
2458                }
2459            }
2460        }
2461
2462        self.finish_reduction(
2463            projected_controls,
2464            &mut stats,
2465            discovery,
2466            None,
2467            max_files,
2468            track_file_progress,
2469            effects,
2470        );
2471
2472        Ok(stats)
2473    }
2474
2475    #[allow(clippy::too_many_arguments)] // One shared tail keeps both reducer lanes identical.
2476    fn finish_reduction<C: ConsequenceSink>(
2477        &mut self,
2478        projected_controls: Option<crate::control::ControlTable>,
2479        stats: &mut ApplyStats,
2480        discovery: Option<DiscoveryCommit>,
2481        observation: Option<ObservationTransition>,
2482        max_files: Option<u64>,
2483        track_file_progress: bool,
2484        effects: &mut C,
2485    ) {
2486        self.apply_control_transition(projected_controls, stats, effects);
2487        let mut discovery = discovery;
2488        if stats.resource_refused > 0 {
2489            let max_files = max_files.expect("resource refusal requires a file limit");
2490            let discovery = discovery.get_or_insert_with(DiscoveryCommit::default);
2491            discovery.directory_complete = None;
2492            discovery.transition =
2493                Some(DiscoveryTransition::BudgetRefused(Issue::resource_budget(max_files)));
2494        }
2495        self.apply_opened_state(discovery, observation, track_file_progress, effects);
2496    }
2497
2498    fn apply_opened_state<C: ConsequenceSink>(
2499        &mut self,
2500        discovery: Option<DiscoveryCommit>,
2501        observation: Option<ObservationTransition>,
2502        track_file_progress: bool,
2503        effects: &mut C,
2504    ) {
2505        if discovery.is_none() && observation.is_none() && !track_file_progress {
2506            return;
2507        }
2508        let previous = self.state;
2509        if track_file_progress {
2510            self.state.progress.files_retained = self.total_scalars().files;
2511        }
2512
2513        if let Some(discovery) = discovery {
2514            if let Some(path) = discovery.directory_complete {
2515                let id = self.lookup(&path).expect("discovery completion was preflighted");
2516                if !self.entry(id).directory().children_complete {
2517                    self.entry_mut(id).directory_mut().children_complete = true;
2518                    self.state.progress.directories_complete =
2519                        self.state.progress.directories_complete.saturating_add(1);
2520                    effects.state(|| StateTransition::DirectoryComplete { path });
2521                }
2522            }
2523
2524            if let Some(transition) = discovery.transition {
2525                match transition {
2526                    DiscoveryTransition::Begin => {
2527                        for slot in &mut self.arena {
2528                            if let Slot::Occupied { entry, .. } = slot {
2529                                if entry.kind == EntryKind::Dir {
2530                                    entry.directory_mut().children_complete = false;
2531                                }
2532                            }
2533                        }
2534                        self.state = IndexState {
2535                            phase: LifecyclePhase::Discovering,
2536                            coverage: Coverage::Partial(CoverageReason::Building),
2537                            freshness: Freshness::Fresh,
2538                            source: Source::Scanned,
2539                            progress: DiscoveryProgress::default(),
2540                            issues: crate::IssueSummary::default(),
2541                        };
2542                        self.issues.clear();
2543                        self.issue_epochs.clear();
2544                        self.omitted_issue_epochs.clear();
2545                    }
2546                    DiscoveryTransition::Finish => {
2547                        self.state.phase = LifecyclePhase::Ready;
2548                        if self.state.coverage == Coverage::Partial(CoverageReason::Building) {
2549                            self.state.coverage = Coverage::Complete;
2550                        }
2551                        self.state.freshness = if self.state.coverage == Coverage::Complete {
2552                            Freshness::Fresh
2553                        } else {
2554                            Freshness::Partial
2555                        };
2556                    }
2557                    DiscoveryTransition::BudgetRefused(issue) => {
2558                        let already_stopped_for_budget = self.state.phase
2559                            == LifecyclePhase::Stopped
2560                            && self.state.coverage == Coverage::Partial(CoverageReason::Budget);
2561                        self.state.phase = LifecyclePhase::Stopped;
2562                        self.state.coverage = Coverage::Partial(CoverageReason::Budget);
2563                        self.state.freshness = Freshness::Fresh;
2564                        if !already_stopped_for_budget {
2565                            self.retain_issue(issue);
2566                        }
2567                    }
2568                    DiscoveryTransition::Inaccessible { issues, omitted } => {
2569                        if self.state.coverage != Coverage::Partial(CoverageReason::Budget) {
2570                            self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
2571                            self.state.freshness = Freshness::Partial;
2572                        }
2573                        for issue in issues {
2574                            self.retain_issue(issue);
2575                        }
2576                        self.retain_omitted(omitted);
2577                    }
2578                    DiscoveryTransition::Cancelled => {
2579                        self.state.phase = LifecyclePhase::Stopped;
2580                        if self.state.coverage != Coverage::Complete {
2581                            self.state.coverage = Coverage::Partial(CoverageReason::Cancelled);
2582                        }
2583                    }
2584                    DiscoveryTransition::Failed(issue) => {
2585                        self.state.phase = LifecyclePhase::Failed;
2586                        self.state.coverage = Coverage::Partial(CoverageReason::Failed);
2587                        self.state.freshness = Freshness::Partial;
2588                        self.retain_issue(issue);
2589                    }
2590                }
2591            }
2592        }
2593
2594        if let Some(observation) = observation {
2595            match observation {
2596                ObservationTransition::Reconciling => {
2597                    if self.state.phase == LifecyclePhase::Ready {
2598                        self.state.phase = LifecyclePhase::Reconciling;
2599                        self.state.freshness = Freshness::Reconciling;
2600                    }
2601                }
2602                ObservationTransition::Watching { issues, omitted } => {
2603                    if self.state.phase == LifecyclePhase::Reconciling {
2604                        self.state.phase = LifecyclePhase::Watching;
2605                        if !issues.is_empty() || omitted > 0 {
2606                            self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
2607                            for issue in issues {
2608                                self.retain_issue(issue);
2609                            }
2610                            self.retain_omitted(omitted);
2611                        } else if self.state.coverage
2612                            == Coverage::Partial(CoverageReason::Inaccessible)
2613                        {
2614                            // The handoff has just read the whole root without one error, so
2615                            // a boundary discovery could not read no longer exists. Coverage
2616                            // says what can be known now. That complete pass has already
2617                            // dropped the issues it disproved and recorded completeness for
2618                            // every directory it listed, so nothing below contradicts it.
2619                            self.state.coverage = Coverage::Complete;
2620                        }
2621                        self.state.freshness = self.freshness();
2622                        if self.state.coverage != Coverage::Complete {
2623                            self.state.freshness = Freshness::Partial;
2624                        }
2625                    }
2626                }
2627                ObservationTransition::Unreadable { issues, omitted } => {
2628                    // `Ready` is a shared index watched without an opened-root lifecycle,
2629                    // which never leaves that phase; a stopped or failed root keeps nothing.
2630                    if matches!(self.state.phase, LifecyclePhase::Watching | LifecyclePhase::Ready)
2631                    {
2632                        for issue in issues {
2633                            self.retain_issue(issue);
2634                        }
2635                        self.retain_omitted(omitted);
2636                    }
2637                }
2638                ObservationTransition::Failed(issue) => {
2639                    if self.state.phase != LifecyclePhase::Stopped {
2640                        self.state.phase = LifecyclePhase::Failed;
2641                        self.state.freshness = Freshness::Partial;
2642                        self.retain_issue(issue);
2643                    }
2644                }
2645            }
2646        }
2647
2648        if previous != self.state {
2649            effects.state(|| StateTransition::IndexState { previous, current: self.state });
2650        }
2651    }
2652
2653    /// Exact regular-file total that would remain after one upsert at the current
2654    /// commit boundary.
2655    fn files_after_upsert(&self, path: &Path, kind: EntryKind) -> u64 {
2656        let current_total = self.total_scalars().files;
2657        let Some(id) = self.lookup(path) else {
2658            return current_total.saturating_add(u64::from(kind == EntryKind::File));
2659        };
2660        let current = self.entry(id);
2661        if current.kind == kind {
2662            return current_total;
2663        }
2664        let removed = match current.kind {
2665            EntryKind::File => 1,
2666            EntryKind::Dir => current.rollup().all.files,
2667            _ => 0,
2668        };
2669        current_total.saturating_sub(removed).saturating_add(u64::from(kind == EntryKind::File))
2670    }
2671
2672    /// Retain one issue, once per cause.
2673    ///
2674    /// A cause is its kind and path: a boundary a producer meets again on every re-walk --
2675    /// an unreadable directory, a gap the observer keeps reporting at one place -- is one
2676    /// issue. Without a key, routine repeats filled the bounded list and every later
2677    /// distinct issue was omitted with no text. A repeat only records that the cause was
2678    /// seen again, which a later reconciliation needs; the retained text stays as it was,
2679    /// since changing what a read returns without a commit would let one version answer two
2680    /// ways. An issue without a path has nothing to key on, so only an identical one counts
2681    /// as a repeat.
2682    fn retain_issue(&mut self, issue: Issue) {
2683        self.retain_issue_at(issue, self.freshness_epoch);
2684    }
2685
2686    fn retain_issue_at(&mut self, issue: Issue, epoch: u64) {
2687        let repeat = self.issues.iter().position(|retained| same_issue_cause(retained, &issue));
2688        if let Some(position) = repeat {
2689            self.issue_epochs[position] = epoch;
2690        } else {
2691            let position = self
2692                .issues
2693                .binary_search_by(|retained| compare_issues(retained, &issue))
2694                .unwrap_or_else(|position| position);
2695            if position < MAX_RETAINED_ISSUES {
2696                self.issues.insert(position, issue);
2697                self.issue_epochs.insert(position, epoch);
2698                if self.issues.len() > MAX_RETAINED_ISSUES {
2699                    self.issues.pop();
2700                    let omitted_epoch =
2701                        self.issue_epochs.pop().expect("issue epochs stay parallel");
2702                    self.retain_omitted_at(1, omitted_epoch);
2703                }
2704            } else {
2705                self.retain_omitted_at(1, epoch);
2706            }
2707            self.state.issues.retained = u64::try_from(self.issues.len()).unwrap_or(u64::MAX);
2708        }
2709    }
2710
2711    fn retain_omitted(&mut self, count: u64) {
2712        if count == 0 {
2713            return;
2714        }
2715        let epoch =
2716            self.active_reconciles.keys().next_back().copied().unwrap_or(self.freshness_epoch);
2717        self.retain_omitted_at(count, epoch);
2718    }
2719
2720    fn retain_omitted_at(&mut self, count: u64, epoch: u64) {
2721        let retained = self.omitted_issue_epochs.entry(epoch).or_default();
2722        *retained = retained.saturating_add(count);
2723        self.state.issues.omitted = self.state.issues.omitted.saturating_add(count);
2724    }
2725
2726    fn drop_disproven_omitted(&mut self, started_at: u64) {
2727        self.omitted_issue_epochs.retain(|epoch, _| *epoch >= started_at);
2728        self.state.issues.omitted =
2729            self.omitted_issue_epochs.values().fold(0_u64, |sum, count| sum.saturating_add(*count));
2730    }
2731
2732    fn compact_omitted_epochs(&mut self) {
2733        let mut compact = BTreeMap::new();
2734        for (epoch, count) in std::mem::take(&mut self.omitted_issue_epochs) {
2735            let owner =
2736                self.active_reconciles.range(..=epoch).next_back().map_or(0, |(epoch, _)| *epoch);
2737            let retained = compact.entry(owner).or_insert(0_u64);
2738            *retained = retained.saturating_add(count);
2739        }
2740        self.omitted_issue_epochs = compact;
2741    }
2742
2743    /// Drop retained issues a reconciliation that visited `path` has disproved.
2744    ///
2745    /// An issue about a path at or below `path`, published before the pass began, described
2746    /// something the pass has just read without an error: a directory that could not be
2747    /// listed, an entry whose metadata could not be read, a control the index refused. It is
2748    /// no longer true, and keeping it would explain a state the root is not in. Three kinds
2749    /// of issue survive. An observation gap records that the observer lost precision and had
2750    /// to recover, which the recovery does not undo. An issue without a path cannot be placed
2751    /// under the pass. And an issue published at or after `started_at` came from a pass
2752    /// that closed while this one ran, whose `Partial` mark this pass leaves in place: the
2753    /// issue is stamped with the same epoch as that mark so the two survive together. The
2754    /// omitted count stays: what it counted was never retained.
2755    fn drop_disproven_issues(&mut self, path: &Path, started_at: u64) {
2756        let mut position = 0;
2757        while position < self.issues.len() {
2758            let issue = &self.issues[position];
2759            let disproven = issue.kind != crate::IssueKind::ObservationGap
2760                && issue.path.as_deref().is_some_and(|issue_path| issue_path.starts_with(path))
2761                && self.issue_epochs[position] < started_at;
2762            if disproven {
2763                self.issues.remove(position);
2764                self.issue_epochs.remove(position);
2765            } else {
2766                position += 1;
2767            }
2768        }
2769        self.state.issues.retained = u64::try_from(self.issues.len()).unwrap_or(u64::MAX);
2770    }
2771
2772    /// Mint and optionally retain one fully evaluated transition.
2773    ///
2774    /// Every fact-only, state-only, or combined mutation reaches this function after
2775    /// its fallible validation and preflight work is complete.
2776    fn publish_effects(
2777        &mut self,
2778        next_clock: Clock,
2779        effects: ExactConsequences,
2780        work: Work,
2781        journal: bool,
2782    ) -> Commit {
2783        debug_assert!(!effects.is_empty());
2784        if crate::counters::enabled() {
2785            let effect_paths = u64::try_from(effects.changes.len()).unwrap_or(u64::MAX);
2786            let effect_path_bytes = effects.changes.iter().fold(0_u64, |total, change| {
2787                total.saturating_add(
2788                    u64::try_from(change.path().as_os_str().as_encoded_bytes().len())
2789                        .unwrap_or(u64::MAX),
2790                )
2791            });
2792            crate::counters::bump(|counts| {
2793                counts.effect_paths = counts.effect_paths.saturating_add(effect_paths);
2794                counts.effect_path_bytes =
2795                    counts.effect_path_bytes.saturating_add(effect_path_bytes);
2796            });
2797        }
2798        let commit = Commit {
2799            clock: next_clock,
2800            impact: derive_impact(&effects.changes, &effects.state),
2801            changes: effects.changes,
2802            state: effects.state,
2803            work,
2804        };
2805        self.clock = next_clock;
2806        if journal {
2807            self.retain_commit(&commit);
2808        }
2809        commit
2810    }
2811
2812    fn retain_commit(&mut self, commit: &Commit) {
2813        let cost = commit.retained_cost();
2814        if cost > self.journal_capacity_bytes {
2815            let dropped = u64::try_from(self.journal.len()).unwrap_or(u64::MAX);
2816            crate::counters::bump(|counts| {
2817                counts.journal_oversized_commits =
2818                    counts.journal_oversized_commits.saturating_add(1);
2819                counts.journal_dropped_commits =
2820                    counts.journal_dropped_commits.saturating_add(dropped);
2821            });
2822            self.journal.clear();
2823            self.journal_cost = 0;
2824            self.journal_floor = commit.clock;
2825            return;
2826        }
2827
2828        while self.journal_cost + cost > self.journal_capacity_bytes {
2829            if let Some(dropped) = self.journal.pop_front() {
2830                crate::counters::bump(|counts| {
2831                    counts.journal_dropped_commits =
2832                        counts.journal_dropped_commits.saturating_add(1);
2833                });
2834                self.journal_cost -= dropped.retained_cost();
2835                self.journal_floor = dropped.clock;
2836            }
2837        }
2838        self.journal_cost += cost;
2839        self.journal.push_back(commit.clone());
2840        crate::counters::bump(|counts| {
2841            counts.journal_cloned_commits = counts.journal_cloned_commits.saturating_add(1);
2842            counts.journal_retained_commits = counts.journal_retained_commits.saturating_add(1);
2843        });
2844    }
2845
2846    /// Apply trusted bootstrap data without exposing it as live change history.
2847    pub(crate) fn apply_baseline(
2848        &mut self,
2849        observation: &Observation,
2850    ) -> crate::Result<ApplyStats> {
2851        let prepared = prepare_observation(observation)?;
2852        #[cfg(test)]
2853        if prepared.reject_before_apply {
2854            return Err(crate::Error::CommitRejected("injected reducer preflight"));
2855        }
2856        let mut effects = NoConsequences;
2857        let stats = self.reduce_prepared(&prepared, None, None, None, false, &mut effects)?;
2858        record_batch(BatchProvenance::Baseline, observation.len(), stats);
2859        self.establish_baseline();
2860        Ok(stats)
2861    }
2862
2863    /// Apply one owned filesystem-walker batch without constructing public history.
2864    pub(crate) fn apply_scanner_baseline(
2865        &mut self,
2866        batch: crate::scan::ScannerBatch,
2867    ) -> crate::Result<ApplyStats> {
2868        let observed = batch.len();
2869        let prepare_started = crate::counters::enabled().then(std::time::Instant::now);
2870        let prepared = self.prepare_scanner_batch(batch)?;
2871        if let Some(started) = prepare_started {
2872            let elapsed = elapsed_micros(started);
2873            crate::counters::bump(|counts| {
2874                counts.scanner_prepare_us = counts.scanner_prepare_us.saturating_add(elapsed);
2875            });
2876        }
2877        let mut effects = NoConsequences;
2878        let reduce_started = crate::counters::enabled().then(std::time::Instant::now);
2879        // Dispatch on the prepared lane: a batch that replaces a kind is general.
2880        let stats = self.reduce_prepared(&prepared, None, None, None, false, &mut effects)?;
2881        if let Some(started) = reduce_started {
2882            let elapsed = elapsed_micros(started);
2883            crate::counters::bump(|counts| {
2884                counts.scanner_reduce_us = counts.scanner_reduce_us.saturating_add(elapsed);
2885            });
2886        }
2887        record_batch(BatchProvenance::Baseline, observed, stats);
2888        self.establish_baseline();
2889        Ok(stats)
2890    }
2891
2892    #[cfg(test)]
2893    pub(crate) fn apply_ok(&mut self, observation: &Observation) -> ApplyOutcome {
2894        self.apply(observation).expect("test observation must be valid")
2895    }
2896
2897    #[cfg(test)]
2898    pub(crate) fn apply_baseline_ok(&mut self, observation: &Observation) -> ApplyStats {
2899        self.apply_baseline(observation).expect("test baseline must be valid")
2900    }
2901
2902    /// Mark the current tree as the process baseline.
2903    pub(crate) fn establish_baseline(&mut self) {
2904        self.clock = Clock::ZERO;
2905        self.journal.clear();
2906        self.journal_cost = 0;
2907        self.journal_floor = Clock::ZERO;
2908        self.pending_invalidations.clear();
2909    }
2910
2911    /// Mark the whole index as not verified against the filesystem.
2912    ///
2913    /// Used by the cache-only open path: a snapshot records the freshness it had when it
2914    /// was written, and replaying that verbatim would let an unverified answer claim
2915    /// currency it has not earned.
2916    pub(crate) fn mark_unverified(&mut self) {
2917        self.freshness_marks.clear();
2918        self.mark_unfresh(Path::new(""), Freshness::Stale);
2919        self.state.source = Source::Cached;
2920        self.state.freshness = Freshness::Stale;
2921    }
2922
2923    pub(crate) fn set_initial_freshness(&mut self, complete: bool) {
2924        self.freshness_marks.clear();
2925        if complete {
2926            for slot in &mut self.arena {
2927                if let Slot::Occupied { entry, .. } = slot {
2928                    if entry.kind == EntryKind::Dir {
2929                        entry.directory_mut().children_complete = true;
2930                    }
2931                }
2932            }
2933            self.state.phase = LifecyclePhase::Ready;
2934            self.state.coverage = Coverage::Complete;
2935            self.state.freshness = Freshness::Fresh;
2936        } else {
2937            self.mark_unfresh(Path::new(""), Freshness::Partial);
2938            self.state.phase = LifecyclePhase::Ready;
2939            self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
2940            self.state.freshness = Freshness::Partial;
2941        }
2942    }
2943
2944    /// Finish a cold walk using all of its failures, before diagnostic retention bounds
2945    /// discard any paths. A scoped failure withdraws its unverified listing boundary;
2946    /// ancestors still have their own listings, and readers fold eligible descendants.
2947    /// An unscoped failure cannot establish completeness anywhere in the walk.
2948    pub(crate) fn set_initial_scan_freshness(&mut self, errors: &[crate::Error]) {
2949        self.set_initial_freshness(errors.is_empty());
2950        if errors.is_empty() {
2951            return;
2952        }
2953        for slot in &mut self.arena {
2954            if let Slot::Occupied { entry, .. } = slot {
2955                if entry.kind.is_dir() {
2956                    entry.directory_mut().children_complete = false;
2957                }
2958            }
2959        }
2960        let mut failed = Vec::with_capacity(errors.len());
2961        for error in errors {
2962            let Some(path) = Issue::from_error_under(&self.root_path, error).path else {
2963                return;
2964            };
2965            if path.is_absolute() || path.as_os_str().is_empty() {
2966                return;
2967            }
2968            failed.push(path);
2969        }
2970        failed.sort();
2971        failed.dedup();
2972        let listings: Vec<_> =
2973            failed.iter().map(|path| self.failed_listing_boundary(path)).collect();
2974        self.freshness_marks.clear();
2975        for path in &failed {
2976            self.mark_unfresh(path, Freshness::Partial);
2977        }
2978        // The walk has terminated and each failure is scoped above. Every retained
2979        // directory outside those boundaries therefore has its complete in-scope
2980        // listing. In particular, never promote descendants of a failed listing.
2981        for slot in 0..self.arena.len() {
2982            let Slot::Occupied { generation, entry } = &self.arena[slot] else {
2983                continue;
2984            };
2985            if !entry.kind.is_dir() {
2986                continue;
2987            }
2988            let id = EntryId {
2989                slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
2990                generation: *generation,
2991            };
2992            let Some(path) = self.path_of(id) else {
2993                continue;
2994            };
2995            self.entry_mut(id).directory_mut().children_complete =
2996                !listings.iter().zip(&failed).any(|(boundary, failure)| {
2997                    path == *boundary || (boundary == failure && path.starts_with(boundary))
2998                });
2999        }
3000    }
3001
3002    /// A retained failed directory has an unknown child set. If the failed entry was
3003    /// omitted (for example after a metadata failure), its nearest retained directory
3004    /// cannot claim a complete listing either.
3005    fn failed_listing_boundary(&self, failed: &Path) -> PathBuf {
3006        let mut boundary = failed.to_path_buf();
3007        loop {
3008            if self.lookup(&boundary).is_some_and(|id| self.entry(id).kind.is_dir()) {
3009                return boundary;
3010            }
3011            if !boundary.pop() {
3012                return PathBuf::new();
3013            }
3014        }
3015    }
3016
3017    pub(crate) fn record_walk_errors(&mut self, errors: &mut Vec<crate::Error>) {
3018        self.issues.clear();
3019        self.issue_epochs.clear();
3020        self.omitted_issue_epochs.clear();
3021        self.state.issues = crate::IssueSummary::default();
3022        let root = self.root_path.clone();
3023        crate::scan::normalize_walk_errors(&root, errors);
3024        self.unreadable_control_paths.clear();
3025        for error in errors {
3026            if let Some(path) = crate::control::unreadable_control(&root, error) {
3027                self.unreadable_control_paths.insert(path);
3028            }
3029            self.retain_issue(Issue::from_error_under(&root, error));
3030        }
3031    }
3032
3033    pub(crate) fn begin_reconcile(&mut self, path: &Path) -> crate::Result<(u64, Option<Commit>)> {
3034        let path = canonical_relative_path(path)?;
3035        let next_clock = self.clock.checked_next().ok_or(crate::Error::ClockExhausted)?;
3036        let previous_index_state = self.state;
3037        let previous = self.freshness_at(&path);
3038        let epoch = self.mark_unfresh(&path, Freshness::Reconciling);
3039        self.active_reconciles.insert(
3040            epoch,
3041            ActiveReconcile {
3042                path: path.clone(),
3043                scope_budget: usize::try_from(self.len()).unwrap_or(usize::MAX),
3044                evidence: ReconcileEvidence::Scopes(BTreeSet::new()),
3045            },
3046        );
3047        if path.as_os_str().is_empty() {
3048            self.active_root_reconciles.insert(epoch, Self::now_unix_nanos());
3049        }
3050        let current = self.freshness_at(&path);
3051        self.state.freshness = self.published_freshness();
3052        let commit = if previous == current && previous_index_state == self.state {
3053            None
3054        } else {
3055            let mut state = Vec::new();
3056            if previous != current {
3057                state.push(StateTransition::Freshness { path, previous, current });
3058            }
3059            if previous_index_state != self.state {
3060                state.push(StateTransition::IndexState {
3061                    previous: previous_index_state,
3062                    current: self.state,
3063                });
3064            }
3065            let effects = ExactConsequences { state, ..ExactConsequences::default() };
3066            Some(self.publish_effects(next_clock, effects, Work::default(), true))
3067        };
3068        Ok((epoch, commit))
3069    }
3070
3071    /// Close one reconciliation opened by [`Self::begin_reconcile`].
3072    ///
3073    /// `listed_incomplete` names the directories the pass listed in full, with no error
3074    /// inside them, that the index did not hold as complete when it listed them. Each is
3075    /// recorded as complete in this commit whether or not the whole pass completed, exactly
3076    /// as discovery's listing commit records the directories it lists, unless a producer
3077    /// invalidated or began verifying it after this pass started: that producer's own pass
3078    /// owns its listing now. A caller passes none when a conditional commit lost a race.
3079    pub(crate) fn finish_reconcile(
3080        &mut self,
3081        path: &Path,
3082        started_at: u64,
3083        complete: bool,
3084        listed_incomplete: &[PathBuf],
3085        failed_paths: &[PathBuf],
3086        errors: ReconcileErrors<'_>,
3087    ) -> crate::Result<ReconcileFinish> {
3088        let path = canonical_relative_path(path)?;
3089        let next_clock = self.clock.checked_next().ok_or(crate::Error::ClockExhausted)?;
3090        let previous_index_state = self.state;
3091        let previous = self.freshness_at(&path);
3092        // Retired evidence is needed only until every older overlapping pass closes.
3093        let evidence = self.active_reconciles.get(&started_at).map_or_else(
3094            || ReconcileEvidence::Scopes(BTreeSet::new()),
3095            |active| active.evidence.clone(),
3096        );
3097        let superseded = match evidence {
3098            ReconcileEvidence::Scopes(scopes) => scopes,
3099            ReconcileEvidence::Retry => {
3100                self.active_root_reconciles.remove(&started_at);
3101                self.active_reconciles.remove(&started_at);
3102                self.compact_omitted_epochs();
3103                self.mark_unfresh(&path, Freshness::Partial);
3104                if self.state.coverage == Coverage::Complete {
3105                    self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
3106                }
3107                self.state.freshness = self.published_freshness();
3108                self.retain_issue(Issue::provider_failure(
3109                    Some(&path),
3110                    "reconciliation interrupted by newer verification; retry this scope"
3111                        .to_string(),
3112                ));
3113                let current = self.freshness_at(&path);
3114                let mut state = Vec::new();
3115                if previous != current {
3116                    state.push(StateTransition::Freshness { path, previous, current });
3117                }
3118                if previous_index_state != self.state {
3119                    state.push(StateTransition::IndexState {
3120                        previous: previous_index_state,
3121                        current: self.state,
3122                    });
3123                }
3124                let commit = if state.is_empty() {
3125                    None
3126                } else {
3127                    let effects = ExactConsequences { state, ..ExactConsequences::default() };
3128                    Some(self.publish_effects(next_clock, effects, Work::default(), true))
3129                };
3130                return Ok(ReconcileFinish { commit, retry: true });
3131            }
3132        };
3133        let fully_superseded = superseded.iter().any(|newer| path.starts_with(newer));
3134        if errors.disproves_old && !fully_superseded {
3135            for (epoch, active) in &mut self.active_reconciles {
3136                if *epoch < started_at
3137                    && (active.path.starts_with(&path) || path.starts_with(&active.path))
3138                {
3139                    active.supersede(&path);
3140                }
3141            }
3142        }
3143        self.freshness_marks
3144            .retain(|marked, mark| !marked.starts_with(&path) || mark.epoch > started_at);
3145        if fully_superseded {
3146            self.active_root_reconciles.remove(&started_at);
3147            self.active_reconciles.remove(&started_at);
3148            self.compact_omitted_epochs();
3149            let current = self.freshness_at(&path);
3150            self.state.freshness = self.published_freshness();
3151            if self.state.coverage == Coverage::Partial(CoverageReason::Inaccessible) {
3152                self.state.freshness = Freshness::Partial;
3153            }
3154            let mut state = Vec::new();
3155            if previous != current {
3156                state.push(StateTransition::Freshness { path, previous, current });
3157            }
3158            if previous_index_state != self.state {
3159                state.push(StateTransition::IndexState {
3160                    previous: previous_index_state,
3161                    current: self.state,
3162                });
3163            }
3164            if state.is_empty() {
3165                return Ok(ReconcileFinish { commit: None, retry: false });
3166            }
3167            let effects = ExactConsequences { state, ..ExactConsequences::default() };
3168            return Ok(ReconcileFinish {
3169                commit: Some(self.publish_effects(next_clock, effects, Work::default(), true)),
3170                retry: false,
3171            });
3172        }
3173        let still_owned =
3174            |candidate: &Path| !superseded.iter().any(|newer| candidate.starts_with(newer));
3175        // A pass over one spelling of a directory's control file, `.GITIGNORE` included,
3176        // verified that directory's control, which is recorded under its canonical path.
3177        let verified_control = crate::control::governing_control(&path);
3178        let in_scope = |control: &Path| {
3179            control.starts_with(&path) || verified_control.as_deref() == Some(control)
3180        };
3181        if errors.disproves_old {
3182            self.unreadable_control_paths
3183                .retain(|control| !in_scope(control) || !still_owned(control));
3184        }
3185        for error in errors.errors.iter().chain(errors.terminal) {
3186            if let Some(control) = crate::control::unreadable_control(&self.root_path, error) {
3187                if in_scope(&control) && still_owned(&control) {
3188                    self.unreadable_control_paths.insert(control);
3189                }
3190            }
3191        }
3192        // Each listed directory is recorded on its own listing, complete pass or not: the
3193        // walk names only those it listed in full with no error inside them, as discovery
3194        // decides per directory, and the caller passes none when a commit lost a race. A
3195        // directory another producer invalidated or began verifying after this pass
3196        // started is left to that producer's pass, so decide here, before this pass's own
3197        // partial mark below would read as such a newer claim.
3198        let recordable: Vec<&PathBuf> = listed_incomplete
3199            .iter()
3200            .filter(|directory| {
3201                directory.starts_with(&path)
3202                    && still_owned(directory)
3203                    && !self.freshness_marks.iter().any(|(marked, mark)| {
3204                        mark.epoch > started_at && directory.starts_with(marked)
3205                    })
3206            })
3207            .collect();
3208        let scoped_failures: Vec<&PathBuf> = failed_paths
3209            .iter()
3210            .filter(|failed| failed.starts_with(&path) && still_owned(failed))
3211            .collect();
3212        // If every failure in this scope was subsequently verified, the older
3213        // pass's remaining evidence is complete. Arbitration/resource refusals do
3214        // not qualify: their caller cannot disprove prior state.
3215        let complete = complete
3216            || (errors.disproves_old
3217                && failed_paths.iter().any(|failed| failed.starts_with(&path))
3218                && scoped_failures.is_empty()
3219                && errors.errors.iter().chain(errors.terminal).all(|error| {
3220                    Issue::from_error_under(&self.root_path, error)
3221                        .path
3222                        .is_some_and(|failed| !failed.starts_with(&path) || !still_owned(&failed))
3223                }));
3224        if errors.disproves_old && self.state.phase != LifecyclePhase::Failed {
3225            self.drop_disproven_issues(&path, started_at);
3226        }
3227        if errors.disproves_old
3228            && self.state.phase != LifecyclePhase::Failed
3229            && path.as_os_str().is_empty()
3230        {
3231            self.drop_disproven_omitted(started_at);
3232        }
3233        let mut state = Vec::new();
3234        // Completeness describes this directory's own listing, not its descendants.
3235        // Withdraw old listing evidence at failures before publishing the partial pass.
3236        // Do not touch successful ancestors: readers compose only eligible descendants,
3237        // and an excluded failed child must not poison an otherwise complete subtree.
3238        if !complete && (!errors.errors.is_empty() || errors.terminal.is_some()) {
3239            let boundaries: Vec<_> = if scoped_failures.is_empty() {
3240                vec![(path.clone(), true)]
3241            } else {
3242                scoped_failures
3243                    .iter()
3244                    .map(|failed| {
3245                        let boundary = self.failed_listing_boundary(failed);
3246                        let subtree = boundary == **failed;
3247                        (boundary, subtree)
3248                    })
3249                    .collect()
3250            };
3251            for slot in 0..self.arena.len() {
3252                let Slot::Occupied { generation, entry } = &self.arena[slot] else {
3253                    continue;
3254                };
3255                if !entry.kind.is_dir() || !entry.directory().children_complete {
3256                    continue;
3257                }
3258                let id = EntryId {
3259                    slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
3260                    generation: *generation,
3261                };
3262                let Some(directory) = self.path_of(id) else {
3263                    continue;
3264                };
3265                if boundaries.iter().any(|(boundary, subtree)| {
3266                    directory == *boundary || (*subtree && directory.starts_with(boundary))
3267                }) && still_owned(&directory)
3268                    && !self.freshness_marks.iter().any(|(marked, mark)| {
3269                        mark.epoch > started_at && directory.starts_with(marked)
3270                    })
3271                {
3272                    self.entry_mut(id).directory_mut().children_complete = false;
3273                    state.push(StateTransition::DirectoryIncomplete { path: directory });
3274                }
3275            }
3276        }
3277        // A complete older walk plus successful newer child verification still proves
3278        // the entire scope. A newer failed child must keep its own evidence and mark.
3279        let verified_scope = superseded.is_empty()
3280            || (complete
3281                && superseded.iter().all(|newer| self.freshness_at(newer) == Freshness::Fresh));
3282        if verified_scope && (complete || !scoped_failures.is_empty()) {
3283            // A sweep stat'd every entry beneath `path` except the precise failure paths,
3284            // which carry stronger `Partial` marks below. Record the successful interval
3285            // once rather than manufacturing millions of unchanged entry updates.
3286            let now = Self::now_unix_nanos();
3287            self.verified.retain(|(verified_path, _)| !verified_path.starts_with(&path));
3288            self.verified.push((path.clone(), now));
3289            if self.verified.len() > MAX_VERIFIED_INTERVALS {
3290                let excess = self.verified.len() - MAX_VERIFIED_INTERVALS;
3291                self.verified.sort_by_key(|(_, at)| *at);
3292                self.verified.drain(..excess);
3293            }
3294            state.push(StateTransition::Verified { path: path.clone() });
3295        }
3296        if complete {
3297            if path.as_os_str().is_empty() {
3298                if let Some(started) = self.active_root_reconciles.remove(&started_at) {
3299                    self.writing_pass_started_at_ns = started;
3300                }
3301                self.state.source = self.applying_source;
3302            }
3303        } else {
3304            if scoped_failures.is_empty() {
3305                self.mark_unfresh(&path, Freshness::Partial);
3306            } else {
3307                for failed in scoped_failures {
3308                    self.mark_unfresh(failed, Freshness::Partial);
3309                }
3310            }
3311            if !errors.errors.is_empty() || errors.terminal.is_some() {
3312                self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
3313            }
3314            if path.as_os_str().is_empty() {
3315                if let Some(started) = self.active_root_reconciles.remove(&started_at) {
3316                    self.writing_pass_started_at_ns = started;
3317                }
3318                self.state.source = self.applying_source;
3319            }
3320        }
3321        // Retain this pass's failures at the epoch its `Partial` marks were just minted at,
3322        // not at `started_at`: a pass that began after this one and closes clean later
3323        // keeps the marks (minted after it began) and must keep the issues that explain
3324        // them, or a partial subtree would have no cause until the next root pass.
3325        // `started_at` is only this pass's own disproof threshold, applied above.
3326        for error in errors.errors.iter().chain(errors.terminal) {
3327            let issue = Issue::from_error_under(&self.root_path, error);
3328            if issue
3329                .path
3330                .as_deref()
3331                .is_none_or(|issue_path| issue_path.starts_with(&path) && still_owned(issue_path))
3332            {
3333                self.retain_issue(issue);
3334            }
3335        }
3336        for directory in recordable {
3337            let Some(id) = self.lookup(directory) else {
3338                continue;
3339            };
3340            let entry = self.entry_mut(id);
3341            if entry.kind != EntryKind::Dir || entry.directory().children_complete {
3342                continue;
3343            }
3344            entry.directory_mut().children_complete = true;
3345            self.state.progress.directories_complete =
3346                self.state.progress.directories_complete.saturating_add(1);
3347            state.push(StateTransition::DirectoryComplete { path: directory.clone() });
3348        }
3349
3350        if complete
3351            && self.state.coverage == Coverage::Partial(CoverageReason::Inaccessible)
3352            && !self.issues.iter().any(|issue| issue.kind != crate::IssueKind::ObservationGap)
3353            && self.state.issues.omitted == 0
3354            && self.arena.iter().all(|slot| {
3355                let Slot::Occupied { entry, .. } = slot else {
3356                    return true;
3357                };
3358                entry.kind != EntryKind::Dir || entry.directory().children_complete
3359            })
3360        {
3361            self.state.coverage = Coverage::Complete;
3362        }
3363
3364        let current = self.freshness_at(&path);
3365        self.state.freshness = self.published_freshness();
3366        if self.state.coverage == Coverage::Partial(CoverageReason::Inaccessible) {
3367            self.state.freshness = Freshness::Partial;
3368        }
3369        self.active_reconciles.remove(&started_at);
3370        self.compact_omitted_epochs();
3371        if previous != current {
3372            state.push(StateTransition::Freshness { path: path.clone(), previous, current });
3373        }
3374        if previous_index_state != self.state {
3375            state.push(StateTransition::IndexState {
3376                previous: previous_index_state,
3377                current: self.state,
3378            });
3379        }
3380        if state.is_empty() {
3381            return Ok(ReconcileFinish { commit: None, retry: false });
3382        }
3383        let effects = ExactConsequences { state, ..ExactConsequences::default() };
3384        Ok(ReconcileFinish {
3385            commit: Some(self.publish_effects(next_clock, effects, Work::default(), true)),
3386            retry: false,
3387        })
3388    }
3389
3390    /// When a completed reconciliation last covered this path, if one did.
3391    fn verified_at(&self, path: &Path) -> Option<i64> {
3392        self.verified
3393            .iter()
3394            .filter(|(covered, _)| path.starts_with(covered))
3395            .map(|(_, at)| *at)
3396            .max()
3397    }
3398
3399    fn mark_unfresh(&mut self, path: &Path, state: Freshness) -> u64 {
3400        self.freshness_epoch =
3401            self.freshness_epoch.checked_add(1).expect("freshness epoch exhausted");
3402        let epoch = self.freshness_epoch;
3403        self.freshness_marks.insert(path.to_path_buf(), FreshnessMark { state, epoch });
3404        epoch
3405    }
3406
3407    /// Current user-visible state for one path.
3408    ///
3409    /// Conditional producers should capture [`Self::expectation`] so ABA and structural
3410    /// races cannot return to the same visible state unnoticed.
3411    pub fn path_state(&self, path: &Path) -> PathState {
3412        let Some(id) = self.lookup(path) else {
3413            return PathState::Absent;
3414        };
3415        let entry = self.entry(id);
3416        PathState::Present { kind: entry.kind, attrs: entry.attrs }
3417    }
3418
3419    /// Conditional baseline with target and nearest-ancestor ABA protection.
3420    pub fn expectation(&self, path: &Path) -> PathExpectation {
3421        let entry = self.entry_identity(path);
3422        PathExpectation::new(
3423            self.path_state(path),
3424            entry,
3425            entry.is_none().then(|| self.absence_guard_identity(path)).flatten(),
3426        )
3427    }
3428
3429    pub(crate) fn relaxed_expectation(&self, path: &Path) -> PathExpectation {
3430        PathExpectation::new(self.path_state(path), self.entry_identity(path), None)
3431    }
3432
3433    /// Exact commits applied since `clock`, oldest first.
3434    pub fn since(&self, clock: Clock) -> Since {
3435        let commits: Vec<Commit> =
3436            self.journal.iter().filter(|commit| commit.clock > clock).cloned().collect();
3437        Since {
3438            commits,
3439            clock: self.clock,
3440            state: self.state,
3441            truncated: clock < self.journal_floor,
3442        }
3443    }
3444
3445    /// Take the subtrees that producers escalated for re-scan.
3446    ///
3447    /// The caller is expected to hand these to the scan layer, which turns them back
3448    /// into precise commits. Escalation is closed-loop: draining this list without
3449    /// re-scanning is what makes an index silently diverge.
3450    pub fn take_pending_invalidations(&mut self) -> Vec<(PathBuf, InvalidateReason)> {
3451        std::mem::take(&mut self.pending_invalidations)
3452    }
3453
3454    /// Put unresolved invalidations back without minting a second public change.
3455    pub(crate) fn restore_pending_invalidations(
3456        &mut self,
3457        invalidations: Vec<(PathBuf, InvalidateReason)>,
3458    ) {
3459        self.pending_invalidations.extend(invalidations);
3460    }
3461
3462    /// Look up an entry id by path relative to the root.
3463    pub fn lookup(&self, path: &Path) -> Option<EntryId> {
3464        let mut current = EntryId::ROOT;
3465        for part in normalize(path)? {
3466            current = self.child(current, part)?;
3467        }
3468        Some(current)
3469    }
3470
3471    /// Owned, self-describing roll-up state for a directory by relative path.
3472    /// The empty path is the root.
3473    pub fn rollup(&self, path: &Path) -> Option<RollUp> {
3474        let id = self.lookup(path)?;
3475        let entry = self.entry(id);
3476        entry.kind.is_dir().then(|| self.named_rollup(&entry.rollup().all))
3477    }
3478
3479    /// Both fixed aggregate partitions for a directory by relative path.
3480    ///
3481    /// `Ok(None)` when the path is absent or not a directory.
3482    ///
3483    /// # Errors
3484    ///
3485    /// [`crate::Error::ControlStateNotObserved`] when the index did not observe control
3486    /// state, whatever the path, as [`Self::partition_total`] refuses.
3487    pub fn partition_rollup(&self, path: &Path) -> crate::Result<Option<PartitionRollUp>> {
3488        self.require_observed_controls()?;
3489        Ok(self
3490            .lookup(path)
3491            .map(|id| self.entry(id))
3492            .filter(|entry| entry.kind.is_dir())
3493            .map(|entry| self.named_partitions(entry.rollup())))
3494    }
3495
3496    /// Both constant-size aggregate partitions for a directory.
3497    ///
3498    /// `Ok(None)` when the path is absent or not a directory.
3499    ///
3500    /// # Errors
3501    ///
3502    /// [`crate::Error::ControlStateNotObserved`] when the index did not observe control
3503    /// state, whatever the path, as [`Self::partition_total`] refuses.
3504    pub fn partition_rollup_summary(
3505        &self,
3506        path: &Path,
3507    ) -> crate::Result<Option<PartitionRollUpSummary>> {
3508        self.require_observed_controls()?;
3509        Ok(self
3510            .lookup(path)
3511            .map(|id| self.entry(id))
3512            .filter(|entry| entry.kind.is_dir())
3513            .map(|entry| partition_summary(entry.rollup())))
3514    }
3515
3516    /// Whether a live entry is ignored, for the opened-root tree projection, without the
3517    /// observation check [`Self::is_ignored`] makes.
3518    ///
3519    /// An opened root always observes control state, so the retained bit is the exact
3520    /// classification; the assertion checks that invariant where it is cheap to.
3521    pub(crate) fn opened_is_ignored(&self, id: EntryId) -> bool {
3522        debug_assert!(self.observes_controls(), "an opened root observes control state");
3523        self.entry(id).ignored
3524    }
3525
3526    /// Capture one retained entry without repeating path lookup in a consumer.
3527    pub(crate) fn entry_value(&self, path: &Path) -> Option<crate::EntryValue> {
3528        let id = self.lookup(path)?;
3529        Some(self.entry_value_of(id, path))
3530    }
3531
3532    pub(crate) fn entry_value_of(&self, id: EntryId, path: &Path) -> crate::EntryValue {
3533        let entry = self.entry(id);
3534        crate::EntryValue {
3535            path: path.to_path_buf(),
3536            portable_path: crate::opened::read::portable_path(path),
3537            kind: entry.kind,
3538            attrs: entry.attrs,
3539            ignored: entry.ignored,
3540            classification: (entry.kind == EntryKind::File)
3541                .then(|| self.types.classify_name(path.file_name().unwrap_or_default())),
3542            rollup: entry.kind.is_dir().then(|| partition_summary(entry.rollup())),
3543            children_complete: entry.kind.is_dir().then(|| entry.directory().children_complete),
3544        }
3545    }
3546
3547    pub(crate) fn portable_children(&self, path: &Path) -> Option<&PortableChildren> {
3548        self.serving.as_ref()?.portable_children.get(path)
3549    }
3550
3551    pub(crate) fn portable_entries(&self) -> &BTreeMap<crate::PortablePath, EntryId> {
3552        &self.serving.as_ref().expect("opened-root reads require serving indexes").portable_entries
3553    }
3554
3555    #[cfg(test)]
3556    pub(crate) const fn serving_indexes_enabled(&self) -> bool {
3557        self.serving.is_some()
3558    }
3559
3560    fn insert_serving_entry(&mut self, path: &Path, kind: EntryKind, attrs: Attrs, id: EntryId) {
3561        if path.as_os_str().is_empty() || self.serving.is_none() {
3562            return;
3563        }
3564        let file = (kind == EntryKind::File).then(|| {
3565            (
3566                self.classify(path).file_type.as_str().to_string(),
3567                path.file_name(),
3568                self.entry(id).ignored,
3569                self.entry(id).parent,
3570            )
3571        });
3572        let arena = &self.arena;
3573        let Some(serving) = self.serving.as_mut() else {
3574            return;
3575        };
3576        if let Some((name, exact_name, ignored, parent)) = file {
3577            let semantic = serving.intern_semantic(&name);
3578            let mut ancestor = parent;
3579            while let Some(directory) = ancestor {
3580                let partition = serving.semantic_by_directory.entry(directory).or_default();
3581                merge_semantic(&mut partition.all, semantic, attrs);
3582                if !ignored {
3583                    merge_semantic(&mut partition.unignored, semantic, attrs);
3584                }
3585                ancestor = retained_parent(arena, directory);
3586            }
3587            if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
3588                let mut ancestor = parent;
3589                while let Some(directory) = ancestor {
3590                    let partition = serving.exact_name_by_directory.entry(directory).or_default();
3591                    merge_semantic(&mut partition.all, exact_name, attrs);
3592                    if !ignored {
3593                        merge_semantic(&mut partition.unignored, exact_name, attrs);
3594                    }
3595                    ancestor = retained_parent(arena, directory);
3596                }
3597            }
3598        }
3599        let portable = crate::opened::read::portable_path(path);
3600        serving.portable_entries.insert(portable.clone(), id);
3601        if kind == EntryKind::File {
3602            serving.recent_files.insert(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 Some(name) = path.file_name().map(crate::opened::read::portable_component) else {
3610            return;
3611        };
3612        let children = serving.portable_children.entry(parent).or_default();
3613        if kind.is_dir() {
3614            children.directories.insert(name, id);
3615        } else {
3616            children.nondirectories.insert(name, id);
3617        }
3618    }
3619
3620    fn remove_serving_entry(&mut self, path: &Path, kind: EntryKind, attrs: Attrs, id: EntryId) {
3621        if path.as_os_str().is_empty() {
3622            return;
3623        }
3624        let Some(serving) = self.serving.as_mut() else {
3625            return;
3626        };
3627        let portable = crate::opened::read::portable_path(path);
3628        serving.portable_entries.remove(&portable);
3629        if kind == EntryKind::File {
3630            serving.recent_files.remove(&RecentKey {
3631                mtime_ns: attrs.mtime_ns,
3632                portable_path: portable,
3633                id,
3634            });
3635        }
3636        let parent = path.parent().unwrap_or_else(|| Path::new("")).to_path_buf();
3637        let remove_parent = if let Some(children) = serving.portable_children.get_mut(&parent) {
3638            if let Some(name) = path.file_name().map(crate::opened::read::portable_component) {
3639                if kind.is_dir() {
3640                    children.directories.remove(&name);
3641                } else {
3642                    children.nondirectories.remove(&name);
3643                }
3644            }
3645            children.directories.is_empty() && children.nondirectories.is_empty()
3646        } else {
3647            false
3648        };
3649        if remove_parent {
3650            serving.portable_children.remove(&parent);
3651        }
3652        if kind.is_dir() {
3653            serving.portable_children.remove(path);
3654        }
3655    }
3656
3657    fn remove_serving_file_semantics(&mut self, path: &Path, id: EntryId, attrs: Attrs) {
3658        // Only a regular file is interned and tallied (`insert_serving_entry`). A symlink
3659        // or special entry of the same classification would otherwise find a real file's
3660        // type and subtract from its tally, or find none and panic under the write guard.
3661        if self.serving.is_none() || self.entry(id).kind != EntryKind::File {
3662            return;
3663        }
3664        let name = self.classify(path).file_type.as_str().to_string();
3665        let exact_name = path.file_name();
3666        let ignored = self.entry(id).ignored;
3667        let parent = self.entry(id).parent;
3668        let arena = &self.arena;
3669        let serving = self.serving.as_mut().expect("checked above");
3670        let semantic = *serving
3671            .semantic_ids
3672            .get(&name)
3673            .expect("every served file has an interned semantic type");
3674        let mut empty = Vec::new();
3675        let mut ancestor = parent;
3676        while let Some(directory) = ancestor {
3677            let partition = serving
3678                .semantic_by_directory
3679                .get_mut(&directory)
3680                .expect("every served file contributes to every ancestor");
3681            unmerge_semantic(&mut partition.all, semantic, attrs);
3682            if !ignored {
3683                unmerge_semantic(&mut partition.unignored, semantic, attrs);
3684            }
3685            if partition.all.is_empty() && partition.unignored.is_empty() {
3686                empty.push(directory);
3687            }
3688            ancestor = retained_parent(arena, directory);
3689        }
3690        for ancestor in empty {
3691            serving.semantic_by_directory.remove(&ancestor);
3692        }
3693        serving.release_semantic(semantic, 1);
3694        if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
3695            let mut exact_empty = Vec::new();
3696            let mut ancestor = parent;
3697            while let Some(directory) = ancestor {
3698                let partition = serving
3699                    .exact_name_by_directory
3700                    .get_mut(&directory)
3701                    .expect("every declared exact-name file contributes to every ancestor");
3702                unmerge_semantic(&mut partition.all, exact_name, attrs);
3703                if !ignored {
3704                    unmerge_semantic(&mut partition.unignored, exact_name, attrs);
3705                }
3706                if partition.all.is_empty() && partition.unignored.is_empty() {
3707                    exact_empty.push(directory);
3708                }
3709                ancestor = retained_parent(arena, directory);
3710            }
3711            for ancestor in exact_empty {
3712                serving.exact_name_by_directory.remove(&ancestor);
3713            }
3714        }
3715    }
3716
3717    fn remove_serving_subtree_semantics(&mut self, root: EntryId, path: &Path) {
3718        if self.serving.is_none() {
3719            return;
3720        }
3721        match self.entry(root).kind {
3722            EntryKind::File => {
3723                let attrs = self.entry(root).attrs;
3724                self.remove_serving_file_semantics(path, root, attrs);
3725            }
3726            EntryKind::Dir => {
3727                let parent = self.entry(root).parent;
3728                let mut stack = vec![root];
3729                let mut directories = Vec::new();
3730                while let Some(id) = stack.pop() {
3731                    let entry = self.entry(id);
3732                    if !entry.kind.is_dir() {
3733                        continue;
3734                    }
3735                    directories.push(id);
3736                    stack.extend(self.child_ids(id));
3737                }
3738                let arena = &self.arena;
3739                let serving = self.serving.as_mut().expect("checked above");
3740                let contribution =
3741                    serving.semantic_by_directory.get(&root).cloned().unwrap_or_default();
3742                let exact_contribution =
3743                    serving.exact_name_by_directory.get(&root).cloned().unwrap_or_default();
3744                let mut empty = Vec::new();
3745                if !contribution.all.is_empty() || !contribution.unignored.is_empty() {
3746                    let mut ancestor = parent;
3747                    while let Some(directory) = ancestor {
3748                        let partition = serving
3749                            .semantic_by_directory
3750                            .get_mut(&directory)
3751                            .expect("a semantic subtree contributes to every ancestor");
3752                        unmerge_semantic_map(&mut partition.all, &contribution.all);
3753                        unmerge_semantic_map(&mut partition.unignored, &contribution.unignored);
3754                        if partition.all.is_empty() && partition.unignored.is_empty() {
3755                            empty.push(directory);
3756                        }
3757                        ancestor = retained_parent(arena, directory);
3758                    }
3759                }
3760                let mut exact_empty = Vec::new();
3761                if !exact_contribution.all.is_empty() || !exact_contribution.unignored.is_empty() {
3762                    let mut ancestor = parent;
3763                    while let Some(directory) = ancestor {
3764                        let partition = serving
3765                            .exact_name_by_directory
3766                            .get_mut(&directory)
3767                            .expect("an exact-name subtree contributes to every ancestor");
3768                        unmerge_semantic_map(&mut partition.all, &exact_contribution.all);
3769                        unmerge_semantic_map(
3770                            &mut partition.unignored,
3771                            &exact_contribution.unignored,
3772                        );
3773                        if partition.all.is_empty() && partition.unignored.is_empty() {
3774                            exact_empty.push(directory);
3775                        }
3776                        ancestor = retained_parent(arena, directory);
3777                    }
3778                }
3779                for ancestor in empty {
3780                    serving.semantic_by_directory.remove(&ancestor);
3781                }
3782                for ancestor in exact_empty {
3783                    serving.exact_name_by_directory.remove(&ancestor);
3784                }
3785                for directory in directories {
3786                    serving.semantic_by_directory.remove(&directory);
3787                    serving.exact_name_by_directory.remove(&directory);
3788                }
3789                for (semantic, tally) in contribution.all {
3790                    serving.release_semantic(semantic, tally.files);
3791                }
3792            }
3793            EntryKind::Symlink | EntryKind::Other => {}
3794        }
3795    }
3796
3797    fn move_serving_file_partition(
3798        &mut self,
3799        path: &Path,
3800        id: EntryId,
3801        previous_ignored: bool,
3802        current_ignored: bool,
3803    ) {
3804        if previous_ignored == current_ignored
3805            || self.serving.is_none()
3806            || self.entry(id).kind != EntryKind::File
3807        {
3808            return;
3809        }
3810        let name = self.classify(path).file_type.as_str().to_string();
3811        let exact_name = path.file_name();
3812        let attrs = self.entry(id).attrs;
3813        let parent = self.entry(id).parent;
3814        let arena = &self.arena;
3815        let serving = self.serving.as_mut().expect("checked above");
3816        let semantic = *serving
3817            .semantic_ids
3818            .get(&name)
3819            .expect("every served file has an interned semantic type");
3820        let mut ancestor = parent;
3821        while let Some(directory) = ancestor {
3822            let partition = serving
3823                .semantic_by_directory
3824                .get_mut(&directory)
3825                .expect("every served file contributes to every ancestor");
3826            if current_ignored {
3827                unmerge_semantic(&mut partition.unignored, semantic, attrs);
3828            } else {
3829                merge_semantic(&mut partition.unignored, semantic, attrs);
3830            }
3831            ancestor = retained_parent(arena, directory);
3832        }
3833        if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
3834            let mut ancestor = parent;
3835            while let Some(directory) = ancestor {
3836                let partition = serving
3837                    .exact_name_by_directory
3838                    .get_mut(&directory)
3839                    .expect("every declared exact-name file contributes to every ancestor");
3840                if current_ignored {
3841                    unmerge_semantic(&mut partition.unignored, exact_name, attrs);
3842                } else {
3843                    merge_semantic(&mut partition.unignored, exact_name, attrs);
3844                }
3845                ancestor = retained_parent(arena, directory);
3846            }
3847        }
3848    }
3849
3850    /// Attributes for any entry, by relative path.
3851    pub fn attrs(&self, path: &Path) -> Option<&Attrs> {
3852        Some(&self.entry(self.lookup(path)?).attrs)
3853    }
3854
3855    /// Kind of an entry, by relative path.
3856    pub fn kind(&self, path: &Path) -> Option<EntryKind> {
3857        Some(self.entry(self.lookup(path)?).kind)
3858    }
3859
3860    /// Effective fixed-control classification for one retained entry.
3861    ///
3862    /// `Ok(Some(ignored))` when a retained entry's governing controls are known;
3863    /// `Ok(None)` for a missing entry or one below a refused control source.
3864    ///
3865    /// # Errors
3866    ///
3867    /// [`crate::Error::ControlStateNotObserved`] when the index was built without
3868    /// observing control state, whatever the path. Every entry of such an index carries
3869    /// "not ignored" only because no rule was read, so that answer would be silently
3870    /// wrong for a tree that has a `.gitignore`.
3871    pub fn is_ignored(&self, path: &Path) -> crate::Result<Option<bool>> {
3872        self.require_observed_controls()?;
3873        Ok(self.ignored_classification(path))
3874    }
3875
3876    /// Known ignore classification of a retained path, or `None` when unavailable.
3877    ///
3878    /// A refusal may hide an ignore or a negation. Its descendants cannot be counted
3879    /// as known members of either population.
3880    pub fn ignored_classification(&self, path: &Path) -> Option<bool> {
3881        let id = self.lookup(path)?;
3882        self.ignored_classification_of(path, id)
3883    }
3884
3885    /// Classification for a retained entry whose handle the caller already has.
3886    pub(crate) fn ignored_classification_of(&self, path: &Path, id: EntryId) -> Option<bool> {
3887        if !self.observes_controls() || !self.control_classification_known(path) {
3888            return None;
3889        }
3890        self.try_entry(id).map(|entry| entry.ignored)
3891    }
3892
3893    pub(crate) fn control_classification_known(&self, path: &Path) -> bool {
3894        self.controls.classification_known(path)
3895            && (self.unreadable_control_paths.is_empty()
3896                || !path.parent().into_iter().flat_map(Path::ancestors).any(|directory| {
3897                    self.unreadable_control_paths
3898                        .iter()
3899                        .any(|control| control.parent() == Some(directory))
3900                }))
3901    }
3902
3903    /// Whether ignored classification is known throughout this retained subtree.
3904    pub fn ignored_classification_complete_below(&self, path: &Path) -> bool {
3905        self.observes_controls()
3906            && self.controls.classification_known(path)
3907            && !self.unreadable_control_paths.iter().any(|control| {
3908                control.starts_with(path)
3909                    || path
3910                        .parent()
3911                        .into_iter()
3912                        .flat_map(Path::ancestors)
3913                        .any(|directory| control.parent() == Some(directory))
3914            })
3915            && !self.controls.refusals().any(|refusal| {
3916                refusal.path.starts_with(path)
3917                    || path
3918                        .parent()
3919                        .into_iter()
3920                        .flat_map(Path::ancestors)
3921                        .any(|directory| refusal.path.parent() == Some(directory))
3922            })
3923    }
3924
3925    /// Borrow direct children of a directory as `(name, id)` pairs in name order.
3926    ///
3927    /// The iterator borrows this owned index and allocates nothing.
3928    pub fn children(
3929        &self,
3930        path: &Path,
3931    ) -> Option<impl DoubleEndedIterator<Item = (&OsStr, EntryId)> + ExactSizeIterator + '_> {
3932        let id = self.lookup(path)?;
3933        let entry = self.entry(id);
3934        entry.kind.is_dir().then(|| IndexChildren::new(self, entry))
3935    }
3936
3937    /// Borrow direct children of an entry id as `(name, id)` pairs in name order.
3938    ///
3939    /// Returns `None` for a stale handle. A live non-directory returns an empty iterator.
3940    pub fn children_of(
3941        &self,
3942        id: EntryId,
3943    ) -> Option<impl DoubleEndedIterator<Item = (&OsStr, EntryId)> + ExactSizeIterator + '_> {
3944        Some(IndexChildren::new(self, self.try_entry(id)?))
3945    }
3946
3947    /// Reconstruct an entry's path relative to the root by walking parent pointers.
3948    pub fn path_of(&self, id: EntryId) -> Option<PathBuf> {
3949        let mut parts = Vec::new();
3950        let mut current = Some(id);
3951        while let Some(node) = current {
3952            let entry = self.try_entry(node)?;
3953            if entry.parent.is_some() {
3954                parts.push(entry.name.as_os_str());
3955            }
3956            current = entry.parent;
3957        }
3958        parts.reverse();
3959        Some(parts.iter().collect())
3960    }
3961
3962    /// Owned, self-describing roll-up state for an entry id, if it is a directory.
3963    pub fn rollup_of(&self, id: EntryId) -> Option<RollUp> {
3964        let entry = self.try_entry(id)?;
3965        entry.kind.is_dir().then(|| self.named_rollup(&entry.rollup().all))
3966    }
3967
3968    /// Map-free totals of a directory's `all` and `unignored` partitions, in that order,
3969    /// for reporting paths that derive an ignored share.
3970    ///
3971    /// No observation check: in an index that observed no control state the two are equal,
3972    /// so a caller that has not checked [`Self::observes_controls`] derives a zero share
3973    /// rather than an error, and must not present it as one.
3974    pub(crate) fn partition_scalars_of(
3975        &self,
3976        id: EntryId,
3977    ) -> Option<(RollUpScalars, RollUpScalars)> {
3978        let entry = self.try_entry(id)?;
3979        entry.kind.is_dir().then(|| {
3980            let rollup = entry.rollup();
3981            (RollUpScalars::from(&rollup.all), RollUpScalars::from(&rollup.unignored))
3982        })
3983    }
3984
3985    /// Whether a live directory's in-scope child set is authoritative: the id form of
3986    /// [`Self::directory_complete`], for a reader that already holds the id. `None` for a
3987    /// stale handle or an entry that is not a directory.
3988    pub(crate) fn directory_complete_of(&self, id: EntryId) -> Option<bool> {
3989        let entry = self.try_entry(id)?;
3990        (entry.kind == EntryKind::Dir).then(|| entry.directory().children_complete)
3991    }
3992
3993    /// Attributes for an entry id, or `None` when the handle is stale.
3994    pub fn attrs_of(&self, id: EntryId) -> Option<&Attrs> {
3995        Some(&self.try_entry(id)?.attrs)
3996    }
3997
3998    /// Kind for an entry id, or `None` when the handle is stale.
3999    pub fn kind_of(&self, id: EntryId) -> Option<EntryKind> {
4000        Some(self.try_entry(id)?.kind)
4001    }
4002
4003    /// Name for an entry id. The root's name is empty; stale handles return `None`.
4004    pub fn name_of(&self, id: EntryId) -> Option<&OsStr> {
4005        Some(&self.try_entry(id)?.name)
4006    }
4007
4008    /// Sparse content tier, when analysis has been enabled.
4009    pub fn content(&self) -> Option<&ContentIndex> {
4010        self.content.as_deref()
4011    }
4012
4013    /// Precomputed content rollup for one relative directory.
4014    pub fn content_rollup(&self, path: &Path) -> Option<&ContentRollUp> {
4015        self.content()?.rollup(path)
4016    }
4017
4018    /// The analyzer set this index's content tier holds records for, or
4019    /// [`AnalysisSet::NONE`] when it holds no content tier.
4020    pub fn content_set(&self) -> AnalysisSet {
4021        self.content().and_then(ContentIndex::profile).unwrap_or(AnalysisSet::NONE)
4022    }
4023
4024    /// Whether the retained content tier lacks a record for any admitted regular file.
4025    ///
4026    /// Every requested analyzer records a coverage outcome, including binary, invalid
4027    /// UTF-8, and unsupported files. A count mismatch therefore means analysis is still
4028    /// pending, while deleting a file removes both its entry and its content record.
4029    pub(crate) fn content_has_pending(&self, profile: AnalysisSet) -> bool {
4030        if !profile.is_enabled() {
4031            return false;
4032        }
4033        let wanted = self.content_identity(profile);
4034        let Some(content) = self.content().and_then(|content| content.admit(&wanted)) else {
4035            return true;
4036        };
4037        if self.scope.population == crate::query::IgnoredEntries::Include {
4038            return u64::try_from(content.len()).unwrap_or(u64::MAX)
4039                < self.entry(EntryId::ROOT).rollup().files;
4040        }
4041        // Narrow populations retain control files for reconciliation and unknown
4042        // files until their governing rules can be verified. Neither is an analysis
4043        // candidate, so comparing against all retained regular files would make a
4044        // complete analysis look perpetually partial.
4045        let mut pending = false;
4046        self.for_each_analysis_file(profile, |_, _, attrs, path| {
4047            pending |=
4048                content.file(&path).is_none_or(|record| record.fingerprint != attrs.fingerprint());
4049        });
4050        pending
4051    }
4052
4053    /// The content tier identity this index gives records of `analysis`: its own entry tier,
4054    /// which holds its type rules, the analyzer set, and the analyzers' versions and options.
4055    pub fn content_identity(&self, analysis: AnalysisSet) -> crate::ContentTierIdentity {
4056        crate::ContentTierIdentity::for_request(
4057            crate::EntryTierIdentity::of_scope(self.scope),
4058            analysis,
4059        )
4060    }
4061
4062    /// Prepare the content tier to hold records of `request`'s identity, clearing it when
4063    /// it holds any other.
4064    pub(crate) fn prepare_content_analysis(&mut self, request: crate::content::AnalysisRequest) {
4065        if !request.profile.is_enabled() {
4066            return;
4067        }
4068        let identity = self.content_identity(request.profile);
4069        self.content.get_or_insert_with(|| Box::new(ContentIndex::default())).prepare(identity);
4070    }
4071
4072    pub(crate) fn set_content_tier_state(
4073        &mut self,
4074        source: Source,
4075        freshness: Freshness,
4076        observed_at_ns: Option<i64>,
4077    ) {
4078        if let Some(content) = self.content.as_deref_mut() {
4079            content.set_state(crate::content::ContentTierState {
4080                source,
4081                freshness,
4082                observed_at_ns,
4083            });
4084        }
4085    }
4086
4087    /// Capture every regular-file analysis candidate without retaining a lock or entry
4088    /// borrow across filesystem I/O.
4089    ///
4090    /// Crate-private until the request model (P1.3) decides whether an out-of-crate
4091    /// analyzer is a supported surface (`fdu-5upj`). A caller outside the crate cannot
4092    /// prepare the content tier, so every result it produced would commit as
4093    /// [`AnalysisApplyOutcome::Stale`]; [`analyze_index`] is the entry that works.
4094    ///
4095    /// [`analyze_index`]: crate::content::analyze_index
4096    pub(crate) fn analysis_candidates(&self, profile: AnalysisSet) -> Vec<AnalysisCandidate> {
4097        let root_files = self.entry(EntryId::ROOT).rollup().files;
4098        let mut candidates = Vec::with_capacity(usize::try_from(root_files).unwrap_or(0));
4099        self.for_each_analysis_file(profile, |id, revision, attrs, relative_path| {
4100            candidates.push(AnalysisCandidate {
4101                entry_id: id,
4102                revision,
4103                absolute_path: self.root_path.join(&relative_path),
4104                classification: self.classify(&relative_path),
4105                relative_path,
4106                attrs,
4107            });
4108        });
4109        candidates
4110    }
4111
4112    /// File identities restore matches against sidecar records, without classifying.
4113    ///
4114    /// The `HashMap` is keyed by relative path because load looks up each decoded record
4115    /// that way. Classification is omitted: cache-only restore commits the sidecar's
4116    /// stored classification, and the apply-path self-check cannot change that answer.
4117    pub(crate) fn restore_analysis_candidates(
4118        &self,
4119        profile: AnalysisSet,
4120    ) -> (HashMap<PathBuf, RestoreCandidate>, u64) {
4121        let root_files = self.entry(EntryId::ROOT).rollup().files;
4122        let mut candidates = HashMap::with_capacity(usize::try_from(root_files).unwrap_or(0));
4123        let mut visited = 0_u64;
4124        self.for_each_analysis_file(profile, |id, revision, attrs, relative_path| {
4125            visited = visited.saturating_add(1);
4126            candidates.insert(
4127                relative_path.clone(),
4128                RestoreCandidate { entry_id: id, revision, relative_path, attrs },
4129            );
4130        });
4131        (candidates, visited)
4132    }
4133
4134    fn for_each_analysis_file(
4135        &self,
4136        profile: AnalysisSet,
4137        mut visit: impl FnMut(EntryId, u64, Attrs, PathBuf),
4138    ) {
4139        if !profile.is_enabled() {
4140            return;
4141        }
4142        // Join the parent path this walk already holds. `path_of` would walk
4143        // ancestors per file for the same bytes.
4144        let mut stack = vec![(EntryId::ROOT, PathBuf::new())];
4145        while let Some((parent, parent_path)) = stack.pop() {
4146            for (name, id) in self.children_of(parent).into_iter().flatten() {
4147                let entry = self.entry(id);
4148                if entry.kind == EntryKind::Dir {
4149                    stack.push((id, parent_path.join(name)));
4150                    continue;
4151                }
4152                if entry.kind != EntryKind::File {
4153                    continue;
4154                }
4155                let relative_path = parent_path.join(name);
4156                if !self.scope.population.admits(entry.ignored)
4157                    || (self.scope.population != crate::query::IgnoredEntries::Include
4158                        && !self.control_classification_known(&relative_path))
4159                {
4160                    continue;
4161                }
4162                let revision = entry.revision;
4163                let attrs = entry.attrs;
4164                visit(id, revision, attrs, relative_path);
4165            }
4166        }
4167    }
4168
4169    /// The candidates `request` still has to read: every one, unless the content tier
4170    /// holds exactly `request`'s identity, and then those without a record whose
4171    /// fingerprint matches.
4172    pub(crate) fn pending_analysis_candidates(
4173        &self,
4174        request: crate::content::AnalysisRequest,
4175    ) -> Vec<AnalysisCandidate> {
4176        let wanted = self.content_identity(request.profile);
4177        // The tier refuses a record of any identity but its own, so one comparison here
4178        // decides for every record it holds.
4179        let held = self.content().and_then(|content| content.admit(&wanted));
4180        self.analysis_candidates(request.profile)
4181            .into_iter()
4182            .filter(|candidate| {
4183                held.and_then(|content| content.file(&candidate.relative_path)).is_none_or(
4184                    |record| {
4185                        record.fingerprint != candidate.attrs.fingerprint() || !record.is_reusable()
4186                    },
4187                )
4188            })
4189            .collect()
4190    }
4191
4192    /// Conditionally commit a worker result if its entry and metadata expectation still
4193    /// match, and the content tier was prepared for the identity the result was produced
4194    /// under.
4195    ///
4196    /// A result of another identity is [`AnalysisApplyOutcome::Stale`]: it answers another
4197    /// request than the one the tier holds, so committing it would mix records of two
4198    /// identities in one tier.
4199    ///
4200    /// Crate-private with [`Index::analysis_candidates`], and for the same reason.
4201    pub(crate) fn apply_analysis(
4202        &mut self,
4203        observation: AnalysisObservation,
4204    ) -> AnalysisApplyOutcome {
4205        self.apply_analysis_record(observation)
4206    }
4207
4208    /// Restore-path apply: insert the record and leave roll-ups for one rebuild.
4209    ///
4210    /// The caller must [`Self::rebuild_content_rollups`] before any query reads a
4211    /// directory total; sidecar load does that after the apply loop.
4212    pub(crate) fn apply_restored_analysis(
4213        &mut self,
4214        candidate: RestoreCandidate,
4215        analysis: crate::stored_state::AdmittedRecord<'_>,
4216    ) -> AnalysisApplyOutcome {
4217        let Some(entry) = self.try_entry(candidate.entry_id) else {
4218            return AnalysisApplyOutcome::Stale;
4219        };
4220        if entry.kind != EntryKind::File
4221            || entry.revision != candidate.revision
4222            || entry.attrs.fingerprint() != candidate.attrs.fingerprint()
4223        {
4224            return AnalysisApplyOutcome::Stale;
4225        }
4226        let Some(content) = self.content.as_mut() else {
4227            return AnalysisApplyOutcome::Stale;
4228        };
4229        if content.commit_without_rollup(candidate.relative_path, analysis) {
4230            AnalysisApplyOutcome::Applied
4231        } else {
4232            AnalysisApplyOutcome::Stale
4233        }
4234    }
4235
4236    pub(crate) fn rebuild_content_rollups(&mut self) {
4237        if let Some(content) = self.content.as_mut() {
4238            content.rebuild_rollups();
4239        }
4240    }
4241
4242    fn apply_analysis_record(&mut self, observation: AnalysisObservation) -> AnalysisApplyOutcome {
4243        let candidate = &observation.candidate;
4244        let Some(entry) = self.try_entry(candidate.entry_id) else {
4245            return AnalysisApplyOutcome::Stale;
4246        };
4247        if entry.kind != EntryKind::File
4248            || entry.revision != candidate.revision
4249            || entry.attrs.fingerprint() != candidate.attrs.fingerprint()
4250            || self.classify(&candidate.relative_path) != candidate.classification
4251        {
4252            return AnalysisApplyOutcome::Stale;
4253        }
4254        let Some(content) = self.content.as_mut() else {
4255            return AnalysisApplyOutcome::Stale;
4256        };
4257        if content.commit(
4258            candidate.relative_path.clone(),
4259            observation.profile,
4260            &observation.provenance,
4261            observation.analysis,
4262        ) {
4263            AnalysisApplyOutcome::Applied
4264        } else {
4265            AnalysisApplyOutcome::Stale
4266        }
4267    }
4268
4269    /// Drop all derived content while preserving metadata and snapshot compatibility.
4270    pub fn clear_content(&mut self) {
4271        self.content = None;
4272    }
4273
4274    // ---- internals ----
4275
4276    fn try_entry(&self, id: EntryId) -> Option<&Entry> {
4277        match self.arena.get(id.idx())? {
4278            Slot::Occupied { generation, entry } if *generation == id.generation => Some(entry),
4279            Slot::Occupied { .. } | Slot::Free { .. } => None,
4280        }
4281    }
4282
4283    fn expectation_matches(&self, op: &Op, expected: PathExpectation) -> bool {
4284        let current = self.path_state(op.path());
4285        // An operation whose target the index already holds changes nothing, whatever
4286        // happened to its baseline: another producer verified the same fact first and
4287        // there is no older state left to overwrite. Refusing it as stale cost the
4288        // observation handoff a full-root walk per convergent refresh, and three in a
4289        // row failed the root, for commits that would have applied as unchanged.
4290        if self.holds_target(op, current) {
4291            return true;
4292        }
4293        if current != expected.state {
4294            return false;
4295        }
4296
4297        let require_structure = match (op, expected.state) {
4298            (Op::Remove { .. }, _) => true,
4299            (Op::Upsert { kind, .. }, PathState::Present { kind: baseline, .. }) => {
4300                *kind != baseline
4301            }
4302            (
4303                Op::Upsert { .. }
4304                | Op::ControlUpsert { .. }
4305                | Op::ControlRemove { .. }
4306                | Op::InvalidateSubtree { .. },
4307                _,
4308            ) => false,
4309        };
4310        if !same_target(self.entry_identity(op.path()), expected.entry(), require_structure) {
4311            return false;
4312        }
4313
4314        match expected.absence_guard() {
4315            Some(expected) => self
4316                .absence_guard_identity(op.path())
4317                .is_some_and(|current| current.same_absence_guard(expected)),
4318            None => true,
4319        }
4320    }
4321
4322    /// Whether the index already holds what `op` would leave behind at `current`, the state
4323    /// of its path.
4324    ///
4325    /// An entry operation's target is a path state. A control operation's is the table:
4326    /// exactly its source retained at its path, or nothing retained there. The walk pushes
4327    /// a control file's entry and rules on one baseline, so both must converge together or
4328    /// the pair is refused for the rules alone. An invalidation always commits a change, so
4329    /// it is arbitrated on its baseline.
4330    fn holds_target(&self, op: &Op, current: PathState) -> bool {
4331        match op {
4332            Op::Upsert { kind, attrs, .. } => {
4333                current == PathState::Present { kind: *kind, attrs: *attrs }
4334            }
4335            Op::Remove { .. } => current == PathState::Absent,
4336            Op::ControlUpsert { path, source } => self.controls.source_is(path, source),
4337            Op::ControlRemove { path } => !self.controls.contains(path),
4338            Op::InvalidateSubtree { .. } => false,
4339        }
4340    }
4341
4342    fn absence_guard_identity(&self, path: &Path) -> Option<EntryIdentity> {
4343        let parts = normalize(path)?;
4344        let (_, ancestors) = parts.split_last()?;
4345        let mut current = EntryId::ROOT;
4346        for part in ancestors {
4347            let Some(child) = self.child(current, part) else {
4348                break;
4349            };
4350            current = child;
4351        }
4352        Some(self.identity(current))
4353    }
4354
4355    /// Prove that every accepted live upsert has a verified parent chain.
4356    ///
4357    /// The overlay follows batch order without touching the real index. That admits
4358    /// parent-first discovery batches and rejects a child whose missing or non-directory
4359    /// ancestry would otherwise be filled with guessed metadata.
4360    fn validate_known_ancestry(
4361        &self,
4362        ops: &[ObservationOp],
4363        accepted: &[bool],
4364    ) -> crate::Result<()> {
4365        if let Some((path, reconcile_from)) =
4366            self.unknown_ancestry(ops, accepted).into_iter().next()
4367        {
4368            return Err(crate::Error::UnknownAncestry { path, reconcile_from });
4369        }
4370        Ok(())
4371    }
4372
4373    fn accepted_operations(&self, ops: &[ObservationOp]) -> Vec<bool> {
4374        ops.iter()
4375            .map(|observed| match observed.expectation {
4376                Expectation::Any => true,
4377                Expectation::State(expected) => self.expectation_matches(&observed.op, expected),
4378            })
4379            .collect()
4380    }
4381
4382    /// Consume a walker-owned batch and prove every parent before mutation begins.
4383    ///
4384    /// Scanner batches contain only unconditional discoveries. The walker publishes a
4385    /// directory before any worker may enumerate it, so almost every parent resolves to
4386    /// an existing id. Serial batches may still contain a parent-first directory and its
4387    /// children together; those children retain the earlier operation index instead.
4388    /// The proof owns no duplicate paths and application performs no second path-tree
4389    /// search.
4390    ///
4391    /// A discovery can also find an entry whose kind the index no longer agrees with: a
4392    /// concurrent refresh may have replaced it after its directory was listed. Replacing a
4393    /// kind drops a subtree, so the parent ids proved here would not survive the batch.
4394    /// That rare batch is prepared for the general lane instead, which proves ancestry in
4395    /// operation order and replaces the entry as it would for any verified observation;
4396    /// the next observation of the path repairs a stale one.
4397    fn prepare_scanner_batch(
4398        &self,
4399        batch: crate::scan::ScannerBatch,
4400    ) -> crate::Result<PreparedObservation> {
4401        let ops = batch.into_ops();
4402        let mut parents = Vec::with_capacity(ops.len());
4403        let mut last_parent: Option<(&Path, ResolvedParent)> = None;
4404        let mut has_batch_parents = false;
4405        let mut path_comparisons = 0_u64;
4406        let mut replaces_kind = false;
4407
4408        for (op_index, observed) in ops.iter().enumerate() {
4409            if !matches!(observed.expectation, Expectation::Any) {
4410                return Err(crate::Error::UnsupportedScanConfig(
4411                    "scanner batches contain unconditional discoveries only",
4412                ));
4413            }
4414            let op = &observed.op;
4415            let path = op.path();
4416            if path.as_os_str().is_empty() {
4417                return Err(crate::Error::UnsupportedScanConfig(
4418                    "scanner batches cannot mutate the index root",
4419                ));
4420            }
4421            for component in path.components() {
4422                match component {
4423                    Component::Normal(_) => {}
4424                    Component::CurDir => {
4425                        return Err(crate::Error::UnsupportedScanConfig(
4426                            "scanner batches require canonical relative paths",
4427                        ));
4428                    }
4429                    Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
4430                        return Err(crate::Error::PathEscapesRoot(path.to_path_buf()));
4431                    }
4432                }
4433            }
4434            match op {
4435                Op::Upsert { .. } => {}
4436                Op::ControlUpsert { .. } | Op::ControlRemove { .. }
4437                    if crate::control::is_control_file(path) => {}
4438                Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {
4439                    return Err(crate::Error::InvalidControlPath(path.to_path_buf()));
4440                }
4441                Op::Remove { .. } | Op::InvalidateSubtree { .. } => {
4442                    return Err(crate::Error::UnsupportedScanConfig(
4443                        "scanner batches contain discoveries only",
4444                    ));
4445                }
4446            }
4447            if replaces_kind {
4448                // The general lane proves every remaining parent. Only the scanner input
4449                // contract above still applies to the rest of the batch.
4450                continue;
4451            }
4452
4453            let parent_path = path.parent().expect("a non-root relative path has a parent");
4454            if last_parent.is_some() {
4455                path_comparisons = path_comparisons.saturating_add(1);
4456            }
4457            let same_parent = last_parent
4458                .filter(|(previous, _)| *previous == parent_path)
4459                .map(|(_, parent)| parent);
4460            let parent = if let Some(parent) = same_parent {
4461                parent
4462            } else {
4463                self.lookup(parent_path)
4464                    .filter(|id| self.entry(*id).kind.is_dir())
4465                    .map(ResolvedParent::Existing)
4466                    .or_else(|| Self::earlier_scanner_parent(&ops, op_index, parent_path))
4467                    .ok_or_else(|| crate::Error::UnknownAncestry {
4468                        path: path.to_path_buf(),
4469                        reconcile_from: PathBuf::new(),
4470                    })?
4471            };
4472            if let (Op::Upsert { kind, .. }, ResolvedParent::Existing(parent)) = (op, parent) {
4473                if path.file_name().is_some_and(|name| {
4474                    self.child(parent, name).is_some_and(|child| self.entry(child).kind != *kind)
4475                }) {
4476                    replaces_kind = true;
4477                    continue;
4478                }
4479            }
4480            has_batch_parents |= matches!(parent, ResolvedParent::Earlier(_));
4481            parents.push(parent);
4482            last_parent = Some((parent_path, parent));
4483        }
4484
4485        if replaces_kind {
4486            return prepare_observation(&Observation::from_ops(ops));
4487        }
4488
4489        crate::counters::bump(|counts| {
4490            counts.ancestry_path_comparisons =
4491                counts.ancestry_path_comparisons.saturating_add(path_comparisons);
4492            counts.ancestry_parent_proofs = counts
4493                .ancestry_parent_proofs
4494                .saturating_add(u64::try_from(ops.len()).unwrap_or(u64::MAX));
4495        });
4496        Ok(PreparedObservation {
4497            ops,
4498            ancestry: PreparedAncestry::Scanner { parents, has_batch_parents },
4499            #[cfg(test)]
4500            reject_before_apply: false,
4501        })
4502    }
4503
4504    /// Resolve a parent produced earlier in the same scanner batch.
4505    fn earlier_scanner_parent(
4506        ops: &[ObservationOp],
4507        before: usize,
4508        parent_path: &Path,
4509    ) -> Option<ResolvedParent> {
4510        let (op_index, op) = ops[..before].iter().enumerate().rev().find(
4511            |(_, observed)| matches!(&observed.op, Op::Upsert { path, .. } if path == parent_path),
4512        )?;
4513        let Op::Upsert { kind, .. } = &op.op else {
4514            unreachable!("the search selected an upsert");
4515        };
4516        kind.is_dir().then_some(ResolvedParent::Earlier(op_index))
4517    }
4518
4519    /// Evaluate the complete resulting control table before any fact or reducer moves.
4520    ///
4521    /// Only a malformed control path fails here; a source the bounds cannot admit is
4522    /// refused inside the projection. Building the whole table first keeps a failing
4523    /// observation fault-atomic even when the same batch also moves ordinary entries.
4524    fn projected_controls(
4525        &self,
4526        ops: &[ObservationOp],
4527        accepted: &[bool],
4528    ) -> crate::Result<Option<crate::control::ControlTable>> {
4529        self.projected_controls_from(
4530            ops.iter()
4531                .zip(accepted)
4532                .filter_map(|(observed, accepted)| accepted.then_some(&observed.op)),
4533        )
4534    }
4535
4536    /// The table this batch would leave behind, or `None` when it leaves the current one.
4537    fn projected_controls_from<'a>(
4538        &self,
4539        ops: impl Iterator<Item = &'a Op> + Clone,
4540    ) -> crate::Result<Option<crate::control::ControlTable>> {
4541        if self.controls_unchanged_by(ops.clone()) {
4542            return Ok(None);
4543        }
4544        let mut projected = self.controls.clone();
4545        #[cfg(test)]
4546        CONTROL_PROJECTION_CLONES.with(|clones| clones.set(clones.get() + 1));
4547        let mut structure = StructuralOverlay::default();
4548        for op in ops {
4549            match op {
4550                Op::Upsert { path, kind, .. } => {
4551                    if crate::control::is_control_file(path) && *kind != EntryKind::File {
4552                        projected.remove(path)?;
4553                    }
4554                    if structure.kind(self, path) == Some(EntryKind::Dir) && !kind.is_dir() {
4555                        projected.remove_subtree(path);
4556                    }
4557                    structure.upsert(self, path, *kind);
4558                }
4559                Op::Remove { path } => {
4560                    if crate::control::is_control_file(path) {
4561                        projected.remove(path)?;
4562                    }
4563                    projected.remove_subtree(path);
4564                    structure.remove(self, path);
4565                }
4566                Op::ControlUpsert { path, source } => {
4567                    projected.upsert(path, source.clone())?;
4568                }
4569                Op::ControlRemove { path } => {
4570                    projected.remove(path)?;
4571                }
4572                Op::InvalidateSubtree { .. } => {}
4573            }
4574        }
4575        Ok(Some(projected))
4576    }
4577
4578    /// Whether no operation in the batch can change the retained control table.
4579    ///
4580    /// Only control ops write the table, and only a structural removal prunes it, so a
4581    /// batch whose control ops are all inert against this table and whose structural ops
4582    /// touch nothing it records leaves it exactly as it is. Each op is decided against the
4583    /// current table rather than against the projection, which is the same thing: an inert
4584    /// op leaves the state the next one is decided against unchanged.
4585    ///
4586    /// This is what keeps a warm revalidate of a tree past its budget from cloning the
4587    /// whole table for every batch of re-read refusals (fdu-hzm5), and a cold scan of a
4588    /// tree with no control files from projecting an empty table onto an empty one
4589    /// (fdu-pro1). A malformed control path is never inert, so the projection still
4590    /// reports it.
4591    fn controls_unchanged_by<'a>(&self, ops: impl Iterator<Item = &'a Op>) -> bool {
4592        // A vacant table decides every op from its kind alone, which is what the cold
4593        // no-controls lane costs per entry: there is nothing for a structural op to drop or
4594        // prune, and no source is inert against it, since `Unchanged` needs a retained
4595        // source and `Refuse` a matching refusal. A removal still asks, so a malformed
4596        // control path stays non-inert and the projection reports it.
4597        if self.controls.is_vacant() {
4598            return ops.into_iter().all(|op| match op {
4599                Op::ControlUpsert { .. } => false,
4600                Op::ControlRemove { path } => self.controls.remove_is_inert(path),
4601                Op::Upsert { .. } | Op::Remove { .. } | Op::InvalidateSubtree { .. } => true,
4602            });
4603        }
4604
4605        ops.into_iter().all(|op| match op {
4606            Op::ControlUpsert { path, source } => self.controls.upsert_is_inert(path, source),
4607            Op::ControlRemove { path } => self.controls.remove_is_inert(path),
4608            Op::Upsert { path, kind, .. } => {
4609                // The kind first: it is one discriminant test, where naming a control file
4610                // parses the path's last component.
4611                let drops_control = *kind != EntryKind::File
4612                    && crate::control::is_control_file(path)
4613                    && self.controls.contains(path);
4614                let prunes_subtree = !kind.is_dir() && self.controls.has_record_at_or_below(path);
4615                !drops_control && !prunes_subtree
4616            }
4617            Op::Remove { path } => {
4618                let drops_control =
4619                    crate::control::is_control_file(path) && self.controls.contains(path);
4620                !drops_control && !self.controls.has_record_at_or_below(path)
4621            }
4622            Op::InvalidateSubtree { .. } => true,
4623        })
4624    }
4625
4626    fn apply_control_transition<C: ConsequenceSink>(
4627        &mut self,
4628        projected: Option<crate::control::ControlTable>,
4629        stats: &mut ApplyStats,
4630        effects: &mut C,
4631    ) {
4632        let Some(projected) = projected else {
4633            return;
4634        };
4635        let changes = projected.changes_from(&self.controls);
4636        let refusals = projected.refusal_changes_from(&self.controls);
4637        if changes.is_empty() && refusals.is_empty() {
4638            return;
4639        }
4640        let affected: Vec<PathBuf> = changes
4641            .iter()
4642            .filter_map(|(path, _, _)| crate::control::ControlTable::affected_subtree(path).ok())
4643            .collect();
4644        self.controls = projected;
4645        stats.controls = u64::try_from(changes.len() + refusals.len()).unwrap_or(u64::MAX);
4646        for (path, previous, current) in changes {
4647            effects.change(|| EffectiveChange::ControlUpdated { path, previous, current });
4648        }
4649        // A refusal changes what classification covers, not what it says, so it moves no
4650        // entry by itself; the source it may have dropped arrived as a change above.
4651        for (path, previous, current) in refusals {
4652            effects.change(|| EffectiveChange::ControlRefusalUpdated { path, previous, current });
4653        }
4654        self.reclassify_controlled_subtrees(&affected, stats, effects);
4655    }
4656
4657    /// Re-evaluate only subtrees governed by changed controls, then rebuild the fixed
4658    /// unignored reducer from the resulting facts.
4659    fn reclassify_controlled_subtrees<C: ConsequenceSink>(
4660        &mut self,
4661        affected: &[PathBuf],
4662        stats: &mut ApplyStats,
4663        effects: &mut C,
4664    ) {
4665        let mut roots: Vec<PathBuf> = affected.to_vec();
4666        roots.sort();
4667        roots.dedup();
4668        let mut collapsed = Vec::new();
4669        for root in roots {
4670            if collapsed.iter().any(|ancestor: &PathBuf| root.starts_with(ancestor)) {
4671                continue;
4672            }
4673            collapsed.push(root);
4674        }
4675
4676        let mut moved = false;
4677        for root in collapsed {
4678            let Some(root_id) = self.lookup(&root) else {
4679                continue;
4680            };
4681            let children: Vec<(PathBuf, EntryId)> = self
4682                .children_of(root_id)
4683                .expect("controlled subtree root is live")
4684                .map(|(name, id)| (root.join(name), id))
4685                .collect();
4686            let mut queue = VecDeque::from(children);
4687            while let Some((path, id)) = queue.pop_front() {
4688                #[cfg(test)]
4689                RECLASSIFY_VISITS.with(|visits| visits.set(visits.get() + 1));
4690                let entry = self.entry(id);
4691                let parent_ignored = entry.parent.is_some_and(|parent| self.entry(parent).ignored);
4692                let current = entry.ignored;
4693                let next = parent_ignored
4694                    || self.controls.matcher_for(&path).is_ignored(entry.kind.is_dir());
4695                let descendants: Vec<(PathBuf, EntryId)> = self
4696                    .children_of(id)
4697                    .expect("controlled subtree entry is live")
4698                    .map(|(name, child)| (path.join(name), child))
4699                    .collect();
4700                if current != next {
4701                    self.move_serving_file_partition(&path, id, current, next);
4702                    self.entry_mut(id).ignored = next;
4703                    stats.reclassified += 1;
4704                    effects.change(|| EffectiveChange::Reclassified {
4705                        path: path.clone(),
4706                        previous_ignored: current,
4707                        current_ignored: next,
4708                    });
4709                    moved = true;
4710                }
4711                queue.extend(descendants);
4712            }
4713        }
4714        if moved {
4715            self.rebuild_unignored_rollups();
4716        }
4717    }
4718
4719    fn rebuild_unignored_rollups(&mut self) {
4720        let mut order = Vec::with_capacity(usize::try_from(self.live).unwrap_or(0));
4721        let mut stack = vec![EntryId::ROOT];
4722        while let Some(id) = stack.pop() {
4723            order.push(id);
4724            if self.entry(id).kind.is_dir() {
4725                stack.extend(self.child_ids(id));
4726            }
4727            if self.entry(id).kind.is_dir() {
4728                self.entry_mut(id).rollup_mut().unignored = InternedRollUp::default();
4729            }
4730        }
4731        for id in order.into_iter().rev() {
4732            let Some(parent) = self.entry(id).parent else {
4733                continue;
4734            };
4735            let contribution = self.contribution(id).unignored;
4736            self.entry_mut(parent).rollup_mut().unignored.merge(&contribution);
4737        }
4738    }
4739
4740    fn unknown_ancestry(
4741        &self,
4742        ops: &[ObservationOp],
4743        accepted: &[bool],
4744    ) -> Vec<(PathBuf, PathBuf)> {
4745        let mut structure = StructuralOverlay::default();
4746        let mut unknown = Vec::new();
4747        let mut overlay_inserts = 0_u64;
4748        let mut path_comparisons = 0_u64;
4749        let mut parent_proofs = 0_u64;
4750        let count_preflight = crate::counters::enabled();
4751        // The last directory this pass proved, with every ancestor of it. A producer
4752        // emits a directory's children together, so consecutive ops overwhelmingly
4753        // share a parent, and re-proving the same chain per op was the largest single
4754        // allocation cost of a cold scan (fdu-pro1): one component vector plus one
4755        // ancestor path rebuilt push-by-push, per entry, for an answer that had not
4756        // changed since the previous entry. The memo is invalidated wherever this loop
4757        // learns something that could change an answer -- a non-directory upsert or a
4758        // removal -- exactly like `ParentMemo` in the apply loop below.
4759        let mut proven_dir: Option<PathBuf> = None;
4760        let mut ancestor = PathBuf::new();
4761        for (observed, accepted) in ops.iter().zip(accepted) {
4762            if !accepted {
4763                continue;
4764            }
4765            match &observed.op {
4766                Op::Upsert { path, .. } | Op::ControlUpsert { path, .. }
4767                    if !path.as_os_str().is_empty() =>
4768                {
4769                    if count_preflight && proven_dir.is_some() {
4770                        path_comparisons = path_comparisons.saturating_add(1);
4771                    }
4772                    let same_proven_parent = matches!(
4773                        (path.parent(), proven_dir.as_deref()),
4774                        (Some(parent), Some(proven)) if parent == proven
4775                    );
4776                    if same_proven_parent {
4777                        if count_preflight {
4778                            parent_proofs = parent_proofs.saturating_add(1);
4779                        }
4780                    } else {
4781                        let mut reconcile_from = PathBuf::new();
4782                        let mut ancestry_known = true;
4783                        let parts = normalize(path).expect("prepared paths are canonical");
4784                        let (_, ancestors) = parts.split_last().expect("non-root path has a name");
4785                        ancestor.clear();
4786                        for part in ancestors {
4787                            ancestor.push(part);
4788                            if structure.kind(self, &ancestor) != Some(EntryKind::Dir) {
4789                                unknown.push((path.clone(), reconcile_from));
4790                                ancestry_known = false;
4791                                break;
4792                            }
4793                            reconcile_from.clone_from(&ancestor);
4794                        }
4795                        if !ancestry_known {
4796                            proven_dir = None;
4797                            continue;
4798                        }
4799                        if count_preflight {
4800                            parent_proofs = parent_proofs.saturating_add(1);
4801                        }
4802                        match &mut proven_dir {
4803                            Some(proven) => {
4804                                proven.clear();
4805                                path.parent().unwrap_or(Path::new("")).clone_into(proven);
4806                            }
4807                            None => {
4808                                proven_dir =
4809                                    Some(path.parent().unwrap_or(Path::new("")).to_path_buf());
4810                            }
4811                        }
4812                    }
4813                    if let Op::Upsert { kind, .. } = &observed.op {
4814                        structure.upsert(self, path, *kind);
4815                        if count_preflight {
4816                            overlay_inserts = overlay_inserts.saturating_add(1);
4817                        }
4818                        if !kind.is_dir() {
4819                            // This path may itself have been somebody's proven ancestor
4820                            // only if it was a directory before; the overlay knows, but
4821                            // the memo does not, so it forgets rather than reasons.
4822                            if proven_dir.as_deref().is_some_and(|proven| proven.starts_with(path))
4823                            {
4824                                proven_dir = None;
4825                            }
4826                        }
4827                    }
4828                }
4829                Op::Remove { path } if !path.as_os_str().is_empty() => {
4830                    structure.remove(self, path);
4831                    if proven_dir.as_deref().is_some_and(|proven| proven.starts_with(path)) {
4832                        proven_dir = None;
4833                    }
4834                }
4835                Op::Upsert { .. }
4836                | Op::Remove { .. }
4837                | Op::ControlUpsert { .. }
4838                | Op::ControlRemove { .. }
4839                | Op::InvalidateSubtree { .. } => {}
4840            }
4841        }
4842        if count_preflight {
4843            crate::counters::bump(|counts| {
4844                counts.ancestry_overlay_inserts =
4845                    counts.ancestry_overlay_inserts.saturating_add(overlay_inserts);
4846                counts.ancestry_path_comparisons =
4847                    counts.ancestry_path_comparisons.saturating_add(path_comparisons);
4848                counts.ancestry_parent_proofs =
4849                    counts.ancestry_parent_proofs.saturating_add(parent_proofs);
4850            });
4851        }
4852        unknown
4853    }
4854
4855    fn entry_identity(&self, path: &Path) -> Option<EntryIdentity> {
4856        Some(self.identity(self.lookup(path)?))
4857    }
4858
4859    fn identity(&self, id: EntryId) -> EntryIdentity {
4860        let entry = self.entry(id);
4861        EntryIdentity::new(
4862            id.slot,
4863            id.generation,
4864            entry.revision,
4865            entry.directory.as_deref().map_or(0, |directory| directory.children_revision),
4866            entry.kind.is_dir(),
4867        )
4868    }
4869
4870    fn bump_revision(entry: &mut Entry) {
4871        entry.revision = entry.revision.checked_add(1).expect("entry revision exhausted");
4872    }
4873
4874    fn bump_children_revision(entry: &mut Entry) {
4875        let directory = entry.directory_mut();
4876        directory.children_revision =
4877            directory.children_revision.checked_add(1).expect("entry children revision exhausted");
4878    }
4879
4880    fn child(&self, parent: EntryId, name: &OsStr) -> Option<EntryId> {
4881        let children = &self.entry(parent).directory.as_deref()?.children;
4882        match children {
4883            DirectoryChildren::Sorted(ids) => ids
4884                .binary_search_by(|id| self.entry(*id).name.as_os_str().cmp(name))
4885                .ok()
4886                .map(|position| ids[position]),
4887            DirectoryChildren::Mutable(children) => children.get(name).copied(),
4888        }
4889    }
4890
4891    fn child_ids(&self, parent: EntryId) -> ChildIds<'_> {
4892        self.entry(parent).directory().children.ids()
4893    }
4894
4895    /// Promote one compact, completed directory when its first mutation arrives.
4896    ///
4897    /// Detached indexes keep each name only on its child entry. Arbitrary public
4898    /// mutation needs keyed insertion and removal, so the touched parent pays the
4899    /// name clones once; untouched one-shot topology stays compact.
4900    fn promote_children(&mut self, parent: EntryId) {
4901        let ids = match &mut self.entry_mut(parent).directory_mut().children {
4902            DirectoryChildren::Sorted(ids) => std::mem::take(ids),
4903            DirectoryChildren::Mutable(_) => return,
4904        };
4905        let expected = ids.len();
4906        let children =
4907            ids.into_iter().map(|id| (self.entry(id).name.clone(), id)).collect::<BTreeMap<_, _>>();
4908        assert_eq!(
4909            children.len(),
4910            expected,
4911            "compact child names must remain unique before promotion"
4912        );
4913        self.entry_mut(parent).directory_mut().children = DirectoryChildren::Mutable(children);
4914    }
4915
4916    fn insert_child(&mut self, parent: EntryId, name: OsString, child: EntryId) {
4917        self.promote_children(parent);
4918        let entry = self.entry_mut(parent);
4919        let DirectoryChildren::Mutable(children) = &mut entry.directory_mut().children else {
4920            unreachable!("child promotion produces mutable storage")
4921        };
4922        children.insert(name, child);
4923        Self::bump_children_revision(entry);
4924    }
4925
4926    fn reserve_detached_children(&mut self, parent: EntryId, additional: usize) {
4927        let entry = self.entry_mut(parent);
4928        let DirectoryChildren::Sorted(children) = &mut entry.directory_mut().children else {
4929            unreachable!("detached directories retain sorted child storage")
4930        };
4931        children.reserve(additional);
4932    }
4933
4934    fn push_detached_child(&mut self, parent: EntryId, child: EntryId) {
4935        let entry = self.entry_mut(parent);
4936        let DirectoryChildren::Sorted(children) = &mut entry.directory_mut().children else {
4937            unreachable!("detached directories retain sorted child storage")
4938        };
4939        children.push(child);
4940        Self::bump_children_revision(entry);
4941    }
4942
4943    /// Merge a completed detached directory without cloning its retained roll-up.
4944    fn merge_detached_descendants(&mut self, parent: EntryId, child: EntryId) {
4945        debug_assert!(parent.idx() < child.idx(), "cold parents must precede descendants");
4946        let (parents, children) = self.arena.split_at_mut(child.idx());
4947        let child_rollup = match &children[0] {
4948            Slot::Occupied { generation, entry } if *generation == child.generation => {
4949                entry.rollup()
4950            }
4951            Slot::Occupied { .. } | Slot::Free { .. } => {
4952                panic!("detached child handle must be live: {child:?}")
4953            }
4954        };
4955        let parent_entry = match &mut parents[parent.idx()] {
4956            Slot::Occupied { generation, entry } if *generation == parent.generation => entry,
4957            Slot::Occupied { .. } | Slot::Free { .. } => {
4958                panic!("detached parent handle must be live: {parent:?}")
4959            }
4960        };
4961        parent_entry.rollup_mut().merge(child_rollup);
4962    }
4963
4964    fn remove_child(&mut self, parent: EntryId, name: &OsStr) {
4965        self.promote_children(parent);
4966        let entry = self.entry_mut(parent);
4967        let DirectoryChildren::Mutable(children) = &mut entry.directory_mut().children else {
4968            unreachable!("child promotion produces mutable storage")
4969        };
4970        if children.remove(name).is_some() {
4971            Self::bump_children_revision(entry);
4972        }
4973    }
4974
4975    fn entry(&self, id: EntryId) -> &Entry {
4976        self.try_entry(id).expect("internal entry handle must be live")
4977    }
4978
4979    fn entry_mut(&mut self, id: EntryId) -> &mut Entry {
4980        match self.arena.get_mut(id.idx()) {
4981            Some(Slot::Occupied { generation, entry }) if *generation == id.generation => entry,
4982            Some(Slot::Occupied { .. } | Slot::Free { .. }) | None => {
4983                panic!("internal entry handle must be live: {id:?}")
4984            }
4985        }
4986    }
4987
4988    fn alloc(&mut self, entry: Entry) -> EntryId {
4989        crate::counters::bump(|c| c.entries_allocated += 1);
4990        self.live += 1;
4991        if let Some(free_slot) = self.free_head {
4992            let free_idx = free_slot as usize;
4993            let (generation, next) = match &self.arena[free_idx] {
4994                Slot::Free { generation, next_free } => (*generation, *next_free),
4995                Slot::Occupied { .. } => unreachable!("free list pointed at a live slot"),
4996            };
4997            self.free_head = next;
4998            self.arena[free_idx] = Slot::Occupied { generation, entry };
4999            return EntryId { slot: free_slot, generation };
5000        }
5001        let slot = u32::try_from(self.arena.len()).expect("index arena exceeded u32 capacity");
5002        let id = EntryId { slot, generation: 0 };
5003        self.arena.push(Slot::Occupied { generation: 0, entry });
5004        id
5005    }
5006
5007    fn free(&mut self, id: EntryId) {
5008        let next_generation = match &self.arena[id.idx()] {
5009            Slot::Occupied { generation, .. } if *generation == id.generation => {
5010                generation.checked_add(1).expect("entry generation exhausted")
5011            }
5012            Slot::Occupied { .. } | Slot::Free { .. } => {
5013                panic!("internal entry handle must be live: {id:?}")
5014            }
5015        };
5016        self.arena[id.idx()] =
5017            Slot::Free { generation: next_generation, next_free: self.free_head };
5018        self.free_head = Some(id.slot);
5019        self.live -= 1;
5020    }
5021
5022    /// Wall-clock now, in nanoseconds since the epoch, or zero if the clock is before
5023    /// it. Provenance timestamps are for display, so a nonsensical clock reads as
5024    /// "unknown" rather than propagating an error through every constructor.
5025    fn now_unix_nanos() -> i64 {
5026        std::time::SystemTime::now()
5027            .duration_since(std::time::UNIX_EPOCH)
5028            .ok()
5029            .and_then(|since| i64::try_from(since.as_nanos()).ok())
5030            .unwrap_or(0)
5031    }
5032
5033    /// Provenance of one path: where its value came from, when, and how settled.
5034    ///
5035    /// Built on demand from the entry's stored source and the index's timestamps
5036    /// rather than read from a field, because the timestamps are shared by nearly
5037    /// every entry and storing them per entry would cost far more than the
5038    /// information is worth.
5039    ///
5040    /// # Two limitations, both tracked
5041    ///
5042    /// **This reports the entry's own provenance, not its subtree's.** A directory
5043    /// whose descendants are less trustworthy than itself will still report its own
5044    /// source, so a `Complete`/`Revalidated` directory can contain `Cached` children.
5045    /// Composition belongs in the roll-up, where it costs one merge rather than an
5046    /// O(subtree) walk per query, and it is not implemented yet (`fdu-fka6`,
5047    /// `fdu-b1ts`). Do not read a directory's provenance as a subtree guarantee.
5048    ///
5049    /// A completed reconciliation records one clocked [`StateTransition::Verified`]
5050    /// for its subtree, including when every entry was unchanged. Consumers of exact
5051    /// commits therefore observe the same provenance movement as readers of this view.
5052    pub fn provenance(&self, path: &Path) -> Option<Provenance> {
5053        let id = self.lookup(path)?;
5054        Some(self.provenance_of(id))
5055    }
5056
5057    fn provenance_of(&self, id: EntryId) -> Provenance {
5058        let entry = self.entry(id);
5059        let status = self.status_of(id);
5060        // A completed sweep over an ancestor verified this entry even if no delta ever
5061        // named it, so an interval beats the entry's own stamp.
5062        //
5063        // Only while the index still considers the path fresh, though. An
5064        // `InvalidateSubtree` marks paths `Stale` and a running sweep marks them
5065        // `Reconciling`; in both cases trust has been withdrawn since the interval was
5066        // recorded, and promoting anyway would produce the self-contradicting answer
5067        // "partial, and verified".
5068        //
5069        // This applies to entries a delta *did* name, too, not only the ones it
5070        // skipped. Those were stamped `Revalidated` by the sweep, but their timestamp
5071        // would otherwise come from `observed_at`, which dates `Revalidated` to when
5072        // the index was constructed. One sweep would then report two different "as of"
5073        // times for equally verified paths — the elided siblings dated correctly to the
5074        // sweep, the touched entries dated to construction — and a consumer comparing
5075        // two rows could not tell which discipline it was reading.
5076        //
5077        // `Scanned` is excluded because it is *stronger* than `Revalidated`: a path
5078        // walked fresh this session is not improved by a sweep having covered it, and
5079        // its own scan time is already the right answer.
5080        if entry.source >= Source::Revalidated {
5081            if let Some(path) = self.path_of(id) {
5082                if self.freshness_at(&path) == Freshness::Fresh {
5083                    if let Some(verified_at) = self.verified_at(&path) {
5084                        return Provenance {
5085                            source: Source::Revalidated,
5086                            observed_at_ns: verified_at,
5087                            status,
5088                        };
5089                    }
5090                }
5091            }
5092        }
5093        Provenance { source: entry.source, observed_at_ns: self.observed_at(entry.source), status }
5094    }
5095
5096    /// Whether this path's totals account for everything beneath it.
5097    ///
5098    /// Derived from the freshness marks rather than stored, and answering the coverage
5099    /// question only. `Reconciling` and `Stale` describe values whose *trust* is in
5100    /// doubt while their coverage is not: a cached subtree still accounts for every
5101    /// entry it knows about, and saying otherwise would report a complete cached
5102    /// baseline as if it were half-built. That distinction is [`Source`]'s job, and
5103    /// collapsing the two axes is what let a value that may shrink advertise itself as
5104    /// a lower bound that can only grow.
5105    ///
5106    /// Only [`Freshness::Partial`] — reconciliation errors left some of the subtree
5107    /// unread — is genuinely missing coverage.
5108    fn status_of(&self, id: EntryId) -> Status {
5109        let Some(path) = self.path_of(id) else {
5110            return Status::Complete;
5111        };
5112        match self.freshness_at(&path) {
5113            Freshness::Fresh | Freshness::Reconciling | Freshness::Stale => Status::Complete,
5114            Freshness::Partial => Status::Partial,
5115        }
5116    }
5117
5118    /// When an entry with this source was observed.
5119    const fn observed_at(&self, source: Source) -> i64 {
5120        match source {
5121            Source::Cached | Source::JournalScoped => self.captured_at_ns,
5122            Source::Scanned | Source::Revalidated => self.scanned_at_ns,
5123        }
5124    }
5125
5126    /// The start of the pass a snapshot of this index records as the one that wrote its
5127    /// image.
5128    pub(crate) const fn writing_pass_started_at_ns(&self) -> i64 {
5129        self.writing_pass_started_at_ns
5130    }
5131
5132    /// Record the pass start a loaded snapshot carried for the facts it restored.
5133    pub(crate) fn set_writing_pass_started_at_ns(&mut self, writing_pass_started_at_ns: i64) {
5134        self.writing_pass_started_at_ns = writing_pass_started_at_ns;
5135    }
5136
5137    /// Whether this index holds entry-tier facts no completed metadata write has recorded.
5138    pub(crate) const fn persistence_owed(&self) -> bool {
5139        self.persistence_owed
5140    }
5141
5142    /// Record that a metadata write of this index completed, or that a pass mutated it
5143    /// since the last one did.
5144    pub(crate) fn set_persistence_owed(&mut self, owed: bool) {
5145        self.persistence_owed = owed;
5146    }
5147
5148    /// Stamp deltas applied from here on with `source`, restoring the previous value
5149    /// when the returned guard value is passed back.
5150    ///
5151    /// Used by snapshot loading, which is replaying observations that describe a tree
5152    /// as it was, not as this process has seen it.
5153    pub(crate) fn set_applying_source(&mut self, source: Source, captured_at_ns: i64) -> Source {
5154        let previous = self.applying_source;
5155        self.applying_source = source;
5156        if source == Source::Cached {
5157            self.state.source = Source::Cached;
5158        }
5159        if captured_at_ns != 0 {
5160            self.captured_at_ns = captured_at_ns;
5161        }
5162        previous
5163    }
5164
5165    /// Intern an extension name and retain one file's reference to it.
5166    ///
5167    /// Every call must be matched by a [`Self::release_ext`] when that file leaves the
5168    /// index, which is what keeps the interner proportional to the extensions the tree
5169    /// currently holds rather than to every extension it has ever held.
5170    fn intern_ext(&mut self, name: &str) -> ExtId {
5171        if let Some(&id) = self.ext_ids.get(name) {
5172            let refcount =
5173                self.ext_refcounts.get_mut(id as usize).expect("a live id has a refcount");
5174            *refcount = refcount.checked_add(1).expect("extension refcount exhausted");
5175            return id;
5176        }
5177        let id = if let Some(id) = self.free_ext_ids.pop() {
5178            let slot = id as usize;
5179            self.ext_names[slot] = Some(name.to_string());
5180            self.ext_refcounts[slot] = 1;
5181            id
5182        } else {
5183            let id = ExtId::try_from(self.ext_names.len()).expect("extension interner exhausted");
5184            self.ext_names.push(Some(name.to_string()));
5185            self.ext_refcounts.push(1);
5186            id
5187        };
5188        self.ext_ids.insert(name.to_string(), id);
5189        id
5190    }
5191
5192    /// Drop one file's reference, freeing the id and its name after the last one.
5193    fn release_ext(&mut self, id: ExtId) {
5194        let slot = id as usize;
5195        let refcount = self.ext_refcounts.get_mut(slot).expect("a live id has a refcount");
5196        debug_assert!(*refcount > 0, "extension reference released twice");
5197        *refcount -= 1;
5198        if *refcount != 0 {
5199            return;
5200        }
5201        let name = self.ext_names[slot].take().expect("a live id has a name");
5202        let removed = self.ext_ids.remove(&name);
5203        debug_assert_eq!(removed, Some(id), "the interner's two maps disagreed");
5204        self.free_ext_ids.push(id);
5205    }
5206
5207    /// Resolve hot-path integer keys exactly once at a public query boundary.
5208    fn named_rollup(&self, rollup: &InternedRollUp) -> RollUp {
5209        let by_ext = rollup
5210            .by_ext
5211            .iter()
5212            .map(|(id, tally)| {
5213                let name = self
5214                    .ext_names
5215                    .get(*id as usize)
5216                    .and_then(Option::as_ref)
5217                    .expect("a live roll-up's extension id has a name");
5218                (name.clone(), *tally)
5219            })
5220            .collect();
5221        RollUp {
5222            files: rollup.files,
5223            dirs: rollup.dirs,
5224            bytes: rollup.bytes,
5225            allocated: rollup.allocated,
5226            newest_mtime_ns: rollup.newest_mtime_ns,
5227            by_ext,
5228        }
5229    }
5230
5231    fn named_partitions(&self, rollup: &InternedPartitionRollUp) -> PartitionRollUp {
5232        PartitionRollUp {
5233            all: self.named_rollup(&rollup.all),
5234            unignored: self.named_rollup(&rollup.unignored),
5235        }
5236    }
5237
5238    /// What an entry contributes to each of its ancestors.
5239    fn contribution(&self, id: EntryId) -> InternedPartitionRollUp {
5240        let entry = self.entry(id);
5241        match entry.kind {
5242            EntryKind::Dir => {
5243                let mut all = entry.rollup().all.clone();
5244                all.dirs += 1;
5245                let mut unignored = InternedRollUp::default();
5246                if !entry.ignored {
5247                    unignored = entry.rollup().unignored.clone();
5248                    unignored.dirs += 1;
5249                }
5250                InternedPartitionRollUp { all, unignored }
5251            }
5252            EntryKind::File => {
5253                let mut all = InternedRollUp {
5254                    files: 1,
5255                    dirs: 0,
5256                    bytes: entry.attrs.size,
5257                    allocated: entry.attrs.allocated,
5258                    newest_mtime_ns: entry.attrs.mtime_ns,
5259                    by_ext: BTreeMap::new(),
5260                };
5261                if let Some(ext_id) = entry.ext_id {
5262                    all.by_ext.insert(
5263                        ext_id,
5264                        ExtTally {
5265                            files: 1,
5266                            bytes: entry.attrs.size,
5267                            allocated: entry.attrs.allocated,
5268                        },
5269                    );
5270                }
5271                let unignored = if entry.ignored { InternedRollUp::default() } else { all.clone() };
5272                InternedPartitionRollUp { all, unignored }
5273            }
5274            EntryKind::Symlink | EntryKind::Other => InternedPartitionRollUp::default(),
5275        }
5276    }
5277
5278    fn merge_upward(
5279        &mut self,
5280        from_parent: Option<EntryId>,
5281        contribution: &InternedPartitionRollUp,
5282    ) {
5283        let mut current = from_parent;
5284        while let Some(id) = current {
5285            // Counted per level rather than per call: the O(depth) shape is the thing
5286            // worth seeing, and it is what S4's bottom-up pass would collapse.
5287            crate::counters::bump(|c| c.rollup_merges += 1);
5288            let entry = self.entry_mut(id);
5289            entry.rollup_mut().merge(contribution);
5290            current = entry.parent;
5291        }
5292    }
5293
5294    fn unmerge_upward(
5295        &mut self,
5296        from_parent: Option<EntryId>,
5297        contribution: &InternedPartitionRollUp,
5298    ) {
5299        let mut current = from_parent;
5300        while let Some(id) = current {
5301            let entry = self.entry_mut(id);
5302            entry.rollup_mut().unmerge(contribution);
5303            current = entry.parent;
5304        }
5305    }
5306
5307    /// Rebuild `newest_mtime_ns` from direct children, walking to the root.
5308    ///
5309    /// Every ancestor must be visited even when the nearest directory is already
5310    /// correct. Differential unmerge/re-merge can repair a single-child directory as
5311    /// it goes while leaving an ancestor with other contributors holding the removed
5312    /// maximum. Stopping at the first unchanged directory therefore strands a stale
5313    /// value higher in the tree.
5314    fn recompute_newest_upward(&mut self, from: Option<EntryId>) {
5315        let mut current = from;
5316        while let Some(id) = current {
5317            let mut newest: Option<i64> = None;
5318            for child in self.child_ids(id) {
5319                let child_entry = self.entry(child);
5320                let candidate = match child_entry.kind {
5321                    EntryKind::Dir => (child_entry.rollup().files > 0)
5322                        .then_some(child_entry.rollup().newest_mtime_ns),
5323                    EntryKind::File => Some(child_entry.attrs.mtime_ns),
5324                    EntryKind::Symlink | EntryKind::Other => None,
5325                };
5326                if let Some(candidate) = candidate {
5327                    newest = Some(newest.map_or(candidate, |current| current.max(candidate)));
5328                }
5329            }
5330            let newest = newest.unwrap_or(0);
5331            self.entry_mut(id).rollup_mut().all.newest_mtime_ns = newest;
5332
5333            let mut newest_unignored: Option<i64> = None;
5334            for child in self.child_ids(id) {
5335                let child_entry = self.entry(child);
5336                let candidate = match child_entry.kind {
5337                    EntryKind::Dir => (!child_entry.ignored
5338                        && child_entry.rollup().unignored.files > 0)
5339                        .then_some(child_entry.rollup().unignored.newest_mtime_ns),
5340                    EntryKind::File => (!child_entry.ignored).then_some(child_entry.attrs.mtime_ns),
5341                    EntryKind::Symlink | EntryKind::Other => None,
5342                };
5343                if let Some(candidate) = candidate {
5344                    newest_unignored =
5345                        Some(newest_unignored.map_or(candidate, |current| current.max(candidate)));
5346                }
5347            }
5348            let entry = self.entry_mut(id);
5349            entry.rollup_mut().unignored.newest_mtime_ns = newest_unignored.unwrap_or(0);
5350            current = entry.parent;
5351        }
5352    }
5353
5354    /// Resolve a parent chain already proved by [`Self::validate_known_ancestry`].
5355    fn resolve_dir_chain(&self, parts: &[&OsStr]) -> EntryId {
5356        let mut current = EntryId::ROOT;
5357        for part in parts {
5358            current = self
5359                .child(current, part)
5360                .expect("validated ancestry remains present under the writer lock");
5361            debug_assert!(self.entry(current).kind.is_dir());
5362        }
5363        current
5364    }
5365
5366    fn apply_upsert<C: ConsequenceSink>(
5367        &mut self,
5368        path: &Path,
5369        kind: EntryKind,
5370        attrs: Attrs,
5371        stats: &mut ApplyStats,
5372        effects: &mut C,
5373        parent_memo: &mut ParentMemo,
5374    ) -> bool {
5375        // A walker reports a directory's children consecutively, because that is the
5376        // order one `getdents64` batch hands them over, so the parent resolved for the
5377        // previous entry is almost always the parent of this one. Checking that first
5378        // turns the common case into a single path comparison and skips both the
5379        // component vector and the descent below.
5380        crate::counters::bump(|c| c.upserts += 1);
5381        if let (Some(dir), Some(name)) = (path.parent(), path.file_name()) {
5382            if let Some(parent) = parent_memo.get(dir) {
5383                crate::counters::bump(|c| c.parent_memo_hits += 1);
5384                return self.upsert_beneath(parent, name, path, kind, attrs, stats, effects);
5385            }
5386        }
5387        crate::counters::bump(|c| c.parent_resolutions += 1);
5388
5389        let Some(parts) = normalize(path) else {
5390            return false;
5391        };
5392        let source = self.applying_source;
5393
5394        let Some((name, ancestors)) = parts.split_last() else {
5395            // The root itself: only its own attributes can change. Its source is
5396            // stamped on both paths for the same reason every other entry's is — a
5397            // producer just looked at it — and the root is the entry where getting this
5398            // wrong costs the most, because the whole-tree totals hang off it and a
5399            // consumer reads its provenance to label the headline number.
5400            if self.entry(EntryId::ROOT).attrs == attrs {
5401                self.entry_mut(EntryId::ROOT).source = source;
5402                stats.unchanged += 1;
5403                return false;
5404            }
5405            let root = self.entry_mut(EntryId::ROOT);
5406            let previous = root.attrs;
5407            root.attrs = attrs;
5408            root.source = source;
5409            Self::bump_revision(root);
5410            stats.updated += 1;
5411            effects.change(|| EffectiveChange::Updated {
5412                path: PathBuf::new(),
5413                kind: EntryKind::Dir,
5414                previous,
5415                current: attrs,
5416            });
5417            return true;
5418        };
5419        let parent = self.resolve_dir_chain(ancestors);
5420        if let Some(dir) = path.parent() {
5421            parent_memo.set(dir, parent);
5422        }
5423        self.upsert_beneath(parent, name, path, kind, attrs, stats, effects)
5424    }
5425
5426    /// Apply one upsert beneath a parent whose id is already resolved.
5427    ///
5428    /// This is the whole of [`apply_upsert`] except for finding the parent, split out so
5429    /// that the memoized and the resolved paths share one body rather than two copies of
5430    /// the arbitration rules.  Every guard the delta contract requires still runs here:
5431    /// the caller has supplied a parent, not a decision.
5432    #[allow(clippy::too_many_arguments)]
5433    fn upsert_beneath<C: ConsequenceSink>(
5434        &mut self,
5435        parent: EntryId,
5436        name: &OsStr,
5437        path: &Path,
5438        kind: EntryKind,
5439        attrs: Attrs,
5440        stats: &mut ApplyStats,
5441        effects: &mut C,
5442    ) -> bool {
5443        let source = self.applying_source;
5444        let existing = self.child(parent, name);
5445
5446        if let Some(id) = existing {
5447            let entry = self.entry(id);
5448            if entry.kind == kind {
5449                if entry.attrs == attrs {
5450                    // Nothing about the value changed, but a producer just looked at
5451                    // it, and that is exactly what provenance records. Without this an
5452                    // entry verified by a revalidation sweep keeps reporting the source
5453                    // it was loaded with, and a consumer could never clear a
5454                    // stale-value indicator no matter how much checking happened.
5455                    self.entry_mut(id).source = source;
5456                    stats.unchanged += 1;
5457                    return false;
5458                }
5459                if kind.is_dir() {
5460                    // A directory's own attributes do not reach its ancestors' roll-ups,
5461                    // so there is nothing to re-merge.
5462                    let entry = self.entry_mut(id);
5463                    let previous = entry.attrs;
5464                    entry.attrs = attrs;
5465                    entry.source = source;
5466                    Self::bump_revision(entry);
5467                    stats.updated += 1;
5468                    effects.change(|| EffectiveChange::Updated {
5469                        path: path.to_path_buf(),
5470                        kind,
5471                        previous,
5472                        current: attrs,
5473                    });
5474                    return true;
5475                }
5476                let previous_attrs = entry.attrs;
5477                self.invalidate_content(path);
5478                if kind == EntryKind::File {
5479                    self.remove_serving_file_semantics(path, id, previous_attrs);
5480                }
5481                self.remove_serving_entry(path, kind, previous_attrs, id);
5482                let old = self.contribution(id);
5483                self.unmerge_upward(Some(parent), &old);
5484                let entry = self.entry_mut(id);
5485                let previous = entry.attrs;
5486                entry.attrs = attrs;
5487                entry.source = source;
5488                Self::bump_revision(entry);
5489                let new = self.contribution(id);
5490                self.merge_upward(Some(parent), &new);
5491                self.insert_serving_entry(path, kind, attrs, id);
5492                if new.newest_mtime_ns < old.newest_mtime_ns {
5493                    self.recompute_newest_upward(Some(parent));
5494                }
5495                stats.updated += 1;
5496                effects.change(|| EffectiveChange::Updated {
5497                    path: path.to_path_buf(),
5498                    kind,
5499                    previous,
5500                    current: attrs,
5501                });
5502                return true;
5503            }
5504            // The kind changed (a file became a directory, say). Remove and re-insert
5505            // rather than trying to mutate one shape into the other.
5506            //
5507            // This drops a subtree but cannot invalidate the memo: the memo holds this
5508            // entry's *parent*, and the subtree removed is rooted at the entry itself.
5509            // Clearing here would be untestable defensive code, which reads as a hazard
5510            // that does not exist.
5511            self.remove_entry(id, stats, effects);
5512        }
5513
5514        let ext_id =
5515            (kind == EntryKind::File).then(|| self.intern_ext(&crate::classify::ext_bucket(name)));
5516        let ignored =
5517            self.entry(parent).ignored || self.controls.matcher_for(path).is_ignored(kind.is_dir());
5518        let id = self.alloc(Entry::new(
5519            NewEntry {
5520                parent: Some(parent),
5521                name: name.to_os_string(),
5522                ext_id,
5523                ignored,
5524                source,
5525                kind,
5526                attrs,
5527            },
5528            false,
5529        ));
5530        self.insert_child(parent, name.to_os_string(), id);
5531        let contribution = self.contribution(id);
5532        self.merge_upward(Some(parent), &contribution);
5533        stats.inserted += 1;
5534        effects.change(|| EffectiveChange::Inserted { path: path.to_path_buf(), kind, attrs });
5535        self.insert_serving_entry(path, kind, attrs, id);
5536        true
5537    }
5538
5539    /// Insert one snapshot record beneath a parent whose id the caller already holds.
5540    ///
5541    /// The snapshot loader is not a producer.  It restores state that the delta contract
5542    /// already arbitrated and serialized, in the order it was written, with parents
5543    /// always preceding their children — so every fact [`apply_upsert`] rediscovers by
5544    /// resolving a path is a fact the loader was handed.  Routing it through the
5545    /// observation path made the loader pay, per record, a `PathBuf` join, an
5546    /// `Observation` vector, a `normalize` vector, and a descent from the root through
5547    /// one `BTreeMap` lookup per level, to arrive at a parent it had in a local variable.
5548    /// A callgrind profile of a 450k-entry load put the allocator at about 27% of the
5549    /// work and path-component iteration at about 15%; this removes both.
5550    ///
5551    /// It stays `pub(crate)` and takes an `EntryId` rather than a path precisely so it
5552    /// cannot become a second mutation surface: no external producer can reach it, and
5553    /// the guarantee that a loaded index equals the saved one is enforced by round-trip
5554    /// tests rather than by making deserialization impersonate a producer.
5555    ///
5556    /// Returns `None` when the parent is not a live directory or already holds `name`,
5557    /// which is how a corrupt snapshot fails closed.
5558    pub(crate) fn insert_loaded_child(
5559        &mut self,
5560        parent: EntryId,
5561        name: OsString,
5562        kind: EntryKind,
5563        attrs: Attrs,
5564    ) -> Option<EntryId> {
5565        let parent_entry = self.try_entry(parent)?;
5566        if parent_entry.kind != EntryKind::Dir || self.child(parent, &name).is_some() {
5567            return None;
5568        }
5569        let source = self.applying_source;
5570        let ext_id =
5571            (kind == EntryKind::File).then(|| self.intern_ext(&crate::classify::ext_bucket(&name)));
5572        let id = self.alloc(Entry::new(
5573            NewEntry {
5574                parent: Some(parent),
5575                name: name.clone(),
5576                ext_id,
5577                ignored: false,
5578                source,
5579                kind,
5580                attrs,
5581            },
5582            true,
5583        ));
5584        self.insert_child(parent, name, id);
5585        // Roll-ups stay eager. The same profile put `merge_upward` at about 3.5%, so
5586        // deferring it to a bottom-up pass would buy little and would introduce a window
5587        // in which the index is structurally complete but numerically wrong.
5588        let contribution = self.contribution(id);
5589        self.merge_upward(Some(parent), &contribution);
5590        // One-shot snapshot load constructs the index with serving off.
5591        // `insert_serving_entry` would discard a reconstructed path.
5592        if self.serving.is_some() {
5593            let path = self.path_of(id).expect("a newly loaded entry has a path");
5594            self.insert_serving_entry(&path, kind, attrs, id);
5595        }
5596        Some(id)
5597    }
5598
5599    fn apply_remove<C: ConsequenceSink>(
5600        &mut self,
5601        path: &Path,
5602        stats: &mut ApplyStats,
5603        effects: &mut C,
5604    ) -> bool {
5605        let Some(id) = self.lookup(path) else {
5606            stats.unchanged += 1;
5607            return false;
5608        };
5609        if id == EntryId::ROOT {
5610            stats.unchanged += 1;
5611            return false;
5612        }
5613        self.remove_entry(id, stats, effects);
5614        true
5615    }
5616
5617    fn remove_entry<C: ConsequenceSink>(
5618        &mut self,
5619        id: EntryId,
5620        stats: &mut ApplyStats,
5621        effects: &mut C,
5622    ) {
5623        let removed_root = self.path_of(id).expect("a live entry has a path");
5624        self.invalidate_content(&removed_root);
5625        self.remove_serving_subtree_semantics(id, &removed_root);
5626        let parent = self.entry(id).parent;
5627        let name = self.entry(id).name.clone();
5628        let contribution = self.contribution(id);
5629
5630        self.unmerge_upward(parent, &contribution);
5631        if let Some(parent) = parent {
5632            self.remove_child(parent, &name);
5633        }
5634
5635        // Free the subtree iteratively; a recursive drop would blow the stack on deep
5636        // trees, which is exactly the shape this engine is built for.
5637        let mut queue = VecDeque::from([(id, removed_root)]);
5638        while let Some((node, path)) = queue.pop_front() {
5639            let entry = self.entry(node);
5640            let kind = entry.kind;
5641            let attrs = entry.attrs;
5642            let children: Vec<(OsString, EntryId)> = self
5643                .children_of(node)
5644                .expect("removed subtree entry is live")
5645                .map(|(name, child)| (name.to_os_string(), child))
5646                .collect();
5647            let ext_id = entry.ext_id;
5648            for (name, child) in children {
5649                queue.push_back((child, path.join(name)));
5650            }
5651            self.remove_serving_entry(&path, kind, attrs, node);
5652            effects.change(|| EffectiveChange::Removed { path, kind, attrs });
5653            // Give the extension back before the entry itself goes, so the interner
5654            // holds only what the tree still contains.
5655            if let Some(ext_id) = ext_id {
5656                self.release_ext(ext_id);
5657            }
5658            self.free(node);
5659            stats.removed += 1;
5660        }
5661
5662        // The max may have lived in what was just removed.
5663        self.recompute_newest_upward(parent);
5664    }
5665
5666    fn invalidate_content(&mut self, path: &Path) {
5667        if let Some(content) = self.content.as_mut() {
5668            content.invalidate(path);
5669        }
5670    }
5671}
5672
5673/// Capture every child's expectation directly off its live entry, with no path work.
5674///
5675/// Both reconcile targets use this. The exclusive path once had a twin in `scan.rs`
5676/// that re-derived each expectation by joining a `PathBuf` and descending from the
5677/// root — two full descents and ~13 allocations per child to recover an `EntryId`
5678/// the iterator already held. The equivalence test below is what lets the twin stay
5679/// deleted.
5680pub(crate) fn collect_child_expectations(
5681    index: &Index,
5682    path: &Path,
5683) -> BTreeMap<OsString, PathExpectation> {
5684    index.children(path).map_or_else(BTreeMap::new, |children| {
5685        children
5686            .map(|(name, id)| {
5687                let entry = index.entry(id);
5688                let expectation = PathExpectation::new(
5689                    PathState::Present { kind: entry.kind, attrs: entry.attrs },
5690                    Some(index.identity(id)),
5691                    None,
5692                );
5693                (name.to_os_string(), expectation)
5694            })
5695            .collect()
5696    })
5697}
5698
5699/// Split a relative path into its normal components, rejecting anything that escapes.
5700///
5701/// Returns `None` for paths containing `..`, a root, or a prefix — an index keyed by
5702/// relative path has no way to represent those, and silently normalizing them away would
5703/// let a delta write outside the tree it claims to describe.
5704/// The components are borrowed from `path`, not copied out of it.
5705///
5706/// Owning them cost an allocation per component, and this runs twice for every
5707/// operation in every batch — once to validate the path and once to apply it. On a
5708/// tree averaging eight levels deep that was on the order of eighteen allocations per
5709/// entry, all of them holding bytes that the caller's `PathBuf` already owned and
5710/// outlives. Only the returned `Vec` allocates now, and only where a slice is
5711/// genuinely needed.
5712/// The parent directory resolved for the previous upsert in a batch.
5713///
5714/// A walker reports a directory's children consecutively, so resolving the parent path
5715/// once per directory rather than once per entry removes the dominant cost of applying a
5716/// cold scan: a callgrind profile attributed about 25 path-component comparisons per
5717/// entry to the descent, and the component vector `normalize` builds is an allocation
5718/// per entry on top of that.
5719///
5720/// It is a single slot rather than a map on purpose.  A map would keep entries alive
5721/// across structural changes and turn every miss into a hash, where consecutive runs are
5722/// what the walker actually produces; one slot captures those and costs a path
5723/// comparison when it misses.  The slot holds an id, so it must be cleared whenever a
5724/// removal could unmake it — [`Index::apply_remove`], an invalidation, and the
5725/// kind-change removal inside an upsert all do.
5726#[derive(Default)]
5727struct ParentMemo {
5728    entry: Option<(PathBuf, EntryId)>,
5729}
5730
5731impl ParentMemo {
5732    /// The id remembered for `dir`, if the last resolved parent was that directory.
5733    fn get(&self, dir: &Path) -> Option<EntryId> {
5734        self.entry.as_ref().filter(|(cached, _)| cached == dir).map(|&(_, id)| id)
5735    }
5736
5737    fn set(&mut self, dir: &Path, id: EntryId) {
5738        match &mut self.entry {
5739            // Overwriting in place keeps this to one allocation per directory rather
5740            // than one per run, which matters because a wide tree alternates often.
5741            Some((cached, cached_id)) => {
5742                cached.clear();
5743                cached.push(dir);
5744                *cached_id = id;
5745            }
5746            slot => *slot = Some((dir.to_path_buf(), id)),
5747        }
5748    }
5749
5750    fn clear(&mut self) {
5751        self.entry = None;
5752    }
5753}
5754
5755/// Structural effects of accepted operations evaluated before the real mutation.
5756#[derive(Default)]
5757struct StructuralOverlay {
5758    // Only point lookup and subtree retention use these keys; no iteration order is
5759    // observed. Ordering every path on lookup and insert dominated public preflight.
5760    entries: HashMap<PathBuf, EntryKind>,
5761    removed_roots: Vec<PathBuf>,
5762}
5763
5764impl StructuralOverlay {
5765    fn kind(&self, index: &Index, path: &Path) -> Option<EntryKind> {
5766        self.entries.get(path).copied().or_else(|| {
5767            (!self.removed_roots.iter().any(|removed| path.starts_with(removed)))
5768                .then(|| index.kind(path))
5769                .flatten()
5770        })
5771    }
5772
5773    fn upsert(&mut self, index: &Index, path: &Path, kind: EntryKind) {
5774        if self.kind(index, path).is_some_and(|current| current != kind) {
5775            self.remove(index, path);
5776        }
5777        self.entries.insert(path.to_path_buf(), kind);
5778    }
5779
5780    fn remove(&mut self, index: &Index, path: &Path) {
5781        if self.kind(index, path).is_none() {
5782            return;
5783        }
5784        self.entries.retain(|candidate, _| !candidate.starts_with(path));
5785        self.removed_roots.retain(|candidate| !candidate.starts_with(path));
5786        if !self.removed_roots.iter().any(|removed| path.starts_with(removed)) {
5787            self.removed_roots.push(path.to_path_buf());
5788        }
5789    }
5790}
5791
5792fn normalize(path: &Path) -> Option<Vec<&OsStr>> {
5793    let mut parts = Vec::new();
5794    for component in path.components() {
5795        match component {
5796            Component::Normal(part) => parts.push(part),
5797            Component::CurDir => {}
5798            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
5799        }
5800    }
5801    Some(parts)
5802}
5803
5804fn prepare_observation(observation: &Observation) -> crate::Result<PreparedObservation> {
5805    let mut ops = Vec::with_capacity(observation.len());
5806    for observed in &observation.ops {
5807        let path = canonical_relative_path(observed.op.path())?;
5808        let op = match &observed.op {
5809            Op::Upsert { kind, attrs, .. } => Op::Upsert { path, kind: *kind, attrs: *attrs },
5810            Op::Remove { .. } => Op::Remove { path },
5811            Op::ControlUpsert { source, .. } => {
5812                if !crate::control::is_control_file(&path) {
5813                    return Err(crate::Error::InvalidControlPath(path));
5814                }
5815                Op::ControlUpsert { path, source: source.clone() }
5816            }
5817            Op::ControlRemove { .. } => {
5818                if !crate::control::is_control_file(&path) {
5819                    return Err(crate::Error::InvalidControlPath(path));
5820                }
5821                Op::ControlRemove { path }
5822            }
5823            Op::InvalidateSubtree { reason, .. } => Op::InvalidateSubtree { path, reason: *reason },
5824        };
5825        ops.push(ObservationOp { op, expectation: observed.expectation });
5826    }
5827    Ok(PreparedObservation {
5828        ops,
5829        ancestry: PreparedAncestry::General,
5830        #[cfg(test)]
5831        reject_before_apply: false,
5832    })
5833}
5834
5835fn canonical_relative_path(path: &Path) -> crate::Result<PathBuf> {
5836    let mut canonical = PathBuf::with_capacity(path.as_os_str().as_encoded_bytes().len());
5837    for component in path.components() {
5838        match component {
5839            Component::Normal(part) => canonical.push(part),
5840            Component::CurDir => {}
5841            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
5842                return Err(crate::Error::PathEscapesRoot(path.to_path_buf()));
5843            }
5844        }
5845    }
5846    Ok(canonical)
5847}
5848
5849fn derive_impact(changes: &[EffectiveChange], state: &[StateTransition]) -> Impact {
5850    let mut domains = BTreeSet::new();
5851    let mut paths = BTreeSet::new();
5852    let mut all_dirty = false;
5853    let mut ancestor_visits = 0_u64;
5854    let count_impact = crate::counters::enabled();
5855
5856    for change in changes {
5857        match change {
5858            EffectiveChange::Inserted { .. } | EffectiveChange::Removed { .. } => {
5859                domains.extend([
5860                    ImpactDomain::Topology,
5861                    ImpactDomain::Metadata,
5862                    ImpactDomain::Classification,
5863                    ImpactDomain::Aggregates,
5864                    ImpactDomain::Content,
5865                ]);
5866            }
5867            EffectiveChange::Updated { .. } => {
5868                domains.extend([
5869                    ImpactDomain::Metadata,
5870                    ImpactDomain::Aggregates,
5871                    ImpactDomain::Content,
5872                ]);
5873            }
5874            EffectiveChange::ControlUpdated { .. } | EffectiveChange::Reclassified { .. } => {
5875                domains.extend([ImpactDomain::Classification, ImpactDomain::Aggregates]);
5876            }
5877            EffectiveChange::ControlRefusalUpdated { .. } => {
5878                domains.insert(ImpactDomain::Classification);
5879            }
5880            EffectiveChange::Invalidated { .. } => {
5881                domains.insert(ImpactDomain::State);
5882            }
5883        }
5884        insert_dirty_ancestors(
5885            change.path(),
5886            &mut paths,
5887            &mut all_dirty,
5888            count_impact,
5889            &mut ancestor_visits,
5890        );
5891    }
5892    for transition in state {
5893        domains.insert(ImpactDomain::State);
5894        insert_dirty_ancestors(
5895            transition.path(),
5896            &mut paths,
5897            &mut all_dirty,
5898            count_impact,
5899            &mut ancestor_visits,
5900        );
5901    }
5902
5903    if count_impact {
5904        let candidates =
5905            u64::try_from(changes.len().saturating_add(state.len())).unwrap_or(u64::MAX);
5906        let retained_dirty_paths = u64::try_from(paths.len()).unwrap_or(u64::MAX);
5907        crate::counters::bump(|counts| {
5908            counts.impact_candidates = counts.impact_candidates.saturating_add(candidates);
5909            counts.impact_ancestor_visits =
5910                counts.impact_ancestor_visits.saturating_add(ancestor_visits);
5911            counts.impact_retained_dirty_paths =
5912                counts.impact_retained_dirty_paths.saturating_add(retained_dirty_paths);
5913            counts.impact_all_dirty = counts.impact_all_dirty.saturating_add(u64::from(all_dirty));
5914        });
5915    }
5916
5917    Impact {
5918        domains: domains.into_iter().collect(),
5919        dirty_paths: if all_dirty { Vec::new() } else { paths.into_iter().collect() },
5920        all_dirty,
5921    }
5922}
5923
5924fn commit_work(observations: u64, stats: ApplyStats) -> Work {
5925    Work {
5926        observations,
5927        unchanged: stats.unchanged,
5928        stale: stats.stale,
5929        resource_refused: stats.resource_refused,
5930        ..Work::default()
5931    }
5932}
5933
5934fn insert_dirty_ancestors(
5935    path: &Path,
5936    paths: &mut BTreeSet<PathBuf>,
5937    all_dirty: &mut bool,
5938    count_impact: bool,
5939    ancestor_visits: &mut u64,
5940) {
5941    if *all_dirty {
5942        return;
5943    }
5944    for ancestor in path.ancestors() {
5945        if count_impact {
5946            *ancestor_visits = ancestor_visits.saturating_add(1);
5947        }
5948        paths.insert(ancestor.to_path_buf());
5949        if paths.len() > MAX_DIRTY_PATHS {
5950            paths.clear();
5951            *all_dirty = true;
5952            return;
5953        }
5954    }
5955}
5956
5957fn same_target(
5958    current: Option<EntryIdentity>,
5959    expected: Option<EntryIdentity>,
5960    require_structure: bool,
5961) -> bool {
5962    match (current, expected) {
5963        (Some(current), Some(expected)) => current.same_target(expected, require_structure),
5964        (None, None) => true,
5965        (Some(_), None) | (None, Some(_)) => false,
5966    }
5967}
5968
5969#[cfg(test)]
5970mod tests {
5971    use super::*;
5972    use crate::engine_contract::ObservationOp;
5973    use std::sync::{Arc, Barrier};
5974
5975    #[test]
5976    fn reusable_entry_keeps_directory_state_out_of_line() {
5977        let entry_bytes = std::mem::size_of::<Entry>();
5978        let slot_bytes = std::mem::size_of::<Slot>();
5979
5980        assert!(
5981            entry_bytes <= 136,
5982            "common entry storage must not inline directory-only maps and roll-ups: {entry_bytes} bytes"
5983        );
5984        assert!(
5985            slot_bytes <= entry_bytes + 16,
5986            "the arena slot must not add a second per-entry allocation: entry={entry_bytes}, slot={slot_bytes}"
5987        );
5988    }
5989
5990    #[test]
5991    fn detached_children_store_each_name_once_and_promote_on_mutation() {
5992        let mut builder = DetachedIndexBuilder::new(
5993            "/root",
5994            ScanScope::default(),
5995            crate::classify::TypeRegistry::compiled_shared(),
5996        );
5997        builder
5998            .push_directory(&mut crate::scan::DetachedDirectory {
5999                path: PathBuf::new(),
6000                children: vec![
6001                    crate::scan::DetachedChild {
6002                        name: OsString::from("dir"),
6003                        kind: EntryKind::Dir,
6004                        attrs: Attrs::default(),
6005                        position: 0,
6006                    },
6007                    crate::scan::DetachedChild {
6008                        name: OsString::from("z.txt"),
6009                        kind: EntryKind::File,
6010                        attrs: file_attrs(1, 1),
6011                        position: 1,
6012                    },
6013                ],
6014                control: None,
6015            })
6016            .expect("detached root listing");
6017        builder
6018            .push_directory(&mut crate::scan::DetachedDirectory {
6019                path: PathBuf::from("dir"),
6020                children: vec![
6021                    crate::scan::DetachedChild {
6022                        name: OsString::from("z.txt"),
6023                        kind: EntryKind::File,
6024                        attrs: file_attrs(2, 2),
6025                        position: 0,
6026                    },
6027                    crate::scan::DetachedChild {
6028                        name: OsString::from("a.txt"),
6029                        kind: EntryKind::File,
6030                        attrs: file_attrs(3, 3),
6031                        position: 1,
6032                    },
6033                ],
6034                control: None,
6035            })
6036            .expect("detached child listing");
6037        let mut index = builder.finish();
6038        let directory = index.lookup(Path::new("dir")).expect("detached directory");
6039
6040        assert!(index.entry(EntryId::ROOT).directory().children.is_sorted());
6041        assert!(index.entry(directory).directory().children.is_sorted());
6042        assert_eq!(
6043            index
6044                .children(Path::new("dir"))
6045                .expect("directory children")
6046                .map(|(name, _)| name.to_os_string())
6047                .collect::<Vec<_>>(),
6048            [OsString::from("a.txt"), OsString::from("z.txt")]
6049        );
6050
6051        index.apply_ok(&Observation::new(vec![upsert(
6052            "dir/m.txt",
6053            EntryKind::File,
6054            file_attrs(4, 4),
6055        )]));
6056
6057        assert!(index.entry(EntryId::ROOT).directory().children.is_sorted());
6058        assert!(index.entry(directory).directory().children.is_mutable());
6059        assert_eq!(
6060            index
6061                .children(Path::new("dir"))
6062                .expect("directory children")
6063                .map(|(name, _)| name.to_os_string())
6064                .collect::<Vec<_>>(),
6065            [OsString::from("a.txt"), OsString::from("m.txt"), OsString::from("z.txt")]
6066        );
6067    }
6068
6069    #[test]
6070    fn detached_builder_tolerates_a_duplicate_readdir_name() {
6071        let mut builder = DetachedIndexBuilder::new(
6072            "/root",
6073            ScanScope::default(),
6074            crate::classify::TypeRegistry::compiled_shared(),
6075        );
6076        let twice = |position, mtime_ns| crate::scan::DetachedChild {
6077            name: OsString::from("twice.txt"),
6078            kind: EntryKind::File,
6079            attrs: file_attrs(1, mtime_ns),
6080            position,
6081        };
6082        let result = builder.push_directory(&mut crate::scan::DetachedDirectory {
6083            path: PathBuf::new(),
6084            children: vec![twice(0, 1), twice(1, 2)],
6085            control: None,
6086        });
6087        assert!(result.is_ok(), "a duplicate listing name must not fail the scan: {result:?}");
6088        let detached = builder.finish();
6089        assert_eq!(detached.total().files, 1);
6090        assert_eq!(detached.attrs(Path::new("twice.txt")), Some(&file_attrs(1, 2)));
6091
6092        // The streaming reducer tolerates the same input, and keeps the same observation.
6093        let mut streaming = Index::new("/root");
6094        streaming
6095            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
6096                upsert("twice.txt", EntryKind::File, file_attrs(1, 1)),
6097                upsert("twice.txt", EntryKind::File, file_attrs(1, 2)),
6098            ]))
6099            .expect("streaming tolerates a re-upsert");
6100        assert_eq!(streaming.total(), detached.total());
6101        assert_eq!(streaming.attrs(Path::new("twice.txt")), detached.attrs(Path::new("twice.txt")));
6102    }
6103
6104    #[test]
6105    fn detached_builder_accepts_the_repeated_walk_of_a_duplicated_directory() {
6106        let child = |name: &str, kind, attrs, position| crate::scan::DetachedChild {
6107            name: OsString::from(name),
6108            kind,
6109            attrs,
6110            position,
6111        };
6112        let listing = |path: &str, children| crate::scan::DetachedDirectory {
6113            path: PathBuf::from(path),
6114            children,
6115            control: None,
6116        };
6117        let mut builder = DetachedIndexBuilder::new(
6118            "/root",
6119            ScanScope::default(),
6120            crate::classify::TypeRegistry::compiled_shared(),
6121        );
6122        // The enumerator returned `dir` twice, and `swapped` first as a directory and then
6123        // as the file that replaced it.
6124        builder
6125            .push_directory(&mut listing(
6126                "",
6127                vec![
6128                    child("dir", EntryKind::Dir, file_attrs(0, 1), 0),
6129                    child("swapped", EntryKind::Dir, file_attrs(0, 1), 1),
6130                    child("dir", EntryKind::Dir, file_attrs(0, 2), 2),
6131                    child("swapped", EntryKind::File, file_attrs(5, 2), 3),
6132                ],
6133            ))
6134            .expect("root listing with repeated names");
6135        // The walker lists `dir`, and everything below it, once per observation.
6136        for _ in 0..2 {
6137            builder
6138                .push_directory(&mut listing(
6139                    "dir",
6140                    vec![child("nested", EntryKind::Dir, file_attrs(0, 3), 0)],
6141                ))
6142                .expect("each walk of the repeated directory");
6143            builder
6144                .push_directory(&mut listing(
6145                    "dir/nested",
6146                    vec![child("file.txt", EntryKind::File, file_attrs(4, 4), 0)],
6147                ))
6148                .expect("each walk below the repeated directory");
6149        }
6150        // It also lists the directory observation that the file superseded.
6151        builder
6152            .push_directory(&mut listing(
6153                "swapped",
6154                vec![child("stale.txt", EntryKind::File, file_attrs(6, 5), 0)],
6155            ))
6156            .expect("the superseded directory's walk");
6157        // A listing that no repeated name explains is still an ancestry failure.
6158        let error = builder
6159            .push_directory(&mut listing("elsewhere", Vec::new()))
6160            .expect_err("a listing whose parent was never listed");
6161        assert!(matches!(
6162            error,
6163            crate::Error::UnknownAncestry { path, .. } if path == Path::new("elsewhere")
6164        ));
6165
6166        let index = builder.finish();
6167        assert_eq!(index.attrs(Path::new("dir")), Some(&file_attrs(0, 2)));
6168        assert_eq!(index.kind(Path::new("swapped")), Some(EntryKind::File));
6169        assert!(index.lookup(Path::new("swapped/stale.txt")).is_none());
6170        let total = index.total();
6171        assert_eq!((total.files, total.dirs, total.bytes), (2, 2, 9));
6172    }
6173
6174    #[test]
6175    fn detached_builder_drains_an_applied_listing_for_its_worker() {
6176        // H159: the scan hands each listing back to the worker that allocated it, so the
6177        // builder must leave an applied listing's buffers in place and empty.
6178        let child = |name: &str, kind, position| crate::scan::DetachedChild {
6179            name: OsString::from(name),
6180            kind,
6181            attrs: file_attrs(1, 1),
6182            position,
6183        };
6184        let mut builder = DetachedIndexBuilder::new(
6185            "/root",
6186            ScanScope::default(),
6187            crate::classify::TypeRegistry::compiled_shared(),
6188        );
6189        let mut root = crate::scan::DetachedDirectory {
6190            path: PathBuf::new(),
6191            children: vec![child("z.txt", EntryKind::File, 0), child("dir", EntryKind::Dir, 1)],
6192            control: Some(Op::ControlUpsert {
6193                path: PathBuf::from(".gitignore"),
6194                source: b"*.log\n".to_vec(),
6195            }),
6196        };
6197        let (buffer, capacity) = (root.children.as_ptr(), root.children.capacity());
6198        builder.push_directory(&mut root).expect("root listing");
6199        assert_eq!(root.path, PathBuf::new());
6200        assert!(root.children.is_empty());
6201        assert_eq!((root.children.as_ptr(), root.children.capacity()), (buffer, capacity));
6202        assert!(root.control.is_none(), "the control is applied, not left to be applied twice");
6203
6204        // A listing the builder does not apply keeps its children for the worker to drop.
6205        let mut orphan = crate::scan::DetachedDirectory {
6206            path: PathBuf::from("elsewhere"),
6207            children: vec![child("kept.txt", EntryKind::File, 0)],
6208            control: None,
6209        };
6210        builder.push_directory(&mut orphan).expect_err("a listing whose parent was never listed");
6211        assert_eq!(orphan.children.len(), 1);
6212
6213        let index = builder.finish();
6214        assert_eq!(index.kind(Path::new("dir")), Some(EntryKind::Dir));
6215        assert_eq!(index.kind(Path::new("z.txt")), Some(EntryKind::File));
6216        assert_eq!(index.total().files, 1);
6217    }
6218
6219    fn file_attrs(size: u64, mtime_ns: i64) -> Attrs {
6220        Attrs {
6221            size,
6222            allocated: size.div_ceil(512) * 512,
6223            mtime_ns,
6224            ctime_ns: mtime_ns,
6225            inode: size.wrapping_mul(31).wrapping_add(mtime_ns.unsigned_abs()),
6226            dev: 1,
6227        }
6228    }
6229
6230    fn upsert(path: &str, kind: EntryKind, attrs: Attrs) -> Op {
6231        Op::Upsert { path: PathBuf::from(path), kind, attrs }
6232    }
6233
6234    fn assert_serving_indexes(index: &Index) {
6235        let serving = index.serving.as_ref().expect("test index has serving state");
6236        let mut entries = BTreeMap::new();
6237        let mut children = BTreeMap::<PathBuf, PortableChildren>::new();
6238        let mut recent_files = BTreeSet::new();
6239        let mut semantic_by_directory =
6240            BTreeMap::<EntryId, (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>)>::new();
6241        let declared_exact_names: BTreeSet<_> =
6242            index.types.exact_filenames().map(str::to_ascii_lowercase).collect();
6243        let mut exact_name_by_directory =
6244            BTreeMap::<EntryId, (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>)>::new();
6245        let mut semantic_refcounts = BTreeMap::<String, u64>::new();
6246        let mut pending = vec![(EntryId::ROOT, PathBuf::new(), vec![EntryId::ROOT])];
6247        while let Some((parent_id, parent_path, ancestors)) = pending.pop() {
6248            let facts: Vec<_> = index
6249                .children_of(parent_id)
6250                .expect("live directory")
6251                .map(|(name, id)| (name.to_os_string(), id))
6252                .collect();
6253            for (name, id) in facts {
6254                let path = parent_path.join(&name);
6255                let kind = index.kind_of(id).expect("live child");
6256                let portable = crate::opened::read::portable_path(&path);
6257                entries.insert(portable.clone(), id);
6258                if kind == EntryKind::File {
6259                    recent_files.insert(RecentKey {
6260                        mtime_ns: index.attrs(&path).expect("live child has attributes").mtime_ns,
6261                        portable_path: portable,
6262                        id,
6263                    });
6264                }
6265                if kind == EntryKind::File {
6266                    let semantic = index.classify(&path).file_type.as_str().to_string();
6267                    *semantic_refcounts.entry(semantic.clone()).or_default() += 1;
6268                    let attrs = *index.attrs(&path).expect("live child has attributes");
6269                    let ignored = index.entry(id).ignored;
6270                    for ancestor in &ancestors {
6271                        let partition = semantic_by_directory.entry(*ancestor).or_default();
6272                        let all = partition.0.entry(semantic.clone()).or_default();
6273                        all.files += 1;
6274                        all.bytes += attrs.size;
6275                        all.allocated += attrs.allocated;
6276                        if !ignored {
6277                            let unignored = partition.1.entry(semantic.clone()).or_default();
6278                            unignored.files += 1;
6279                            unignored.bytes += attrs.size;
6280                            unignored.allocated += attrs.allocated;
6281                        }
6282                    }
6283                    if let Some(exact_name) = name
6284                        .to_str()
6285                        .map(str::to_ascii_lowercase)
6286                        .filter(|name| declared_exact_names.contains(name))
6287                    {
6288                        for ancestor in &ancestors {
6289                            let partition = exact_name_by_directory.entry(*ancestor).or_default();
6290                            let all = partition.0.entry(exact_name.clone()).or_default();
6291                            all.files += 1;
6292                            all.bytes += attrs.size;
6293                            all.allocated += attrs.allocated;
6294                            if !ignored {
6295                                let unignored = partition.1.entry(exact_name.clone()).or_default();
6296                                unignored.files += 1;
6297                                unignored.bytes += attrs.size;
6298                                unignored.allocated += attrs.allocated;
6299                            }
6300                        }
6301                    }
6302                }
6303                let partition = children.entry(parent_path.clone()).or_default();
6304                let portable_name = crate::opened::read::portable_component(&name);
6305                if kind.is_dir() {
6306                    partition.directories.insert(portable_name, id);
6307                } else {
6308                    partition.nondirectories.insert(portable_name, id);
6309                }
6310                if kind.is_dir() {
6311                    let mut child_ancestors = ancestors.clone();
6312                    child_ancestors.insert(0, id);
6313                    pending.push((id, path, child_ancestors));
6314                }
6315            }
6316        }
6317
6318        assert_eq!(serving.portable_entries, entries);
6319        assert_eq!(serving.recent_files, recent_files);
6320        let actual_semantics: BTreeMap<_, _> = serving
6321            .semantic_by_directory
6322            .iter()
6323            .map(|(directory, partitions)| {
6324                let named = |source: &BTreeMap<u32, ExtTally>| {
6325                    source
6326                        .iter()
6327                        .map(|(semantic, tally)| {
6328                            let name = serving.semantic_names[*semantic as usize]
6329                                .as_ref()
6330                                .expect("live semantic has a name")
6331                                .clone();
6332                            (name, *tally)
6333                        })
6334                        .collect()
6335                };
6336                (*directory, (named(&partitions.all), named(&partitions.unignored)))
6337            })
6338            .collect();
6339        assert_eq!(actual_semantics, semantic_by_directory);
6340        let actual_exact_names: BTreeMap<_, _> = serving
6341            .exact_name_by_directory
6342            .iter()
6343            .map(|(directory, partitions)| {
6344                let named = |source: &BTreeMap<u32, ExtTally>| {
6345                    source
6346                        .iter()
6347                        .map(|(exact_name, tally)| {
6348                            (serving.exact_names[*exact_name as usize].clone(), *tally)
6349                        })
6350                        .collect()
6351                };
6352                (*directory, (named(&partitions.all), named(&partitions.unignored)))
6353            })
6354            .collect();
6355        assert_eq!(actual_exact_names, exact_name_by_directory);
6356        assert_eq!(
6357            serving.exact_names.iter().cloned().collect::<BTreeSet<_>>(),
6358            declared_exact_names
6359        );
6360        assert_eq!(
6361            serving.exact_name_ids,
6362            serving
6363                .exact_names
6364                .iter()
6365                .enumerate()
6366                .map(|(position, name)| {
6367                    (
6368                        name.clone(),
6369                        u32::try_from(position).expect("the exact-name vocabulary fits u32"),
6370                    )
6371                })
6372                .collect()
6373        );
6374        assert!(serving.exact_name_by_directory.len() <= index.arena.len());
6375        assert!(serving.exact_name_by_directory.values().all(|partitions| {
6376            partitions.all.len() <= serving.exact_names.len()
6377                && partitions.unignored.len() <= serving.exact_names.len()
6378                && partitions
6379                    .all
6380                    .keys()
6381                    .chain(partitions.unignored.keys())
6382                    .all(|name| (*name as usize) < serving.exact_names.len())
6383        }));
6384        let actual_refcounts: BTreeMap<_, _> = serving
6385            .semantic_ids
6386            .iter()
6387            .map(|(name, semantic)| (name.clone(), serving.semantic_refcounts[*semantic as usize]))
6388            .collect();
6389        assert_eq!(actual_refcounts, semantic_refcounts);
6390        assert_eq!(serving.portable_children, children);
6391
6392        // Every retained entry has a portable name, and the names are unique. The second
6393        // half is what the escaping has to earn: `%` is escaped in every name precisely so
6394        // a file called `x%FF` and one whose bytes are `x\xff` cannot collide here.
6395        assert_eq!(
6396            u64::try_from(serving.portable_entries.len()).expect("entry count fits u64"),
6397            index.len().saturating_sub(1),
6398            "every retained non-root entry has exactly one portable name"
6399        );
6400    }
6401
6402    #[test]
6403    fn portable_indexes_conserve_insert_kind_change_and_subtree_removal() {
6404        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6405            "/root",
6406            ScanScope::default(),
6407            crate::classify::TypeRegistry::compiled_shared(),
6408            DEFAULT_JOURNAL_CAPACITY_BYTES,
6409        );
6410        index.apply_ok(&Observation::new(vec![
6411            upsert("dir", EntryKind::Dir, Attrs::default()),
6412            upsert("dir/a", EntryKind::File, file_attrs(1, 1)),
6413            upsert("replace", EntryKind::File, file_attrs(2, 2)),
6414        ]));
6415        assert_serving_indexes(&index);
6416
6417        index.apply_ok(&Observation::new(vec![
6418            upsert("replace", EntryKind::Dir, Attrs::default()),
6419            upsert("replace/child", EntryKind::File, file_attrs(3, 3)),
6420        ]));
6421        assert_serving_indexes(&index);
6422
6423        index.apply_ok(&Observation::new(vec![upsert("dir/a", EntryKind::File, file_attrs(4, 9))]));
6424        assert_serving_indexes(&index);
6425        assert_eq!(
6426            index
6427                .serving
6428                .as_ref()
6429                .expect("opened test index")
6430                .recent_files
6431                .iter()
6432                .map(|entry| entry.portable_path.as_str())
6433                .collect::<Vec<_>>(),
6434            vec!["dir/a", "replace/child"]
6435        );
6436
6437        // Same-kind attribute updates of entries that hold no semantic tally: a symlink
6438        // re-created in place (`ln -sfn`) and a special entry replaced by another. Every
6439        // file here is extensionless, so each non-file shares its classification with a
6440        // real file, and a non-file update that touched semantics would move that file's
6441        // tally rather than fail loudly.
6442        index.apply_ok(&Observation::new(vec![
6443            upsert("dir/current", EntryKind::Symlink, file_attrs(5, 5)),
6444            upsert("dir/pipe", EntryKind::Other, file_attrs(6, 6)),
6445        ]));
6446        assert_serving_indexes(&index);
6447        index.apply_ok(&Observation::new(vec![
6448            upsert("dir/current", EntryKind::Symlink, file_attrs(7, 7)),
6449            upsert("dir/pipe", EntryKind::Other, file_attrs(8, 8)),
6450        ]));
6451        assert_serving_indexes(&index);
6452
6453        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("replace") }]));
6454        assert_serving_indexes(&index);
6455        assert_eq!(
6456            index.portable_entries().keys().map(crate::PortablePath::as_str).collect::<Vec<_>>(),
6457            vec!["dir", "dir/a", "dir/current", "dir/pipe"]
6458        );
6459    }
6460
6461    /// A symlink or special entry holds no semantic tally, so updating one must neither
6462    /// panic looking for a tally it never had nor subtract from a real file's.
6463    ///
6464    /// Both failures were reachable from ordinary filesystem churn on an opened root. With
6465    /// no file of the same classification, the update panicked inside the commit while the
6466    /// index write guard was held, poisoning the root. With one, it silently subtracted the
6467    /// link's attributes from that file's tally and released the file's interned type, so
6468    /// the file's own later removal panicked instead.
6469    #[test]
6470    fn non_file_attrs_updates_leave_file_semantics_untouched() {
6471        for kind in [EntryKind::Symlink, EntryKind::Other] {
6472            let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6473                "/root",
6474                ScanScope::default(),
6475                crate::classify::TypeRegistry::compiled_shared(),
6476                DEFAULT_JOURNAL_CAPACITY_BYTES,
6477            );
6478            index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(1, 1))]));
6479            assert_serving_indexes(&index);
6480            index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(1, 2))]));
6481            assert_serving_indexes(&index);
6482
6483            index.apply_ok(&Observation::new(vec![upsert(
6484                "notes",
6485                EntryKind::File,
6486                file_attrs(5, 3),
6487            )]));
6488            assert_serving_indexes(&index);
6489            index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(4, 4))]));
6490            assert_serving_indexes(&index);
6491
6492            index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("notes") }]));
6493            assert_serving_indexes(&index);
6494        }
6495    }
6496
6497    /// Escaping touches exactly two things and leaves everything else byte-identical.
6498    ///
6499    /// The rule is narrow on purpose: a byte that is not valid UTF-8, and `%` itself.
6500    /// Everything else — spaces, non-ASCII scalars, punctuation — passes through, because
6501    /// this produces a JSON string rather than a URL and mangling readable names would be
6502    /// a cost with no benefit.
6503    ///
6504    /// This test used to assert the opposite property, that the derived name could be
6505    /// turned back into a filesystem path with `PathBuf::from`. That held only while the
6506    /// derivation was the identity, and it is now unsound: `100%.txt` derives to
6507    /// `100%25.txt`, which names no file. The conversion was deleted rather than kept
6508    /// working, and callers ask the arena for a native path instead.
6509    #[test]
6510    fn escaping_touches_only_invalid_bytes_and_percent() {
6511        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6512            "/root",
6513            ScanScope::default(),
6514            crate::classify::TypeRegistry::compiled_shared(),
6515            DEFAULT_JOURNAL_CAPACITY_BYTES,
6516        );
6517        index.apply_ok(&Observation::new(vec![
6518            upsert("dir", EntryKind::Dir, Attrs::default()),
6519            upsert("dir/plain.txt", EntryKind::File, file_attrs(1, 1)),
6520            upsert("café", EntryKind::Dir, Attrs::default()),
6521            upsert("café/naïve.txt", EntryKind::File, file_attrs(2, 2)),
6522            upsert("日本語.md", EntryKind::File, file_attrs(3, 3)),
6523            upsert("a b", EntryKind::Dir, Attrs::default()),
6524            upsert("a b/c d.txt", EntryKind::File, file_attrs(5, 5)),
6525            upsert("100%.txt", EntryKind::File, file_attrs(4, 4)),
6526        ]));
6527
6528        let names: Vec<_> =
6529            index.portable_entries().keys().map(crate::PortablePath::as_str).collect();
6530        assert_eq!(
6531            names,
6532            vec![
6533                "100%25.txt",
6534                "a b",
6535                "a b/c d.txt",
6536                "café",
6537                "café/naïve.txt",
6538                "dir",
6539                "dir/plain.txt",
6540                "日本語.md",
6541            ],
6542            "only the literal percent is rewritten; separators, spaces and non-ASCII are not"
6543        );
6544    }
6545
6546    #[test]
6547    fn declared_exact_names_roll_up_by_ancestor_and_partition() {
6548        let types = Arc::new(
6549            crate::classify::TypeRegistry::from_manifest(
6550                "[[kind]]\nid = \"make\"\nfamily = \"code\"\nfilenames = [\"Makefile\"]\n",
6551            )
6552            .expect("custom registry"),
6553        );
6554        let scope =
6555            ScanScope { type_rules_fingerprint: types.fingerprint(), ..ScanScope::default() };
6556        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6557            "/root",
6558            scope,
6559            types,
6560            DEFAULT_JOURNAL_CAPACITY_BYTES,
6561        );
6562        index.apply_ok(&Observation::new(vec![
6563            upsert("Makefile", EntryKind::File, file_attrs(2, 1)),
6564            upsert("dir", EntryKind::Dir, Attrs::default()),
6565            upsert("dir/makefile", EntryKind::File, file_attrs(3, 2)),
6566            upsert("dir/notes", EntryKind::File, file_attrs(5, 3)),
6567        ]));
6568
6569        let serving = index.serving.as_ref().expect("opened test index");
6570        let exact_name = serving.exact_name_ids["makefile"];
6571        let root = &serving.exact_name_by_directory[&EntryId::ROOT];
6572        assert_eq!(root.all[&exact_name], ExtTally { files: 2, bytes: 5, allocated: 1_024 });
6573        assert_eq!(root.unignored, root.all);
6574
6575        let directory = index.lookup(Path::new("dir")).expect("directory");
6576        let nested = &serving.exact_name_by_directory[&directory];
6577        assert_eq!(nested.all[&exact_name], ExtTally { files: 1, bytes: 3, allocated: 512 });
6578        assert_eq!(nested.unignored, nested.all);
6579    }
6580
6581    #[test]
6582    #[ignore = "manual opened-root commit-cost evidence"]
6583    fn measure_opened_serving_commit_cost() {
6584        const DIRECTORY_COUNT: usize = 100;
6585        const FILES_PER_DIRECTORY: usize = 100;
6586        const SAMPLE_COUNT: usize = 7;
6587
6588        let mut operations = Vec::with_capacity(
6589            DIRECTORY_COUNT.saturating_mul(FILES_PER_DIRECTORY.saturating_add(1)),
6590        );
6591        for directory in 0..DIRECTORY_COUNT {
6592            let parent = format!("d{directory:03}");
6593            operations.push(upsert(&parent, EntryKind::Dir, Attrs::default()));
6594            for file in 0..FILES_PER_DIRECTORY {
6595                let size = u64::try_from(file).expect("the probe file count fits u64") + 1;
6596                let mtime = i64::try_from(file).expect("the probe file count fits i64");
6597                let name = if file == 0 {
6598                    format!("{parent}/Makefile")
6599                } else {
6600                    format!("{parent}/f{file:03}.rs")
6601                };
6602                operations.push(upsert(&name, EntryKind::File, file_attrs(size, mtime)));
6603            }
6604        }
6605        let observation = Observation::new(operations);
6606        let types = crate::classify::TypeRegistry::compiled_shared();
6607        let scope =
6608            ScanScope { type_rules_fingerprint: types.fingerprint(), ..ScanScope::default() };
6609        let measure = |opened: bool| {
6610            let mut index = if opened {
6611                Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6612                    "/root",
6613                    scope,
6614                    Arc::clone(&types),
6615                    DEFAULT_JOURNAL_CAPACITY_BYTES,
6616                )
6617            } else {
6618                Index::new_with_scope_types_and_journal_capacity_bytes(
6619                    "/root",
6620                    scope,
6621                    Arc::clone(&types),
6622                    DEFAULT_JOURNAL_CAPACITY_BYTES,
6623                )
6624            };
6625            let started = std::time::Instant::now();
6626            index.apply_ok(&observation);
6627            let elapsed = started.elapsed();
6628            std::hint::black_box(index.len());
6629            (elapsed, index)
6630        };
6631
6632        let _ = measure(false);
6633        let _ = measure(true);
6634        let mut detached = Vec::with_capacity(SAMPLE_COUNT);
6635        let mut opened = Vec::with_capacity(SAMPLE_COUNT);
6636        let mut last_opened = None;
6637        for sample in 0..SAMPLE_COUNT {
6638            if sample % 2 == 0 {
6639                detached.push(measure(false).0);
6640                let (duration, index) = measure(true);
6641                opened.push(duration);
6642                last_opened = Some(index);
6643            } else {
6644                let (duration, index) = measure(true);
6645                opened.push(duration);
6646                last_opened = Some(index);
6647                detached.push(measure(false).0);
6648            }
6649        }
6650        detached.sort_unstable();
6651        opened.sort_unstable();
6652        let detached_median = detached[SAMPLE_COUNT / 2];
6653        let opened_median = opened[SAMPLE_COUNT / 2];
6654        let ratio = opened_median.as_secs_f64() / detached_median.as_secs_f64();
6655
6656        let index = last_opened.expect("an opened sample ran");
6657        let serving = index.serving.as_ref().expect("opened sample has serving indexes");
6658        let semantic_rows: usize = serving
6659            .semantic_by_directory
6660            .values()
6661            .map(|partitions| partitions.all.len() + partitions.unignored.len())
6662            .sum();
6663        let exact_name_rows: usize = serving
6664            .exact_name_by_directory
6665            .values()
6666            .map(|partitions| partitions.all.len() + partitions.unignored.len())
6667            .sum();
6668        eprintln!(
6669            "entries={} detached_median_us={} opened_median_us={} ratio={ratio:.3} \
6670             portable_rows={} child_rows={} recent_rows={} semantic_rows={} exact_name_rows={} \
6671             exact_name_vocabulary={}",
6672            index.len(),
6673            detached_median.as_micros(),
6674            opened_median.as_micros(),
6675            serving.portable_entries.len(),
6676            serving
6677                .portable_children
6678                .values()
6679                .map(|children| children.directories.len() + children.nondirectories.len())
6680                .sum::<usize>(),
6681            serving.recent_files.len(),
6682            semantic_rows,
6683            exact_name_rows,
6684            serving.exact_names.len(),
6685        );
6686    }
6687
6688    #[test]
6689    fn detached_indexes_never_allocate_or_populate_serving_state() {
6690        let mut index = Index::new("/root");
6691        index.apply_ok(&Observation::new(vec![
6692            upsert("dir", EntryKind::Dir, Attrs::default()),
6693            upsert("dir/a", EntryKind::File, file_attrs(1, 1)),
6694        ]));
6695
6696        assert!(index.serving.is_none());
6697        assert!(index.portable_children(Path::new("dir")).is_none());
6698    }
6699
6700    #[test]
6701    fn insert_loaded_child_skips_serving_path_when_serving_is_off() {
6702        let mut index = Index::new("/root");
6703        let id = index
6704            .insert_loaded_child(
6705                EntryId::ROOT,
6706                OsString::from("a.rs"),
6707                EntryKind::File,
6708                file_attrs(4, 1),
6709            )
6710            .expect("parent is a live directory");
6711        assert!(!index.serving_indexes_enabled());
6712        assert_eq!(index.path_of(id), Some(PathBuf::from("a.rs")));
6713        assert_eq!(index.lookup(Path::new("a.rs")), Some(id));
6714        assert_eq!(index.total().files, 1);
6715    }
6716
6717    #[test]
6718    fn insert_loaded_child_fills_serving_when_enabled() {
6719        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6720            "/root",
6721            ScanScope::default(),
6722            crate::classify::TypeRegistry::compiled_shared(),
6723            DEFAULT_JOURNAL_CAPACITY_BYTES,
6724        );
6725        let id = index
6726            .insert_loaded_child(
6727                EntryId::ROOT,
6728                OsString::from("a.rs"),
6729                EntryKind::File,
6730                file_attrs(4, 1),
6731            )
6732            .expect("parent is a live directory");
6733        assert!(index.serving_indexes_enabled());
6734        assert_eq!(index.path_of(id), Some(PathBuf::from("a.rs")));
6735        let serving = index.serving.as_ref().expect("opened test index");
6736        assert!(serving.portable_entries.keys().any(|path| path.as_str() == "a.rs"));
6737    }
6738
6739    #[test]
6740    fn opened_entry_values_project_name_identity_without_retaining_it_on_detached_entries() {
6741        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6742            "/root",
6743            ScanScope::default(),
6744            crate::classify::TypeRegistry::compiled_shared(),
6745            DEFAULT_JOURNAL_CAPACITY_BYTES,
6746        );
6747        index.apply_ok(&Observation::new(vec![upsert(
6748            "bundle.umd.min.js",
6749            EntryKind::File,
6750            file_attrs(7, 1),
6751        )]));
6752
6753        let row = index.entry_value(Path::new("bundle.umd.min.js")).expect("entry value");
6754        let identity = row.classification.expect("regular files carry name identity");
6755        assert_eq!(identity.logical_extension(), Some(".min.js"));
6756        assert_eq!(identity.canonical_extension(), Some(".js"));
6757        assert_eq!(identity.kind_id(), Some("javascript"));
6758        assert_eq!(identity.content_family(), crate::classify::ContentFamily::Code);
6759    }
6760
6761    #[test]
6762    fn a_shared_snapshot_drops_opened_root_serving_state() {
6763        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6764            "/root",
6765            ScanScope::default(),
6766            crate::classify::TypeRegistry::compiled_shared(),
6767            DEFAULT_JOURNAL_CAPACITY_BYTES,
6768        );
6769        index.apply_ok(&Observation::new(vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))]));
6770        assert!(index.serving_indexes_enabled());
6771
6772        let snapshot = IndexHandle::new(index).snapshot().expect("detached snapshot");
6773
6774        assert!(!snapshot.serving_indexes_enabled());
6775        assert_eq!(snapshot.total().files, 1);
6776    }
6777
6778    #[test]
6779    #[should_panic(expected = "an index's registry must match its semantic scope")]
6780    fn an_index_cannot_claim_a_registry_different_from_its_scope() {
6781        let types = Arc::new(
6782            crate::classify::TypeRegistry::from_manifest(
6783                "[[kind]]\nid = \"notes\"\nfamily = \"prose\"\nextensions = [\"rs\"]\n",
6784            )
6785            .expect("custom registry"),
6786        );
6787
6788        let _ = Index::new_with_scope_and_types("/root", ScanScope::default(), types);
6789    }
6790
6791    /// The parent memo skips resolving a path when consecutive upserts share a parent,
6792    /// so every test below puts the op that could invalidate it *between* two upserts
6793    /// into the same directory — the arrangement where a stale hit would be believed.
6794    /// A memo that never cleared would still pass an ordinary scan-shaped workload,
6795    /// which is why these are written as batches rather than as separate applies: one
6796    /// `apply_validated` call is the memo's whole lifetime.
6797    #[test]
6798    fn parent_memo_does_not_survive_removing_the_directory_it_remembers() {
6799        let mut index = Index::new(PathBuf::from("/root"));
6800        index.apply_ok(&Observation::new(vec![
6801            upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
6802            upsert("dir/a.txt", EntryKind::File, file_attrs(10, 1)),
6803        ]));
6804
6805        // Rebuild the directory explicitly after the removal. The following child
6806        // must resolve through that new entry rather than a memoized id for the entry
6807        // that was just removed.
6808        index.apply_ok(&Observation::new(vec![
6809            upsert("dir/b.txt", EntryKind::File, file_attrs(20, 1)),
6810            Op::Remove { path: PathBuf::from("dir") },
6811            upsert("dir", EntryKind::Dir, file_attrs(0, 2)),
6812            upsert("dir/c.txt", EntryKind::File, file_attrs(30, 2)),
6813        ]));
6814
6815        let children = index.children(Path::new("dir")).expect("dir survives");
6816        let names: Vec<_> = children.map(|(name, _)| name.to_os_string()).collect();
6817        assert_eq!(names, vec![OsString::from("c.txt")], "only the re-added child remains");
6818        assert_eq!(index.total().bytes, 30, "totals match the surviving child");
6819    }
6820
6821    #[test]
6822    fn a_kind_change_mid_batch_leaves_the_memo_usable_for_the_next_sibling() {
6823        let mut index = Index::new(PathBuf::from("/root"));
6824        index.apply_ok(&Observation::new(vec![
6825            upsert("swap", EntryKind::Dir, file_attrs(0, 1)),
6826            upsert("swap/inner", EntryKind::Dir, file_attrs(0, 1)),
6827            upsert("swap/inner/deep.txt", EntryKind::File, file_attrs(40, 1)),
6828        ]));
6829
6830        // A kind change drops a subtree, which looks like it should invalidate the memo
6831        // and does not: the memo holds the changed entry's parent, and the subtree
6832        // removed is rooted at the entry itself. This pins that reasoning, so that if
6833        // the removal ever widens to touch the parent the failure lands here rather
6834        // than as a dangling id in a scan.
6835        index.apply_ok(&Observation::new(vec![
6836            upsert("swap/inner/other.txt", EntryKind::File, file_attrs(50, 1)),
6837            upsert("swap/inner", EntryKind::File, file_attrs(60, 2)),
6838            upsert("swap/sibling.txt", EntryKind::File, file_attrs(70, 2)),
6839        ]));
6840
6841        let children = index.children(Path::new("swap")).expect("swap survives");
6842        let names: Vec<_> = children.map(|(name, _)| name.to_os_string()).collect();
6843        assert_eq!(names, vec![OsString::from("inner"), OsString::from("sibling.txt")]);
6844        assert_eq!(index.total().files, 2, "inner counts once, as a file");
6845        assert_eq!(index.total().bytes, 130, "the dropped subtree's bytes are gone");
6846    }
6847
6848    #[test]
6849    fn parent_memo_distinguishes_directories_that_share_a_name_prefix() {
6850        let mut index = Index::new(PathBuf::from("/root"));
6851        // `src` and `src2` differ only after the memo's stored bytes end, which is the
6852        // comparison a prefix check rather than an equality check would get wrong.
6853        index.apply_ok(&Observation::new(vec![
6854            upsert("src", EntryKind::Dir, file_attrs(0, 1)),
6855            upsert("src2", EntryKind::Dir, file_attrs(0, 1)),
6856            upsert("src/one.txt", EntryKind::File, file_attrs(11, 1)),
6857            upsert("src2/two.txt", EntryKind::File, file_attrs(22, 1)),
6858            upsert("src/three.txt", EntryKind::File, file_attrs(33, 1)),
6859        ]));
6860
6861        let in_src: Vec<_> = index
6862            .children(Path::new("src"))
6863            .expect("src")
6864            .map(|(name, _)| name.to_os_string())
6865            .collect();
6866        let in_src2: Vec<_> = index
6867            .children(Path::new("src2"))
6868            .expect("src2")
6869            .map(|(name, _)| name.to_os_string())
6870            .collect();
6871        assert_eq!(in_src, vec![OsString::from("one.txt"), OsString::from("three.txt")]);
6872        assert_eq!(in_src2, vec![OsString::from("two.txt")]);
6873    }
6874
6875    #[test]
6876    fn parent_memo_leaves_root_level_entries_alone() {
6877        // A root-level path has `Some("")` as its parent, which must not be confused
6878        // with the root entry itself or with a sibling's empty-parent lookup.
6879        let mut index = Index::new(PathBuf::from("/root"));
6880        index.apply_ok(&Observation::new(vec![
6881            upsert("a.txt", EntryKind::File, file_attrs(5, 1)),
6882            upsert("b.txt", EntryKind::File, file_attrs(6, 1)),
6883            upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
6884            upsert("dir/c.txt", EntryKind::File, file_attrs(7, 1)),
6885            upsert("d.txt", EntryKind::File, file_attrs(8, 1)),
6886        ]));
6887
6888        let top: Vec<_> = index
6889            .children(Path::new(""))
6890            .expect("root children")
6891            .map(|(name, _)| name.to_os_string())
6892            .collect();
6893        assert_eq!(
6894            top,
6895            vec![
6896                OsString::from("a.txt"),
6897                OsString::from("b.txt"),
6898                OsString::from("d.txt"),
6899                OsString::from("dir"),
6900            ]
6901        );
6902        assert_eq!(index.total().files, 4);
6903        assert_eq!(index.total().bytes, 26);
6904    }
6905
6906    #[test]
6907    fn shared_queries_return_owned_values_and_release_the_lock() {
6908        let handle = IndexHandle::new(index_with_sample_tree());
6909        let retained_total = handle.total().expect("total");
6910        let retained_history = handle.since(Clock::ZERO).expect("history");
6911        let retained_children = handle.children(Path::new("src")).expect("children");
6912        let retained_snapshot = handle.snapshot().expect("snapshot");
6913
6914        let writer = handle.clone();
6915        let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
6916        let thread = std::thread::spawn(move || {
6917            let result = writer.apply(&Observation::new(vec![upsert(
6918                "concurrent.txt",
6919                EntryKind::File,
6920                file_attrs(7, 30),
6921            )]));
6922            done_tx.send(result).expect("report writer result");
6923        });
6924
6925        let outcome = done_rx
6926            .recv_timeout(std::time::Duration::from_secs(5))
6927            .expect("owned query results must not retain the read lock")
6928            .expect("writer apply");
6929        thread.join().expect("writer thread");
6930
6931        assert_eq!(outcome.inserted, 1);
6932        assert_eq!(retained_total.files, 3);
6933        assert!(!retained_history.commits.is_empty());
6934        assert_eq!(retained_children.expect("src directory").len(), 2);
6935        assert!(retained_snapshot.lookup(Path::new("concurrent.txt")).is_none());
6936        assert!(handle.kind(Path::new("concurrent.txt")).expect("query").is_some());
6937    }
6938
6939    #[test]
6940    fn cloned_indexes_are_independent_detached_images() {
6941        let original = index_with_sample_tree();
6942        let original_clock = original.clock();
6943        let mut detached = original.clone();
6944
6945        detached.apply_ok(&Observation::new(vec![upsert(
6946            "detached-only.txt",
6947            EntryKind::File,
6948            file_attrs(7, 30),
6949        )]));
6950
6951        assert_eq!(original.clock(), original_clock);
6952        assert!(original.lookup(Path::new("detached-only.txt")).is_none());
6953        assert!(detached.lookup(Path::new("detached-only.txt")).is_some());
6954        assert_eq!(detached.total().files, original.total().files + 1);
6955    }
6956
6957    #[test]
6958    fn captured_child_expectations_match_individual_path_lookups() {
6959        let index = index_with_sample_tree();
6960        let captured = collect_child_expectations(&index, Path::new("src"));
6961
6962        assert!(!captured.is_empty());
6963        for (name, expectation) in captured {
6964            assert_eq!(expectation, index.expectation(&Path::new("src").join(name)));
6965        }
6966    }
6967
6968    #[test]
6969    fn index_and_shared_handle_are_send_and_sync() {
6970        fn assert_send_sync<T: Send + Sync>() {}
6971
6972        assert_send_sync::<Index>();
6973        assert_send_sync::<IndexHandle>();
6974    }
6975
6976    #[test]
6977    fn simultaneous_writers_commit_unique_contiguous_clocks_in_journal_order() {
6978        let writer_count: usize = 8;
6979        let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
6980        let barrier: Arc<Barrier> = Arc::new(Barrier::new(writer_count));
6981        let (result_tx, result_rx) = std::sync::mpsc::sync_channel(writer_count);
6982
6983        std::thread::scope(|scope| {
6984            for worker_id in 0..writer_count {
6985                let worker: IndexHandle = handle.clone();
6986                let start: Arc<Barrier> = Arc::clone(&barrier);
6987                let results = result_tx.clone();
6988                scope.spawn(move || {
6989                    let ordinal: u64 = u64::try_from(worker_id + 1).expect("small worker count");
6990                    let path: String = format!("writer-{ordinal}.txt");
6991                    start.wait();
6992                    let outcome: crate::Result<ApplyOutcome> =
6993                        worker.apply(&Observation::new(vec![upsert(
6994                            &path,
6995                            EntryKind::File,
6996                            file_attrs(ordinal, i64::try_from(ordinal).expect("small ordinal")),
6997                        )]));
6998                    results.send((path, outcome)).expect("report writer result");
6999                });
7000            }
7001        });
7002        drop(result_tx);
7003
7004        let mut committed_clocks: Vec<u64> = Vec::with_capacity(writer_count);
7005        for (path, outcome) in result_rx {
7006            let commit = outcome.expect("writer apply").commit.expect("unique upsert must commit");
7007            committed_clocks.push(commit.clock.0);
7008            assert!(handle.kind(Path::new(&path)).expect("query committed path").is_some());
7009        }
7010        committed_clocks.sort_unstable();
7011
7012        let last_clock: u64 = u64::try_from(writer_count).expect("small writer count");
7013        let expected_clocks: Vec<u64> = (1..=last_clock).collect();
7014        assert_eq!(committed_clocks, expected_clocks);
7015        assert_eq!(handle.clock().expect("clock"), Clock(last_clock));
7016
7017        let journal_clocks: Vec<u64> = handle
7018            .since(Clock::ZERO)
7019            .expect("journal")
7020            .commits
7021            .iter()
7022            .map(|commit| commit.clock.0)
7023            .collect();
7024        assert_eq!(journal_clocks, expected_clocks);
7025    }
7026
7027    #[test]
7028    fn readers_observe_only_complete_states_around_a_large_batch() {
7029        let file_count: u64 = 2_048;
7030        let expected_bytes: u64 = file_count * (file_count + 1) / 2;
7031        let operations: Vec<Op> =
7032            std::iter::once(upsert("batch", EntryKind::Dir, file_attrs(0, 1)))
7033                .chain((1..=file_count).map(|ordinal| {
7034                    upsert(
7035                        &format!("batch/file-{ordinal}.bin"),
7036                        EntryKind::File,
7037                        file_attrs(ordinal, i64::try_from(ordinal).expect("small ordinal")),
7038                    )
7039                }))
7040                .collect();
7041        let observation: Observation = Observation::new(operations);
7042        let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
7043        let before: Index = handle.snapshot().expect("before snapshot");
7044        assert_eq!(before.total().files, 0);
7045
7046        let barrier: Arc<Barrier> = Arc::new(Barrier::new(2));
7047        let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
7048        std::thread::scope(|scope| {
7049            let writer: IndexHandle = handle.clone();
7050            let writer_start: Arc<Barrier> = Arc::clone(&barrier);
7051            scope.spawn(move || {
7052                writer_start.wait();
7053                let result: crate::Result<ApplyOutcome> = writer.apply(&observation);
7054                done_tx.send(result).expect("report batch result");
7055            });
7056
7057            let reader: IndexHandle = handle.clone();
7058            let reader_start: Arc<Barrier> = Arc::clone(&barrier);
7059            scope.spawn(move || {
7060                reader_start.wait();
7061                let deadline: std::time::Instant =
7062                    std::time::Instant::now() + std::time::Duration::from_secs(10);
7063                loop {
7064                    assert!(
7065                        std::time::Instant::now() < deadline,
7066                        "reader did not observe batch completion before the deadline"
7067                    );
7068                    let image: Index = reader.snapshot().expect("coherent reader snapshot");
7069                    let total = image.total();
7070                    match total.files {
7071                        0 => {
7072                            assert_eq!(total.bytes, 0);
7073                            assert_eq!(image.len(), 1);
7074                        }
7075                        count if count == file_count => {
7076                            assert_eq!(total.bytes, expected_bytes);
7077                            assert_eq!(total.dirs, 1);
7078                            assert_eq!(image.len(), file_count + 2);
7079                        }
7080                        partial => panic!("reader observed partial batch with {partial} files"),
7081                    }
7082
7083                    match done_rx.try_recv() {
7084                        Ok(result) => {
7085                            assert_eq!(result.expect("batch apply").inserted, file_count + 1);
7086                            break;
7087                        }
7088                        Err(std::sync::mpsc::TryRecvError::Empty) => {}
7089                        Err(std::sync::mpsc::TryRecvError::Disconnected) => {
7090                            panic!("batch writer disconnected")
7091                        }
7092                    }
7093                }
7094            });
7095        });
7096
7097        let after: Index = handle.snapshot().expect("after snapshot");
7098        assert_eq!(after.total().files, file_count);
7099        assert_eq!(after.total().bytes, expected_bytes);
7100        assert_eq!(after.len(), file_count + 2);
7101    }
7102
7103    #[test]
7104    fn poisoned_shared_lock_returns_typed_errors() {
7105        let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
7106        let poisoner: IndexHandle = handle.clone();
7107        let panic_result: std::thread::Result<()> = std::thread::spawn(move || {
7108            let _poison_guard = poisoner.write_index().expect("initial write lock");
7109            panic!("intentional lock poison");
7110        })
7111        .join();
7112        assert!(panic_result.is_err());
7113
7114        assert!(matches!(handle.total(), Err(crate::Error::IndexLockPoisoned)));
7115        assert!(matches!(
7116            handle.apply(&Observation::new(vec![upsert(
7117                "never-applied.txt",
7118                EntryKind::File,
7119                file_attrs(1, 1),
7120            )])),
7121            Err(crate::Error::IndexLockPoisoned)
7122        ));
7123    }
7124
7125    #[test]
7126    fn clock_exhaustion_rejects_before_any_mutation() {
7127        let mut index = index_with_sample_tree();
7128        index.clock = Clock(u64::MAX);
7129        let before_total = index.total();
7130        let before_len = index.len();
7131
7132        let error = index
7133            .apply(&Observation::new(vec![upsert(
7134                "too-late.txt",
7135                EntryKind::File,
7136                file_attrs(1, 1),
7137            )]))
7138            .expect_err("clock exhaustion must be typed");
7139
7140        assert!(matches!(error, crate::Error::ClockExhausted));
7141        assert_eq!(index.clock(), Clock(u64::MAX));
7142        assert_eq!(index.len(), before_len);
7143        assert_eq!(index.total(), before_total);
7144        assert!(index.lookup(Path::new("too-late.txt")).is_none());
7145    }
7146
7147    #[test]
7148    fn terminal_clock_still_accepts_no_op_and_stale_observations() {
7149        let mut index = index_with_sample_tree();
7150        let current = *index.attrs(Path::new("src/main.rs")).expect("sample attributes");
7151        let stale_baseline = index.expectation(Path::new("src/main.rs"));
7152        index.apply_ok(&Observation::new(vec![upsert(
7153            "src/main.rs",
7154            EntryKind::File,
7155            file_attrs(99, 99),
7156        )]));
7157        index.clock = Clock(u64::MAX);
7158        let before_total = index.total();
7159        let before_len = index.len();
7160        let before_journal = index.journal.clone();
7161
7162        let no_op = index
7163            .apply(&Observation::new(vec![upsert(
7164                "src/main.rs",
7165                EntryKind::File,
7166                file_attrs(99, 99),
7167            )]))
7168            .expect("a no-op needs no new clock");
7169        let stale = index
7170            .apply(&Observation::from_ops(vec![ObservationOp::if_state(
7171                upsert("src/main.rs", EntryKind::File, current),
7172                stale_baseline,
7173            )]))
7174            .expect("a rejected stale observation needs no new clock");
7175
7176        assert_eq!(no_op.unchanged, 1);
7177        assert!(no_op.commit.is_none());
7178        assert_eq!(stale.stale, 1);
7179        assert!(stale.commit.is_none());
7180        assert_eq!(index.clock(), Clock(u64::MAX));
7181        assert_eq!(index.len(), before_len);
7182        assert_eq!(index.total(), before_total);
7183        assert_eq!(index.journal, before_journal);
7184    }
7185
7186    #[test]
7187    fn delayed_conditional_observation_cannot_overwrite_newer_state() {
7188        let mut index = Index::new("/root");
7189        index.apply_ok(&Observation::new(vec![upsert(
7190            "file.txt",
7191            EntryKind::File,
7192            file_attrs(10, 1),
7193        )]));
7194
7195        let baseline = index.expectation(Path::new("file.txt"));
7196        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7197            upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
7198            baseline,
7199        )]);
7200
7201        index.apply_ok(&Observation::new(vec![upsert(
7202            "file.txt",
7203            EntryKind::File,
7204            file_attrs(30, 3),
7205        )]));
7206        let outcome = index.apply_ok(&delayed);
7207
7208        assert_eq!(outcome.stats.stale, 1);
7209        assert!(outcome.commit.is_none());
7210        assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 30);
7211    }
7212
7213    #[test]
7214    fn delayed_absent_child_cannot_replace_a_newer_parent_file() {
7215        let mut index = Index::new("/root");
7216        index.apply_ok(&Observation::new(vec![upsert("parent", EntryKind::Dir, file_attrs(0, 1))]));
7217        let child_baseline = index.expectation(Path::new("parent/child.txt"));
7218        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7219            upsert("parent/child.txt", EntryKind::File, file_attrs(10, 2)),
7220            child_baseline,
7221        )]);
7222
7223        index.apply_ok(&Observation::new(vec![upsert(
7224            "parent",
7225            EntryKind::File,
7226            file_attrs(20, 3),
7227        )]));
7228        let outcome = index.apply_ok(&delayed);
7229
7230        assert_eq!(outcome.stats.stale, 1);
7231        assert!(outcome.commit.is_none());
7232        assert_eq!(index.kind(Path::new("parent")), Some(EntryKind::File));
7233        assert!(index.lookup(Path::new("parent/child.txt")).is_none());
7234    }
7235
7236    #[test]
7237    fn conditional_observation_rejects_present_state_aba() {
7238        let mut index = Index::new("/root");
7239        index.apply_ok(&Observation::new(vec![upsert(
7240            "file.txt",
7241            EntryKind::File,
7242            file_attrs(10, 1),
7243        )]));
7244        let baseline = index.expectation(Path::new("file.txt"));
7245        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7246            upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
7247            baseline,
7248        )]);
7249
7250        index.apply_ok(&Observation::new(vec![upsert(
7251            "file.txt",
7252            EntryKind::File,
7253            file_attrs(30, 3),
7254        )]));
7255        index.apply_ok(&Observation::new(vec![upsert(
7256            "file.txt",
7257            EntryKind::File,
7258            file_attrs(10, 1),
7259        )]));
7260        let outcome = index.apply_ok(&delayed);
7261
7262        assert_eq!(outcome.stats.stale, 1);
7263        assert!(outcome.commit.is_none());
7264        assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 10);
7265    }
7266
7267    #[test]
7268    fn conditional_observation_rejects_absent_state_aba() {
7269        let mut index = Index::new("/root");
7270        let baseline = index.expectation(Path::new("file.txt"));
7271        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7272            upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
7273            baseline,
7274        )]);
7275
7276        index.apply_ok(&Observation::new(vec![upsert(
7277            "file.txt",
7278            EntryKind::File,
7279            file_attrs(30, 3),
7280        )]));
7281        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("file.txt") }]));
7282        let outcome = index.apply_ok(&delayed);
7283
7284        assert_eq!(outcome.stats.stale, 1);
7285        assert!(outcome.commit.is_none());
7286        assert!(index.lookup(Path::new("file.txt")).is_none());
7287    }
7288
7289    /// A delayed conditional upsert whose baseline moved to exactly its target is no
7290    /// conflict: the other producer verified the same fact first. It applies as unchanged,
7291    /// not stale, so a refresh that converges with the observation handoff does not send
7292    /// the handoff back for another full-root walk.
7293    #[test]
7294    fn convergent_conditional_upsert_applies_as_unchanged_not_stale() {
7295        let mut index = Index::new("/root");
7296        index.apply_ok(&Observation::new(vec![upsert(
7297            "file.txt",
7298            EntryKind::File,
7299            file_attrs(10, 1),
7300        )]));
7301        let baseline = index.expectation(Path::new("file.txt"));
7302        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7303            upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
7304            baseline,
7305        )]);
7306
7307        index.apply_ok(&Observation::new(vec![upsert(
7308            "file.txt",
7309            EntryKind::File,
7310            file_attrs(20, 2),
7311        )]));
7312        let outcome = index.apply_ok(&delayed);
7313
7314        assert_eq!(outcome.stats.stale, 0);
7315        assert_eq!(outcome.stats.unchanged, 1);
7316        assert!(outcome.commit.is_none());
7317        assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 20);
7318    }
7319
7320    #[test]
7321    fn convergent_conditional_remove_applies_as_unchanged_not_stale() {
7322        let mut index = Index::new("/root");
7323        index.apply_ok(&Observation::new(vec![
7324            upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
7325            upsert("dir/file.txt", EntryKind::File, file_attrs(10, 1)),
7326        ]));
7327        let baseline = index.expectation(Path::new("dir/file.txt"));
7328        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7329            Op::Remove { path: PathBuf::from("dir/file.txt") },
7330            baseline,
7331        )]);
7332
7333        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("dir/file.txt") }]));
7334        let outcome = index.apply_ok(&delayed);
7335
7336        assert_eq!(outcome.stats.stale, 0);
7337        assert_eq!(outcome.stats.unchanged, 1);
7338        assert!(outcome.commit.is_none());
7339        assert!(index.lookup(Path::new("dir/file.txt")).is_none());
7340    }
7341
7342    /// A control file's entry and rules are pushed on one baseline, so a refresh that
7343    /// verified both first leaves nothing for either to change. Both apply as unchanged; a
7344    /// control op whose rules the table does not hold is still refused on the moved
7345    /// baseline.
7346    #[test]
7347    fn convergent_conditional_control_ops_apply_as_unchanged_not_stale() {
7348        let path = PathBuf::from(".gitignore");
7349        let rules =
7350            |source: &[u8]| Op::ControlUpsert { path: path.clone(), source: source.to_vec() };
7351        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
7352        index.apply_ok(&Observation::new(vec![
7353            upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
7354            rules(b"before"),
7355        ]));
7356        let baseline = index.expectation(&path);
7357        index.apply_ok(&Observation::new(vec![
7358            upsert(".gitignore", EntryKind::File, file_attrs(7, 2)),
7359            rules(b"changed"),
7360        ]));
7361
7362        let outcome = index.apply_ok(&Observation::from_ops(vec![
7363            ObservationOp::if_state(
7364                upsert(".gitignore", EntryKind::File, file_attrs(7, 2)),
7365                baseline,
7366            ),
7367            ObservationOp::if_state(rules(b"changed"), baseline),
7368        ]));
7369        assert_eq!(outcome.stats.stale, 0);
7370        assert!(outcome.commit.is_none());
7371        let diverged = index.apply_ok(&Observation::from_ops(vec![ObservationOp::if_state(
7372            rules(b"other"),
7373            baseline,
7374        )]));
7375        assert_eq!(diverged.stats.stale, 1);
7376        assert!(index.controls().expect("control state observed").source_is(&path, b"changed"));
7377
7378        let baseline = index.expectation(&path);
7379        index.apply_ok(&Observation::new(vec![Op::Remove { path: path.clone() }]));
7380        let outcome = index.apply_ok(&Observation::from_ops(vec![
7381            ObservationOp::if_state(Op::Remove { path: path.clone() }, baseline),
7382            ObservationOp::if_state(Op::ControlRemove { path: path.clone() }, baseline),
7383        ]));
7384        assert_eq!(outcome.stats.stale, 0);
7385        assert!(outcome.commit.is_none());
7386        assert!(!index.controls().expect("control state observed").contains(&path));
7387    }
7388
7389    #[test]
7390    fn unrelated_mutation_does_not_stale_an_absent_path() {
7391        let mut index = Index::new("/root");
7392        index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
7393        let baseline = index.expectation(Path::new("dir/new.txt"));
7394        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7395            upsert("dir/new.txt", EntryKind::File, file_attrs(20, 2)),
7396            baseline,
7397        )]);
7398
7399        index.apply_ok(&Observation::new(vec![upsert(
7400            "other.txt",
7401            EntryKind::File,
7402            file_attrs(30, 3),
7403        )]));
7404        let outcome = index.apply_ok(&delayed);
7405
7406        assert_eq!(outcome.stats.stale, 0);
7407        assert_eq!(outcome.stats.inserted, 1);
7408        assert_eq!(index.attrs(Path::new("dir/new.txt")).expect("file").size, 20);
7409    }
7410
7411    #[test]
7412    fn directory_metadata_change_does_not_stale_an_absent_child() {
7413        let mut index = Index::new("/root");
7414        index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
7415        let baseline = index.expectation(Path::new("dir/new.txt"));
7416        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7417            upsert("dir/new.txt", EntryKind::File, file_attrs(20, 2)),
7418            baseline,
7419        )]);
7420
7421        index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 3))]));
7422        let outcome = index.apply_ok(&delayed);
7423
7424        assert_eq!(outcome.stats.stale, 0);
7425        assert_eq!(outcome.stats.inserted, 1);
7426    }
7427
7428    #[test]
7429    fn file_parent_metadata_change_stales_an_absent_child() {
7430        let mut index = Index::new("/root");
7431        index.apply_ok(&Observation::new(vec![upsert(
7432            "parent",
7433            EntryKind::File,
7434            file_attrs(10, 1),
7435        )]));
7436        let baseline = index.expectation(Path::new("parent/child.txt"));
7437        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7438            upsert("parent/child.txt", EntryKind::File, file_attrs(20, 2)),
7439            baseline,
7440        )]);
7441
7442        index.apply_ok(&Observation::new(vec![upsert(
7443            "parent",
7444            EntryKind::File,
7445            file_attrs(30, 3),
7446        )]));
7447        let outcome = index.apply_ok(&delayed);
7448
7449        assert_eq!(outcome.stats.stale, 1);
7450        assert_eq!(index.kind(Path::new("parent")), Some(EntryKind::File));
7451        assert!(index.lookup(Path::new("parent/child.txt")).is_none());
7452    }
7453
7454    #[test]
7455    fn delayed_directory_remove_cannot_delete_a_newer_child() {
7456        let mut index = Index::new("/root");
7457        index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
7458        let baseline = index.expectation(Path::new("dir"));
7459        let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7460            Op::Remove { path: PathBuf::from("dir") },
7461            baseline,
7462        )]);
7463
7464        index.apply_ok(&Observation::new(vec![upsert(
7465            "dir/new.txt",
7466            EntryKind::File,
7467            file_attrs(20, 2),
7468        )]));
7469        let outcome = index.apply_ok(&delayed);
7470
7471        assert_eq!(outcome.stats.stale, 1);
7472        assert!(index.lookup(Path::new("dir/new.txt")).is_some());
7473    }
7474
7475    #[test]
7476    fn conditional_batch_is_validated_at_one_boundary() {
7477        let mut index = Index::new("/root");
7478        let first = index.expectation(Path::new("first.txt"));
7479        let second = index.expectation(Path::new("second.txt"));
7480
7481        let outcome = index.apply_ok(&Observation::from_ops(vec![
7482            ObservationOp::if_state(upsert("first.txt", EntryKind::File, file_attrs(10, 1)), first),
7483            ObservationOp::if_state(
7484                upsert("second.txt", EntryKind::File, file_attrs(20, 2)),
7485                second,
7486            ),
7487        ]));
7488
7489        assert_eq!(outcome.stats.inserted, 2);
7490        assert_eq!(outcome.stats.stale, 0);
7491    }
7492
7493    #[test]
7494    fn public_observation_outputs_use_canonical_encoded_paths() {
7495        let separator = std::path::MAIN_SEPARATOR;
7496        let dotted = PathBuf::from(format!("dotted{separator}.{separator}file.txt"));
7497        let dotted_canonical = PathBuf::from(format!("dotted{separator}file.txt"));
7498        let repeated = PathBuf::from(format!("repeated{separator}{separator}file.txt"));
7499        let repeated_canonical = PathBuf::from(format!("repeated{separator}file.txt"));
7500        let mut index = Index::new("/root");
7501
7502        let outcome = index.apply_ok(&Observation::new(vec![
7503            upsert("dotted", EntryKind::Dir, file_attrs(0, 1)),
7504            Op::Upsert { path: dotted, kind: EntryKind::File, attrs: file_attrs(10, 2) },
7505            upsert("repeated", EntryKind::Dir, file_attrs(0, 3)),
7506            Op::Upsert { path: repeated, kind: EntryKind::File, attrs: file_attrs(20, 4) },
7507        ]));
7508        let commit = outcome.commit.as_ref().expect("one exact commit");
7509
7510        for (actual, canonical) in [
7511            (commit.changes[1].path(), dotted_canonical.as_path()),
7512            (commit.changes[3].path(), repeated_canonical.as_path()),
7513        ] {
7514            assert_eq!(
7515                actual.as_os_str().as_encoded_bytes(),
7516                canonical.as_os_str().as_encoded_bytes()
7517            );
7518        }
7519
7520        for canonical in [&dotted_canonical, &repeated_canonical] {
7521            let dirty = commit
7522                .impact
7523                .dirty_paths
7524                .iter()
7525                .find(|candidate| candidate.as_path() == canonical)
7526                .expect("changed path is dirty");
7527            assert_eq!(
7528                dirty.as_os_str().as_encoded_bytes(),
7529                canonical.as_os_str().as_encoded_bytes()
7530            );
7531        }
7532    }
7533
7534    /// The trailing spellings `Path::components` hides reach public values canonically.
7535    ///
7536    /// `a/b/` and `a/b/.` compare equal to `a/b` component by component, so lookups never
7537    /// notice a preserved spelling; only a value that carries the bytes out does. The
7538    /// reproduction this pins is an unknown-ancestry error that named `a/b/` (PR #51
7539    /// review COMMIT-4).
7540    #[test]
7541    fn trailing_path_spellings_leave_errors_and_changes_canonical() {
7542        let separator = std::path::MAIN_SEPARATOR;
7543        let canonical = format!("a{separator}b");
7544        for spelling in [format!("a{separator}b{separator}"), format!("a{separator}b{separator}.")]
7545        {
7546            let file = |mtime_ns| Op::Upsert {
7547                path: PathBuf::from(&spelling),
7548                kind: EntryKind::File,
7549                attrs: file_attrs(1, mtime_ns),
7550            };
7551            let mut index = Index::new("/root");
7552
7553            let error = index
7554                .apply(&Observation::new(vec![file(1)]))
7555                .expect_err("a child of an unknown directory is refused");
7556            let crate::Error::UnknownAncestry { path, .. } = error else {
7557                panic!("expected unknown ancestry for {spelling:?}, got {error}");
7558            };
7559            assert_eq!(path.as_os_str().as_encoded_bytes(), canonical.as_bytes(), "{spelling:?}");
7560
7561            let outcome = index.apply_ok(&Observation::new(vec![
7562                upsert("a", EntryKind::Dir, file_attrs(0, 1)),
7563                file(2),
7564            ]));
7565            let commit = outcome.commit.as_ref().expect("one exact commit");
7566            assert_eq!(
7567                commit.changes[1].path().as_os_str().as_encoded_bytes(),
7568                canonical.as_bytes(),
7569                "{spelling:?}"
7570            );
7571        }
7572    }
7573
7574    #[test]
7575    fn malformed_batch_is_rejected_before_any_index_mutation() {
7576        let invalid_paths = [
7577            PathBuf::from("../escape"),
7578            PathBuf::from(format!("{}absolute", std::path::MAIN_SEPARATOR)),
7579        ];
7580
7581        for invalid_path in invalid_paths {
7582            for invalid_first in [false, true] {
7583                let mut index = index_with_sample_tree();
7584                let before_clock = index.clock;
7585                let before_live = index.live;
7586                let before_total = index.total();
7587                let before_journal = index.journal.clone();
7588                let before_journal_cost = index.journal_cost;
7589                let before_journal_floor = index.journal_floor;
7590                let before_invalidations = index.pending_invalidations.clone();
7591                let before_freshness_epoch = index.freshness_epoch;
7592                let before_freshness = index.freshness();
7593                let valid = upsert("new.txt", EntryKind::File, file_attrs(99, 99));
7594                let invalid = Op::InvalidateSubtree {
7595                    path: invalid_path.clone(),
7596                    reason: InvalidateReason::Requested,
7597                };
7598                let ops = if invalid_first { vec![invalid, valid] } else { vec![valid, invalid] };
7599
7600                let error = index.apply(&Observation::new(ops)).expect_err("malformed batch");
7601
7602                assert!(
7603                    matches!(error, crate::Error::PathEscapesRoot(path) if path == invalid_path)
7604                );
7605                assert_eq!(index.clock, before_clock);
7606                assert_eq!(index.live, before_live);
7607                assert_eq!(index.total(), before_total);
7608                assert_eq!(index.journal, before_journal);
7609                assert_eq!(index.journal_cost, before_journal_cost);
7610                assert_eq!(index.journal_floor, before_journal_floor);
7611                assert_eq!(index.pending_invalidations, before_invalidations);
7612                assert_eq!(index.freshness_epoch, before_freshness_epoch);
7613                assert_eq!(index.freshness(), before_freshness);
7614                assert!(index.lookup(Path::new("new.txt")).is_none());
7615            }
7616        }
7617    }
7618
7619    #[cfg(windows)]
7620    #[test]
7621    fn windows_prefix_is_rejected_before_mutation() {
7622        let mut index = index_with_sample_tree();
7623        let before_clock = index.clock();
7624
7625        let error = index
7626            .apply(&Observation::new(vec![Op::Remove { path: PathBuf::from(r"C:\escape") }]))
7627            .expect_err("prefixed path");
7628
7629        assert!(
7630            matches!(error, crate::Error::PathEscapesRoot(path) if path == Path::new(r"C:\escape"))
7631        );
7632        assert_eq!(index.clock(), before_clock);
7633    }
7634
7635    fn index_with_sample_tree() -> Index {
7636        let mut index = Index::new("/root");
7637        index.apply_ok(&Observation::new(vec![
7638            upsert("src", EntryKind::Dir, Attrs::default()),
7639            upsert("src/main.rs", EntryKind::File, file_attrs(100, 10)),
7640            upsert("src/lib.rs", EntryKind::File, file_attrs(200, 20)),
7641            upsert("docs", EntryKind::Dir, Attrs::default()),
7642            upsert("docs/guide.md", EntryKind::File, file_attrs(300, 30)),
7643        ]));
7644        index
7645    }
7646
7647    #[test]
7648    fn the_extension_interner_reclaims_ids_after_churn() {
7649        // The long-lived case: a watched tree that keeps creating and deleting files
7650        // with distinct extensions. Without reclamation both interner maps grow for the
7651        // life of the process, which for `fdu --watch` means forever.
7652        let mut index = Index::new("/root");
7653        for sequence in 0..128 {
7654            let path = PathBuf::from(format!("build.out-{sequence}"));
7655            index.apply_ok(&Observation::new(vec![Op::Upsert {
7656                path: path.clone(),
7657                kind: EntryKind::File,
7658                attrs: file_attrs(1, sequence),
7659            }]));
7660            index.apply_ok(&Observation::new(vec![Op::Remove { path }]));
7661        }
7662
7663        assert!(index.ext_ids.is_empty(), "no extension survives the file that named it");
7664        assert_eq!(index.ext_names.len(), 1, "128 dead extensions reuse one interner slot");
7665        assert!(index.total().by_ext.is_empty());
7666    }
7667
7668    #[test]
7669    fn a_reclaimed_extension_id_does_not_alias_a_live_tally() {
7670        // Reissuing a slot is only safe if nothing still points at it. Keep one file on
7671        // the recycled extension while another one comes and goes.
7672        let mut index = Index::new("/root");
7673        index.apply_ok(&Observation::new(vec![
7674            upsert("keep.rs", EntryKind::File, file_attrs(10, 1)),
7675            upsert("drop.tmp", EntryKind::File, file_attrs(20, 2)),
7676        ]));
7677        let retained = index.total();
7678        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("drop.tmp") }]));
7679        index.apply_ok(&Observation::new(vec![upsert(
7680            "next.bak",
7681            EntryKind::File,
7682            file_attrs(30, 3),
7683        )]));
7684
7685        let tallies = index.total().by_ext;
7686        assert_eq!(tallies[".rs"], ExtTally { files: 1, bytes: 10, allocated: 512 });
7687        assert_eq!(tallies[".bak"], ExtTally { files: 1, bytes: 30, allocated: 512 });
7688        assert!(!tallies.contains_key(".tmp"), "the removed extension is gone");
7689        assert_eq!(
7690            retained.by_ext[".tmp"],
7691            ExtTally { files: 1, bytes: 20, allocated: 512 },
7692            "an owned roll-up stays self-describing after its interner slot is reused"
7693        );
7694        assert!(!retained.by_ext.contains_key(".bak"));
7695    }
7696
7697    #[test]
7698    fn rollups_aggregate_up_the_tree() {
7699        let index = index_with_sample_tree();
7700
7701        let total = index.total();
7702        assert_eq!(total.files, 3);
7703        assert_eq!(total.dirs, 2);
7704        assert_eq!(total.bytes, 600);
7705        assert_eq!(total.newest_mtime_ns, 30);
7706
7707        let src = index.rollup(Path::new("src")).expect("src is a directory");
7708        assert_eq!(src.files, 2);
7709        assert_eq!(src.dirs, 0);
7710        assert_eq!(src.bytes, 300);
7711        assert_eq!(src.newest_mtime_ns, 20);
7712    }
7713
7714    #[test]
7715    fn rollups_conserve_hand_counted_populations_through_updates_and_subtree_replacement() {
7716        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
7717        let attrs =
7718            |size, allocated, inode| Attrs { size, allocated, inode, dev: 1, ..Attrs::default() };
7719        index.apply_ok(&Observation::new(vec![
7720            upsert(".gitignore", EntryKind::File, attrs(6, 512, 1)),
7721            upsert("a", EntryKind::Dir, Attrs::default()),
7722            upsert("a/keep.rs", EntryKind::File, attrs(7, 512, 99)),
7723            upsert("a/drop.log", EntryKind::File, attrs(11, 1_024, 3)),
7724            upsert("z.rs", EntryKind::File, attrs(5, 4_096, 99)),
7725            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
7726        ]));
7727
7728        let total = index.partition_total().expect("control state observed");
7729        assert_eq!(
7730            (total.all.files, total.all.dirs, total.all.bytes, total.all.allocated),
7731            (4, 1, 29, 6_144)
7732        );
7733        assert_eq!(
7734            (total.unignored.files, total.unignored.bytes, total.unignored.allocated),
7735            (3, 18, 5_120)
7736        );
7737        assert_eq!(index.rollup(Path::new("a")).expect("directory").bytes, 18);
7738        assert_eq!(
7739            total.all.by_ext[".rs"].files, 2,
7740            "two paths with one inode each contribute a file"
7741        );
7742
7743        index.apply_ok(&Observation::new(vec![upsert(
7744            "a/drop.log",
7745            EntryKind::File,
7746            attrs(13, 1_536, 3),
7747        )]));
7748        let total = index.partition_total().expect("control state observed");
7749        assert_eq!((total.all.files, total.all.bytes, total.all.allocated), (4, 31, 6_656));
7750        assert_eq!(
7751            (total.unignored.files, total.unignored.bytes, total.unignored.allocated),
7752            (3, 18, 5_120)
7753        );
7754
7755        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("a") }]));
7756        let total = index.partition_total().expect("control state observed");
7757        assert_eq!(
7758            (total.all.files, total.all.dirs, total.all.bytes, total.all.allocated),
7759            (2, 0, 11, 4_608)
7760        );
7761        assert_eq!(total.all, total.unignored);
7762
7763        index.apply_ok(&Observation::new(vec![
7764            upsert("a", EntryKind::Dir, Attrs::default()),
7765            upsert("a/final.log", EntryKind::File, attrs(17, 4_096, 4)),
7766        ]));
7767        let total = index.partition_total().expect("control state observed");
7768        assert_eq!(
7769            (total.all.files, total.all.dirs, total.all.bytes, total.all.allocated),
7770            (3, 1, 28, 8_704)
7771        );
7772        assert_eq!(
7773            (
7774                total.unignored.files,
7775                total.unignored.dirs,
7776                total.unignored.bytes,
7777                total.unignored.allocated
7778            ),
7779            (2, 1, 11, 4_608)
7780        );
7781        assert_eq!(index.rollup(Path::new("a")).expect("directory").bytes, 17);
7782    }
7783
7784    #[test]
7785    fn symlinks_and_special_nodes_do_not_contribute_regular_file_tallies() {
7786        let mut index = Index::new("/root");
7787        index.apply_ok(&Observation::new(vec![
7788            upsert("regular.txt", EntryKind::File, file_attrs(10, 10)),
7789            upsert("link.rs", EntryKind::Symlink, file_attrs(99, 99)),
7790            upsert("socket.md", EntryKind::Other, file_attrs(88, 88)),
7791        ]));
7792
7793        let total = index.total();
7794        assert_eq!(total.files, 1);
7795        assert_eq!(total.bytes, 10);
7796        assert_eq!(total.allocated, 512);
7797        assert_eq!(total.newest_mtime_ns, 10);
7798        assert_eq!(total.by_ext[".txt"], ExtTally { files: 1, bytes: 10, allocated: 512 });
7799        assert!(!total.by_ext.contains_key(".rs"));
7800        assert!(!total.by_ext.contains_key(".md"));
7801
7802        index.apply_ok(&Observation::new(vec![upsert(
7803            "link.rs",
7804            EntryKind::File,
7805            file_attrs(99, 99),
7806        )]));
7807        assert_eq!(index.total().files, 2);
7808        assert_eq!(index.total().bytes, 109);
7809
7810        index.apply_ok(&Observation::new(vec![upsert(
7811            "link.rs",
7812            EntryKind::Symlink,
7813            file_attrs(99, 99),
7814        )]));
7815        assert_eq!(index.total().files, 1);
7816        assert_eq!(index.total().bytes, 10);
7817    }
7818
7819    #[test]
7820    fn per_extension_tallies_roll_up_hierarchically() {
7821        let index = index_with_sample_tree();
7822
7823        let total = index.total();
7824        assert_eq!(total.by_ext[".rs"], ExtTally { files: 2, bytes: 300, allocated: 1024 });
7825        assert_eq!(total.by_ext[".md"], ExtTally { files: 1, bytes: 300, allocated: 512 });
7826
7827        // Per-directory breakdown, which no surveyed tool provides.
7828        let src = index.rollup(Path::new("src")).expect("src is a directory");
7829        assert_eq!(src.by_ext[".rs"], ExtTally { files: 2, bytes: 300, allocated: 1024 });
7830        assert!(!src.by_ext.contains_key(".md"));
7831    }
7832
7833    #[test]
7834    fn per_extension_allocated_tracks_apparent_bytes_separately() {
7835        // Both size metrics ride in the same tally, so a report asked for allocated bytes
7836        // keeps its per-type breakdown instead of silently answering in apparent bytes.
7837        let mut index = Index::new("/root");
7838        index.apply_ok(&Observation::new(vec![
7839            // Two small files: apparent bytes are tiny, allocation rounds each to a block.
7840            upsert("a.rs", EntryKind::File, file_attrs(1, 10)),
7841            upsert("b.rs", EntryKind::File, file_attrs(2, 20)),
7842        ]));
7843
7844        let rs = index.total().by_ext[".rs"];
7845        assert_eq!((rs.files, rs.bytes), (2, 3));
7846        assert_eq!(rs.allocated, 1024, "each file occupies one 512-byte block");
7847
7848        // Removing one file withdraws its allocation from the tally, not just its bytes.
7849        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("b.rs") }]));
7850        let rs = index.total().by_ext[".rs"];
7851        assert_eq!((rs.files, rs.bytes, rs.allocated), (1, 1, 512));
7852    }
7853
7854    #[test]
7855    fn upsert_with_matching_fingerprint_is_a_no_op() {
7856        let mut index = index_with_sample_tree();
7857        let before = index.total();
7858        let mark = index.clock();
7859
7860        let stats = index.apply_ok(&Observation::new(vec![upsert(
7861            "src/main.rs",
7862            EntryKind::File,
7863            file_attrs(100, 10),
7864        )]));
7865
7866        assert_eq!(stats.unchanged, 1);
7867        assert_eq!(stats.updated, 0);
7868        assert_eq!(index.total(), before);
7869        assert_eq!(index.clock(), mark);
7870        assert!(index.since(mark).commits.is_empty());
7871    }
7872
7873    #[test]
7874    fn commit_contains_only_effective_mutations() {
7875        let mut index = index_with_sample_tree();
7876        let outcome = index.apply_ok(&Observation::new(vec![
7877            upsert("src/main.rs", EntryKind::File, file_attrs(100, 10)),
7878            upsert("new.txt", EntryKind::File, file_attrs(4, 4)),
7879            Op::Remove { path: PathBuf::from("missing.txt") },
7880        ]));
7881
7882        assert_eq!(outcome.stats.unchanged, 2);
7883        let commit = outcome.commit.expect("one effective insert");
7884        assert_eq!(commit.changes.len(), 1);
7885        assert_eq!(commit.changes[0].path(), Path::new("new.txt"));
7886    }
7887
7888    #[test]
7889    fn mutation_counters_match_exact_batch_and_consequence_totals() {
7890        struct DisableCounters;
7891
7892        impl Drop for DisableCounters {
7893            fn drop(&mut self) {
7894                crate::counters::enable(false);
7895                crate::counters::test_thread_reset();
7896            }
7897        }
7898
7899        let _serial = crate::counters::test_serial();
7900        crate::counters::enable(true);
7901        let _disable = DisableCounters;
7902        let observation = Observation::new(vec![
7903            upsert("a", EntryKind::Dir, Attrs::default()),
7904            upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
7905            upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
7906        ]);
7907
7908        crate::counters::test_thread_reset();
7909        let mut baseline = Index::new("/root");
7910        baseline.apply_baseline_ok(&observation);
7911        let counts = crate::counters::test_thread_snapshot();
7912        assert_eq!(counts.baseline_batches, 1);
7913        assert_eq!(counts.baseline_accepted_ops, 3);
7914        assert_eq!(counts.opened_batches, 0);
7915        assert_eq!(counts.public_batches, 0);
7916        assert_eq!(counts.ancestry_overlay_inserts, 3);
7917        assert_eq!(counts.ancestry_path_comparisons, 2);
7918        assert_eq!(counts.ancestry_parent_proofs, 3);
7919        assert_eq!(counts.effect_paths, 0);
7920        assert_eq!(counts.effect_path_bytes, 0);
7921        assert_eq!(counts.impact_candidates, 0);
7922        assert_eq!(counts.impact_ancestor_visits, 0);
7923        assert_eq!(counts.impact_retained_dirty_paths, 0);
7924        assert_eq!(counts.impact_all_dirty, 0);
7925        assert_eq!(counts.journal_cloned_commits, 0);
7926        assert_eq!(counts.journal_retained_commits, 0);
7927
7928        crate::counters::test_thread_reset();
7929        let mut public = Index::new("/root");
7930        public.apply_ok(&observation);
7931        let counts = crate::counters::test_thread_snapshot();
7932        assert_eq!(counts.baseline_batches, 0);
7933        assert_eq!(counts.opened_batches, 0);
7934        assert_eq!(counts.public_batches, 1);
7935        assert_eq!(counts.public_accepted_ops, 3);
7936        assert_eq!(counts.effect_paths, 3);
7937        assert_eq!(counts.journal_cloned_commits, 1);
7938        assert_eq!(counts.journal_retained_commits, 1);
7939        assert_eq!(counts.journal_oversized_commits, 0);
7940        assert_eq!(counts.journal_dropped_commits, 0);
7941
7942        crate::counters::test_thread_reset();
7943        let opened = IndexHandle::new(Index::new("/root"));
7944        opened
7945            .apply_discovery(&observation, DiscoveryCommit::default())
7946            .expect("opened discovery batch");
7947        let counts = crate::counters::test_thread_snapshot();
7948        assert_eq!(counts.baseline_batches, 0);
7949        assert_eq!(counts.opened_batches, 1);
7950        assert_eq!(counts.opened_accepted_ops, 3);
7951        assert_eq!(counts.public_batches, 0);
7952
7953        crate::counters::test_thread_reset();
7954        let mut scanner = Index::new("/root");
7955        scanner
7956            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
7957                upsert("a", EntryKind::Dir, Attrs::default()),
7958                upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
7959                upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
7960            ]))
7961            .expect("private scanner batch");
7962        let counts = crate::counters::test_thread_snapshot();
7963        assert_eq!(counts.baseline_batches, 1);
7964        assert_eq!(counts.baseline_accepted_ops, 3);
7965        assert_eq!(counts.ancestry_overlay_inserts, 0);
7966        assert_eq!(counts.ancestry_path_comparisons, 2);
7967        assert_eq!(counts.ancestry_parent_proofs, 3);
7968        assert_eq!(counts.parent_resolutions, 0);
7969        assert_eq!(counts.parent_memo_hits, 0);
7970        assert_eq!(scanner.total().dirs, 1);
7971        assert_eq!(scanner.total().files, 2);
7972
7973        crate::counters::test_thread_reset();
7974        let opened_scanner = IndexHandle::new(Index::new("/root"));
7975        opened_scanner
7976            .apply_scanner_discovery_bounded(
7977                crate::scan::ScannerBatch::from_ops(vec![
7978                    upsert("a", EntryKind::Dir, Attrs::default()),
7979                    upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
7980                    upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
7981                ]),
7982                DiscoveryCommit::default(),
7983                None,
7984            )
7985            .expect("opened scanner batch");
7986        let counts = crate::counters::test_thread_snapshot();
7987        assert_eq!(counts.opened_batches, 1);
7988        assert_eq!(counts.opened_accepted_ops, 3);
7989        assert_eq!(counts.ancestry_overlay_inserts, 0);
7990        assert_eq!(counts.effect_paths, 3);
7991        assert_eq!(counts.journal_retained_commits, 1);
7992
7993        // Room for exactly two one-file commits; measured before the counters reset so the
7994        // probe's own journal work is not counted.
7995        let two_commits = 2 * commit_cost(vec![upsert("one", EntryKind::File, file_attrs(1, 1))]);
7996        crate::counters::test_thread_reset();
7997        let mut bounded = Index::new("/root");
7998        bounded.journal_capacity_bytes = two_commits;
7999        bounded.apply_ok(&Observation::new(vec![upsert("one", EntryKind::File, file_attrs(1, 1))]));
8000        bounded.apply_ok(&Observation::new(vec![upsert("two", EntryKind::File, file_attrs(2, 2))]));
8001        bounded.journal_capacity_bytes = 1;
8002        bounded.apply_ok(&Observation::new(vec![upsert(
8003            "three",
8004            EntryKind::File,
8005            file_attrs(3, 3),
8006        )]));
8007        let counts = crate::counters::test_thread_snapshot();
8008        assert_eq!(counts.journal_cloned_commits, 2);
8009        assert_eq!(counts.journal_retained_commits, 2);
8010        assert_eq!(counts.journal_oversized_commits, 1);
8011        assert_eq!(counts.journal_dropped_commits, 2);
8012    }
8013
8014    #[test]
8015    fn detached_and_exact_reducers_produce_the_same_facts_and_stats() {
8016        fn fact_image(index: &Index) -> Vec<(PathBuf, EntryKind, Attrs, bool, Source, bool)> {
8017            let mut image = Vec::new();
8018            let mut frontier = VecDeque::from([EntryId::ROOT]);
8019            while let Some(id) = frontier.pop_front() {
8020                let entry = index.entry(id);
8021                if entry.kind.is_dir() {
8022                    frontier.extend(index.child_ids(id));
8023                }
8024                image.push((
8025                    index.path_of(id).expect("live entry path"),
8026                    entry.kind,
8027                    entry.attrs,
8028                    entry.ignored,
8029                    entry.source,
8030                    entry.directory.as_deref().is_none_or(|directory| directory.children_complete),
8031                ));
8032            }
8033            image.sort_by(|left, right| left.0.cmp(&right.0));
8034            image
8035        }
8036
8037        fn assert_same_facts(detached: &Index, exact: &Index) {
8038            assert_eq!(fact_image(detached), fact_image(exact));
8039            assert_eq!(detached.total(), exact.total());
8040            let partitions =
8041                |index: &Index| index.named_partitions(index.entry(EntryId::ROOT).rollup());
8042            assert_eq!(partitions(detached), partitions(exact));
8043            let controls = |index: &Index| {
8044                index
8045                    .controls
8046                    .sources()
8047                    .map(|(path, source)| (path, source.to_vec()))
8048                    .collect::<Vec<_>>()
8049            };
8050            assert_eq!(controls(detached), controls(exact));
8051            assert_eq!(detached.state, exact.state);
8052            assert_eq!(detached.issues, exact.issues);
8053            let freshness = |index: &Index| {
8054                index
8055                    .freshness_marks
8056                    .iter()
8057                    .map(|(path, mark)| (path.clone(), mark.state, mark.epoch))
8058                    .collect::<Vec<_>>()
8059            };
8060            assert_eq!(freshness(detached), freshness(exact));
8061            assert_eq!(detached.verified, exact.verified);
8062            assert_eq!(detached.pending_invalidations, exact.pending_invalidations);
8063        }
8064
8065        let mut detached = Index::new("/root");
8066        let mut exact = detached.clone();
8067        let batches = [
8068            Observation::new(vec![
8069                upsert("a", EntryKind::Dir, file_attrs(0, 1)),
8070                upsert("a/one.rs", EntryKind::File, file_attrs(10, 2)),
8071                upsert("a/two.txt", EntryKind::File, file_attrs(20, 3)),
8072            ]),
8073            Observation::new(vec![
8074                upsert("a/one.rs", EntryKind::File, file_attrs(30, 4)),
8075                Op::Remove { path: PathBuf::from("a/two.txt") },
8076                Op::InvalidateSubtree {
8077                    path: PathBuf::from("a"),
8078                    reason: InvalidateReason::VerificationFailed,
8079                },
8080            ]),
8081        ];
8082
8083        for batch in batches {
8084            let detached_stats = detached.apply_baseline(&batch).expect("detached batch");
8085            let exact_outcome = exact.apply(&batch).expect("exact batch");
8086            assert_eq!(detached_stats, exact_outcome.stats);
8087            exact.establish_baseline();
8088            assert_same_facts(&detached, &exact);
8089        }
8090    }
8091
8092    #[test]
8093    fn exact_commit_records_verified_ancestry_and_kind_replacement() {
8094        let mut index = Index::new("/root");
8095        let inserted = index.apply_ok(&Observation::new(vec![
8096            upsert("unknown", EntryKind::Dir, file_attrs(0, 1)),
8097            upsert("unknown/deep", EntryKind::Dir, file_attrs(0, 2)),
8098            upsert("unknown/deep/file.txt", EntryKind::File, file_attrs(10, 3)),
8099        ]));
8100        let inserted = inserted.commit.expect("ancestry commit");
8101        assert_eq!(
8102            inserted.changes.iter().map(EffectiveChange::path).collect::<Vec<_>>(),
8103            [Path::new("unknown"), Path::new("unknown/deep"), Path::new("unknown/deep/file.txt")]
8104        );
8105        assert!(
8106            inserted
8107                .changes
8108                .iter()
8109                .all(|change| matches!(change, EffectiveChange::Inserted { .. }))
8110        );
8111
8112        let replaced = index.apply_ok(&Observation::new(vec![upsert(
8113            "unknown",
8114            EntryKind::File,
8115            file_attrs(20, 2),
8116        )]));
8117        let replaced = replaced.commit.expect("replacement commit");
8118        assert_eq!(
8119            replaced.changes,
8120            vec![
8121                EffectiveChange::Removed {
8122                    path: "unknown".into(),
8123                    kind: EntryKind::Dir,
8124                    attrs: file_attrs(0, 1),
8125                },
8126                EffectiveChange::Removed {
8127                    path: "unknown/deep".into(),
8128                    kind: EntryKind::Dir,
8129                    attrs: file_attrs(0, 2),
8130                },
8131                EffectiveChange::Removed {
8132                    path: "unknown/deep/file.txt".into(),
8133                    kind: EntryKind::File,
8134                    attrs: file_attrs(10, 3),
8135                },
8136                EffectiveChange::Inserted {
8137                    path: "unknown".into(),
8138                    kind: EntryKind::File,
8139                    attrs: file_attrs(20, 2),
8140                },
8141            ]
8142        );
8143        assert_eq!(
8144            replaced.impact.domains,
8145            vec![
8146                ImpactDomain::Topology,
8147                ImpactDomain::Metadata,
8148                ImpactDomain::Classification,
8149                ImpactDomain::Aggregates,
8150                ImpactDomain::Content,
8151            ]
8152        );
8153        assert_eq!(
8154            replaced.impact.dirty_paths,
8155            vec![
8156                PathBuf::new(),
8157                "unknown".into(),
8158                "unknown/deep".into(),
8159                "unknown/deep/file.txt".into(),
8160            ]
8161        );
8162    }
8163
8164    #[test]
8165    fn rejected_prepared_commit_is_fault_atomic() {
8166        let mut index = index_with_sample_tree();
8167        let before_clock = index.clock();
8168        let before_total = index.total();
8169        let before_len = index.len();
8170        let before_history = index.since(Clock::ZERO);
8171        let mut prepared = prepare_observation(&Observation::new(vec![upsert(
8172            "new/deep.txt",
8173            EntryKind::File,
8174            file_attrs(99, 99),
8175        )]))
8176        .expect("valid preparation");
8177        prepared.reject_before_apply = true;
8178
8179        let error = index.commit_prepared(prepared, true).expect_err("injected preflight");
8180
8181        assert!(matches!(error, crate::Error::CommitRejected("injected reducer preflight")));
8182        assert_eq!(index.clock(), before_clock);
8183        assert_eq!(index.total(), before_total);
8184        assert_eq!(index.len(), before_len);
8185        assert_eq!(index.since(Clock::ZERO), before_history);
8186        assert!(index.lookup(Path::new("new")).is_none());
8187    }
8188
8189    #[test]
8190    fn reconciliation_state_moves_through_exact_commits() {
8191        let mut index = Index::new("/root");
8192        let (started, start) = index.begin_reconcile(Path::new("src")).expect("begin");
8193        let start = start.expect("start commit");
8194        assert!(start.changes.is_empty());
8195        assert_eq!(
8196            start.state,
8197            vec![
8198                StateTransition::Freshness {
8199                    path: "src".into(),
8200                    previous: Freshness::Fresh,
8201                    current: Freshness::Reconciling,
8202                },
8203                StateTransition::IndexState {
8204                    previous: IndexState::default(),
8205                    current: IndexState {
8206                        freshness: Freshness::Reconciling,
8207                        ..IndexState::default()
8208                    },
8209                },
8210            ]
8211        );
8212
8213        let finish = index
8214            .finish_reconcile(
8215                Path::new("src"),
8216                started,
8217                true,
8218                &[],
8219                &[],
8220                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
8221            )
8222            .expect("finish")
8223            .commit
8224            .expect("finish commit");
8225        assert!(finish.changes.is_empty());
8226        assert_eq!(
8227            finish.state,
8228            vec![
8229                StateTransition::Verified { path: "src".into() },
8230                StateTransition::Freshness {
8231                    path: "src".into(),
8232                    previous: Freshness::Reconciling,
8233                    current: Freshness::Fresh,
8234                },
8235                StateTransition::IndexState {
8236                    previous: IndexState {
8237                        freshness: Freshness::Reconciling,
8238                        ..IndexState::default()
8239                    },
8240                    current: IndexState::default(),
8241                },
8242            ]
8243        );
8244    }
8245
8246    #[cfg(feature = "watch")]
8247    #[test]
8248    fn observation_failure_cannot_overwrite_a_terminal_resource_stop() {
8249        let handle = IndexHandle::new(Index::new("/root"));
8250        handle
8251            .transition_discovery(DiscoveryTransition::BudgetRefused(Issue::resource_budget(0)))
8252            .expect("stop for budget");
8253        let stopped = handle.state().expect("stopped state");
8254        let clock = handle.clock().expect("stopped clock");
8255
8256        let outcome = handle
8257            .transition_observation(ObservationTransition::Failed(Issue::from_error(
8258                &crate::Error::WatchStopped,
8259            )))
8260            .expect("late observer failure is ignored");
8261
8262        assert_eq!(outcome.commit, None);
8263        assert_eq!(handle.state().expect("terminal state"), stopped);
8264        assert_eq!(handle.clock().expect("terminal clock"), clock);
8265    }
8266
8267    /// Once a root has stopped or failed, discovery can neither expand it nor reopen it.
8268    #[test]
8269    fn a_terminal_root_refuses_every_discovery_commit() {
8270        let terminals = [
8271            DiscoveryTransition::BudgetRefused(Issue::resource_budget(1)),
8272            DiscoveryTransition::Failed(Issue::from_error(&crate::Error::OpenedIndexClosed)),
8273        ];
8274        for terminal in terminals {
8275            let handle = IndexHandle::new(Index::new("/root"));
8276            handle.transition_discovery(DiscoveryTransition::Begin).expect("begin");
8277            handle
8278                .apply_discovery(
8279                    &Observation::new(vec![upsert("dir", EntryKind::Dir, Attrs::default())]),
8280                    DiscoveryCommit::default(),
8281                )
8282                .expect("listing before the stop");
8283            handle.transition_discovery(terminal.clone()).expect("terminal transition");
8284            let state = handle.state().expect("terminal state");
8285            let clock = handle.clock().expect("terminal clock");
8286
8287            let late = [
8288                (
8289                    vec![upsert("dir/late.txt", EntryKind::File, file_attrs(1, 1))],
8290                    DiscoveryCommit {
8291                        directory_complete: Some(PathBuf::from("dir")),
8292                        transition: None,
8293                    },
8294                ),
8295                (
8296                    Vec::new(),
8297                    DiscoveryCommit {
8298                        directory_complete: None,
8299                        transition: Some(DiscoveryTransition::Finish),
8300                    },
8301                ),
8302                (
8303                    Vec::new(),
8304                    DiscoveryCommit {
8305                        directory_complete: None,
8306                        transition: Some(DiscoveryTransition::Inaccessible {
8307                            issues: vec![Issue::resource_budget(2)],
8308                            omitted: 0,
8309                        }),
8310                    },
8311                ),
8312            ];
8313            for (ops, discovery) in late {
8314                assert!(
8315                    matches!(
8316                        handle.apply_discovery(&Observation::new(ops), discovery.clone()),
8317                        Err(crate::Error::OpenedIndexStopped)
8318                    ),
8319                    "after {terminal:?}, {discovery:?} was accepted"
8320                );
8321            }
8322            assert_eq!(handle.state().expect("state"), state, "after {terminal:?}");
8323            assert_eq!(handle.clock().expect("clock"), clock, "after {terminal:?}");
8324            assert_eq!(handle.kind(Path::new("dir/late.txt")).expect("lookup"), None);
8325        }
8326    }
8327
8328    #[test]
8329    fn replaying_the_same_delta_twice_changes_nothing() {
8330        let mut index = Index::new("/root");
8331        let delta = Observation::new(vec![
8332            upsert("a", EntryKind::Dir, Attrs::default()),
8333            upsert("a/f.txt", EntryKind::File, file_attrs(10, 1)),
8334        ]);
8335        index.apply_ok(&delta);
8336        let after_first = index.total();
8337        let stats = index.apply_ok(&delta);
8338
8339        assert_eq!(stats.unchanged, 2);
8340        assert_eq!(index.total(), after_first);
8341        assert_eq!(index.len(), 3);
8342    }
8343
8344    #[test]
8345    fn changed_size_updates_every_ancestor() {
8346        let mut index = index_with_sample_tree();
8347        index.apply_ok(&Observation::new(vec![upsert(
8348            "src/main.rs",
8349            EntryKind::File,
8350            file_attrs(150, 11),
8351        )]));
8352
8353        assert_eq!(index.rollup(Path::new("src")).expect("dir").bytes, 350);
8354        assert_eq!(index.total().bytes, 650);
8355        assert_eq!(index.total().by_ext[".rs"], ExtTally { files: 2, bytes: 350, allocated: 1024 });
8356    }
8357
8358    #[test]
8359    fn allocated_size_change_updates_rollups_even_when_fingerprint_matches() {
8360        let mut index = Index::new("/root");
8361        let original = Attrs { allocated: 512, ..file_attrs(100, 10) };
8362        index.apply_ok(&Observation::new(vec![upsert("file.bin", EntryKind::File, original)]));
8363
8364        let repacked = Attrs { allocated: 4096, ..original };
8365        let outcome =
8366            index.apply_ok(&Observation::new(vec![upsert("file.bin", EntryKind::File, repacked)]));
8367
8368        assert_eq!(outcome.stats.updated, 1);
8369        assert_eq!(outcome.stats.unchanged, 0);
8370        assert_eq!(index.total().allocated, 4096);
8371        assert_eq!(index.attrs(Path::new("file.bin")), Some(&repacked));
8372    }
8373
8374    #[test]
8375    fn newest_mtime_preserves_pre_epoch_values_through_updates_and_removals() {
8376        let mut index = Index::new("/root");
8377        index.apply_ok(&Observation::new(vec![
8378            upsert("newer.txt", EntryKind::File, file_attrs(10, -10)),
8379            upsert("older.txt", EntryKind::File, file_attrs(20, -20)),
8380        ]));
8381
8382        assert_eq!(index.total().newest_mtime_ns, -10);
8383
8384        index.apply_ok(&Observation::new(vec![upsert(
8385            "newer.txt",
8386            EntryKind::File,
8387            file_attrs(10, -30),
8388        )]));
8389        assert_eq!(index.total().newest_mtime_ns, -20);
8390
8391        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("older.txt") }]));
8392        assert_eq!(index.total().newest_mtime_ns, -30);
8393    }
8394
8395    #[test]
8396    fn removing_a_file_corrects_sums_and_rebuilds_the_max() {
8397        let mut index = index_with_sample_tree();
8398        // guide.md holds the newest mtime for the whole tree.
8399        let stats = index
8400            .apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("docs/guide.md") }]));
8401
8402        assert_eq!(stats.removed, 1);
8403        let total = index.total();
8404        assert_eq!(total.files, 2);
8405        assert_eq!(total.bytes, 300);
8406        assert_eq!(total.newest_mtime_ns, 20, "max must fall back to src/lib.rs");
8407        assert!(!total.by_ext.contains_key(".md"), "emptied tallies are dropped");
8408    }
8409
8410    #[test]
8411    fn removing_a_directory_cascades_to_descendants() {
8412        let mut index = index_with_sample_tree();
8413        let stats = index
8414            .apply(&Observation::new(vec![Op::Remove { path: PathBuf::from("src") }]))
8415            .expect("valid observation");
8416
8417        assert_eq!(stats.removed, 3, "the directory and both files");
8418        let total = index.total();
8419        assert_eq!(total.files, 1);
8420        assert_eq!(total.dirs, 1);
8421        assert_eq!(total.bytes, 300);
8422        assert!(index.lookup(Path::new("src/main.rs")).is_none());
8423        assert!(!total.by_ext.contains_key(".rs"));
8424    }
8425
8426    #[test]
8427    fn freed_slots_are_reused() {
8428        let mut index = index_with_sample_tree();
8429        let before = index.len();
8430        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("src") }]));
8431        index.apply_ok(&Observation::new(vec![
8432            upsert("other", EntryKind::Dir, Attrs::default()),
8433            upsert("other/x.rs", EntryKind::File, file_attrs(1, 1)),
8434            upsert("other/y.rs", EntryKind::File, file_attrs(1, 1)),
8435        ]));
8436        assert_eq!(index.len(), before, "three freed slots, three new entries");
8437    }
8438
8439    #[test]
8440    fn stale_entry_handle_does_not_alias_a_reused_slot() {
8441        let mut index = Index::new("/root");
8442        index.apply_ok(&Observation::new(vec![upsert(
8443            "first.txt",
8444            EntryKind::File,
8445            file_attrs(1, 1),
8446        )]));
8447        let stale = index.lookup(Path::new("first.txt")).expect("first id");
8448        index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("first.txt") }]));
8449        index.apply_ok(&Observation::new(vec![upsert(
8450            "second.txt",
8451            EntryKind::File,
8452            file_attrs(2, 2),
8453        )]));
8454        let current = index.lookup(Path::new("second.txt")).expect("second id");
8455
8456        assert_ne!(stale, current, "generation participates in handle identity");
8457        assert!(index.attrs_of(stale).is_none());
8458        assert!(index.kind_of(stale).is_none());
8459        assert!(index.name_of(stale).is_none());
8460        assert!(index.path_of(stale).is_none());
8461        assert!(index.children_of(stale).is_none());
8462        assert!(index.rollup_of(stale).is_none());
8463        assert_eq!(index.attrs_of(current).map(|attrs| attrs.size), Some(2));
8464    }
8465
8466    #[test]
8467    fn parent_first_batch_establishes_exact_ancestry() {
8468        let mut index = Index::new("/root");
8469        let deep = file_attrs(0, 1);
8470        let nested = file_attrs(0, 2);
8471        let tree = file_attrs(0, 3);
8472        index.apply_ok(&Observation::new(vec![
8473            upsert("deep", EntryKind::Dir, deep),
8474            upsert("deep/nested", EntryKind::Dir, nested),
8475            upsert("deep/nested/tree", EntryKind::Dir, tree),
8476            upsert("deep/nested/tree/file.txt", EntryKind::File, file_attrs(42, 7)),
8477        ]));
8478
8479        assert_eq!(index.total().files, 1);
8480        assert_eq!(index.total().dirs, 3);
8481        assert_eq!(index.total().bytes, 42);
8482        assert_eq!(index.rollup(Path::new("deep/nested")).expect("created").files, 1);
8483        assert_eq!(index.attrs(Path::new("deep")), Some(&deep));
8484        assert_eq!(index.attrs(Path::new("deep/nested")), Some(&nested));
8485        assert_eq!(index.attrs(Path::new("deep/nested/tree")), Some(&tree));
8486    }
8487
8488    #[test]
8489    fn scanner_parent_proof_matches_public_parent_first_application() {
8490        let ops = vec![
8491            upsert("deep", EntryKind::Dir, file_attrs(0, 1)),
8492            upsert("deep/nested", EntryKind::Dir, file_attrs(0, 2)),
8493            upsert("deep/nested/file.txt", EntryKind::File, file_attrs(42, 3)),
8494        ];
8495        let mut scanner = Index::new("/root");
8496        let scanner_stats = scanner
8497            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(ops.clone()))
8498            .expect("scanner proof");
8499        let mut public = Index::new("/root");
8500        let public_stats = public.apply(&Observation::new(ops)).expect("public proof").stats;
8501
8502        assert_eq!(scanner_stats, public_stats);
8503        assert_eq!(scanner.total(), public.total());
8504        assert_eq!(scanner.len(), public.len());
8505        for path in ["deep", "deep/nested", "deep/nested/file.txt"] {
8506            assert_eq!(scanner.kind(Path::new(path)), public.kind(Path::new(path)));
8507            assert_eq!(scanner.attrs(Path::new(path)), public.attrs(Path::new(path)));
8508        }
8509    }
8510
8511    #[test]
8512    fn scanner_parent_proof_rejects_unknown_ancestry_before_mutation() {
8513        let mut index = Index::new("/root");
8514        let before = index.total();
8515        let error = index
8516            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![upsert(
8517                "missing/child.txt",
8518                EntryKind::File,
8519                file_attrs(1, 1),
8520            )]))
8521            .expect_err("scanner proof must reject an unknown parent");
8522
8523        assert!(matches!(
8524            error,
8525            crate::Error::UnknownAncestry { path, .. }
8526                if path == Path::new("missing/child.txt")
8527        ));
8528        assert_eq!(index.total(), before);
8529        assert_eq!(index.len(), 1);
8530        assert_eq!(index.clock(), Clock::ZERO);
8531    }
8532
8533    #[test]
8534    fn scanner_kind_replacement_is_proved_by_the_general_lane() {
8535        let mut index = Index::new("/root");
8536        index.apply_ok(&Observation::new(vec![
8537            upsert("a", EntryKind::Dir, file_attrs(0, 1)),
8538            upsert("a/old.txt", EntryKind::File, file_attrs(7, 1)),
8539        ]));
8540        let before = index.total();
8541        let before_clock = index.clock();
8542
8543        // Once `a` is a file nothing can attach below it, and the batch is refused before
8544        // any fact moves, exactly as a public observation would be.
8545        let error = index
8546            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
8547                upsert("a", EntryKind::File, file_attrs(2, 2)),
8548                upsert("a/new.txt", EntryKind::File, file_attrs(3, 2)),
8549            ]))
8550            .expect_err("a child cannot attach after its parent became a file");
8551
8552        assert!(matches!(
8553            error,
8554            crate::Error::UnknownAncestry { path, .. } if path == Path::new("a/new.txt")
8555        ));
8556        assert_eq!(index.total(), before);
8557        assert_eq!(index.clock(), before_clock);
8558        assert_eq!(index.kind(Path::new("a")), Some(EntryKind::Dir));
8559        assert!(index.lookup(Path::new("a/old.txt")).is_some());
8560        assert!(index.lookup(Path::new("a/new.txt")).is_none());
8561
8562        // The replacement on its own is an ordinary verified observation.
8563        index
8564            .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![upsert(
8565                "a",
8566                EntryKind::File,
8567                file_attrs(2, 2),
8568            )]))
8569            .expect("a scanner batch can replace an entry's kind");
8570        assert_eq!(index.kind(Path::new("a")), Some(EntryKind::File));
8571        assert!(index.lookup(Path::new("a/old.txt")).is_none());
8572        let total = index.total();
8573        assert_eq!((total.files, total.dirs, total.bytes), (1, 0, 2));
8574    }
8575
8576    #[test]
8577    fn scanner_discovery_survives_a_kind_changed_by_a_concurrent_refresh() {
8578        let handle = IndexHandle::new(Index::new("/root"));
8579        handle
8580            .apply_scanner_discovery_bounded(
8581                crate::scan::ScannerBatch::from_ops(vec![upsert(
8582                    "p",
8583                    EntryKind::Dir,
8584                    Attrs::default(),
8585                )]),
8586                DiscoveryCommit::default(),
8587                None,
8588            )
8589            .expect("root listing");
8590        // A refresh, on the general lane, saw that p/d is now a file on disk.
8591        handle
8592            .apply(&Observation::new(vec![upsert("p/d", EntryKind::File, file_attrs(1, 1))]))
8593            .expect("refresh insert");
8594        // Discovery's pending batch still carries the directory observation it listed.
8595        let outcome = handle.apply_scanner_discovery_bounded(
8596            crate::scan::ScannerBatch::from_ops(vec![upsert(
8597                "p/d",
8598                EntryKind::Dir,
8599                Attrs::default(),
8600            )]),
8601            DiscoveryCommit { directory_complete: Some(PathBuf::from("p")), transition: None },
8602            None,
8603        );
8604        assert!(outcome.is_ok(), "discovery must not die on a kind race: {outcome:?}");
8605        // The listed observation replaces the entry, as any verified observation does, and
8606        // the directory it came from is complete.
8607        assert_eq!(handle.kind(Path::new("p/d")).expect("kind read"), Some(EntryKind::Dir));
8608        assert_eq!(handle.directory_complete(Path::new("p")).expect("read"), Some(true));
8609    }
8610
8611    #[test]
8612    fn live_upsert_refuses_unknown_ancestry_without_mutation() {
8613        let mut index = Index::new("/root");
8614        let before = index.clock();
8615
8616        let error = index
8617            .apply(&Observation::new(vec![upsert(
8618                "unknown/deep/file.txt",
8619                EntryKind::File,
8620                file_attrs(10, 1),
8621            )]))
8622            .expect_err("live input must not invent parent metadata");
8623
8624        assert!(matches!(
8625            error,
8626            crate::Error::UnknownAncestry { path, reconcile_from }
8627                if path == Path::new("unknown/deep/file.txt")
8628                    && reconcile_from.as_os_str().is_empty()
8629        ));
8630        assert_eq!(index.clock(), before);
8631        assert_eq!(index.len(), 1);
8632        assert!(index.since(before).commits.is_empty());
8633    }
8634
8635    #[test]
8636    fn explicit_kind_replacement_precedes_attaching_a_child() {
8637        let mut index = Index::new("/root");
8638        index.apply_ok(&Observation::new(vec![upsert(
8639            "conflict",
8640            EntryKind::File,
8641            file_attrs(9, 1),
8642        )]));
8643
8644        let outcome = index.apply_ok(&Observation::new(vec![
8645            upsert("conflict", EntryKind::Dir, file_attrs(0, 2)),
8646            upsert("conflict/child.txt", EntryKind::File, file_attrs(4, 2)),
8647        ]));
8648
8649        assert_eq!(index.kind(Path::new("conflict")), Some(EntryKind::Dir));
8650        assert!(index.lookup(Path::new("conflict/child.txt")).is_some());
8651        assert_eq!(index.total().files, 1);
8652        assert_eq!(index.total().dirs, 1);
8653        assert_eq!(index.total().bytes, 4);
8654        assert_eq!(outcome.removed, 1);
8655    }
8656
8657    #[test]
8658    fn kind_change_replaces_the_entry() {
8659        let mut index = Index::new("/root");
8660        index.apply_ok(&Observation::new(vec![upsert(
8661            "thing",
8662            EntryKind::File,
8663            file_attrs(50, 5),
8664        )]));
8665        assert_eq!(index.total().files, 1);
8666
8667        index.apply_ok(&Observation::new(vec![upsert("thing", EntryKind::Dir, Attrs::default())]));
8668        let total = index.total();
8669        assert_eq!(total.files, 0);
8670        assert_eq!(total.dirs, 1);
8671        assert_eq!(total.bytes, 0);
8672    }
8673
8674    #[test]
8675    fn paths_are_reconstructed_from_parent_pointers() {
8676        let index = index_with_sample_tree();
8677        let id = index.lookup(Path::new("src/main.rs")).expect("present");
8678        assert_eq!(index.path_of(id), Some(PathBuf::from("src/main.rs")));
8679        assert_eq!(index.path_of(EntryId::ROOT), Some(PathBuf::new()));
8680    }
8681
8682    #[test]
8683    fn since_returns_commits_after_a_clock() {
8684        let mut index = Index::new("/root");
8685        index.apply_ok(&Observation::new(vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))]));
8686        let mark = index.clock();
8687        index.apply_ok(&Observation::new(vec![upsert("b.txt", EntryKind::File, file_attrs(2, 2))]));
8688
8689        let since = index.since(mark);
8690        assert!(!since.truncated);
8691        assert_eq!(since.commits.len(), 1);
8692        assert_eq!(since.commits[0].changes[0].path(), Path::new("b.txt"));
8693
8694        assert_eq!(index.since(index.clock()).commits.len(), 0);
8695    }
8696
8697    /// The bytes one batch is charged when it commits against an empty tree.
8698    fn commit_cost(ops: Vec<Op>) -> usize {
8699        let mut probe = Index::new("/root");
8700        probe.apply_ok(&Observation::new(ops)).commit.expect("effective commit").retained_cost()
8701    }
8702
8703    #[test]
8704    fn oversized_single_batch_is_not_retained() {
8705        let batch = || {
8706            vec![
8707                upsert("a.txt", EntryKind::File, file_attrs(1, 1)),
8708                upsert("b.txt", EntryKind::File, file_attrs(2, 2)),
8709                upsert("c.txt", EntryKind::File, file_attrs(3, 3)),
8710            ]
8711        };
8712        let mut index = Index::with_journal_capacity_bytes("/root", commit_cost(batch()) - 1);
8713        let outcome = index.apply_ok(&Observation::new(batch()));
8714
8715        assert_eq!(outcome.commit.as_ref().expect("committed").changes.len(), 3);
8716        let since = index.since(Clock::ZERO);
8717        assert!(since.truncated);
8718        assert!(since.commits.is_empty());
8719    }
8720
8721    #[test]
8722    fn journal_eviction_charges_the_complete_retained_payload() {
8723        let first = || {
8724            vec![
8725                upsert("a.txt", EntryKind::File, file_attrs(1, 1)),
8726                upsert("b.txt", EntryKind::File, file_attrs(2, 2)),
8727            ]
8728        };
8729        let second = || {
8730            vec![
8731                upsert("c.txt", EntryKind::File, file_attrs(3, 3)),
8732                upsert("d.txt", EntryKind::File, file_attrs(4, 4)),
8733            ]
8734        };
8735        // Room for the second commit and all but one byte of the first.
8736        let capacity = commit_cost(first()) + commit_cost(second()) - 1;
8737        let mut index = Index::with_journal_capacity_bytes("/root", capacity);
8738        index.apply_ok(&Observation::new(first()));
8739        index.apply_ok(&Observation::new(second()));
8740
8741        let since = index.since(Clock::ZERO);
8742        assert!(since.truncated);
8743        assert_eq!(since.commits.len(), 1);
8744        assert_eq!(since.commits[0].changes.len(), 2);
8745        assert_eq!(since.commits[0].changes[0].path(), Path::new("c.txt"));
8746    }
8747
8748    /// Two commits of one inserted file each: the same item count, but the second names a
8749    /// path whose bytes alone dwarf the first commit. A budget counted in items held both
8750    /// and let a long-path tree retain tens of mebibytes under a 64 Ki budget; a budget in
8751    /// bytes evicts the first.
8752    #[test]
8753    fn journal_eviction_is_charged_in_path_bytes() {
8754        let long_name = format!("{}.txt", "n".repeat(4096));
8755        let short = || vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))];
8756        let long = || vec![upsert(&long_name, EntryKind::File, file_attrs(2, 2))];
8757        let short_cost = commit_cost(short());
8758        let long_cost = commit_cost(long());
8759        assert!(
8760            long_cost > short_cost + 4096,
8761            "path bytes must be charged: short {short_cost}, long {long_cost}"
8762        );
8763
8764        let mut index = Index::with_journal_capacity_bytes("/root", short_cost + long_cost - 1);
8765        index.apply_ok(&Observation::new(short()));
8766        index.apply_ok(&Observation::new(long()));
8767
8768        let since = index.since(Clock::ZERO);
8769        assert!(since.truncated, "an item budget kept both commits; a byte budget cannot");
8770        assert_eq!(since.commits.len(), 1);
8771        assert_eq!(since.commits[0].changes[0].path(), Path::new(&long_name));
8772    }
8773
8774    #[test]
8775    fn impact_drops_an_overflowing_path_set_instead_of_truncating_it() {
8776        let mut index = Index::new("/root");
8777        let ops = (0..=MAX_DIRTY_PATHS)
8778            .map(|which| {
8779                upsert(
8780                    &format!("file-{which}.txt"),
8781                    EntryKind::File,
8782                    file_attrs(u64::try_from(which).expect("bounded"), 1),
8783                )
8784            })
8785            .collect();
8786
8787        let commit =
8788            index.apply_ok(&Observation::new(ops)).commit.expect("overflowing impact commit");
8789
8790        assert!(commit.impact.all_dirty);
8791        assert!(commit.impact.dirty_paths.is_empty(), "a partial path list must not escape");
8792        assert_eq!(commit.changes.len(), MAX_DIRTY_PATHS + 1);
8793    }
8794
8795    #[test]
8796    fn invalidations_are_queued_for_the_scan_layer() {
8797        let mut index = Index::new("/root");
8798        let stats = index.apply_ok(&Observation::new(vec![Op::InvalidateSubtree {
8799            path: PathBuf::from("src"),
8800            reason: InvalidateReason::WatchOverflow,
8801        }]));
8802
8803        assert_eq!(stats.invalidated, 1);
8804        let pending = index.take_pending_invalidations();
8805        assert_eq!(pending.len(), 1);
8806        assert_eq!(pending[0].0, PathBuf::from("src"));
8807        assert_eq!(pending[0].1, InvalidateReason::WatchOverflow);
8808        assert!(index.take_pending_invalidations().is_empty(), "drained once");
8809    }
8810
8811    #[test]
8812    fn paths_escaping_the_root_are_rejected() {
8813        assert!(normalize(Path::new("../escape")).is_none());
8814        assert!(normalize(Path::new("/absolute")).is_none());
8815        assert_eq!(
8816            normalize(Path::new("./a/b")).expect("relative"),
8817            vec![OsString::from("a"), OsString::from("b")]
8818        );
8819
8820        let mut index = Index::new("/root");
8821        let upsert_error = index
8822            .apply(&Observation::new(vec![upsert("../escape", EntryKind::File, file_attrs(1, 1))]))
8823            .expect_err("escaping upsert");
8824        assert!(matches!(upsert_error, crate::Error::PathEscapesRoot(_)));
8825        assert_eq!(index.total().files, 0);
8826
8827        let invalidation_error = index
8828            .apply(&Observation::new(vec![Op::InvalidateSubtree {
8829                path: PathBuf::from("../outside"),
8830                reason: InvalidateReason::Requested,
8831            }]))
8832            .expect_err("escaping invalidation");
8833        assert!(matches!(invalidation_error, crate::Error::PathEscapesRoot(_)));
8834        assert!(index.take_pending_invalidations().is_empty());
8835        assert_eq!(index.freshness(), Freshness::Fresh);
8836    }
8837
8838    #[cfg(unix)]
8839    #[test]
8840    fn distinct_non_utf8_names_have_distinct_identity() {
8841        use std::ffi::OsString;
8842        use std::os::unix::ffi::OsStringExt;
8843
8844        let first = PathBuf::from(OsString::from_vec(vec![b'n', 0x80]));
8845        let second = PathBuf::from(OsString::from_vec(vec![b'n', 0x81]));
8846        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
8847            "/root",
8848            ScanScope::default(),
8849            crate::classify::TypeRegistry::compiled_shared(),
8850            DEFAULT_JOURNAL_CAPACITY_BYTES,
8851        );
8852        index.apply_ok(&Observation::new(vec![
8853            Op::Upsert { path: first.clone(), kind: EntryKind::File, attrs: file_attrs(10, 1) },
8854            Op::Upsert { path: second.clone(), kind: EntryKind::File, attrs: file_attrs(20, 2) },
8855        ]));
8856
8857        assert_eq!(index.total().files, 2);
8858        assert_eq!(index.total().bytes, 30);
8859        assert!(index.lookup(&first).is_some());
8860        assert!(index.lookup(&second).is_some());
8861        assert_serving_indexes(&index);
8862    }
8863
8864    #[cfg(unix)]
8865    #[test]
8866    fn a_non_utf8_parent_still_lists_its_children() {
8867        use std::ffi::OsString;
8868        use std::os::unix::ffi::OsStringExt;
8869
8870        let directory = PathBuf::from(OsString::from_vec(vec![b'd', 0x80]));
8871        let child = directory.join("child");
8872        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
8873            "/root",
8874            ScanScope::default(),
8875            crate::classify::TypeRegistry::compiled_shared(),
8876            DEFAULT_JOURNAL_CAPACITY_BYTES,
8877        );
8878        index.apply_ok(&Observation::new(vec![
8879            Op::Upsert { path: directory.clone(), kind: EntryKind::Dir, attrs: Attrs::default() },
8880            Op::Upsert { path: child, kind: EntryKind::File, attrs: file_attrs(1, 1) },
8881        ]));
8882
8883        assert_serving_indexes(&index);
8884        // The directory's own name escapes to `d%80`, and its child is reachable beneath
8885        // it. While the encoding was partial this directory had no portable name, so its
8886        // whole subtree was unlistable and the assertion here counted the loss instead.
8887        assert_eq!(
8888            index.portable_children(&directory).map(|children| children.nondirectories.len()),
8889            Some(1)
8890        );
8891        assert!(
8892            index.portable_entries().keys().any(|portable| portable.as_str() == "d%80/child"),
8893            "a child under a non-utf8 directory is listed at its escaped path"
8894        );
8895    }
8896
8897    #[cfg(unix)]
8898    #[test]
8899    fn non_utf8_stem_keeps_ascii_extension_tally() {
8900        use std::ffi::OsString;
8901        use std::os::unix::ffi::OsStringExt;
8902
8903        let path = PathBuf::from(OsString::from_vec(vec![b'n', 0x80, b'.', b'R', b'S']));
8904        let mut index = Index::new("/root");
8905        index.apply_ok(&Observation::new(vec![Op::Upsert {
8906            path,
8907            kind: EntryKind::File,
8908            attrs: file_attrs(10, 1),
8909        }]));
8910
8911        let tallies = index.total().by_ext;
8912        assert_eq!(
8913            tallies.get(".rs"),
8914            Some(&ExtTally { files: 1, bytes: 10, allocated: file_attrs(10, 1).allocated })
8915        );
8916    }
8917
8918    #[test]
8919    fn restore_candidates_count_visited_files_not_pathbuf_aliases() {
8920        use crate::content::AnalysisSet;
8921
8922        let mut index = Index::new("/root");
8923        let attrs = file_attrs(10, 1);
8924        assert!(
8925            index
8926                .insert_loaded_child(EntryId::ROOT, OsString::from("a"), EntryKind::File, attrs)
8927                .is_some()
8928        );
8929        assert!(
8930            index
8931                .insert_loaded_child(EntryId::ROOT, OsString::from("a/"), EntryKind::File, attrs)
8932                .is_some()
8933        );
8934        let (candidates, visited) =
8935            index.restore_analysis_candidates(AnalysisSet::NONE.with_lines());
8936        assert_eq!(visited, 2, "each visited regular file is a completeness slot");
8937        assert_eq!(candidates.len(), 1, "PathBuf keys merge trailing-separator aliases");
8938    }
8939
8940    #[test]
8941    fn restore_candidates_match_analysis_file_identities() {
8942        use crate::content::AnalysisSet;
8943
8944        let mut index = Index::new("/root");
8945        index.apply_ok(&Observation::new(vec![
8946            upsert("src", EntryKind::Dir, file_attrs(0, 1)),
8947            upsert("src/nested", EntryKind::Dir, file_attrs(0, 2)),
8948            upsert("src/nested/lib.rs", EntryKind::File, file_attrs(10, 1)),
8949            upsert("README.md", EntryKind::File, file_attrs(20, 2)),
8950        ]));
8951        let profile = AnalysisSet::NONE.with_lines();
8952        let live = index.analysis_candidates(profile);
8953        let (restore, visited) = index.restore_analysis_candidates(profile);
8954        assert_eq!(restore.len(), live.len());
8955        assert_eq!(visited, u64::try_from(live.len()).expect("candidate count fits u64"));
8956        assert_eq!(restore.len(), 2);
8957        for candidate in &live {
8958            assert_eq!(
8959                index.path_of(candidate.entry_id).as_deref(),
8960                Some(candidate.relative_path.as_path())
8961            );
8962            let restored = restore.get(&candidate.relative_path).expect("same relative path");
8963            assert_eq!(restored.entry_id, candidate.entry_id);
8964            assert_eq!(restored.revision, candidate.revision);
8965            assert_eq!(restored.attrs.fingerprint(), candidate.attrs.fingerprint());
8966        }
8967    }
8968
8969    #[test]
8970    fn content_results_commit_conditionally_and_metadata_changes_invalidate_them() {
8971        use crate::content::{
8972            AnalysisApplyOutcome, AnalysisRequest, AnalysisSet, AnalyzerOutcome, BasicMetrics,
8973            ContentProvenance, FileAnalysis,
8974        };
8975
8976        let mut index = Index::new("/root");
8977        index.apply_ok(&Observation::new(vec![
8978            upsert("src", EntryKind::Dir, file_attrs(0, 1)),
8979            upsert("src/lib.rs", EntryKind::File, file_attrs(10, 1)),
8980        ]));
8981        let profile = AnalysisSet::NONE.with_lines();
8982        let candidate =
8983            index.analysis_candidates(profile).into_iter().next().expect("file candidate");
8984        let analysis = FileAnalysis {
8985            fingerprint: candidate.attrs.fingerprint(),
8986            bytes: candidate.attrs.size,
8987            detection: candidate.classification.clone().into(),
8988            lines: AnalyzerOutcome::analyzed(BasicMetrics {
8989                physical_lines: 2,
8990                nonblank_lines: 2,
8991                ..BasicMetrics::default()
8992            }),
8993            code: None,
8994            words: None,
8995            error: None,
8996        };
8997        let provenance = ContentProvenance::for_request(
8998            AnalysisRequest { profile, ..AnalysisRequest::default() },
8999            crate::classify::type_rule_fingerprint(),
9000        );
9001        let observation = AnalysisObservation {
9002            candidate: candidate.clone(),
9003            profile,
9004            provenance: provenance.clone(),
9005            analysis: analysis.clone(),
9006        };
9007        assert_eq!(
9008            index.apply_analysis(observation.clone()),
9009            AnalysisApplyOutcome::Stale,
9010            "an index prepared for no content identity holds no record"
9011        );
9012        assert!(index.content().is_none());
9013
9014        index.prepare_content_analysis(AnalysisRequest { profile, ..AnalysisRequest::default() });
9015        assert_eq!(index.content_set(), profile);
9016        assert_eq!(index.apply_analysis(observation), AnalysisApplyOutcome::Applied);
9017        assert_eq!(
9018            index
9019                .content_rollup(Path::new(""))
9020                .expect("content root")
9021                .total
9022                .lines
9023                .metrics
9024                .physical_lines,
9025            2
9026        );
9027
9028        index.apply_ok(&Observation::new(vec![upsert(
9029            "src/lib.rs",
9030            EntryKind::File,
9031            file_attrs(20, 2),
9032        )]));
9033        assert!(index.content_rollup(Path::new("")).is_none());
9034        assert_eq!(
9035            index.apply_analysis(AnalysisObservation { candidate, profile, provenance, analysis }),
9036            AnalysisApplyOutcome::Stale
9037        );
9038    }
9039
9040    #[test]
9041    fn operational_content_failures_are_retained_but_retried_until_recovery() {
9042        use crate::content::{
9043            AnalysisApplyOutcome, AnalysisRequest, AnalysisSet, AnalyzerOutcome, BasicMetrics,
9044            ContentProvenance, CoverageReason, FileAnalysis,
9045        };
9046
9047        let mut index = Index::new("/root");
9048        index.apply_ok(&Observation::new(vec![
9049            upsert("src", EntryKind::Dir, file_attrs(0, 1)),
9050            upsert("src/lib.rs", EntryKind::File, file_attrs(10, 1)),
9051        ]));
9052        let profile = AnalysisSet::LINES_ONLY;
9053        let request = AnalysisRequest { profile, ..AnalysisRequest::default() };
9054        index.prepare_content_analysis(request);
9055        let candidate = index.pending_analysis_candidates(request).pop().expect("candidate");
9056        let provenance =
9057            ContentProvenance::for_request(request, crate::classify::type_rule_fingerprint());
9058        let record = |reason, error: &str| FileAnalysis {
9059            fingerprint: candidate.attrs.fingerprint(),
9060            bytes: candidate.attrs.size,
9061            detection: candidate.classification.clone().into(),
9062            lines: AnalyzerOutcome::unavailable(reason),
9063            code: None,
9064            words: None,
9065            error: Some(error.to_owned()),
9066        };
9067
9068        for (reason, error) in [
9069            (CoverageReason::IoError, "read failed"),
9070            (CoverageReason::ChangedDuringRead, "changed during read"),
9071        ] {
9072            assert_eq!(
9073                index.apply_analysis(AnalysisObservation {
9074                    candidate: candidate.clone(),
9075                    profile,
9076                    provenance: provenance.clone(),
9077                    analysis: record(reason, error),
9078                }),
9079                AnalysisApplyOutcome::Applied
9080            );
9081            let retained = index.content().expect("content").file(Path::new("src/lib.rs"));
9082            assert_eq!(retained.and_then(FileAnalysis::operational_failure), Some(reason));
9083            assert_eq!(
9084                index.pending_analysis_candidates(request).len(),
9085                1,
9086                "an operational failure must remain pending"
9087            );
9088        }
9089
9090        let recovered = FileAnalysis {
9091            fingerprint: candidate.attrs.fingerprint(),
9092            bytes: candidate.attrs.size,
9093            detection: candidate.classification.clone().into(),
9094            lines: AnalyzerOutcome::analyzed(BasicMetrics {
9095                physical_lines: 1,
9096                nonblank_lines: 1,
9097                raw_words: 1,
9098                ..BasicMetrics::default()
9099            }),
9100            code: None,
9101            words: None,
9102            error: None,
9103        };
9104        assert_eq!(
9105            index.apply_analysis(AnalysisObservation {
9106                candidate,
9107                profile,
9108                provenance,
9109                analysis: recovered,
9110            }),
9111            AnalysisApplyOutcome::Applied
9112        );
9113        assert!(index.pending_analysis_candidates(request).is_empty());
9114        assert_eq!(
9115            index
9116                .content()
9117                .expect("content")
9118                .file(Path::new("src/lib.rs"))
9119                .and_then(FileAnalysis::operational_failure),
9120            None
9121        );
9122    }
9123
9124    /// An index that read no control file cannot say what is ignored, so it says that,
9125    /// rather than calling every entry unignored.
9126    #[test]
9127    fn an_index_that_did_not_observe_controls_refuses_ignore_questions() {
9128        let mut index =
9129            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
9130        assert!(!index.observes_controls());
9131        index.apply_ok(&Observation::new(vec![upsert(
9132            "debug.log",
9133            EntryKind::File,
9134            file_attrs(10, 1),
9135        )]));
9136        for path in ["debug.log", "absent.log"] {
9137            assert!(
9138                matches!(
9139                    index.is_ignored(Path::new(path)),
9140                    Err(crate::Error::ControlStateNotObserved)
9141                ),
9142                "{path}"
9143            );
9144        }
9145        assert!(matches!(index.controls(), Err(crate::Error::ControlStateNotObserved)));
9146
9147        let mut observed =
9148            Index::new_with_scope("/root", crate::test_support::observing_controls());
9149        assert!(observed.observes_controls());
9150        observed.apply_ok(&Observation::new(vec![upsert(
9151            "debug.log",
9152            EntryKind::File,
9153            file_attrs(10, 1),
9154        )]));
9155        assert_eq!(observed.is_ignored(Path::new("debug.log")).ok(), Some(Some(false)));
9156        assert_eq!(observed.is_ignored(Path::new("absent.log")).ok(), Some(None));
9157        assert!(observed.controls().is_ok_and(crate::control::ControlTable::is_empty));
9158    }
9159
9160    /// Control input to an index that observes no control state is refused, typed, and
9161    /// changes nothing. Accepted, it installed a table and reclassified entries under a
9162    /// scope that says no rule was read, so `is_ignored` refused over classification the
9163    /// index held and a snapshot saved from it loaded into an open that turned observation
9164    /// off as an exact match (`fdu-agb6`). A stale conditional control op is refused as
9165    /// well: the refusal is about the index's scope, not its state.
9166    #[test]
9167    fn an_index_that_does_not_observe_controls_refuses_control_input() {
9168        let mut index =
9169            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
9170        index.apply_ok(&Observation::new(vec![
9171            upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9172            upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
9173        ]));
9174        let stale_baseline = index.expectation(Path::new(".gitignore"));
9175        index.apply_ok(&Observation::new(vec![upsert(
9176            ".gitignore",
9177            EntryKind::File,
9178            file_attrs(7, 3),
9179        )]));
9180        let clock = index.clock();
9181        let total = index.total();
9182        let controls = || {
9183            [
9184                Op::ControlUpsert {
9185                    path: PathBuf::from(".gitignore"),
9186                    source: b"*.log\n".to_vec(),
9187                },
9188                Op::ControlRemove { path: PathBuf::from(".gitignore") },
9189            ]
9190        };
9191
9192        for control in controls() {
9193            let batch = Observation::new(vec![
9194                upsert("new.txt", EntryKind::File, file_attrs(1, 4)),
9195                control.clone(),
9196            ]);
9197            assert!(
9198                matches!(index.apply(&batch), Err(crate::Error::ControlStateNotObserved)),
9199                "{control:?}"
9200            );
9201            assert!(
9202                matches!(index.apply_baseline(&batch), Err(crate::Error::ControlStateNotObserved)),
9203                "{control:?}"
9204            );
9205            let stale = Observation::from_ops(vec![ObservationOp::if_state(
9206                control.clone(),
9207                stale_baseline,
9208            )]);
9209            assert!(
9210                matches!(index.apply(&stale), Err(crate::Error::ControlStateNotObserved)),
9211                "stale {control:?}"
9212            );
9213        }
9214        assert_eq!(index.clock(), clock, "a refused batch commits nothing");
9215        assert_eq!(index.total(), total);
9216        assert!(index.lookup(Path::new("new.txt")).is_none());
9217        assert!(index.control_table().is_empty());
9218
9219        let mut table = crate::control::ControlTable::default();
9220        table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control source");
9221        assert!(matches!(
9222            index.install_controls(table),
9223            Err(crate::Error::ControlStateNotObserved)
9224        ));
9225        assert!(index.control_table().is_empty());
9226
9227        let mut observed =
9228            Index::new_with_scope("/root", crate::test_support::observing_controls());
9229        for control in controls() {
9230            observed
9231                .apply(&Observation::new(vec![
9232                    upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9233                    control,
9234                ]))
9235                .expect("an observing index accepts control input");
9236        }
9237    }
9238
9239    /// The unignored partition and a shared child's ignore bit are ignore facts too. On an
9240    /// index that read no rule the partition equals `all` and every bit reads "not
9241    /// ignored" only because nobody looked, so they refuse the way `is_ignored` does,
9242    /// while the `all` roll-up and the children themselves still answer (`fdu-agb6`).
9243    #[test]
9244    fn an_index_that_did_not_observe_controls_states_no_partition_or_child_ignore_fact() {
9245        let tree = Observation::new(vec![
9246            upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
9247            upsert("dir/debug.log", EntryKind::File, file_attrs(10, 2)),
9248        ]);
9249        let mut unobserved =
9250            Index::new_with_scope("/root", crate::test_support::not_observing_controls());
9251        unobserved.apply_ok(&tree);
9252        assert!(matches!(unobserved.partition_total(), Err(crate::Error::ControlStateNotObserved)));
9253        for path in ["", "dir", "dir/debug.log", "absent"] {
9254            assert!(
9255                matches!(
9256                    unobserved.partition_rollup(Path::new(path)),
9257                    Err(crate::Error::ControlStateNotObserved)
9258                ),
9259                "{path}"
9260            );
9261            assert!(
9262                matches!(
9263                    unobserved.partition_rollup_summary(Path::new(path)),
9264                    Err(crate::Error::ControlStateNotObserved)
9265                ),
9266                "{path}"
9267            );
9268        }
9269        assert_eq!(unobserved.total().files, 1, "the all partition still answers");
9270        let children = IndexHandle::new(unobserved)
9271            .children(Path::new(""))
9272            .expect("children read")
9273            .expect("root directory");
9274        assert_eq!(children.len(), 1);
9275        assert_eq!(children[0].ignored, None);
9276        assert_eq!(children[0].partitions, None);
9277        assert_eq!(children[0].rollup.as_ref().map(|rollup| rollup.files), Some(1));
9278
9279        let mut observed =
9280            Index::new_with_scope("/root", crate::test_support::observing_controls());
9281        observed.apply_ok(&tree);
9282        assert_eq!(observed.partition_total().expect("control state observed").unignored.files, 1);
9283        assert!(
9284            observed.partition_rollup(Path::new("dir")).expect("control state observed").is_some()
9285        );
9286        assert_eq!(
9287            observed
9288                .partition_rollup_summary(Path::new("dir/debug.log"))
9289                .expect("control state observed"),
9290            None,
9291            "a file has no partitions"
9292        );
9293        let children = IndexHandle::new(observed)
9294            .children(Path::new(""))
9295            .expect("children read")
9296            .expect("root directory");
9297        assert_eq!(children[0].ignored, Some(false));
9298        assert_eq!(
9299            children[0].partitions.as_ref().map(|partitions| partitions.unignored.files),
9300            Some(1)
9301        );
9302    }
9303
9304    #[test]
9305    fn control_changes_atomically_move_fixed_partitions_without_changing_all() {
9306        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9307        index.apply_ok(&Observation::new(vec![
9308            upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9309            upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
9310            upsert("keep.rs", EntryKind::File, file_attrs(20, 3)),
9311            upsert("docs", EntryKind::Dir, file_attrs(0, 4)),
9312            upsert("docs/other.log", EntryKind::File, file_attrs(30, 5)),
9313            upsert("docs/keep.log", EntryKind::File, file_attrs(40, 6)),
9314        ]));
9315        let before = index.partition_total().expect("control state observed");
9316
9317        let outcome = index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
9318            path: PathBuf::from(".gitignore"),
9319            source: b"*.log\n".to_vec(),
9320        }]));
9321        let partitions = index.partition_total().expect("control state observed");
9322
9323        assert_eq!(partitions.all, before.all, "classification never changes all facts");
9324        assert_eq!(partitions.all.files, 5);
9325        assert_eq!(partitions.unignored.files, 2);
9326        assert_eq!(partitions.unignored.bytes, 26);
9327        assert_eq!(outcome.controls, 1);
9328        assert_eq!(outcome.reclassified, 3);
9329        assert_eq!(
9330            index.is_ignored(Path::new("debug.log")).expect("control state observed"),
9331            Some(true)
9332        );
9333        assert_eq!(
9334            index.is_ignored(Path::new("keep.rs")).expect("control state observed"),
9335            Some(false)
9336        );
9337
9338        let commit = outcome.commit.expect("control and classification commit together");
9339        assert!(matches!(
9340            commit.changes.first(),
9341            Some(EffectiveChange::ControlUpdated { path, previous: None, current: Some(_) })
9342                if path == Path::new(".gitignore")
9343        ));
9344        assert_eq!(
9345            commit
9346                .changes
9347                .iter()
9348                .filter(|change| matches!(change, EffectiveChange::Reclassified { .. }))
9349                .count(),
9350            3
9351        );
9352    }
9353
9354    #[test]
9355    fn serving_semantics_follow_ignore_reclassification_exactly() {
9356        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
9357            "/root",
9358            crate::test_support::observing_controls(),
9359            crate::classify::TypeRegistry::compiled_shared(),
9360            DEFAULT_JOURNAL_CAPACITY_BYTES,
9361        );
9362        index.apply_ok(&Observation::new(vec![
9363            upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9364            upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
9365            upsert("keep.rs", EntryKind::File, file_attrs(20, 3)),
9366            upsert("Makefile", EntryKind::File, file_attrs(30, 4)),
9367        ]));
9368        assert_serving_indexes(&index);
9369
9370        index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
9371            path: PathBuf::from(".gitignore"),
9372            source: b"*.log\nMakefile\n".to_vec(),
9373        }]));
9374        assert_serving_indexes(&index);
9375
9376        index.apply_ok(&Observation::new(vec![Op::ControlRemove {
9377            path: PathBuf::from(".gitignore"),
9378        }]));
9379        assert_serving_indexes(&index);
9380    }
9381
9382    #[test]
9383    fn nested_negation_edit_and_last_control_deletion_reclassify_exactly() {
9384        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9385        index.apply_ok(&Observation::new(vec![
9386            upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9387            upsert("docs", EntryKind::Dir, file_attrs(0, 2)),
9388            upsert("docs/.gitignore", EntryKind::File, file_attrs(10, 3)),
9389            upsert("docs/keep.log", EntryKind::File, file_attrs(40, 4)),
9390            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
9391            Op::ControlUpsert {
9392                path: PathBuf::from("docs/.gitignore"),
9393                source: b"!keep.log\n".to_vec(),
9394            },
9395        ]));
9396        assert_eq!(
9397            index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
9398            Some(false)
9399        );
9400
9401        let edited = index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
9402            path: PathBuf::from("docs/.gitignore"),
9403            source: b"# no exception\n".to_vec(),
9404        }]));
9405        assert_eq!(edited.reclassified, 1);
9406        assert_eq!(
9407            index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
9408            Some(true)
9409        );
9410
9411        let removed = index
9412            .apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from(".gitignore") }]));
9413        assert_eq!(removed.controls, 1, "removing the retained row removes its control state");
9414        assert_eq!(removed.reclassified, 1);
9415        assert_eq!(
9416            index.controls().expect("control state observed").len(),
9417            1,
9418            "the nested control remains"
9419        );
9420        assert_eq!(
9421            index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
9422            Some(false)
9423        );
9424
9425        index.apply_ok(&Observation::new(vec![Op::Remove {
9426            path: PathBuf::from("docs/.gitignore"),
9427        }]));
9428        assert!(index.controls().expect("control state observed").is_empty());
9429        assert_eq!(
9430            index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
9431            Some(false)
9432        );
9433    }
9434
9435    #[test]
9436    fn replacing_batch_ancestors_prunes_retained_and_transient_controls() {
9437        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9438        index
9439            .apply(&Observation::new(vec![
9440                upsert("docs", EntryKind::Dir, file_attrs(0, 1)),
9441                upsert("docs/.gitignore", EntryKind::File, file_attrs(6, 2)),
9442                Op::ControlUpsert { path: "docs/.gitignore".into(), source: b"*.log\n".to_vec() },
9443            ]))
9444            .expect("retained control");
9445
9446        let outcome = index
9447            .apply(&Observation::new(vec![
9448                upsert("scratch", EntryKind::Dir, file_attrs(0, 3)),
9449                upsert("scratch/.gitignore", EntryKind::File, file_attrs(6, 4)),
9450                Op::ControlUpsert {
9451                    path: "scratch/.gitignore".into(),
9452                    source: b"*.log\n".to_vec(),
9453                },
9454                upsert("scratch", EntryKind::File, file_attrs(1, 5)),
9455                upsert("scratch", EntryKind::Dir, file_attrs(0, 6)),
9456                upsert("scratch/new.log", EntryKind::File, file_attrs(7, 7)),
9457                Op::Remove { path: "docs".into() },
9458                upsert("docs", EntryKind::Dir, file_attrs(0, 8)),
9459                upsert("docs/new.log", EntryKind::File, file_attrs(9, 9)),
9460            ]))
9461            .expect("mixed control and structural batch");
9462
9463        assert!(index.controls().expect("control state observed").is_empty());
9464        assert_eq!(
9465            index.is_ignored(Path::new("scratch/new.log")).expect("control state observed"),
9466            Some(false)
9467        );
9468        assert_eq!(
9469            index.is_ignored(Path::new("docs/new.log")).expect("control state observed"),
9470            Some(false)
9471        );
9472        assert_eq!(outcome.stats.controls, 1, "only the retained control has a net change");
9473        let controls = outcome
9474            .commit
9475            .as_ref()
9476            .expect("one exact commit")
9477            .changes
9478            .iter()
9479            .filter(|change| matches!(change, EffectiveChange::ControlUpdated { .. }))
9480            .collect::<Vec<_>>();
9481        assert!(matches!(
9482            controls.as_slice(),
9483            [EffectiveChange::ControlUpdated { path, previous: Some(_), current: None }]
9484                if path == Path::new("docs/.gitignore")
9485        ));
9486    }
9487
9488    /// A control the budget cannot admit is refused inside the commit that carried it: the
9489    /// batch's ordinary entries land, the refusal is a change of its own, and a source it
9490    /// replaces is dropped and its entries reclassified.
9491    #[test]
9492    fn an_over_budget_control_is_refused_while_its_batch_commits() {
9493        let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
9494            "/root",
9495            crate::test_support::observing_controls(),
9496            crate::classify::TypeRegistry::compiled_shared(),
9497            DEFAULT_JOURNAL_CAPACITY_BYTES,
9498        );
9499        index.apply_ok(&Observation::new(vec![
9500            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
9501            upsert("debug.log", EntryKind::File, file_attrs(10, 1)),
9502        ]));
9503        assert_eq!(index.is_ignored(Path::new("debug.log")).expect("observed"), Some(true));
9504        let mut oversized = crate::control::source_at_test_limit();
9505        oversized.push(b'a');
9506
9507        let outcome = index.apply_ok(&Observation::new(vec![
9508            upsert("ordinary.txt", EntryKind::File, file_attrs(1, 2)),
9509            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: oversized },
9510        ]));
9511
9512        assert!(index.lookup(Path::new("ordinary.txt")).is_some(), "ordinary work commits");
9513        assert_eq!(index.total().files, 2);
9514        assert_eq!(
9515            index.is_ignored(Path::new("debug.log")).expect("observed"),
9516            None,
9517            "the refused replacement leaves classification unknown"
9518        );
9519        let changes = &outcome.commit.as_ref().expect("one commit").changes;
9520        let refused = Some(crate::control::ControlRefusalReason::Budget);
9521        assert!(changes.iter().any(|change| matches!(
9522            change,
9523            EffectiveChange::ControlUpdated { previous: Some(_), current: None, .. }
9524        )));
9525        assert!(changes.iter().any(|change| matches!(
9526            change,
9527            EffectiveChange::ControlRefusalUpdated { previous: None, current, .. }
9528                if *current == refused
9529        )));
9530        let crate::control::ControlCoverage::Observed(coverage) = index.control_coverage() else {
9531            panic!("an observing index reports observed coverage");
9532        };
9533        assert_eq!((coverage.applied, coverage.refused), (0, 1));
9534        assert_eq!(coverage.refusals[0].path, Path::new(".gitignore"));
9535
9536        // Removing the refused file lifts the refusal in a commit of its own.
9537        let lifted = index.apply_ok(&Observation::new(vec![Op::ControlRemove {
9538            path: PathBuf::from(".gitignore"),
9539        }]));
9540        assert!(matches!(
9541            lifted.commit.as_ref().expect("lifting a refusal commits").changes.as_slice(),
9542            [EffectiveChange::ControlRefusalUpdated { previous, current: None, .. }]
9543                if *previous == refused
9544        ));
9545        assert_eq!(
9546            index.control_coverage(),
9547            crate::control::ControlCoverage::Observed(crate::control::ControlObservation {
9548                limits: crate::control::ControlLimits::default(),
9549                applied: 0,
9550                rules: 0,
9551                refused: 0,
9552                refusals: Vec::new(),
9553            })
9554        );
9555    }
9556
9557    /// A batch that cannot change the control table does not copy it.
9558    ///
9559    /// Every warm revalidate re-reads each refused `.gitignore`, because nothing is
9560    /// retained where one was refused, and re-upserts it; projecting each such batch copied
9561    /// the whole table, once per batch, to arrive at the table it started from (fdu-hzm5).
9562    /// A batch that does change it still projects.
9563    #[test]
9564    fn a_batch_that_cannot_change_the_control_table_does_not_copy_it() {
9565        let projection_clones = |index: &mut Index, ops: Vec<Op>| {
9566            CONTROL_PROJECTION_CLONES.with(|clones| clones.set(0));
9567            let outcome = index.apply_ok(&Observation::new(ops));
9568            (CONTROL_PROJECTION_CLONES.with(std::cell::Cell::get), outcome)
9569        };
9570        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9571
9572        // The cold lane first: against a table that records nothing, a batch of ordinary
9573        // entries has nothing to drop or prune, so it never projects (fdu-pro1).
9574        let (clones, _) = projection_clones(
9575            &mut index,
9576            vec![
9577                upsert("cold", EntryKind::Dir, file_attrs(0, 1)),
9578                upsert("cold/file.txt", EntryKind::File, file_attrs(1, 1)),
9579            ],
9580        );
9581        assert_eq!(clones, 0, "an empty table has nothing a structural batch can change");
9582
9583        let mut over_budget = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
9584        over_budget.push(b'\n');
9585        index.apply_ok(&Observation::new(vec![
9586            upsert("keep", EntryKind::Dir, file_attrs(0, 1)),
9587            upsert("keep/file.txt", EntryKind::File, file_attrs(3, 1)),
9588            upsert("vendor", EntryKind::Dir, file_attrs(0, 1)),
9589            Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
9590            Op::ControlUpsert {
9591                path: PathBuf::from("vendor/.gitignore"),
9592                source: over_budget.clone(),
9593            },
9594        ]));
9595        let coverage = index.control_coverage();
9596
9597        // A warm revalidate's shape: the refused source re-read, the retained one re-read
9598        // unchanged, and ordinary entries beside them.
9599        let (clones, outcome) = projection_clones(
9600            &mut index,
9601            vec![
9602                upsert("keep/file.txt", EntryKind::File, file_attrs(4, 1)),
9603                Op::ControlUpsert {
9604                    path: PathBuf::from(".gitignore"),
9605                    source: b"*.log\n".to_vec(),
9606                },
9607                Op::ControlUpsert { path: PathBuf::from("vendor/.gitignore"), source: over_budget },
9608            ],
9609        );
9610        assert_eq!(clones, 0, "no control op changes anything");
9611        assert_eq!(index.control_coverage(), coverage);
9612        assert!(!outcome.commit.expect("the file's size changed").changes.iter().any(
9613            |change| matches!(
9614                change,
9615                EffectiveChange::ControlUpdated { .. }
9616                    | EffectiveChange::ControlRefusalUpdated { .. }
9617            )
9618        ));
9619
9620        // Removing the refused file is a change, so this batch projects.
9621        let (clones, _) = projection_clones(
9622            &mut index,
9623            vec![Op::ControlRemove { path: PathBuf::from("vendor/.gitignore") }],
9624        );
9625        assert_eq!(clones, 1);
9626        assert_eq!(index.controls().expect("observed").refused_len(), 0);
9627    }
9628
9629    /// A structural removal takes the refusals under it along, even when no rule is retained.
9630    #[test]
9631    fn removing_a_subtree_lifts_the_refusals_beneath_it() {
9632        let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9633        let mut line = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
9634        line.push(b'\n');
9635        index.apply_ok(&Observation::new(vec![
9636            upsert("vendor", EntryKind::Dir, file_attrs(0, 1)),
9637            upsert("vendor/.gitignore", EntryKind::File, file_attrs(16_386, 1)),
9638            Op::ControlUpsert { path: PathBuf::from("vendor/.gitignore"), source: line },
9639        ]));
9640        assert_eq!(index.controls().expect("observed").refused_len(), 1);
9641
9642        let outcome =
9643            index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("vendor") }]));
9644
9645        assert_eq!(index.controls().expect("observed").refused_len(), 0);
9646        assert!(outcome.commit.expect("commit").changes.iter().any(|change| matches!(
9647            change,
9648            EffectiveChange::ControlRefusalUpdated { current: None, .. }
9649        )));
9650    }
9651
9652    #[test]
9653    fn one_sweep_reports_one_as_of_time_for_everything_it_verified() {
9654        // Found by reviewing the PR #6 provenance work against the composable-CLI
9655        // merge. A revalidation sweep elides entries whose attributes did not change,
9656        // so within one pass some paths are named by a delta and some are not. Both
9657        // were verified at the same moment and must say so identically: if the
9658        // delta-touched entry dates itself to index construction while its untouched
9659        // sibling dates itself to the sweep, a consumer sorting rows by age is
9660        // comparing two different clocks and cannot tell.
9661        let mut index = Index::new("/root");
9662        index.set_applying_source(Source::Cached, 1_000);
9663        index.apply_ok(&Observation::new(vec![
9664            Op::Upsert { path: "a".into(), kind: EntryKind::Dir, attrs: file_attrs(0, 1) },
9665            Op::Upsert {
9666                path: "a/kept.txt".into(),
9667                kind: EntryKind::File,
9668                attrs: file_attrs(1, 1),
9669            },
9670            Op::Upsert {
9671                path: "a/changed.txt".into(),
9672                kind: EntryKind::File,
9673                attrs: file_attrs(2, 2),
9674            },
9675        ]));
9676
9677        // A sweep re-observes both: one is unchanged and elided, one is updated.
9678        index.set_applying_source(Source::Revalidated, 2_000);
9679        index.begin_reconcile(Path::new("")).expect("begin reconciliation");
9680        index.apply_ok(&Observation::new(vec![
9681            Op::Upsert {
9682                path: "a/kept.txt".into(),
9683                kind: EntryKind::File,
9684                attrs: file_attrs(1, 1),
9685            },
9686            Op::Upsert {
9687                path: "a/changed.txt".into(),
9688                kind: EntryKind::File,
9689                attrs: file_attrs(9, 2),
9690            },
9691        ]));
9692        index
9693            .finish_reconcile(
9694                Path::new(""),
9695                0,
9696                true,
9697                &[],
9698                &[],
9699                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9700            )
9701            .expect("finish reconciliation");
9702
9703        let kept = index.provenance(Path::new("a/kept.txt")).expect("present");
9704        let changed = index.provenance(Path::new("a/changed.txt")).expect("present");
9705        assert!(kept.is_verified() && changed.is_verified(), "the sweep covered both");
9706        assert_eq!(
9707            kept.observed_at_ns, changed.observed_at_ns,
9708            "one sweep, one as-of time: {kept:?} vs {changed:?}"
9709        );
9710    }
9711
9712    #[test]
9713    fn withdrawn_trust_beats_a_verification_interval() {
9714        // A verification interval records that a sweep once covered a path. If the
9715        // index has since withdrawn trust — an InvalidateSubtree marking it Stale, or
9716        // a sweep in progress marking it Reconciling — the interval must not promote
9717        // it, or provenance answers "partial, and verified" in one breath.
9718        let mut index = Index::new("/root");
9719        // The entry arrives the way a snapshot load delivers it: unverified.
9720        index.set_applying_source(Source::Cached, 1_000);
9721        index.apply_baseline_ok(&Observation::new(vec![
9722            Op::Upsert { path: PathBuf::from("a"), kind: EntryKind::Dir, attrs: Attrs::default() },
9723            Op::Upsert {
9724                path: PathBuf::from("a/file.txt"),
9725                kind: EntryKind::File,
9726                attrs: Attrs { size: 1, ..Attrs::default() },
9727            },
9728        ]));
9729        assert_eq!(
9730            index.provenance(Path::new("a/file.txt")).expect("present").source,
9731            Source::Cached,
9732            "nothing has checked it yet"
9733        );
9734        // A completed sweep then covers the whole tree.
9735        index
9736            .finish_reconcile(
9737                Path::new(""),
9738                0,
9739                true,
9740                &[],
9741                &[],
9742                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9743            )
9744            .expect("finish reconciliation");
9745        let path = Path::new("a/file.txt");
9746        assert_eq!(
9747            index.provenance(path).expect("present").source,
9748            Source::Revalidated,
9749            "a completed sweep covers this path"
9750        );
9751
9752        // Now withdraw trust over the subtree.
9753        index.mark_unfresh(Path::new("a"), Freshness::Stale);
9754        let provenance = index.provenance(path).expect("present");
9755        assert!(
9756            !provenance.is_verified(),
9757            "an invalidated path must not read as verified: {provenance:?}"
9758        );
9759        assert_eq!(
9760            provenance.status,
9761            Status::Complete,
9762            "withdrawing trust changes how far to believe the value, not how much of \
9763             the subtree it covers: the cached total still accounts for every entry \
9764             beneath this path, and reporting it as Partial would tell a consumer the \
9765             number is still being built when it is merely unverified"
9766        );
9767    }
9768
9769    #[test]
9770    fn verification_intervals_stay_bounded() {
9771        // Repeated scoped sweeps of sibling subtrees must not grow without bound;
9772        // dropping the oldest only ever under-claims trust.
9773        let mut index = Index::new("/root");
9774        for which in 0..(MAX_VERIFIED_INTERVALS * 2) {
9775            index
9776                .finish_reconcile(
9777                    &PathBuf::from(format!("dir-{which}")),
9778                    0,
9779                    true,
9780                    &[],
9781                    &[],
9782                    ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9783                )
9784                .expect("finish reconciliation");
9785        }
9786        assert!(
9787            index.verified.len() <= MAX_VERIFIED_INTERVALS,
9788            "interval list grew to {}",
9789            index.verified.len()
9790        );
9791    }
9792
9793    #[test]
9794    fn retained_walk_issues_are_the_same_first_paths_for_every_arrival_order() {
9795        let make = |order: Vec<usize>| {
9796            order
9797                .into_iter()
9798                .map(|number| {
9799                    crate::Error::io(
9800                        PathBuf::from(format!("/root/file-{number:02}")),
9801                        std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
9802                    )
9803                })
9804                .collect::<Vec<_>>()
9805        };
9806        let mut reverse_order: Vec<_> = (0..66).rev().collect();
9807        reverse_order.push(65);
9808        let mut shuffled_order: Vec<_> = (0..66).step_by(2).collect();
9809        shuffled_order.extend((0..66).skip(1).step_by(2));
9810        shuffled_order.push(65);
9811
9812        let mut reverse = Index::new("/root");
9813        let mut reverse_errors = make(reverse_order);
9814        reverse.record_walk_errors(&mut reverse_errors);
9815        let mut shuffled = Index::new("/root");
9816        let mut shuffled_errors = make(shuffled_order);
9817        shuffled.record_walk_errors(&mut shuffled_errors);
9818
9819        let reverse_paths: Vec<_> =
9820            reverse.issues().iter().map(|issue| issue.path.clone().expect("path")).collect();
9821        let shuffled_paths: Vec<_> =
9822            shuffled.issues().iter().map(|issue| issue.path.clone().expect("path")).collect();
9823        assert_eq!(reverse_paths, shuffled_paths);
9824        assert_eq!(reverse_paths.len(), MAX_RETAINED_ISSUES);
9825        assert_eq!(reverse_paths.first().map(PathBuf::as_path), Some(Path::new("file-00")));
9826        assert_eq!(reverse_paths.last().map(PathBuf::as_path), Some(Path::new("file-63")));
9827        assert_eq!(reverse.state().issues.omitted, 2);
9828        assert_eq!(shuffled.state().issues.omitted, 2);
9829        assert_eq!(reverse.issue_epochs.len(), reverse.issues.len());
9830        assert_eq!(shuffled.issue_epochs.len(), shuffled.issues.len());
9831    }
9832
9833    #[test]
9834    fn repeated_partial_root_pass_replaces_bounded_issues_and_omitted_count() {
9835        let root = Path::new("/root");
9836        let mut index = Index::new(root);
9837        let run = |index: &mut Index, range: std::ops::Range<usize>| {
9838            let errors: Vec<_> = range
9839                .clone()
9840                .map(|number| {
9841                    crate::Error::io(
9842                        root.join(format!("file-{number:02}")),
9843                        std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
9844                    )
9845                })
9846                .collect();
9847            let failed: Vec<_> =
9848                range.map(|number| PathBuf::from(format!("file-{number:02}"))).collect();
9849            let (started, _) = index.begin_reconcile(Path::new("")).expect("begin");
9850            index
9851                .finish_reconcile(
9852                    Path::new(""),
9853                    started,
9854                    false,
9855                    &[],
9856                    &failed,
9857                    ReconcileErrors { errors: &errors, terminal: None, disproves_old: true },
9858                )
9859                .expect("finish");
9860        };
9861
9862        run(&mut index, 0..66);
9863        assert_eq!(index.state().issues.omitted, 2);
9864        run(&mut index, 10..76);
9865
9866        assert_eq!(index.state().issues.omitted, 2, "a retry replaces the old omission count");
9867        assert_eq!(index.omitted_issue_epochs.len(), 1, "sequential failures use one bucket");
9868        assert_eq!(index.issues().len(), crate::MAX_RETAINED_ISSUES);
9869        assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("file-10")));
9870        assert_eq!(index.issues()[63].path.as_deref(), Some(Path::new("file-73")));
9871    }
9872
9873    #[test]
9874    fn partial_pass_preserves_an_issue_published_after_it_began() {
9875        let root = Path::new("/root");
9876        let mut index = Index::new(root);
9877        let (started, _) = index.begin_reconcile(Path::new("")).expect("begin");
9878        index.mark_unfresh(Path::new("concurrent"), Freshness::Stale);
9879        index.retain_issue(Issue::from_error_under(
9880            root,
9881            &crate::Error::io(
9882                root.join("concurrent"),
9883                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "concurrent"),
9884            ),
9885        ));
9886        let pass_error = crate::Error::io(
9887            root.join("pass"),
9888            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "pass"),
9889        );
9890
9891        index
9892            .finish_reconcile(
9893                Path::new(""),
9894                started,
9895                false,
9896                &[],
9897                &[PathBuf::from("pass")],
9898                ReconcileErrors { errors: &[pass_error], terminal: None, disproves_old: true },
9899            )
9900            .expect("finish");
9901
9902        let paths: Vec<_> =
9903            index.issues().iter().filter_map(|issue| issue.path.as_deref()).collect();
9904        assert_eq!(paths, [Path::new("concurrent"), Path::new("pass")]);
9905    }
9906
9907    #[test]
9908    fn older_root_closer_preserves_newer_pass_omissions_and_partial_coverage() {
9909        let root = Path::new("/root");
9910        let mut index = Index::new(root);
9911        for number in 0..66 {
9912            let path = PathBuf::from(format!("a-{number:02}"));
9913            index.retain_issue(Issue::observation_gap(
9914                &path,
9915                crate::InvalidateReason::WatchOverflow,
9916            ));
9917        }
9918        index.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
9919        assert_eq!(index.state.issues.omitted, 2);
9920
9921        let (older, _) = index.begin_reconcile(Path::new("")).expect("begin older pass");
9922        let (newer, _) = index.begin_reconcile(Path::new("")).expect("begin newer pass");
9923        let newer_errors = ["z-one", "z-two"].map(|path| {
9924            crate::Error::io(
9925                root.join(path),
9926                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
9927            )
9928        });
9929        index
9930            .finish_reconcile(
9931                Path::new(""),
9932                newer,
9933                false,
9934                &[],
9935                &[PathBuf::from("z-one"), PathBuf::from("z-two")],
9936                ReconcileErrors { errors: &newer_errors, terminal: None, disproves_old: true },
9937            )
9938            .expect("finish newer pass");
9939        assert_eq!(index.state.issues.omitted, 2);
9940
9941        index
9942            .finish_reconcile(
9943                Path::new(""),
9944                older,
9945                true,
9946                &[],
9947                &[],
9948                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9949            )
9950            .expect("finish older pass");
9951
9952        assert_eq!(index.state.issues.omitted, 2, "the older closer cannot erase newer omissions");
9953        assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
9954    }
9955
9956    #[test]
9957    fn an_unvisited_aborted_scope_does_not_disprove_its_old_issue() {
9958        let root = Path::new("/root");
9959        let mut index = Index::new(root);
9960        index.retain_issue(Issue::from_error_under(
9961            root,
9962            &crate::Error::io(
9963                root.join("later/blocked"),
9964                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "old failure"),
9965            ),
9966        ));
9967        let (started, _) = index.begin_reconcile(Path::new("later")).expect("begin later scope");
9968
9969        index
9970            .finish_reconcile(
9971                Path::new("later"),
9972                started,
9973                false,
9974                &[],
9975                &[],
9976                ReconcileErrors { errors: &[], terminal: None, disproves_old: false },
9977            )
9978            .expect("close skipped scope");
9979
9980        assert_eq!(index.issues().len(), 1);
9981        assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("later/blocked")));
9982    }
9983    #[test]
9984    fn older_pass_cannot_publish_errors_after_newer_clean_verification() {
9985        let root = Path::new("/root");
9986        let mut index = Index::new(root);
9987        let (older, _) = index.begin_reconcile(Path::new("")).expect("begin older pass");
9988        let (newer, _) = index.begin_reconcile(Path::new("")).expect("begin newer pass");
9989        index
9990            .finish_reconcile(
9991                Path::new(""),
9992                newer,
9993                true,
9994                &[],
9995                &[],
9996                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9997            )
9998            .expect("finish newer pass");
9999        let stale_error = crate::Error::io(
10000            root.join("stale"),
10001            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "older failure"),
10002        );
10003
10004        index
10005            .finish_reconcile(
10006                Path::new(""),
10007                older,
10008                false,
10009                &[],
10010                &[PathBuf::from("stale")],
10011                ReconcileErrors { errors: &[stale_error], terminal: None, disproves_old: true },
10012            )
10013            .expect("finish superseded older pass");
10014
10015        assert!(index.issues().is_empty());
10016        assert_eq!(index.state.coverage, Coverage::Complete);
10017        assert_eq!(index.state.freshness, Freshness::Fresh);
10018    }
10019
10020    #[test]
10021    fn newer_unchanged_verification_refuses_an_older_conditional_fact() {
10022        let mut index = Index::new("/root");
10023        index
10024            .apply(&Observation::new(vec![Op::Upsert {
10025                path: PathBuf::from("same"),
10026                kind: EntryKind::File,
10027                attrs: file_attrs(1, 1),
10028            }]))
10029            .expect("fixture");
10030        let handle = IndexHandle::new(index);
10031        let baseline = handle.expectation(Path::new("same")).expect("baseline");
10032        let (older, _) = handle.begin_reconcile(Path::new("")).expect("begin older pass");
10033        let (newer, _) = handle.begin_reconcile(Path::new("")).expect("begin newer pass");
10034        handle
10035            .finish_reconcile(
10036                Path::new(""),
10037                newer,
10038                true,
10039                &[],
10040                &[],
10041                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10042            )
10043            .expect("finish unchanged newer pass");
10044
10045        let stale = handle
10046            .apply_reconcile(
10047                older,
10048                &Observation::from_ops(vec![ObservationOp::if_state(
10049                    Op::Remove { path: PathBuf::from("same") },
10050                    baseline,
10051                )]),
10052            )
10053            .expect("arbitrate older fact");
10054
10055        assert_eq!(stale.stats.stale, 1);
10056        assert!(stale.commit.is_none());
10057        assert!(handle.attrs(Path::new("same")).expect("attrs").is_some());
10058    }
10059
10060    #[test]
10061    fn newer_disjoint_verification_does_not_refuse_an_older_fact() {
10062        let mut index = Index::new("/root");
10063        index
10064            .apply(&Observation::new(vec![
10065                Op::Upsert {
10066                    path: PathBuf::from("a"),
10067                    kind: EntryKind::File,
10068                    attrs: file_attrs(1, 1),
10069                },
10070                Op::Upsert {
10071                    path: PathBuf::from("b"),
10072                    kind: EntryKind::File,
10073                    attrs: file_attrs(1, 1),
10074                },
10075            ]))
10076            .expect("fixture");
10077        let handle = IndexHandle::new(index);
10078        let baseline = handle.expectation(Path::new("a")).expect("baseline");
10079        let (older, _) = handle.begin_reconcile(Path::new("a")).expect("begin a");
10080        let (newer, _) = handle.begin_reconcile(Path::new("b")).expect("begin b");
10081        handle
10082            .finish_reconcile(
10083                Path::new("b"),
10084                newer,
10085                true,
10086                &[],
10087                &[],
10088                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10089            )
10090            .expect("finish b");
10091
10092        let applied = handle
10093            .apply_reconcile(
10094                older,
10095                &Observation::from_ops(vec![ObservationOp::if_state(
10096                    Op::Remove { path: PathBuf::from("a") },
10097                    baseline,
10098                )]),
10099            )
10100            .expect("apply disjoint a fact");
10101
10102        assert_eq!(applied.stats.removed, 1);
10103        assert_eq!(applied.stats.stale, 0);
10104        assert!(handle.attrs(Path::new("a")).expect("attrs").is_none());
10105    }
10106    #[test]
10107    fn newer_child_verification_preserves_older_sibling_failure() {
10108        let root = Path::new("/root");
10109        let mut index = Index::new(root);
10110        index.apply_ok(&Observation::new(
10111            ["a", "a/old", "b", "b/blocked", "healthy"]
10112                .map(|path| Op::Upsert {
10113                    path: PathBuf::from(path),
10114                    kind: EntryKind::Dir,
10115                    attrs: Attrs::default(),
10116                })
10117                .to_vec(),
10118        ));
10119        index.set_initial_scan_freshness(&[]);
10120        let (older, _) = index.begin_reconcile(Path::new("")).expect("older root");
10121        let (newer, _) = index.begin_reconcile(Path::new("a")).expect("newer child");
10122        index
10123            .finish_reconcile(
10124                Path::new("a"),
10125                newer,
10126                true,
10127                &[],
10128                &[],
10129                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10130            )
10131            .expect("verify a");
10132        let errors = ["a/old", "b/blocked"].map(|path| {
10133            crate::Error::io(
10134                root.join(path),
10135                std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed read"),
10136            )
10137        });
10138        index
10139            .finish_reconcile(
10140                Path::new(""),
10141                older,
10142                false,
10143                &[],
10144                &[PathBuf::from("a/old"), PathBuf::from("b/blocked")],
10145                ReconcileErrors { errors: &errors, terminal: None, disproves_old: true },
10146            )
10147            .expect("finish older root");
10148        for path in ["", "a", "a/old", "b", "healthy"] {
10149            assert_eq!(index.directory_complete(Path::new(path)), Some(true), "{path}");
10150        }
10151        assert_eq!(index.directory_complete(Path::new("b/blocked")), Some(false));
10152        assert_eq!(index.issues().len(), 1);
10153        assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("b/blocked")));
10154        assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10155        assert_eq!(index.freshness_at(Path::new("a")), Freshness::Fresh);
10156        assert_eq!(index.freshness_at(Path::new("b/blocked")), Freshness::Partial);
10157    }
10158    #[test]
10159    fn failure_published_after_a_newer_pass_began_keeps_its_issue_with_its_mark() {
10160        // A root pass begins, a child pass begins under it, and the root pass fails on
10161        // that child and closes first. Its `Partial` mark is minted after the child pass
10162        // began, so the child's clean finish leaves the mark in place; the issue that
10163        // explains the mark must follow the same rule, or the root reports a partial with
10164        // no explanation until another root pass runs.
10165        let root = Path::new("/root");
10166        let mut index = Index::new(root);
10167        index.apply_ok(&Observation::new(
10168            ["x", "x/deep", "healthy"]
10169                .map(|path| Op::Upsert {
10170                    path: PathBuf::from(path),
10171                    kind: EntryKind::Dir,
10172                    attrs: Attrs::default(),
10173                })
10174                .to_vec(),
10175        ));
10176        index.set_initial_scan_freshness(&[]);
10177        let (older_root, _) = index.begin_reconcile(Path::new("")).expect("older root");
10178        let (newer_child, _) = index.begin_reconcile(Path::new("x")).expect("newer child");
10179        let error = crate::Error::io(
10180            root.join("x"),
10181            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed read"),
10182        );
10183        index
10184            .finish_reconcile(
10185                Path::new(""),
10186                older_root,
10187                false,
10188                &[],
10189                &[PathBuf::from("x")],
10190                ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
10191            )
10192            .expect("older root fails on x");
10193        assert_eq!(index.freshness_at(Path::new("x")), Freshness::Partial);
10194        assert_eq!(index.issues().len(), 1);
10195
10196        index
10197            .finish_reconcile(
10198                Path::new("x"),
10199                newer_child,
10200                true,
10201                &[],
10202                &[],
10203                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10204            )
10205            .expect("newer child verifies clean");
10206
10207        let explained = !index.issues().is_empty();
10208        let partial = index.freshness_at(Path::new("x")) == Freshness::Partial;
10209        assert_eq!(
10210            partial,
10211            explained,
10212            "a surviving partial mark and its issue must be kept or dropped together: \
10213             partial={partial}, issues={:?}",
10214            index.issues()
10215        );
10216        assert!(partial, "the mark minted after the child pass began is the newer claim");
10217        assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("x")));
10218        assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10219        assert_eq!(index.state.freshness, Freshness::Partial);
10220        let request = crate::query::Request::new(
10221            crate::query::Basis::held_by(&index),
10222            crate::query::Query::default(),
10223            std::time::UNIX_EPOCH,
10224        );
10225        let status = crate::query::TreeStatus::of(&index, &request);
10226        assert!(!status.complete);
10227        assert_eq!(status.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10228        assert_eq!(status.errors.len(), 1, "an incomplete status names its cause");
10229    }
10230    #[test]
10231    fn cold_scan_failure_does_not_verify_unknown_descendants_or_unscoped_work() {
10232        let mut index = Index::new("/root");
10233        index.apply_ok(&Observation::new(
10234            ["blocked", "blocked/nested", "healthy"]
10235                .map(|path| Op::Upsert {
10236                    path: PathBuf::from(path),
10237                    kind: EntryKind::Dir,
10238                    attrs: Attrs::default(),
10239                })
10240                .to_vec(),
10241        ));
10242        let error = crate::Error::io(
10243            PathBuf::from("/root/blocked"),
10244            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed listing"),
10245        );
10246        index.set_initial_scan_freshness(&[error]);
10247        assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
10248        assert_eq!(index.directory_complete(Path::new("")), Some(true));
10249        assert_eq!(index.freshness_at(Path::new("")), Freshness::Partial);
10250        for path in ["blocked", "blocked/nested"] {
10251            assert_eq!(index.directory_complete(Path::new(path)), Some(false), "{path}");
10252            assert_eq!(index.freshness_at(Path::new(path)), Freshness::Partial, "{path}");
10253        }
10254        index.set_initial_scan_freshness(&[crate::Error::Snapshot("unscoped failure".into())]);
10255        assert_eq!(index.directory_complete(Path::new("healthy")), Some(false));
10256        assert_eq!(index.freshness_at(Path::new("healthy")), Freshness::Partial);
10257    }
10258
10259    #[test]
10260    fn unscoped_failure_publishes_listing_withdrawal_when_root_state_is_unchanged() {
10261        let mut index = Index::new("/root");
10262        index.apply_ok(&Observation::new(vec![Op::Upsert {
10263            path: PathBuf::from("healthy"),
10264            kind: EntryKind::Dir,
10265            attrs: Attrs::default(),
10266        }]));
10267        index.set_initial_scan_freshness(&[]);
10268        index.mark_unfresh(Path::new("elsewhere"), Freshness::Partial);
10269        index.state.freshness = Freshness::Partial;
10270        index.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
10271        let error = crate::Error::Snapshot("unscoped failure".into());
10272        index.retain_issue(Issue::from_error_under(&index.root_path, &error));
10273        let progress = index.state.progress;
10274        let (epoch, _) = index.begin_reconcile(Path::new("")).expect("begin partial root");
10275        let before = index.state;
10276        let clock = index.clock;
10277        let finished = index
10278            .finish_reconcile(
10279                Path::new(""),
10280                epoch,
10281                false,
10282                &[],
10283                &[],
10284                ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
10285            )
10286            .expect("finish failure");
10287        assert_eq!(index.state, before, "aggregate root state remains identical");
10288        assert_eq!(index.state.progress, progress, "discovery progress is cumulative");
10289        assert_eq!(index.directory_complete(Path::new("healthy")), Some(false));
10290        let commit = finished.commit.expect("withdrawal must publish even without another effect");
10291        assert!(index.clock > clock);
10292        assert_eq!(commit.clock, index.clock);
10293        assert!(
10294            commit
10295                .state
10296                .iter()
10297                .all(|effect| matches!(effect, StateTransition::DirectoryIncomplete { .. }))
10298        );
10299        assert!(
10300            commit
10301                .state
10302                .contains(&StateTransition::DirectoryIncomplete { path: PathBuf::from("healthy") })
10303        );
10304        assert!(commit.impact.dirty_paths.contains(&PathBuf::from("healthy")));
10305    }
10306
10307    #[test]
10308    fn omitted_failed_entry_withdraws_parent_listing_without_tainting_siblings() {
10309        let mut index = Index::new("/root");
10310        index.apply_ok(&Observation::new(vec![Op::Upsert {
10311            path: PathBuf::from("healthy"),
10312            kind: EntryKind::Dir,
10313            attrs: Attrs::default(),
10314        }]));
10315        let error = crate::Error::io(
10316            PathBuf::from("/root/missing"),
10317            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "metadata failed"),
10318        );
10319        index.set_initial_scan_freshness(&[error]);
10320        assert_eq!(index.directory_complete(Path::new("")), Some(false));
10321        assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
10322        index.set_initial_scan_freshness(&[]);
10323        let (epoch, _) = index.begin_reconcile(Path::new("")).expect("begin root");
10324        let error = crate::Error::io(
10325            PathBuf::from("/root/missing"),
10326            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "metadata failed"),
10327        );
10328        index
10329            .finish_reconcile(
10330                Path::new(""),
10331                epoch,
10332                false,
10333                &[],
10334                &[PathBuf::from("missing")],
10335                ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
10336            )
10337            .expect("partial root");
10338        assert_eq!(index.directory_complete(Path::new("")), Some(false));
10339        assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
10340    }
10341
10342    #[test]
10343    fn complete_older_root_does_not_verify_a_newer_failed_child() {
10344        let root = Path::new("/root");
10345        let mut index = Index::new(root);
10346        let (older, _) = index.begin_reconcile(Path::new("")).expect("older root");
10347        let (newer, _) = index.begin_reconcile(Path::new("child")).expect("newer child");
10348        let error = crate::Error::io(
10349            root.join("child"),
10350            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "new failure"),
10351        );
10352        index
10353            .finish_reconcile(
10354                Path::new("child"),
10355                newer,
10356                false,
10357                &[],
10358                &[PathBuf::from("child")],
10359                ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
10360            )
10361            .expect("failed child");
10362        let finish = index
10363            .finish_reconcile(
10364                Path::new(""),
10365                older,
10366                true,
10367                &[],
10368                &[],
10369                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10370            )
10371            .expect("complete older walk");
10372        assert_eq!(index.issues().len(), 1);
10373        assert_eq!(index.freshness_at(Path::new("child")), Freshness::Partial);
10374        assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10375        assert!(!finish.commit.iter().flat_map(|commit| commit.state.iter()).any(|state| {
10376            matches!(state, StateTransition::Verified { path } if path.as_os_str().is_empty())
10377        }));
10378    }
10379
10380    #[test]
10381    fn reconciliation_scope_budget_preserves_issues_and_newer_facts() {
10382        let mut index = Index::new("/root");
10383        index.apply_ok(&Observation::new(vec![Op::Upsert {
10384            path: PathBuf::from("kept"),
10385            kind: EntryKind::File,
10386            attrs: file_attrs(1, 1),
10387        }]));
10388        index.retain_issue(Issue::provider_failure(
10389            Some(Path::new("unvisited")),
10390            "earlier failure".into(),
10391        ));
10392        let (older, _) = index.begin_reconcile(Path::new("")).expect("older pass");
10393        let budget = index.active_reconciles[&older].scope_budget;
10394        assert_eq!(budget, 2, "root and kept file define the evidence budget");
10395        for number in 0..100 {
10396            let path = PathBuf::from(format!("missing-{number}"));
10397            let (newer, _) = index.begin_reconcile(&path).expect("newer pass");
10398            index
10399                .finish_reconcile(
10400                    &path,
10401                    newer,
10402                    true,
10403                    &[],
10404                    &[],
10405                    ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10406                )
10407                .expect("newer verification");
10408            if let ReconcileEvidence::Scopes(scopes) = &index.active_reconciles[&older].evidence {
10409                assert!(scopes.len() <= budget);
10410            }
10411        }
10412        assert!(matches!(index.active_reconciles[&older].evidence, ReconcileEvidence::Retry));
10413        index.apply_ok(&Observation::new(vec![Op::Upsert {
10414            path: PathBuf::from("kept"),
10415            kind: EntryKind::File,
10416            attrs: file_attrs(9, 2),
10417        }]));
10418        let finished = index
10419            .finish_reconcile(
10420                Path::new(""),
10421                older,
10422                true,
10423                &[],
10424                &[],
10425                ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10426            )
10427            .expect("close interrupted pass");
10428        assert!(finished.retry);
10429        assert!(finished.commit.is_some());
10430        assert_eq!(index.total_scalars().bytes, 9);
10431        assert!(
10432            index
10433                .issues()
10434                .iter()
10435                .any(|issue| issue.path.as_deref() == Some(Path::new("unvisited")))
10436        );
10437        assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10438        assert!(index.active_reconciles.is_empty(), "closed passes retain no shadow history");
10439    }
10440}