1use 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
49const 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 pub(crate) static RECLASSIFY_VISITS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
86
87 pub(crate) static CONTROL_PROJECTION_CLONES: std::cell::Cell<u64> =
92 const { std::cell::Cell::new(0) };
93}
94
95pub const DEFAULT_JOURNAL_CAPACITY_BYTES: usize = 8 * 1024 * 1024;
106
107#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
109pub struct EntryId {
110 slot: u32,
111 generation: u64,
112}
113
114impl EntryId {
115 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
124type ExtId = u32;
126
127#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
129pub struct ExtTally {
130 pub files: u64,
132 pub bytes: u64,
134 pub allocated: u64,
140}
141
142#[derive(Clone, PartialEq, Eq, Debug, Default)]
156pub struct RollUp {
157 pub files: u64,
159 pub dirs: u64,
161 pub bytes: u64,
163 pub allocated: u64,
165 pub newest_mtime_ns: i64,
167 pub by_ext: BTreeMap<String, ExtTally>,
169}
170
171#[derive(Clone, PartialEq, Eq, Debug, Default)]
173pub struct PartitionRollUp {
174 pub all: RollUp,
176 pub unignored: RollUp,
178}
179
180#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
182pub struct RollUpSummary {
183 pub files: u64,
185 pub dirs: u64,
187 pub bytes: u64,
189 pub allocated: u64,
191 pub newest_mtime_ns: Option<i64>,
193}
194
195#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
197pub struct PartitionRollUpSummary {
198 pub all: RollUpSummary,
200 pub unignored: RollUpSummary,
202}
203
204#[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#[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#[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 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 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 Sorted(Vec<EntryId>),
349 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 ext_id: Option<ExtId>,
399 ignored: bool,
401 source: Source,
407 kind: EntryKind,
408 attrs: Attrs,
409 revision: u64,
412 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#[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#[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#[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#[derive(Clone, PartialEq, Eq, Debug, Default)]
650#[must_use]
651pub struct Since {
652 pub commits: Vec<Commit>,
654 pub clock: Clock,
656 pub state: IndexState,
658 pub truncated: bool,
661}
662
663#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
665pub struct ApplyStats {
666 pub inserted: u64,
668 pub updated: u64,
670 pub removed: u64,
672 pub unchanged: u64,
674 pub invalidated: u64,
676 pub controls: u64,
678 pub reclassified: u64,
680 pub stale: u64,
683 pub resource_refused: u64,
686}
687
688impl ApplyStats {
689 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#[derive(Clone, PartialEq, Eq, Debug, Default)]
702pub struct ApplyOutcome {
703 pub stats: ApplyStats,
705 pub commit: Option<Commit>,
707}
708
709#[derive(Clone, PartialEq, Eq, Debug)]
714pub struct ChildSnapshot {
715 pub id: EntryId,
717 pub name: OsString,
719 pub kind: EntryKind,
721 pub attrs: Attrs,
723 pub ignored: Option<bool>,
729 pub rollup: Option<RollUp>,
731 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#[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#[derive(Clone, Copy, Debug)]
798enum ResolvedParent {
799 Existing(EntryId),
801 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
820trait 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#[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 journal_floor: Clock,
864 pending_invalidations: Vec<(PathBuf, InvalidateReason)>,
865 applying_source: Source,
871 scanned_at_ns: i64,
873 captured_at_ns: i64,
876 writing_pass_started_at_ns: i64,
886 persistence_owed: bool,
894 active_root_reconciles: BTreeMap<u64, i64>,
896 active_reconciles: BTreeMap<u64, ActiveReconcile>,
898 verified: Vec<(PathBuf, i64)>,
906 ext_names: Vec<Option<String>>,
909 ext_ids: BTreeMap<String, ExtId>,
911 ext_refcounts: Vec<u64>,
918 free_ext_ids: Vec<ExtId>,
920 content: Option<Box<ContentIndex>>,
922 types: std::sync::Arc<crate::classify::TypeRegistry>,
929 controls: crate::control::ControlTable,
931 unreadable_control_paths: BTreeSet<PathBuf>,
934 freshness_epoch: u64,
935 freshness_marks: BTreeMap<PathBuf, FreshnessMark>,
936 state: IndexState,
939 issues: Vec<Issue>,
941 issue_epochs: Vec<u64>,
944 omitted_issue_epochs: BTreeMap<u64, u64>,
948 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 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 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#[derive(Clone, Debug)]
1124pub struct IndexHandle {
1125 inner: Arc<RwLock<Index>>,
1126}
1127
1128#[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#[derive(Clone, Debug)]
1150#[cfg_attr(not(feature = "watch"), allow(dead_code))]
1151pub(crate) enum ObservationTransition {
1152 Reconciling,
1154 Watching { issues: Vec<Issue>, omitted: u64 },
1159 Unreadable { issues: Vec<Issue>, omitted: u64 },
1165 Failed(Issue),
1167}
1168
1169impl IndexHandle {
1170 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 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 #[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 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 pub fn root_path(&self) -> crate::Result<PathBuf> {
1290 Ok(self.read_index()?.root_path().to_path_buf())
1291 }
1292
1293 pub fn scope(&self) -> crate::Result<ScanScope> {
1295 Ok(self.read_index()?.scope())
1296 }
1297
1298 pub fn freshness(&self) -> crate::Result<Freshness> {
1300 Ok(self.read_index()?.freshness())
1301 }
1302
1303 pub fn freshness_at(&self, path: &Path) -> crate::Result<Freshness> {
1305 Ok(self.read_index()?.freshness_at(path))
1306 }
1307
1308 pub fn clock(&self) -> crate::Result<Clock> {
1310 Ok(self.read_index()?.clock())
1311 }
1312
1313 pub fn len(&self) -> crate::Result<u64> {
1315 Ok(self.read_index()?.len())
1316 }
1317
1318 pub fn is_empty(&self) -> crate::Result<bool> {
1320 Ok(self.read_index()?.is_empty())
1321 }
1322
1323 pub fn total(&self) -> crate::Result<RollUp> {
1325 Ok(self.read_index()?.total())
1326 }
1327
1328 pub(crate) fn state(&self) -> crate::Result<IndexState> {
1330 Ok(self.read_index()?.state())
1331 }
1332
1333 #[allow(dead_code)] pub(crate) fn issues(&self) -> crate::Result<Vec<Issue>> {
1335 Ok(self.read_index()?.issues().to_vec())
1336 }
1337
1338 #[allow(dead_code)] pub(crate) fn directory_complete(&self, path: &Path) -> crate::Result<Option<bool>> {
1341 Ok(self.read_index()?.directory_complete(path))
1342 }
1343
1344 pub fn rollup(&self, path: &Path) -> crate::Result<Option<RollUp>> {
1346 Ok(self.read_index()?.rollup(path))
1347 }
1348
1349 pub fn attrs(&self, path: &Path) -> crate::Result<Option<Attrs>> {
1351 Ok(self.read_index()?.attrs(path).copied())
1352 }
1353
1354 pub fn kind(&self, path: &Path) -> crate::Result<Option<EntryKind>> {
1356 Ok(self.read_index()?.kind(path))
1357 }
1358
1359 pub fn path_state(&self, path: &Path) -> crate::Result<PathState> {
1361 Ok(self.read_index()?.path_state(path))
1362 }
1363
1364 pub fn expectation(&self, path: &Path) -> crate::Result<PathExpectation> {
1366 Ok(self.read_index()?.expectation(path))
1367 }
1368
1369 pub fn since(&self, clock: Clock) -> crate::Result<Since> {
1371 Ok(self.read_index()?.since(clock))
1372 }
1373
1374 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 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 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
1576pub(crate) struct DetachedIndexBuilder {
1582 index: Index,
1583 directory_ids: HashMap<PathBuf, EntryId>,
1584 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 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 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 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 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 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 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 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 pub(crate) fn finish(mut self) -> Index {
1726 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 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 pub fn new(root_path: impl Into<PathBuf>) -> Self {
1763 Self::new_with_scope(root_path, ScanScope::default())
1764 }
1765
1766 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 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 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 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 pub fn types(&self) -> &crate::classify::TypeRegistry {
1902 self.types.as_ref()
1903 }
1904
1905 pub fn controls(&self) -> crate::Result<&crate::control::ControlTable> {
1914 self.require_observed_controls()?;
1915 Ok(&self.controls)
1916 }
1917
1918 pub const fn observes_controls(&self) -> bool {
1921 self.scope.observes_controls()
1922 }
1923
1924 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 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 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 pub(crate) fn control_table(&self) -> &crate::control::ControlTable {
1965 &self.controls
1966 }
1967
1968 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 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 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 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 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 if !controls.is_vacant() {
2031 self.require_observed_controls()?;
2032 }
2033 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 pub(crate) fn types_shared(&self) -> std::sync::Arc<crate::classify::TypeRegistry> {
2049 std::sync::Arc::clone(&self.types)
2050 }
2051
2052 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 pub fn root_path(&self) -> &Path {
2073 &self.root_path
2074 }
2075
2076 pub const fn scope(&self) -> ScanScope {
2078 self.scope
2079 }
2080
2081 pub fn freshness(&self) -> Freshness {
2083 self.freshness_at(Path::new(""))
2084 }
2085
2086 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 pub(crate) const fn state(&self) -> IndexState {
2105 self.state
2106 }
2107
2108 #[allow(dead_code)] pub(crate) fn issues(&self) -> &[Issue] {
2110 &self.issues
2111 }
2112
2113 #[allow(dead_code)] 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 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 pub fn clock(&self) -> Clock {
2133 self.clock
2134 }
2135
2136 pub fn len(&self) -> u64 {
2138 self.live
2139 }
2140
2141 pub fn is_empty(&self) -> bool {
2143 self.live <= 1
2144 }
2145
2146 pub fn total(&self) -> RollUp {
2148 self.named_rollup(&self.entry(EntryId::ROOT).rollup().all)
2149 }
2150
2151 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 pub(crate) fn total_scalars(&self) -> RollUpScalars {
2165 RollUpScalars::from(&self.entry(EntryId::ROOT).rollup().all)
2166 }
2167
2168 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 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 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 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 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 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 parent_memo.clear();
2340 self.apply_remove(path, &mut stats, effects);
2341 }
2342 Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {
2343 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 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)] 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn take_pending_invalidations(&mut self) -> Vec<(PathBuf, InvalidateReason)> {
3451 std::mem::take(&mut self.pending_invalidations)
3452 }
3453
3454 pub(crate) fn restore_pending_invalidations(
3456 &mut self,
3457 invalidations: Vec<(PathBuf, InvalidateReason)>,
3458 ) {
3459 self.pending_invalidations.extend(invalidations);
3460 }
3461
3462 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 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 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 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 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 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 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 pub fn attrs(&self, path: &Path) -> Option<&Attrs> {
3852 Some(&self.entry(self.lookup(path)?).attrs)
3853 }
3854
3855 pub fn kind(&self, path: &Path) -> Option<EntryKind> {
3857 Some(self.entry(self.lookup(path)?).kind)
3858 }
3859
3860 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 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 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 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 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 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 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 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 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 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 pub fn attrs_of(&self, id: EntryId) -> Option<&Attrs> {
3995 Some(&self.try_entry(id)?.attrs)
3996 }
3997
3998 pub fn kind_of(&self, id: EntryId) -> Option<EntryKind> {
4000 Some(self.try_entry(id)?.kind)
4001 }
4002
4003 pub fn name_of(&self, id: EntryId) -> Option<&OsStr> {
4005 Some(&self.try_entry(id)?.name)
4006 }
4007
4008 pub fn content(&self) -> Option<&ContentIndex> {
4010 self.content.as_deref()
4011 }
4012
4013 pub fn content_rollup(&self, path: &Path) -> Option<&ContentRollUp> {
4015 self.content()?.rollup(path)
4016 }
4017
4018 pub fn content_set(&self) -> AnalysisSet {
4021 self.content().and_then(ContentIndex::profile).unwrap_or(AnalysisSet::NONE)
4022 }
4023
4024 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 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 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 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 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 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 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 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 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 pub(crate) fn apply_analysis(
4202 &mut self,
4203 observation: AnalysisObservation,
4204 ) -> AnalysisApplyOutcome {
4205 self.apply_analysis_record(observation)
4206 }
4207
4208 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 pub fn clear_content(&mut self) {
4271 self.content = None;
4272 }
4273
4274 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 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 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 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 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 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 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 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 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 fn controls_unchanged_by<'a>(&self, ops: impl Iterator<Item = &'a Op>) -> bool {
4592 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 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 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 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 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 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 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 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 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 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 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 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 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 pub(crate) const fn writing_pass_started_at_ns(&self) -> i64 {
5129 self.writing_pass_started_at_ns
5130 }
5131
5132 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 pub(crate) const fn persistence_owed(&self) -> bool {
5139 self.persistence_owed
5140 }
5141
5142 pub(crate) fn set_persistence_owed(&mut self, owed: bool) {
5145 self.persistence_owed = owed;
5146 }
5147
5148 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 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 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 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 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 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 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 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 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 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 #[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 self.entry_mut(id).source = source;
5456 stats.unchanged += 1;
5457 return false;
5458 }
5459 if kind.is_dir() {
5460 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 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 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 let contribution = self.contribution(id);
5589 self.merge_upward(Some(parent), &contribution);
5590 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 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 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 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
5673pub(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#[derive(Default)]
5727struct ParentMemo {
5728 entry: Option<(PathBuf, EntryId)>,
5729}
5730
5731impl ParentMemo {
5732 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 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#[derive(Default)]
5757struct StructuralOverlay {
5758 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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 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 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 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 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 #[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 #[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 #[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 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 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 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 let mut index = Index::new("/root");
7838 index.apply_ok(&Observation::new(vec![
7839 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 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 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 #[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 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 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 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 handle
8592 .apply(&Observation::new(vec![upsert("p/d", EntryKind::File, file_attrs(1, 1))]))
8593 .expect("refresh insert");
8594 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 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 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 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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 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 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 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 #[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 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 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 let mut index = Index::new("/root");
9719 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 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 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 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 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}