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(
1622 &mut self,
1623 directory: crate::scan::DetachedDirectory,
1624 ) -> crate::Result<()> {
1625 let crate::scan::DetachedDirectory { path, mut children, control } = directory;
1626 let Some(parent) = self.directory_ids.remove(&path) else {
1630 if self.repeated_directories.iter().any(|repeated| path.starts_with(repeated)) {
1631 return Ok(());
1632 }
1633 return Err(crate::Error::UnknownAncestry { path, reconcile_from: PathBuf::new() });
1634 };
1635
1636 if let Some(control) = control {
1641 match control {
1642 Op::ControlUpsert { path, source } => {
1643 self.index.controls.upsert(&path, source)?;
1644 }
1645 Op::ControlRemove { path } => {
1646 self.index.controls.remove(&path)?;
1647 }
1648 _ => unreachable!("detached directory retains only fixed-control operations"),
1649 }
1650 self.inserted = self.inserted.saturating_add(1);
1651 }
1652
1653 children.sort_unstable_by(|left, right| {
1657 left.name.cmp(&right.name).then(left.position.cmp(&right.position))
1658 });
1659 let repeated_directories = &mut self.repeated_directories;
1660 children.dedup_by(|later, kept| {
1661 if later.name != kept.name {
1662 return false;
1663 }
1664 if later.kind.is_dir() || kept.kind.is_dir() {
1665 let repeated = path.join(&kept.name);
1666 if repeated_directories.last() != Some(&repeated) {
1667 repeated_directories.push(repeated);
1668 }
1669 }
1670 std::mem::swap(later, kept);
1671 true
1672 });
1673
1674 let parent_ignored = self.index.entry(parent).ignored;
1675 let mut match_path =
1676 (!parent_ignored && !self.index.controls.is_empty()).then(|| path.clone());
1677 self.index.reserve_detached_children(parent, children.len());
1678 for child in children {
1679 let crate::scan::DetachedChild { name, kind, attrs, .. } = child;
1680 crate::counters::bump(|counts| counts.upserts += 1);
1681 let ext_id = (kind == EntryKind::File)
1682 .then(|| self.index.intern_ext(&crate::classify::ext_bucket(&name)));
1683 let (ignored, child_path) = if let Some(scratch) = &mut match_path {
1684 scratch.push(&name);
1685 let ignored = self.index.controls.matcher_for(scratch).is_ignored(kind.is_dir());
1686 let child_path = kind.is_dir().then(|| scratch.clone());
1687 let popped = scratch.pop();
1688 debug_assert!(popped);
1689 (ignored, child_path)
1690 } else {
1691 (parent_ignored, kind.is_dir().then(|| path.join(&name)))
1692 };
1693 let child_id = self.index.alloc(Entry::new_detached(
1694 NewEntry {
1695 parent: Some(parent),
1696 name,
1697 ext_id,
1698 ignored,
1699 source: Source::Scanned,
1700 kind,
1701 attrs,
1702 },
1703 false,
1704 ));
1705 self.index.push_detached_child(parent, child_id);
1706 let direct = self.index.contribution(child_id);
1710 crate::counters::bump(|counts| counts.rollup_merges += 1);
1711 self.index.entry_mut(parent).rollup_mut().merge(&direct);
1712 if let Some(child_path) = child_path {
1713 self.directory_ids.insert(child_path, child_id);
1714 }
1715 self.inserted = self.inserted.saturating_add(1);
1716 }
1717 Ok(())
1718 }
1719
1720 pub(crate) fn finish(mut self) -> Index {
1722 for slot in (1..self.index.arena.len()).rev() {
1726 let id = EntryId {
1727 slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
1728 generation: 0,
1729 };
1730 if !self.index.entry(id).kind.is_dir() {
1731 continue;
1732 }
1733 let parent = self.index.entry(id).parent.expect("every non-root entry has a parent");
1734 crate::counters::bump(|counts| counts.rollup_merges += 1);
1736 self.index.merge_detached_descendants(parent, id);
1737 }
1738
1739 crate::counters::bump(|counts| {
1740 counts.baseline_batches = counts.baseline_batches.saturating_add(1);
1741 counts.baseline_accepted_ops =
1742 counts.baseline_accepted_ops.saturating_add(self.inserted);
1743 });
1744 self.index.establish_baseline();
1745 self.index
1746 }
1747}
1748
1749impl Index {
1750 pub fn new(root_path: impl Into<PathBuf>) -> Self {
1759 Self::new_with_scope(root_path, ScanScope::default())
1760 }
1761
1762 pub fn new_with_scope(root_path: impl Into<PathBuf>, scope: ScanScope) -> Self {
1769 Self::new_with_scope_and_types(
1770 root_path,
1771 scope,
1772 crate::classify::TypeRegistry::compiled_shared(),
1773 )
1774 }
1775
1776 pub fn new_with_config(root_path: impl Into<PathBuf>, config: &crate::ScanConfig) -> Self {
1782 let mut index =
1783 Self::new_with_scope_and_types(root_path, config.scope(), config.types_shared());
1784 index.set_control_limits(config.control_limits);
1785 index
1786 }
1787
1788 pub(crate) fn new_with_scope_and_types(
1790 root_path: impl Into<PathBuf>,
1791 scope: ScanScope,
1792 types: std::sync::Arc<crate::classify::TypeRegistry>,
1793 ) -> Self {
1794 Self::new_with_scope_types_and_journal_capacity_bytes(
1795 root_path,
1796 scope,
1797 types,
1798 DEFAULT_JOURNAL_CAPACITY_BYTES,
1799 )
1800 }
1801
1802 pub(crate) fn new_with_scope_types_and_journal_capacity_bytes(
1803 root_path: impl Into<PathBuf>,
1804 scope: ScanScope,
1805 types: std::sync::Arc<crate::classify::TypeRegistry>,
1806 journal_capacity_bytes: usize,
1807 ) -> Self {
1808 assert_eq!(
1809 scope.type_rules_fingerprint,
1810 types.fingerprint(),
1811 "an index's registry must match its semantic scope"
1812 );
1813 Self::new_with_journal_capacity_bytes(root_path, scope, journal_capacity_bytes, types, None)
1814 }
1815
1816 pub(crate) fn new_opened_with_scope_types_and_journal_capacity_bytes(
1818 root_path: impl Into<PathBuf>,
1819 scope: ScanScope,
1820 types: std::sync::Arc<crate::classify::TypeRegistry>,
1821 journal_capacity_bytes: usize,
1822 ) -> Self {
1823 assert_eq!(
1824 scope.type_rules_fingerprint,
1825 types.fingerprint(),
1826 "an index's registry must match its semantic scope"
1827 );
1828 let serving = ServingIndexes::for_types(&types);
1829 Self::new_with_journal_capacity_bytes(
1830 root_path,
1831 scope,
1832 journal_capacity_bytes,
1833 types,
1834 Some(Box::new(serving)),
1835 )
1836 }
1837
1838 fn new_with_journal_capacity_bytes(
1839 root_path: impl Into<PathBuf>,
1840 scope: ScanScope,
1841 journal_capacity_bytes: usize,
1842 types: std::sync::Arc<crate::classify::TypeRegistry>,
1843 serving: Option<Box<ServingIndexes>>,
1844 ) -> Self {
1845 let root = Entry::new(
1846 NewEntry {
1847 parent: None,
1848 name: OsString::new(),
1849 ext_id: None,
1850 ignored: false,
1851 source: Source::Scanned,
1852 kind: EntryKind::Dir,
1853 attrs: Attrs::default(),
1854 },
1855 true,
1856 );
1857 let constructed_at_ns = Self::now_unix_nanos();
1858 Self {
1859 root_path: root_path.into(),
1860 scope,
1861 arena: vec![Slot::Occupied { generation: 0, entry: root }],
1862 free_head: None,
1863 live: 1,
1864 clock: Clock::ZERO,
1865 journal: VecDeque::new(),
1866 journal_cost: 0,
1867 journal_capacity_bytes,
1868 journal_floor: Clock::ZERO,
1869 pending_invalidations: Vec::new(),
1870 freshness_epoch: 0,
1871 freshness_marks: BTreeMap::new(),
1872 state: IndexState::default(),
1873 issues: Vec::new(),
1874 issue_epochs: Vec::new(),
1875 omitted_issue_epochs: BTreeMap::new(),
1876 serving,
1877 applying_source: Source::Scanned,
1878 scanned_at_ns: constructed_at_ns,
1879 captured_at_ns: 0,
1880 writing_pass_started_at_ns: constructed_at_ns,
1881 persistence_owed: true,
1882 active_root_reconciles: BTreeMap::new(),
1883 active_reconciles: BTreeMap::new(),
1884 verified: Vec::new(),
1885 ext_names: Vec::new(),
1886 ext_ids: BTreeMap::new(),
1887 ext_refcounts: Vec::new(),
1888 free_ext_ids: Vec::new(),
1889 content: None,
1890 types,
1891 controls: crate::control::ControlTable::default(),
1892 unreadable_control_paths: BTreeSet::new(),
1893 }
1894 }
1895
1896 pub fn types(&self) -> &crate::classify::TypeRegistry {
1898 self.types.as_ref()
1899 }
1900
1901 pub fn controls(&self) -> crate::Result<&crate::control::ControlTable> {
1910 self.require_observed_controls()?;
1911 Ok(&self.controls)
1912 }
1913
1914 pub const fn observes_controls(&self) -> bool {
1917 self.scope.observes_controls()
1918 }
1919
1920 pub fn control_identity(&self) -> crate::ControlTierIdentity {
1922 if self.observes_controls() {
1923 crate::ControlTierIdentity::Observed { limits: self.controls.limits() }
1924 } else {
1925 crate::ControlTierIdentity::NotObserved
1926 }
1927 }
1928
1929 pub fn snapshot_identity(&self) -> crate::SnapshotIdentity {
1931 crate::SnapshotIdentity {
1932 entries: crate::EntryTierIdentity::of_scope(self.scope),
1933 controls: self.control_identity(),
1934 }
1935 }
1936
1937 fn require_observed_controls(&self) -> crate::Result<()> {
1938 if self.observes_controls() { Ok(()) } else { Err(crate::Error::ControlStateNotObserved) }
1939 }
1940
1941 fn carries_unobserved_control_input(&self, ops: &[ObservationOp]) -> bool {
1950 !self.observes_controls()
1951 && ops.iter().any(|observed| {
1952 matches!(observed.op, Op::ControlUpsert { .. } | Op::ControlRemove { .. })
1953 })
1954 }
1955
1956 pub(crate) fn control_table(&self) -> &crate::control::ControlTable {
1961 &self.controls
1962 }
1963
1964 pub fn control_coverage(&self) -> crate::control::ControlCoverage {
1971 if self.observes_controls() {
1972 crate::control::ControlCoverage::Observed(self.controls.observation())
1973 } else {
1974 crate::control::ControlCoverage::NotObserved
1975 }
1976 }
1977
1978 pub(crate) fn set_control_limits(&mut self, limits: crate::control::ControlLimits) {
1982 debug_assert!(self.controls.is_vacant(), "the control limits are set before any control");
1983 self.controls = crate::control::ControlTable::with_limits(limits);
1984 }
1985
1986 pub(crate) fn require_control_limits_in_scope(
1998 &self,
1999 limits: crate::control::ControlLimits,
2000 ) -> crate::Result<()> {
2001 if self.scope.observes_controls()
2002 && (crate::ControlTierIdentity::Observed { limits }).ignore_rules_fingerprint()
2003 != self.scope.ignore_rules_fingerprint
2004 {
2005 return Err(crate::Error::ControlLimitsOutsideScope { limits });
2006 }
2007 Ok(())
2008 }
2009
2010 pub(crate) fn install_controls(
2012 &mut self,
2013 controls: crate::control::ControlTable,
2014 ) -> crate::Result<()> {
2015 self.require_control_limits_in_scope(controls.limits())?;
2016 if controls.limits().budget.is_some_and(|budget| controls.retained_cost() > budget) {
2019 return Err(crate::Error::Snapshot(
2020 "a snapshot's control table exceeds its own control budget".into(),
2021 ));
2022 }
2023 if !controls.is_vacant() {
2027 self.require_observed_controls()?;
2028 }
2029 let unchanged = controls.is_empty() && self.controls.is_empty();
2033 self.controls = controls;
2034 if unchanged {
2035 return Ok(());
2036 }
2037 let mut stats = ApplyStats::default();
2038 let mut effects = NoConsequences;
2039 self.reclassify_controlled_subtrees(&[PathBuf::new()], &mut stats, &mut effects);
2040 Ok(())
2041 }
2042
2043 pub(crate) fn types_shared(&self) -> std::sync::Arc<crate::classify::TypeRegistry> {
2045 std::sync::Arc::clone(&self.types)
2046 }
2047
2048 pub fn classify(&self, relative_path: &Path) -> crate::classify::Classification {
2050 crate::classify::classify_with(&self.types, relative_path, None)
2051 }
2052
2053 #[cfg(test)]
2054 fn with_journal_capacity_bytes(
2055 root_path: impl Into<PathBuf>,
2056 journal_capacity_bytes: usize,
2057 ) -> Self {
2058 Self::new_with_journal_capacity_bytes(
2059 root_path,
2060 ScanScope::default(),
2061 journal_capacity_bytes,
2062 crate::classify::TypeRegistry::compiled_shared(),
2063 None,
2064 )
2065 }
2066
2067 pub fn root_path(&self) -> &Path {
2069 &self.root_path
2070 }
2071
2072 pub const fn scope(&self) -> ScanScope {
2074 self.scope
2075 }
2076
2077 pub fn freshness(&self) -> Freshness {
2079 self.freshness_at(Path::new(""))
2080 }
2081
2082 fn published_freshness(&self) -> Freshness {
2091 let derived = self.freshness();
2092 if self.state.phase == LifecyclePhase::Reconciling && derived == Freshness::Fresh {
2093 Freshness::Reconciling
2094 } else {
2095 derived
2096 }
2097 }
2098
2099 pub(crate) const fn state(&self) -> IndexState {
2101 self.state
2102 }
2103
2104 #[allow(dead_code)] pub(crate) fn issues(&self) -> &[Issue] {
2106 &self.issues
2107 }
2108
2109 #[allow(dead_code)] pub(crate) fn directory_complete(&self, path: &Path) -> Option<bool> {
2112 let id = self.lookup(path)?;
2113 let entry = self.entry(id);
2114 (entry.kind == EntryKind::Dir).then(|| entry.directory().children_complete)
2115 }
2116
2117 pub fn freshness_at(&self, path: &Path) -> Freshness {
2119 self.freshness_marks
2120 .iter()
2121 .filter(|(marked, _)| path.starts_with(marked) || marked.starts_with(path))
2122 .map(|(_, mark)| mark.state)
2123 .max_by_key(|state| state.rank())
2124 .unwrap_or(Freshness::Fresh)
2125 }
2126
2127 pub fn clock(&self) -> Clock {
2129 self.clock
2130 }
2131
2132 pub fn len(&self) -> u64 {
2134 self.live
2135 }
2136
2137 pub fn is_empty(&self) -> bool {
2139 self.live <= 1
2140 }
2141
2142 pub fn total(&self) -> RollUp {
2144 self.named_rollup(&self.entry(EntryId::ROOT).rollup().all)
2145 }
2146
2147 pub fn partition_total(&self) -> crate::Result<PartitionRollUp> {
2155 self.require_observed_controls()?;
2156 Ok(self.named_partitions(self.entry(EntryId::ROOT).rollup()))
2157 }
2158
2159 pub(crate) fn total_scalars(&self) -> RollUpScalars {
2161 RollUpScalars::from(&self.entry(EntryId::ROOT).rollup().all)
2162 }
2163
2164 pub fn apply(&mut self, observation: &Observation) -> crate::Result<ApplyOutcome> {
2173 let prepared = prepare_observation(observation)?;
2174 let outcome = self.commit_prepared(prepared, true)?;
2175 record_batch(BatchProvenance::Public, observation.len(), outcome.stats);
2176 Ok(outcome)
2177 }
2178
2179 fn commit_prepared(
2181 &mut self,
2182 prepared: PreparedObservation,
2183 journal: bool,
2184 ) -> crate::Result<ApplyOutcome> {
2185 self.commit_prepared_with(prepared, journal, None, None, None, false)
2186 }
2187
2188 fn commit_prepared_with(
2189 &mut self,
2190 prepared: PreparedObservation,
2191 journal: bool,
2192 discovery: Option<DiscoveryCommit>,
2193 observation: Option<ObservationTransition>,
2194 max_files: Option<u64>,
2195 track_file_progress: bool,
2196 ) -> crate::Result<ApplyOutcome> {
2197 if prepared.ops.is_empty()
2198 && discovery.as_ref().is_none_or(|discovery| {
2199 discovery.directory_complete.is_none() && discovery.transition.is_none()
2200 })
2201 && observation.is_none()
2202 {
2203 return Ok(ApplyOutcome::default());
2204 }
2205
2206 if discovery.is_some()
2212 && matches!(self.state.phase, LifecyclePhase::Stopped | LifecyclePhase::Failed)
2213 {
2214 return Err(crate::Error::OpenedIndexStopped);
2215 }
2216
2217 let mut discovery = discovery;
2218 if let Some(path) =
2219 discovery.as_mut().and_then(|discovery| discovery.directory_complete.as_mut())
2220 {
2221 let canonical = canonical_relative_path(path)?;
2225 let Some(id) = self.lookup(&canonical) else {
2226 return Err(crate::Error::InvalidDirectoryCompletion(canonical));
2227 };
2228 if self.entry(id).kind != EntryKind::Dir {
2229 return Err(crate::Error::InvalidDirectoryCompletion(canonical));
2230 }
2231 *path = canonical;
2232 }
2233
2234 #[cfg(test)]
2235 if prepared.reject_before_apply {
2236 return Err(crate::Error::CommitRejected("injected reducer preflight"));
2237 }
2238
2239 let Some(next_clock) = self.clock.checked_next() else {
2240 let mut probe = self.clone();
2244 probe.clock = Clock(self.clock.0 - 1);
2245 let outcome = probe.commit_prepared_with(
2246 prepared,
2247 false,
2248 discovery,
2249 observation,
2250 max_files,
2251 track_file_progress,
2252 )?;
2253 return if outcome.commit.is_some() {
2254 Err(crate::Error::ClockExhausted)
2255 } else {
2256 Ok(outcome)
2257 };
2258 };
2259
2260 let observed = u64::try_from(prepared.ops.len()).unwrap_or(u64::MAX);
2261 let mut effects = ExactConsequences::default();
2262 let stats = self.reduce_prepared(
2263 &prepared,
2264 discovery,
2265 observation,
2266 max_files,
2267 track_file_progress,
2268 &mut effects,
2269 )?;
2270
2271 if effects.is_empty() {
2272 return Ok(ApplyOutcome::from_commit(stats, None));
2273 }
2274
2275 let commit =
2276 self.publish_effects(next_clock, effects, commit_work(observed, stats), journal);
2277 Ok(ApplyOutcome::from_commit(stats, Some(commit)))
2278 }
2279
2280 fn reduce_prepared<C: ConsequenceSink>(
2285 &mut self,
2286 prepared: &PreparedObservation,
2287 discovery: Option<DiscoveryCommit>,
2288 observation: Option<ObservationTransition>,
2289 max_files: Option<u64>,
2290 track_file_progress: bool,
2291 effects: &mut C,
2292 ) -> crate::Result<ApplyStats> {
2293 if self.carries_unobserved_control_input(&prepared.ops) {
2294 return Err(crate::Error::ControlStateNotObserved);
2295 }
2296 if matches!(prepared.ancestry, PreparedAncestry::Scanner { .. }) {
2297 debug_assert!(observation.is_none());
2298 return self.reduce_scanner_prepared(
2299 prepared,
2300 discovery,
2301 max_files,
2302 track_file_progress,
2303 effects,
2304 );
2305 }
2306 let mut stats = ApplyStats::default();
2307 let mut parent_memo = ParentMemo::default();
2308 let accepted = self.accepted_operations(&prepared.ops);
2309 stats.stale = u64::try_from(accepted.iter().filter(|accepted| !**accepted).count())
2310 .unwrap_or(u64::MAX);
2311 self.validate_known_ancestry(&prepared.ops, &accepted)?;
2312 let projected_controls = self.projected_controls(&prepared.ops, &accepted)?;
2313
2314 for (observed, accepted) in prepared.ops.iter().zip(accepted) {
2315 if !accepted {
2316 continue;
2317 }
2318 let op = &observed.op;
2319 if let (Some(max_files), Op::Upsert { path, kind, .. }) = (max_files, op) {
2320 if self.files_after_upsert(path, *kind) > max_files {
2321 stats.resource_refused = stats.resource_refused.saturating_add(1);
2322 continue;
2323 }
2324 }
2325 match op {
2326 Op::Upsert { path, kind, attrs } => {
2327 self.apply_upsert(path, *kind, *attrs, &mut stats, effects, &mut parent_memo);
2328 }
2329 Op::Remove { path } => {
2330 parent_memo.clear();
2336 self.apply_remove(path, &mut stats, effects);
2337 }
2338 Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {
2339 parent_memo.clear();
2343 }
2344 Op::InvalidateSubtree { path, reason } => {
2345 parent_memo.clear();
2346 let previous_index_state = self.state;
2347 let previous = self.freshness_at(path);
2348 self.pending_invalidations.push((path.clone(), *reason));
2349 self.mark_unfresh(path, Freshness::Stale);
2350 let current = self.freshness_at(path);
2351 self.state.freshness = self.published_freshness();
2352 if matches!(
2353 reason,
2354 InvalidateReason::WatchOverflow
2355 | InvalidateReason::UnpairedRename
2356 | InvalidateReason::WatchSetupRace
2357 | InvalidateReason::VerificationFailed
2358 | InvalidateReason::UnknownAncestry
2359 | InvalidateReason::WatchContention
2360 ) {
2361 self.retain_issue(Issue::observation_gap(path, *reason));
2362 }
2363 stats.invalidated += 1;
2364 effects.change(|| EffectiveChange::Invalidated {
2365 path: path.clone(),
2366 reason: *reason,
2367 });
2368 if previous != current {
2369 effects.state(|| StateTransition::Freshness {
2370 path: path.clone(),
2371 previous,
2372 current,
2373 });
2374 }
2375 if previous_index_state != self.state {
2376 effects.state(|| StateTransition::IndexState {
2377 previous: previous_index_state,
2378 current: self.state,
2379 });
2380 }
2381 }
2382 }
2383 }
2384
2385 self.finish_reduction(
2386 projected_controls,
2387 &mut stats,
2388 discovery,
2389 observation,
2390 max_files,
2391 track_file_progress,
2392 effects,
2393 );
2394
2395 Ok(stats)
2396 }
2397
2398 fn reduce_scanner_prepared<C: ConsequenceSink>(
2400 &mut self,
2401 prepared: &PreparedObservation,
2402 discovery: Option<DiscoveryCommit>,
2403 max_files: Option<u64>,
2404 track_file_progress: bool,
2405 effects: &mut C,
2406 ) -> crate::Result<ApplyStats> {
2407 let PreparedAncestry::Scanner { parents, has_batch_parents } = &prepared.ancestry else {
2408 unreachable!("scanner reduction requires scanner ancestry");
2409 };
2410 debug_assert_eq!(prepared.ops.len(), parents.len());
2411 let projection_started = crate::counters::enabled().then(std::time::Instant::now);
2412 let projected_controls =
2413 self.projected_controls_from(prepared.ops.iter().map(|observed| &observed.op))?;
2414 if let Some(started) = projection_started {
2415 let elapsed = elapsed_micros(started);
2416 crate::counters::bump(|counts| {
2417 counts.scanner_control_projection_us =
2418 counts.scanner_control_projection_us.saturating_add(elapsed);
2419 });
2420 }
2421 let mut stats = ApplyStats::default();
2422 let mut applied_ids = has_batch_parents.then(|| vec![None; prepared.ops.len()]);
2423
2424 for (op_index, (observed, parent)) in prepared.ops.iter().zip(parents.iter()).enumerate() {
2425 match &observed.op {
2426 Op::Upsert { path, kind, attrs } => {
2427 if let Some(max_files) = max_files {
2428 if self.files_after_upsert(path, *kind) > max_files {
2429 stats.resource_refused = stats.resource_refused.saturating_add(1);
2430 continue;
2431 }
2432 }
2433 let parent = match parent {
2434 ResolvedParent::Existing(parent) => *parent,
2435 ResolvedParent::Earlier(parent_op) => applied_ids
2436 .as_ref()
2437 .and_then(|ids| ids.get(*parent_op))
2438 .copied()
2439 .flatten()
2440 .expect("a proved parent directory was applied earlier"),
2441 };
2442 let name = path.file_name().expect("scanner upserts are not root mutations");
2443 crate::counters::bump(|counts| counts.upserts += 1);
2444 self.upsert_beneath(parent, name, path, *kind, *attrs, &mut stats, effects);
2445 if kind.is_dir() {
2446 if let Some(ids) = &mut applied_ids {
2447 ids[op_index] = self.child(parent, name);
2448 }
2449 }
2450 }
2451 Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {}
2452 Op::Remove { .. } | Op::InvalidateSubtree { .. } => {
2453 unreachable!("scanner preparation rejects non-discovery operations");
2454 }
2455 }
2456 }
2457
2458 self.finish_reduction(
2459 projected_controls,
2460 &mut stats,
2461 discovery,
2462 None,
2463 max_files,
2464 track_file_progress,
2465 effects,
2466 );
2467
2468 Ok(stats)
2469 }
2470
2471 #[allow(clippy::too_many_arguments)] fn finish_reduction<C: ConsequenceSink>(
2473 &mut self,
2474 projected_controls: Option<crate::control::ControlTable>,
2475 stats: &mut ApplyStats,
2476 discovery: Option<DiscoveryCommit>,
2477 observation: Option<ObservationTransition>,
2478 max_files: Option<u64>,
2479 track_file_progress: bool,
2480 effects: &mut C,
2481 ) {
2482 self.apply_control_transition(projected_controls, stats, effects);
2483 let mut discovery = discovery;
2484 if stats.resource_refused > 0 {
2485 let max_files = max_files.expect("resource refusal requires a file limit");
2486 let discovery = discovery.get_or_insert_with(DiscoveryCommit::default);
2487 discovery.directory_complete = None;
2488 discovery.transition =
2489 Some(DiscoveryTransition::BudgetRefused(Issue::resource_budget(max_files)));
2490 }
2491 self.apply_opened_state(discovery, observation, track_file_progress, effects);
2492 }
2493
2494 fn apply_opened_state<C: ConsequenceSink>(
2495 &mut self,
2496 discovery: Option<DiscoveryCommit>,
2497 observation: Option<ObservationTransition>,
2498 track_file_progress: bool,
2499 effects: &mut C,
2500 ) {
2501 if discovery.is_none() && observation.is_none() && !track_file_progress {
2502 return;
2503 }
2504 let previous = self.state;
2505 if track_file_progress {
2506 self.state.progress.files_retained = self.total_scalars().files;
2507 }
2508
2509 if let Some(discovery) = discovery {
2510 if let Some(path) = discovery.directory_complete {
2511 let id = self.lookup(&path).expect("discovery completion was preflighted");
2512 if !self.entry(id).directory().children_complete {
2513 self.entry_mut(id).directory_mut().children_complete = true;
2514 self.state.progress.directories_complete =
2515 self.state.progress.directories_complete.saturating_add(1);
2516 effects.state(|| StateTransition::DirectoryComplete { path });
2517 }
2518 }
2519
2520 if let Some(transition) = discovery.transition {
2521 match transition {
2522 DiscoveryTransition::Begin => {
2523 for slot in &mut self.arena {
2524 if let Slot::Occupied { entry, .. } = slot {
2525 if entry.kind == EntryKind::Dir {
2526 entry.directory_mut().children_complete = false;
2527 }
2528 }
2529 }
2530 self.state = IndexState {
2531 phase: LifecyclePhase::Discovering,
2532 coverage: Coverage::Partial(CoverageReason::Building),
2533 freshness: Freshness::Fresh,
2534 source: Source::Scanned,
2535 progress: DiscoveryProgress::default(),
2536 issues: crate::IssueSummary::default(),
2537 };
2538 self.issues.clear();
2539 self.issue_epochs.clear();
2540 self.omitted_issue_epochs.clear();
2541 }
2542 DiscoveryTransition::Finish => {
2543 self.state.phase = LifecyclePhase::Ready;
2544 if self.state.coverage == Coverage::Partial(CoverageReason::Building) {
2545 self.state.coverage = Coverage::Complete;
2546 }
2547 self.state.freshness = if self.state.coverage == Coverage::Complete {
2548 Freshness::Fresh
2549 } else {
2550 Freshness::Partial
2551 };
2552 }
2553 DiscoveryTransition::BudgetRefused(issue) => {
2554 let already_stopped_for_budget = self.state.phase
2555 == LifecyclePhase::Stopped
2556 && self.state.coverage == Coverage::Partial(CoverageReason::Budget);
2557 self.state.phase = LifecyclePhase::Stopped;
2558 self.state.coverage = Coverage::Partial(CoverageReason::Budget);
2559 self.state.freshness = Freshness::Fresh;
2560 if !already_stopped_for_budget {
2561 self.retain_issue(issue);
2562 }
2563 }
2564 DiscoveryTransition::Inaccessible { issues, omitted } => {
2565 if self.state.coverage != Coverage::Partial(CoverageReason::Budget) {
2566 self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
2567 self.state.freshness = Freshness::Partial;
2568 }
2569 for issue in issues {
2570 self.retain_issue(issue);
2571 }
2572 self.retain_omitted(omitted);
2573 }
2574 DiscoveryTransition::Cancelled => {
2575 self.state.phase = LifecyclePhase::Stopped;
2576 if self.state.coverage != Coverage::Complete {
2577 self.state.coverage = Coverage::Partial(CoverageReason::Cancelled);
2578 }
2579 }
2580 DiscoveryTransition::Failed(issue) => {
2581 self.state.phase = LifecyclePhase::Failed;
2582 self.state.coverage = Coverage::Partial(CoverageReason::Failed);
2583 self.state.freshness = Freshness::Partial;
2584 self.retain_issue(issue);
2585 }
2586 }
2587 }
2588 }
2589
2590 if let Some(observation) = observation {
2591 match observation {
2592 ObservationTransition::Reconciling => {
2593 if self.state.phase == LifecyclePhase::Ready {
2594 self.state.phase = LifecyclePhase::Reconciling;
2595 self.state.freshness = Freshness::Reconciling;
2596 }
2597 }
2598 ObservationTransition::Watching { issues, omitted } => {
2599 if self.state.phase == LifecyclePhase::Reconciling {
2600 self.state.phase = LifecyclePhase::Watching;
2601 if !issues.is_empty() || omitted > 0 {
2602 self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
2603 for issue in issues {
2604 self.retain_issue(issue);
2605 }
2606 self.retain_omitted(omitted);
2607 } else if self.state.coverage
2608 == Coverage::Partial(CoverageReason::Inaccessible)
2609 {
2610 self.state.coverage = Coverage::Complete;
2616 }
2617 self.state.freshness = self.freshness();
2618 if self.state.coverage != Coverage::Complete {
2619 self.state.freshness = Freshness::Partial;
2620 }
2621 }
2622 }
2623 ObservationTransition::Unreadable { issues, omitted } => {
2624 if matches!(self.state.phase, LifecyclePhase::Watching | LifecyclePhase::Ready)
2627 {
2628 for issue in issues {
2629 self.retain_issue(issue);
2630 }
2631 self.retain_omitted(omitted);
2632 }
2633 }
2634 ObservationTransition::Failed(issue) => {
2635 if self.state.phase != LifecyclePhase::Stopped {
2636 self.state.phase = LifecyclePhase::Failed;
2637 self.state.freshness = Freshness::Partial;
2638 self.retain_issue(issue);
2639 }
2640 }
2641 }
2642 }
2643
2644 if previous != self.state {
2645 effects.state(|| StateTransition::IndexState { previous, current: self.state });
2646 }
2647 }
2648
2649 fn files_after_upsert(&self, path: &Path, kind: EntryKind) -> u64 {
2652 let current_total = self.total_scalars().files;
2653 let Some(id) = self.lookup(path) else {
2654 return current_total.saturating_add(u64::from(kind == EntryKind::File));
2655 };
2656 let current = self.entry(id);
2657 if current.kind == kind {
2658 return current_total;
2659 }
2660 let removed = match current.kind {
2661 EntryKind::File => 1,
2662 EntryKind::Dir => current.rollup().all.files,
2663 _ => 0,
2664 };
2665 current_total.saturating_sub(removed).saturating_add(u64::from(kind == EntryKind::File))
2666 }
2667
2668 fn retain_issue(&mut self, issue: Issue) {
2679 self.retain_issue_at(issue, self.freshness_epoch);
2680 }
2681
2682 fn retain_issue_at(&mut self, issue: Issue, epoch: u64) {
2683 let repeat = self.issues.iter().position(|retained| same_issue_cause(retained, &issue));
2684 if let Some(position) = repeat {
2685 self.issue_epochs[position] = epoch;
2686 } else {
2687 let position = self
2688 .issues
2689 .binary_search_by(|retained| compare_issues(retained, &issue))
2690 .unwrap_or_else(|position| position);
2691 if position < MAX_RETAINED_ISSUES {
2692 self.issues.insert(position, issue);
2693 self.issue_epochs.insert(position, epoch);
2694 if self.issues.len() > MAX_RETAINED_ISSUES {
2695 self.issues.pop();
2696 let omitted_epoch =
2697 self.issue_epochs.pop().expect("issue epochs stay parallel");
2698 self.retain_omitted_at(1, omitted_epoch);
2699 }
2700 } else {
2701 self.retain_omitted_at(1, epoch);
2702 }
2703 self.state.issues.retained = u64::try_from(self.issues.len()).unwrap_or(u64::MAX);
2704 }
2705 }
2706
2707 fn retain_omitted(&mut self, count: u64) {
2708 if count == 0 {
2709 return;
2710 }
2711 let epoch =
2712 self.active_reconciles.keys().next_back().copied().unwrap_or(self.freshness_epoch);
2713 self.retain_omitted_at(count, epoch);
2714 }
2715
2716 fn retain_omitted_at(&mut self, count: u64, epoch: u64) {
2717 let retained = self.omitted_issue_epochs.entry(epoch).or_default();
2718 *retained = retained.saturating_add(count);
2719 self.state.issues.omitted = self.state.issues.omitted.saturating_add(count);
2720 }
2721
2722 fn drop_disproven_omitted(&mut self, started_at: u64) {
2723 self.omitted_issue_epochs.retain(|epoch, _| *epoch >= started_at);
2724 self.state.issues.omitted =
2725 self.omitted_issue_epochs.values().fold(0_u64, |sum, count| sum.saturating_add(*count));
2726 }
2727
2728 fn compact_omitted_epochs(&mut self) {
2729 let mut compact = BTreeMap::new();
2730 for (epoch, count) in std::mem::take(&mut self.omitted_issue_epochs) {
2731 let owner =
2732 self.active_reconciles.range(..=epoch).next_back().map_or(0, |(epoch, _)| *epoch);
2733 let retained = compact.entry(owner).or_insert(0_u64);
2734 *retained = retained.saturating_add(count);
2735 }
2736 self.omitted_issue_epochs = compact;
2737 }
2738
2739 fn drop_disproven_issues(&mut self, path: &Path, started_at: u64) {
2752 let mut position = 0;
2753 while position < self.issues.len() {
2754 let issue = &self.issues[position];
2755 let disproven = issue.kind != crate::IssueKind::ObservationGap
2756 && issue.path.as_deref().is_some_and(|issue_path| issue_path.starts_with(path))
2757 && self.issue_epochs[position] < started_at;
2758 if disproven {
2759 self.issues.remove(position);
2760 self.issue_epochs.remove(position);
2761 } else {
2762 position += 1;
2763 }
2764 }
2765 self.state.issues.retained = u64::try_from(self.issues.len()).unwrap_or(u64::MAX);
2766 }
2767
2768 fn publish_effects(
2773 &mut self,
2774 next_clock: Clock,
2775 effects: ExactConsequences,
2776 work: Work,
2777 journal: bool,
2778 ) -> Commit {
2779 debug_assert!(!effects.is_empty());
2780 if crate::counters::enabled() {
2781 let effect_paths = u64::try_from(effects.changes.len()).unwrap_or(u64::MAX);
2782 let effect_path_bytes = effects.changes.iter().fold(0_u64, |total, change| {
2783 total.saturating_add(
2784 u64::try_from(change.path().as_os_str().as_encoded_bytes().len())
2785 .unwrap_or(u64::MAX),
2786 )
2787 });
2788 crate::counters::bump(|counts| {
2789 counts.effect_paths = counts.effect_paths.saturating_add(effect_paths);
2790 counts.effect_path_bytes =
2791 counts.effect_path_bytes.saturating_add(effect_path_bytes);
2792 });
2793 }
2794 let commit = Commit {
2795 clock: next_clock,
2796 impact: derive_impact(&effects.changes, &effects.state),
2797 changes: effects.changes,
2798 state: effects.state,
2799 work,
2800 };
2801 self.clock = next_clock;
2802 if journal {
2803 self.retain_commit(&commit);
2804 }
2805 commit
2806 }
2807
2808 fn retain_commit(&mut self, commit: &Commit) {
2809 let cost = commit.retained_cost();
2810 if cost > self.journal_capacity_bytes {
2811 let dropped = u64::try_from(self.journal.len()).unwrap_or(u64::MAX);
2812 crate::counters::bump(|counts| {
2813 counts.journal_oversized_commits =
2814 counts.journal_oversized_commits.saturating_add(1);
2815 counts.journal_dropped_commits =
2816 counts.journal_dropped_commits.saturating_add(dropped);
2817 });
2818 self.journal.clear();
2819 self.journal_cost = 0;
2820 self.journal_floor = commit.clock;
2821 return;
2822 }
2823
2824 while self.journal_cost + cost > self.journal_capacity_bytes {
2825 if let Some(dropped) = self.journal.pop_front() {
2826 crate::counters::bump(|counts| {
2827 counts.journal_dropped_commits =
2828 counts.journal_dropped_commits.saturating_add(1);
2829 });
2830 self.journal_cost -= dropped.retained_cost();
2831 self.journal_floor = dropped.clock;
2832 }
2833 }
2834 self.journal_cost += cost;
2835 self.journal.push_back(commit.clone());
2836 crate::counters::bump(|counts| {
2837 counts.journal_cloned_commits = counts.journal_cloned_commits.saturating_add(1);
2838 counts.journal_retained_commits = counts.journal_retained_commits.saturating_add(1);
2839 });
2840 }
2841
2842 pub(crate) fn apply_baseline(
2844 &mut self,
2845 observation: &Observation,
2846 ) -> crate::Result<ApplyStats> {
2847 let prepared = prepare_observation(observation)?;
2848 #[cfg(test)]
2849 if prepared.reject_before_apply {
2850 return Err(crate::Error::CommitRejected("injected reducer preflight"));
2851 }
2852 let mut effects = NoConsequences;
2853 let stats = self.reduce_prepared(&prepared, None, None, None, false, &mut effects)?;
2854 record_batch(BatchProvenance::Baseline, observation.len(), stats);
2855 self.establish_baseline();
2856 Ok(stats)
2857 }
2858
2859 pub(crate) fn apply_scanner_baseline(
2861 &mut self,
2862 batch: crate::scan::ScannerBatch,
2863 ) -> crate::Result<ApplyStats> {
2864 let observed = batch.len();
2865 let prepare_started = crate::counters::enabled().then(std::time::Instant::now);
2866 let prepared = self.prepare_scanner_batch(batch)?;
2867 if let Some(started) = prepare_started {
2868 let elapsed = elapsed_micros(started);
2869 crate::counters::bump(|counts| {
2870 counts.scanner_prepare_us = counts.scanner_prepare_us.saturating_add(elapsed);
2871 });
2872 }
2873 let mut effects = NoConsequences;
2874 let reduce_started = crate::counters::enabled().then(std::time::Instant::now);
2875 let stats = self.reduce_prepared(&prepared, None, None, None, false, &mut effects)?;
2877 if let Some(started) = reduce_started {
2878 let elapsed = elapsed_micros(started);
2879 crate::counters::bump(|counts| {
2880 counts.scanner_reduce_us = counts.scanner_reduce_us.saturating_add(elapsed);
2881 });
2882 }
2883 record_batch(BatchProvenance::Baseline, observed, stats);
2884 self.establish_baseline();
2885 Ok(stats)
2886 }
2887
2888 #[cfg(test)]
2889 pub(crate) fn apply_ok(&mut self, observation: &Observation) -> ApplyOutcome {
2890 self.apply(observation).expect("test observation must be valid")
2891 }
2892
2893 #[cfg(test)]
2894 pub(crate) fn apply_baseline_ok(&mut self, observation: &Observation) -> ApplyStats {
2895 self.apply_baseline(observation).expect("test baseline must be valid")
2896 }
2897
2898 pub(crate) fn establish_baseline(&mut self) {
2900 self.clock = Clock::ZERO;
2901 self.journal.clear();
2902 self.journal_cost = 0;
2903 self.journal_floor = Clock::ZERO;
2904 self.pending_invalidations.clear();
2905 }
2906
2907 pub(crate) fn mark_unverified(&mut self) {
2913 self.freshness_marks.clear();
2914 self.mark_unfresh(Path::new(""), Freshness::Stale);
2915 self.state.source = Source::Cached;
2916 self.state.freshness = Freshness::Stale;
2917 }
2918
2919 pub(crate) fn set_initial_freshness(&mut self, complete: bool) {
2920 self.freshness_marks.clear();
2921 if complete {
2922 for slot in &mut self.arena {
2923 if let Slot::Occupied { entry, .. } = slot {
2924 if entry.kind == EntryKind::Dir {
2925 entry.directory_mut().children_complete = true;
2926 }
2927 }
2928 }
2929 self.state.phase = LifecyclePhase::Ready;
2930 self.state.coverage = Coverage::Complete;
2931 self.state.freshness = Freshness::Fresh;
2932 } else {
2933 self.mark_unfresh(Path::new(""), Freshness::Partial);
2934 self.state.phase = LifecyclePhase::Ready;
2935 self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
2936 self.state.freshness = Freshness::Partial;
2937 }
2938 }
2939
2940 pub(crate) fn set_initial_scan_freshness(&mut self, errors: &[crate::Error]) {
2945 self.set_initial_freshness(errors.is_empty());
2946 if errors.is_empty() {
2947 return;
2948 }
2949 for slot in &mut self.arena {
2950 if let Slot::Occupied { entry, .. } = slot {
2951 if entry.kind.is_dir() {
2952 entry.directory_mut().children_complete = false;
2953 }
2954 }
2955 }
2956 let mut failed = Vec::with_capacity(errors.len());
2957 for error in errors {
2958 let Some(path) = Issue::from_error_under(&self.root_path, error).path else {
2959 return;
2960 };
2961 if path.is_absolute() || path.as_os_str().is_empty() {
2962 return;
2963 }
2964 failed.push(path);
2965 }
2966 failed.sort();
2967 failed.dedup();
2968 let listings: Vec<_> =
2969 failed.iter().map(|path| self.failed_listing_boundary(path)).collect();
2970 self.freshness_marks.clear();
2971 for path in &failed {
2972 self.mark_unfresh(path, Freshness::Partial);
2973 }
2974 for slot in 0..self.arena.len() {
2978 let Slot::Occupied { generation, entry } = &self.arena[slot] else {
2979 continue;
2980 };
2981 if !entry.kind.is_dir() {
2982 continue;
2983 }
2984 let id = EntryId {
2985 slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
2986 generation: *generation,
2987 };
2988 let Some(path) = self.path_of(id) else {
2989 continue;
2990 };
2991 self.entry_mut(id).directory_mut().children_complete =
2992 !listings.iter().zip(&failed).any(|(boundary, failure)| {
2993 path == *boundary || (boundary == failure && path.starts_with(boundary))
2994 });
2995 }
2996 }
2997
2998 fn failed_listing_boundary(&self, failed: &Path) -> PathBuf {
3002 let mut boundary = failed.to_path_buf();
3003 loop {
3004 if self.lookup(&boundary).is_some_and(|id| self.entry(id).kind.is_dir()) {
3005 return boundary;
3006 }
3007 if !boundary.pop() {
3008 return PathBuf::new();
3009 }
3010 }
3011 }
3012
3013 pub(crate) fn record_walk_errors(&mut self, errors: &mut Vec<crate::Error>) {
3014 self.issues.clear();
3015 self.issue_epochs.clear();
3016 self.omitted_issue_epochs.clear();
3017 self.state.issues = crate::IssueSummary::default();
3018 let root = self.root_path.clone();
3019 crate::scan::normalize_walk_errors(&root, errors);
3020 self.unreadable_control_paths.clear();
3021 for error in errors {
3022 if let crate::Error::Io { .. } = error {
3023 if let Some(path) = Issue::from_error_under(&root, error).path {
3024 if crate::control::is_control_file(&path) {
3025 self.unreadable_control_paths.insert(path);
3026 }
3027 }
3028 }
3029 self.retain_issue(Issue::from_error_under(&root, error));
3030 }
3031 }
3032
3033 pub(crate) fn begin_reconcile(&mut self, path: &Path) -> crate::Result<(u64, Option<Commit>)> {
3034 let path = canonical_relative_path(path)?;
3035 let next_clock = self.clock.checked_next().ok_or(crate::Error::ClockExhausted)?;
3036 let previous_index_state = self.state;
3037 let previous = self.freshness_at(&path);
3038 let epoch = self.mark_unfresh(&path, Freshness::Reconciling);
3039 self.active_reconciles.insert(
3040 epoch,
3041 ActiveReconcile {
3042 path: path.clone(),
3043 scope_budget: usize::try_from(self.len()).unwrap_or(usize::MAX),
3044 evidence: ReconcileEvidence::Scopes(BTreeSet::new()),
3045 },
3046 );
3047 if path.as_os_str().is_empty() {
3048 self.active_root_reconciles.insert(epoch, Self::now_unix_nanos());
3049 }
3050 let current = self.freshness_at(&path);
3051 self.state.freshness = self.published_freshness();
3052 let commit = if previous == current && previous_index_state == self.state {
3053 None
3054 } else {
3055 let mut state = Vec::new();
3056 if previous != current {
3057 state.push(StateTransition::Freshness { path, previous, current });
3058 }
3059 if previous_index_state != self.state {
3060 state.push(StateTransition::IndexState {
3061 previous: previous_index_state,
3062 current: self.state,
3063 });
3064 }
3065 let effects = ExactConsequences { state, ..ExactConsequences::default() };
3066 Some(self.publish_effects(next_clock, effects, Work::default(), true))
3067 };
3068 Ok((epoch, commit))
3069 }
3070
3071 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 if errors.disproves_old {
3176 self.unreadable_control_paths
3177 .retain(|control| !control.starts_with(&path) || !still_owned(control));
3178 }
3179 for error in errors.errors.iter().chain(errors.terminal) {
3180 if let crate::Error::Io { .. } = error {
3181 if let Some(control) = Issue::from_error_under(&self.root_path, error).path {
3182 if control.starts_with(&path)
3183 && still_owned(&control)
3184 && crate::control::is_control_file(&control)
3185 {
3186 self.unreadable_control_paths.insert(control);
3187 }
3188 }
3189 }
3190 }
3191 let recordable: Vec<&PathBuf> = listed_incomplete
3198 .iter()
3199 .filter(|directory| {
3200 directory.starts_with(&path)
3201 && still_owned(directory)
3202 && !self.freshness_marks.iter().any(|(marked, mark)| {
3203 mark.epoch > started_at && directory.starts_with(marked)
3204 })
3205 })
3206 .collect();
3207 let scoped_failures: Vec<&PathBuf> = failed_paths
3208 .iter()
3209 .filter(|failed| failed.starts_with(&path) && still_owned(failed))
3210 .collect();
3211 let complete = complete
3215 || (errors.disproves_old
3216 && failed_paths.iter().any(|failed| failed.starts_with(&path))
3217 && scoped_failures.is_empty()
3218 && errors.errors.iter().chain(errors.terminal).all(|error| {
3219 Issue::from_error_under(&self.root_path, error)
3220 .path
3221 .is_some_and(|failed| !failed.starts_with(&path) || !still_owned(&failed))
3222 }));
3223 if errors.disproves_old && self.state.phase != LifecyclePhase::Failed {
3224 self.drop_disproven_issues(&path, started_at);
3225 }
3226 if errors.disproves_old
3227 && self.state.phase != LifecyclePhase::Failed
3228 && path.as_os_str().is_empty()
3229 {
3230 self.drop_disproven_omitted(started_at);
3231 }
3232 let mut state = Vec::new();
3233 if !complete && (!errors.errors.is_empty() || errors.terminal.is_some()) {
3238 let boundaries: Vec<_> = if scoped_failures.is_empty() {
3239 vec![(path.clone(), true)]
3240 } else {
3241 scoped_failures
3242 .iter()
3243 .map(|failed| {
3244 let boundary = self.failed_listing_boundary(failed);
3245 let subtree = boundary == **failed;
3246 (boundary, subtree)
3247 })
3248 .collect()
3249 };
3250 for slot in 0..self.arena.len() {
3251 let Slot::Occupied { generation, entry } = &self.arena[slot] else {
3252 continue;
3253 };
3254 if !entry.kind.is_dir() || !entry.directory().children_complete {
3255 continue;
3256 }
3257 let id = EntryId {
3258 slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
3259 generation: *generation,
3260 };
3261 let Some(directory) = self.path_of(id) else {
3262 continue;
3263 };
3264 if boundaries.iter().any(|(boundary, subtree)| {
3265 directory == *boundary || (*subtree && directory.starts_with(boundary))
3266 }) && still_owned(&directory)
3267 && !self.freshness_marks.iter().any(|(marked, mark)| {
3268 mark.epoch > started_at && directory.starts_with(marked)
3269 })
3270 {
3271 self.entry_mut(id).directory_mut().children_complete = false;
3272 state.push(StateTransition::DirectoryIncomplete { path: directory });
3273 }
3274 }
3275 }
3276 let verified_scope = superseded.is_empty()
3279 || (complete
3280 && superseded.iter().all(|newer| self.freshness_at(newer) == Freshness::Fresh));
3281 if verified_scope && (complete || !scoped_failures.is_empty()) {
3282 let now = Self::now_unix_nanos();
3286 self.verified.retain(|(verified_path, _)| !verified_path.starts_with(&path));
3287 self.verified.push((path.clone(), now));
3288 if self.verified.len() > MAX_VERIFIED_INTERVALS {
3289 let excess = self.verified.len() - MAX_VERIFIED_INTERVALS;
3290 self.verified.sort_by_key(|(_, at)| *at);
3291 self.verified.drain(..excess);
3292 }
3293 state.push(StateTransition::Verified { path: path.clone() });
3294 }
3295 if complete {
3296 if path.as_os_str().is_empty() {
3297 if let Some(started) = self.active_root_reconciles.remove(&started_at) {
3298 self.writing_pass_started_at_ns = started;
3299 }
3300 self.state.source = self.applying_source;
3301 }
3302 } else {
3303 if scoped_failures.is_empty() {
3304 self.mark_unfresh(&path, Freshness::Partial);
3305 } else {
3306 for failed in scoped_failures {
3307 self.mark_unfresh(failed, Freshness::Partial);
3308 }
3309 }
3310 if !errors.errors.is_empty() || errors.terminal.is_some() {
3311 self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
3312 }
3313 if path.as_os_str().is_empty() {
3314 if let Some(started) = self.active_root_reconciles.remove(&started_at) {
3315 self.writing_pass_started_at_ns = started;
3316 }
3317 self.state.source = self.applying_source;
3318 }
3319 }
3320 for error in errors.errors.iter().chain(errors.terminal) {
3326 let issue = Issue::from_error_under(&self.root_path, error);
3327 if issue
3328 .path
3329 .as_deref()
3330 .is_none_or(|issue_path| issue_path.starts_with(&path) && still_owned(issue_path))
3331 {
3332 self.retain_issue(issue);
3333 }
3334 }
3335 for directory in recordable {
3336 let Some(id) = self.lookup(directory) else {
3337 continue;
3338 };
3339 let entry = self.entry_mut(id);
3340 if entry.kind != EntryKind::Dir || entry.directory().children_complete {
3341 continue;
3342 }
3343 entry.directory_mut().children_complete = true;
3344 self.state.progress.directories_complete =
3345 self.state.progress.directories_complete.saturating_add(1);
3346 state.push(StateTransition::DirectoryComplete { path: directory.clone() });
3347 }
3348
3349 if complete
3350 && self.state.coverage == Coverage::Partial(CoverageReason::Inaccessible)
3351 && !self.issues.iter().any(|issue| issue.kind != crate::IssueKind::ObservationGap)
3352 && self.state.issues.omitted == 0
3353 && self.arena.iter().all(|slot| {
3354 let Slot::Occupied { entry, .. } = slot else {
3355 return true;
3356 };
3357 entry.kind != EntryKind::Dir || entry.directory().children_complete
3358 })
3359 {
3360 self.state.coverage = Coverage::Complete;
3361 }
3362
3363 let current = self.freshness_at(&path);
3364 self.state.freshness = self.published_freshness();
3365 if self.state.coverage == Coverage::Partial(CoverageReason::Inaccessible) {
3366 self.state.freshness = Freshness::Partial;
3367 }
3368 self.active_reconciles.remove(&started_at);
3369 self.compact_omitted_epochs();
3370 if previous != current {
3371 state.push(StateTransition::Freshness { path: path.clone(), previous, current });
3372 }
3373 if previous_index_state != self.state {
3374 state.push(StateTransition::IndexState {
3375 previous: previous_index_state,
3376 current: self.state,
3377 });
3378 }
3379 if state.is_empty() {
3380 return Ok(ReconcileFinish { commit: None, retry: false });
3381 }
3382 let effects = ExactConsequences { state, ..ExactConsequences::default() };
3383 Ok(ReconcileFinish {
3384 commit: Some(self.publish_effects(next_clock, effects, Work::default(), true)),
3385 retry: false,
3386 })
3387 }
3388
3389 fn verified_at(&self, path: &Path) -> Option<i64> {
3391 self.verified
3392 .iter()
3393 .filter(|(covered, _)| path.starts_with(covered))
3394 .map(|(_, at)| *at)
3395 .max()
3396 }
3397
3398 fn mark_unfresh(&mut self, path: &Path, state: Freshness) -> u64 {
3399 self.freshness_epoch =
3400 self.freshness_epoch.checked_add(1).expect("freshness epoch exhausted");
3401 let epoch = self.freshness_epoch;
3402 self.freshness_marks.insert(path.to_path_buf(), FreshnessMark { state, epoch });
3403 epoch
3404 }
3405
3406 pub fn path_state(&self, path: &Path) -> PathState {
3411 let Some(id) = self.lookup(path) else {
3412 return PathState::Absent;
3413 };
3414 let entry = self.entry(id);
3415 PathState::Present { kind: entry.kind, attrs: entry.attrs }
3416 }
3417
3418 pub fn expectation(&self, path: &Path) -> PathExpectation {
3420 let entry = self.entry_identity(path);
3421 PathExpectation::new(
3422 self.path_state(path),
3423 entry,
3424 entry.is_none().then(|| self.absence_guard_identity(path)).flatten(),
3425 )
3426 }
3427
3428 pub(crate) fn relaxed_expectation(&self, path: &Path) -> PathExpectation {
3429 PathExpectation::new(self.path_state(path), self.entry_identity(path), None)
3430 }
3431
3432 pub fn since(&self, clock: Clock) -> Since {
3434 let commits: Vec<Commit> =
3435 self.journal.iter().filter(|commit| commit.clock > clock).cloned().collect();
3436 Since {
3437 commits,
3438 clock: self.clock,
3439 state: self.state,
3440 truncated: clock < self.journal_floor,
3441 }
3442 }
3443
3444 pub fn take_pending_invalidations(&mut self) -> Vec<(PathBuf, InvalidateReason)> {
3450 std::mem::take(&mut self.pending_invalidations)
3451 }
3452
3453 pub(crate) fn restore_pending_invalidations(
3455 &mut self,
3456 invalidations: Vec<(PathBuf, InvalidateReason)>,
3457 ) {
3458 self.pending_invalidations.extend(invalidations);
3459 }
3460
3461 pub fn lookup(&self, path: &Path) -> Option<EntryId> {
3463 let mut current = EntryId::ROOT;
3464 for part in normalize(path)? {
3465 current = self.child(current, part)?;
3466 }
3467 Some(current)
3468 }
3469
3470 pub fn rollup(&self, path: &Path) -> Option<RollUp> {
3473 let id = self.lookup(path)?;
3474 let entry = self.entry(id);
3475 entry.kind.is_dir().then(|| self.named_rollup(&entry.rollup().all))
3476 }
3477
3478 pub fn partition_rollup(&self, path: &Path) -> crate::Result<Option<PartitionRollUp>> {
3487 self.require_observed_controls()?;
3488 Ok(self
3489 .lookup(path)
3490 .map(|id| self.entry(id))
3491 .filter(|entry| entry.kind.is_dir())
3492 .map(|entry| self.named_partitions(entry.rollup())))
3493 }
3494
3495 pub fn partition_rollup_summary(
3504 &self,
3505 path: &Path,
3506 ) -> crate::Result<Option<PartitionRollUpSummary>> {
3507 self.require_observed_controls()?;
3508 Ok(self
3509 .lookup(path)
3510 .map(|id| self.entry(id))
3511 .filter(|entry| entry.kind.is_dir())
3512 .map(|entry| partition_summary(entry.rollup())))
3513 }
3514
3515 pub(crate) fn opened_is_ignored(&self, id: EntryId) -> bool {
3521 debug_assert!(self.observes_controls(), "an opened root observes control state");
3522 self.entry(id).ignored
3523 }
3524
3525 pub(crate) fn entry_value(&self, path: &Path) -> Option<crate::EntryValue> {
3527 let id = self.lookup(path)?;
3528 Some(self.entry_value_of(id, path))
3529 }
3530
3531 pub(crate) fn entry_value_of(&self, id: EntryId, path: &Path) -> crate::EntryValue {
3532 let entry = self.entry(id);
3533 crate::EntryValue {
3534 path: path.to_path_buf(),
3535 portable_path: crate::opened::read::portable_path(path),
3536 kind: entry.kind,
3537 attrs: entry.attrs,
3538 ignored: entry.ignored,
3539 classification: (entry.kind == EntryKind::File)
3540 .then(|| self.types.classify_name(path.file_name().unwrap_or_default())),
3541 rollup: entry.kind.is_dir().then(|| partition_summary(entry.rollup())),
3542 children_complete: entry.kind.is_dir().then(|| entry.directory().children_complete),
3543 }
3544 }
3545
3546 pub(crate) fn portable_children(&self, path: &Path) -> Option<&PortableChildren> {
3547 self.serving.as_ref()?.portable_children.get(path)
3548 }
3549
3550 pub(crate) fn portable_entries(&self) -> &BTreeMap<crate::PortablePath, EntryId> {
3551 &self.serving.as_ref().expect("opened-root reads require serving indexes").portable_entries
3552 }
3553
3554 #[cfg(test)]
3555 pub(crate) const fn serving_indexes_enabled(&self) -> bool {
3556 self.serving.is_some()
3557 }
3558
3559 fn insert_serving_entry(&mut self, path: &Path, kind: EntryKind, attrs: Attrs, id: EntryId) {
3560 if path.as_os_str().is_empty() || self.serving.is_none() {
3561 return;
3562 }
3563 let file = (kind == EntryKind::File).then(|| {
3564 (
3565 self.classify(path).file_type.as_str().to_string(),
3566 path.file_name(),
3567 self.entry(id).ignored,
3568 self.entry(id).parent,
3569 )
3570 });
3571 let arena = &self.arena;
3572 let Some(serving) = self.serving.as_mut() else {
3573 return;
3574 };
3575 if let Some((name, exact_name, ignored, parent)) = file {
3576 let semantic = serving.intern_semantic(&name);
3577 let mut ancestor = parent;
3578 while let Some(directory) = ancestor {
3579 let partition = serving.semantic_by_directory.entry(directory).or_default();
3580 merge_semantic(&mut partition.all, semantic, attrs);
3581 if !ignored {
3582 merge_semantic(&mut partition.unignored, semantic, attrs);
3583 }
3584 ancestor = retained_parent(arena, directory);
3585 }
3586 if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
3587 let mut ancestor = parent;
3588 while let Some(directory) = ancestor {
3589 let partition = serving.exact_name_by_directory.entry(directory).or_default();
3590 merge_semantic(&mut partition.all, exact_name, attrs);
3591 if !ignored {
3592 merge_semantic(&mut partition.unignored, exact_name, attrs);
3593 }
3594 ancestor = retained_parent(arena, directory);
3595 }
3596 }
3597 }
3598 let portable = crate::opened::read::portable_path(path);
3599 serving.portable_entries.insert(portable.clone(), id);
3600 if kind == EntryKind::File {
3601 serving.recent_files.insert(RecentKey {
3602 mtime_ns: attrs.mtime_ns,
3603 portable_path: portable,
3604 id,
3605 });
3606 }
3607 let parent = path.parent().unwrap_or_else(|| Path::new("")).to_path_buf();
3608 let Some(name) = path.file_name().map(crate::opened::read::portable_component) else {
3609 return;
3610 };
3611 let children = serving.portable_children.entry(parent).or_default();
3612 if kind.is_dir() {
3613 children.directories.insert(name, id);
3614 } else {
3615 children.nondirectories.insert(name, id);
3616 }
3617 }
3618
3619 fn remove_serving_entry(&mut self, path: &Path, kind: EntryKind, attrs: Attrs, id: EntryId) {
3620 if path.as_os_str().is_empty() {
3621 return;
3622 }
3623 let Some(serving) = self.serving.as_mut() else {
3624 return;
3625 };
3626 let portable = crate::opened::read::portable_path(path);
3627 serving.portable_entries.remove(&portable);
3628 if kind == EntryKind::File {
3629 serving.recent_files.remove(&RecentKey {
3630 mtime_ns: attrs.mtime_ns,
3631 portable_path: portable,
3632 id,
3633 });
3634 }
3635 let parent = path.parent().unwrap_or_else(|| Path::new("")).to_path_buf();
3636 let remove_parent = if let Some(children) = serving.portable_children.get_mut(&parent) {
3637 if let Some(name) = path.file_name().map(crate::opened::read::portable_component) {
3638 if kind.is_dir() {
3639 children.directories.remove(&name);
3640 } else {
3641 children.nondirectories.remove(&name);
3642 }
3643 }
3644 children.directories.is_empty() && children.nondirectories.is_empty()
3645 } else {
3646 false
3647 };
3648 if remove_parent {
3649 serving.portable_children.remove(&parent);
3650 }
3651 if kind.is_dir() {
3652 serving.portable_children.remove(path);
3653 }
3654 }
3655
3656 fn remove_serving_file_semantics(&mut self, path: &Path, id: EntryId, attrs: Attrs) {
3657 if self.serving.is_none() || self.entry(id).kind != EntryKind::File {
3661 return;
3662 }
3663 let name = self.classify(path).file_type.as_str().to_string();
3664 let exact_name = path.file_name();
3665 let ignored = self.entry(id).ignored;
3666 let parent = self.entry(id).parent;
3667 let arena = &self.arena;
3668 let serving = self.serving.as_mut().expect("checked above");
3669 let semantic = *serving
3670 .semantic_ids
3671 .get(&name)
3672 .expect("every served file has an interned semantic type");
3673 let mut empty = Vec::new();
3674 let mut ancestor = parent;
3675 while let Some(directory) = ancestor {
3676 let partition = serving
3677 .semantic_by_directory
3678 .get_mut(&directory)
3679 .expect("every served file contributes to every ancestor");
3680 unmerge_semantic(&mut partition.all, semantic, attrs);
3681 if !ignored {
3682 unmerge_semantic(&mut partition.unignored, semantic, attrs);
3683 }
3684 if partition.all.is_empty() && partition.unignored.is_empty() {
3685 empty.push(directory);
3686 }
3687 ancestor = retained_parent(arena, directory);
3688 }
3689 for ancestor in empty {
3690 serving.semantic_by_directory.remove(&ancestor);
3691 }
3692 serving.release_semantic(semantic, 1);
3693 if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
3694 let mut exact_empty = Vec::new();
3695 let mut ancestor = parent;
3696 while let Some(directory) = ancestor {
3697 let partition = serving
3698 .exact_name_by_directory
3699 .get_mut(&directory)
3700 .expect("every declared exact-name file contributes to every ancestor");
3701 unmerge_semantic(&mut partition.all, exact_name, attrs);
3702 if !ignored {
3703 unmerge_semantic(&mut partition.unignored, exact_name, attrs);
3704 }
3705 if partition.all.is_empty() && partition.unignored.is_empty() {
3706 exact_empty.push(directory);
3707 }
3708 ancestor = retained_parent(arena, directory);
3709 }
3710 for ancestor in exact_empty {
3711 serving.exact_name_by_directory.remove(&ancestor);
3712 }
3713 }
3714 }
3715
3716 fn remove_serving_subtree_semantics(&mut self, root: EntryId, path: &Path) {
3717 if self.serving.is_none() {
3718 return;
3719 }
3720 match self.entry(root).kind {
3721 EntryKind::File => {
3722 let attrs = self.entry(root).attrs;
3723 self.remove_serving_file_semantics(path, root, attrs);
3724 }
3725 EntryKind::Dir => {
3726 let parent = self.entry(root).parent;
3727 let mut stack = vec![root];
3728 let mut directories = Vec::new();
3729 while let Some(id) = stack.pop() {
3730 let entry = self.entry(id);
3731 if !entry.kind.is_dir() {
3732 continue;
3733 }
3734 directories.push(id);
3735 stack.extend(self.child_ids(id));
3736 }
3737 let arena = &self.arena;
3738 let serving = self.serving.as_mut().expect("checked above");
3739 let contribution =
3740 serving.semantic_by_directory.get(&root).cloned().unwrap_or_default();
3741 let exact_contribution =
3742 serving.exact_name_by_directory.get(&root).cloned().unwrap_or_default();
3743 let mut empty = Vec::new();
3744 if !contribution.all.is_empty() || !contribution.unignored.is_empty() {
3745 let mut ancestor = parent;
3746 while let Some(directory) = ancestor {
3747 let partition = serving
3748 .semantic_by_directory
3749 .get_mut(&directory)
3750 .expect("a semantic subtree contributes to every ancestor");
3751 unmerge_semantic_map(&mut partition.all, &contribution.all);
3752 unmerge_semantic_map(&mut partition.unignored, &contribution.unignored);
3753 if partition.all.is_empty() && partition.unignored.is_empty() {
3754 empty.push(directory);
3755 }
3756 ancestor = retained_parent(arena, directory);
3757 }
3758 }
3759 let mut exact_empty = Vec::new();
3760 if !exact_contribution.all.is_empty() || !exact_contribution.unignored.is_empty() {
3761 let mut ancestor = parent;
3762 while let Some(directory) = ancestor {
3763 let partition = serving
3764 .exact_name_by_directory
3765 .get_mut(&directory)
3766 .expect("an exact-name subtree contributes to every ancestor");
3767 unmerge_semantic_map(&mut partition.all, &exact_contribution.all);
3768 unmerge_semantic_map(
3769 &mut partition.unignored,
3770 &exact_contribution.unignored,
3771 );
3772 if partition.all.is_empty() && partition.unignored.is_empty() {
3773 exact_empty.push(directory);
3774 }
3775 ancestor = retained_parent(arena, directory);
3776 }
3777 }
3778 for ancestor in empty {
3779 serving.semantic_by_directory.remove(&ancestor);
3780 }
3781 for ancestor in exact_empty {
3782 serving.exact_name_by_directory.remove(&ancestor);
3783 }
3784 for directory in directories {
3785 serving.semantic_by_directory.remove(&directory);
3786 serving.exact_name_by_directory.remove(&directory);
3787 }
3788 for (semantic, tally) in contribution.all {
3789 serving.release_semantic(semantic, tally.files);
3790 }
3791 }
3792 EntryKind::Symlink | EntryKind::Other => {}
3793 }
3794 }
3795
3796 fn move_serving_file_partition(
3797 &mut self,
3798 path: &Path,
3799 id: EntryId,
3800 previous_ignored: bool,
3801 current_ignored: bool,
3802 ) {
3803 if previous_ignored == current_ignored
3804 || self.serving.is_none()
3805 || self.entry(id).kind != EntryKind::File
3806 {
3807 return;
3808 }
3809 let name = self.classify(path).file_type.as_str().to_string();
3810 let exact_name = path.file_name();
3811 let attrs = self.entry(id).attrs;
3812 let parent = self.entry(id).parent;
3813 let arena = &self.arena;
3814 let serving = self.serving.as_mut().expect("checked above");
3815 let semantic = *serving
3816 .semantic_ids
3817 .get(&name)
3818 .expect("every served file has an interned semantic type");
3819 let mut ancestor = parent;
3820 while let Some(directory) = ancestor {
3821 let partition = serving
3822 .semantic_by_directory
3823 .get_mut(&directory)
3824 .expect("every served file contributes to every ancestor");
3825 if current_ignored {
3826 unmerge_semantic(&mut partition.unignored, semantic, attrs);
3827 } else {
3828 merge_semantic(&mut partition.unignored, semantic, attrs);
3829 }
3830 ancestor = retained_parent(arena, directory);
3831 }
3832 if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
3833 let mut ancestor = parent;
3834 while let Some(directory) = ancestor {
3835 let partition = serving
3836 .exact_name_by_directory
3837 .get_mut(&directory)
3838 .expect("every declared exact-name file contributes to every ancestor");
3839 if current_ignored {
3840 unmerge_semantic(&mut partition.unignored, exact_name, attrs);
3841 } else {
3842 merge_semantic(&mut partition.unignored, exact_name, attrs);
3843 }
3844 ancestor = retained_parent(arena, directory);
3845 }
3846 }
3847 }
3848
3849 pub fn attrs(&self, path: &Path) -> Option<&Attrs> {
3851 Some(&self.entry(self.lookup(path)?).attrs)
3852 }
3853
3854 pub fn kind(&self, path: &Path) -> Option<EntryKind> {
3856 Some(self.entry(self.lookup(path)?).kind)
3857 }
3858
3859 pub fn is_ignored(&self, path: &Path) -> crate::Result<Option<bool>> {
3871 self.require_observed_controls()?;
3872 Ok(self.ignored_classification(path))
3873 }
3874
3875 pub fn ignored_classification(&self, path: &Path) -> Option<bool> {
3880 let id = self.lookup(path)?;
3881 self.ignored_classification_of(path, id)
3882 }
3883
3884 pub(crate) fn ignored_classification_of(&self, path: &Path, id: EntryId) -> Option<bool> {
3886 if !self.observes_controls() || !self.control_classification_known(path) {
3887 return None;
3888 }
3889 self.try_entry(id).map(|entry| entry.ignored)
3890 }
3891
3892 pub(crate) fn control_classification_known(&self, path: &Path) -> bool {
3893 self.controls.classification_known(path)
3894 && (self.unreadable_control_paths.is_empty()
3895 || !path.parent().into_iter().flat_map(Path::ancestors).any(|directory| {
3896 self.unreadable_control_paths
3897 .iter()
3898 .any(|control| control.parent() == Some(directory))
3899 }))
3900 }
3901
3902 pub fn ignored_classification_complete_below(&self, path: &Path) -> bool {
3904 self.observes_controls()
3905 && self.controls.classification_known(path)
3906 && !self.unreadable_control_paths.iter().any(|control| {
3907 control.starts_with(path)
3908 || path
3909 .parent()
3910 .into_iter()
3911 .flat_map(Path::ancestors)
3912 .any(|directory| control.parent() == Some(directory))
3913 })
3914 && !self.controls.refusals().any(|refusal| {
3915 refusal.path.starts_with(path)
3916 || path
3917 .parent()
3918 .into_iter()
3919 .flat_map(Path::ancestors)
3920 .any(|directory| refusal.path.parent() == Some(directory))
3921 })
3922 }
3923
3924 pub fn children(
3928 &self,
3929 path: &Path,
3930 ) -> Option<impl DoubleEndedIterator<Item = (&OsStr, EntryId)> + ExactSizeIterator + '_> {
3931 let id = self.lookup(path)?;
3932 let entry = self.entry(id);
3933 entry.kind.is_dir().then(|| IndexChildren::new(self, entry))
3934 }
3935
3936 pub fn children_of(
3940 &self,
3941 id: EntryId,
3942 ) -> Option<impl DoubleEndedIterator<Item = (&OsStr, EntryId)> + ExactSizeIterator + '_> {
3943 Some(IndexChildren::new(self, self.try_entry(id)?))
3944 }
3945
3946 pub fn path_of(&self, id: EntryId) -> Option<PathBuf> {
3948 let mut parts = Vec::new();
3949 let mut current = Some(id);
3950 while let Some(node) = current {
3951 let entry = self.try_entry(node)?;
3952 if entry.parent.is_some() {
3953 parts.push(entry.name.as_os_str());
3954 }
3955 current = entry.parent;
3956 }
3957 parts.reverse();
3958 Some(parts.iter().collect())
3959 }
3960
3961 pub fn rollup_of(&self, id: EntryId) -> Option<RollUp> {
3963 let entry = self.try_entry(id)?;
3964 entry.kind.is_dir().then(|| self.named_rollup(&entry.rollup().all))
3965 }
3966
3967 pub(crate) fn partition_scalars_of(
3974 &self,
3975 id: EntryId,
3976 ) -> Option<(RollUpScalars, RollUpScalars)> {
3977 let entry = self.try_entry(id)?;
3978 entry.kind.is_dir().then(|| {
3979 let rollup = entry.rollup();
3980 (RollUpScalars::from(&rollup.all), RollUpScalars::from(&rollup.unignored))
3981 })
3982 }
3983
3984 pub(crate) fn directory_complete_of(&self, id: EntryId) -> Option<bool> {
3988 let entry = self.try_entry(id)?;
3989 (entry.kind == EntryKind::Dir).then(|| entry.directory().children_complete)
3990 }
3991
3992 pub fn attrs_of(&self, id: EntryId) -> Option<&Attrs> {
3994 Some(&self.try_entry(id)?.attrs)
3995 }
3996
3997 pub fn kind_of(&self, id: EntryId) -> Option<EntryKind> {
3999 Some(self.try_entry(id)?.kind)
4000 }
4001
4002 pub fn name_of(&self, id: EntryId) -> Option<&OsStr> {
4004 Some(&self.try_entry(id)?.name)
4005 }
4006
4007 pub fn content(&self) -> Option<&ContentIndex> {
4009 self.content.as_deref()
4010 }
4011
4012 pub fn content_rollup(&self, path: &Path) -> Option<&ContentRollUp> {
4014 self.content()?.rollup(path)
4015 }
4016
4017 pub fn content_set(&self) -> AnalysisSet {
4020 self.content().and_then(ContentIndex::profile).unwrap_or(AnalysisSet::NONE)
4021 }
4022
4023 pub(crate) fn content_has_pending(&self, profile: AnalysisSet) -> bool {
4029 if !profile.is_enabled() {
4030 return false;
4031 }
4032 let wanted = self.content_identity(profile);
4033 let Some(content) = self.content().and_then(|content| content.admit(&wanted)) else {
4034 return true;
4035 };
4036 if self.scope.population == crate::query::IgnoredEntries::Include {
4037 return u64::try_from(content.len()).unwrap_or(u64::MAX)
4038 < self.entry(EntryId::ROOT).rollup().files;
4039 }
4040 let mut pending = false;
4045 self.for_each_analysis_file(profile, |_, _, attrs, path| {
4046 pending |=
4047 content.file(&path).is_none_or(|record| record.fingerprint != attrs.fingerprint());
4048 });
4049 pending
4050 }
4051
4052 pub fn content_identity(&self, analysis: AnalysisSet) -> crate::ContentTierIdentity {
4055 crate::ContentTierIdentity::for_request(
4056 crate::EntryTierIdentity::of_scope(self.scope),
4057 analysis,
4058 )
4059 }
4060
4061 pub(crate) fn prepare_content_analysis(&mut self, request: crate::content::AnalysisRequest) {
4064 if !request.profile.is_enabled() {
4065 return;
4066 }
4067 let identity = self.content_identity(request.profile);
4068 self.content.get_or_insert_with(|| Box::new(ContentIndex::default())).prepare(identity);
4069 }
4070
4071 pub(crate) fn set_content_tier_state(
4072 &mut self,
4073 source: Source,
4074 freshness: Freshness,
4075 observed_at_ns: Option<i64>,
4076 ) {
4077 if let Some(content) = self.content.as_deref_mut() {
4078 content.set_state(crate::content::ContentTierState {
4079 source,
4080 freshness,
4081 observed_at_ns,
4082 });
4083 }
4084 }
4085
4086 pub(crate) fn analysis_candidates(&self, profile: AnalysisSet) -> Vec<AnalysisCandidate> {
4096 let root_files = self.entry(EntryId::ROOT).rollup().files;
4097 let mut candidates = Vec::with_capacity(usize::try_from(root_files).unwrap_or(0));
4098 self.for_each_analysis_file(profile, |id, revision, attrs, relative_path| {
4099 candidates.push(AnalysisCandidate {
4100 entry_id: id,
4101 revision,
4102 absolute_path: self.root_path.join(&relative_path),
4103 classification: self.classify(&relative_path),
4104 relative_path,
4105 attrs,
4106 });
4107 });
4108 candidates
4109 }
4110
4111 pub(crate) fn restore_analysis_candidates(
4117 &self,
4118 profile: AnalysisSet,
4119 ) -> (HashMap<PathBuf, RestoreCandidate>, u64) {
4120 let root_files = self.entry(EntryId::ROOT).rollup().files;
4121 let mut candidates = HashMap::with_capacity(usize::try_from(root_files).unwrap_or(0));
4122 let mut visited = 0_u64;
4123 self.for_each_analysis_file(profile, |id, revision, attrs, relative_path| {
4124 visited = visited.saturating_add(1);
4125 candidates.insert(
4126 relative_path.clone(),
4127 RestoreCandidate { entry_id: id, revision, relative_path, attrs },
4128 );
4129 });
4130 (candidates, visited)
4131 }
4132
4133 fn for_each_analysis_file(
4134 &self,
4135 profile: AnalysisSet,
4136 mut visit: impl FnMut(EntryId, u64, Attrs, PathBuf),
4137 ) {
4138 if !profile.is_enabled() {
4139 return;
4140 }
4141 let mut stack = vec![(EntryId::ROOT, PathBuf::new())];
4144 while let Some((parent, parent_path)) = stack.pop() {
4145 for (name, id) in self.children_of(parent).into_iter().flatten() {
4146 let entry = self.entry(id);
4147 if entry.kind == EntryKind::Dir {
4148 stack.push((id, parent_path.join(name)));
4149 continue;
4150 }
4151 if entry.kind != EntryKind::File {
4152 continue;
4153 }
4154 let relative_path = parent_path.join(name);
4155 if !self.scope.population.admits(entry.ignored)
4156 || (self.scope.population != crate::query::IgnoredEntries::Include
4157 && !self.control_classification_known(&relative_path))
4158 {
4159 continue;
4160 }
4161 let revision = entry.revision;
4162 let attrs = entry.attrs;
4163 visit(id, revision, attrs, relative_path);
4164 }
4165 }
4166 }
4167
4168 pub(crate) fn pending_analysis_candidates(
4172 &self,
4173 request: crate::content::AnalysisRequest,
4174 ) -> Vec<AnalysisCandidate> {
4175 let wanted = self.content_identity(request.profile);
4176 let held = self.content().and_then(|content| content.admit(&wanted));
4179 self.analysis_candidates(request.profile)
4180 .into_iter()
4181 .filter(|candidate| {
4182 held.and_then(|content| content.file(&candidate.relative_path)).is_none_or(
4183 |record| {
4184 record.fingerprint != candidate.attrs.fingerprint() || !record.is_reusable()
4185 },
4186 )
4187 })
4188 .collect()
4189 }
4190
4191 pub(crate) fn apply_analysis(
4201 &mut self,
4202 observation: AnalysisObservation,
4203 ) -> AnalysisApplyOutcome {
4204 self.apply_analysis_record(observation)
4205 }
4206
4207 pub(crate) fn apply_restored_analysis(
4212 &mut self,
4213 candidate: RestoreCandidate,
4214 analysis: crate::stored_state::AdmittedRecord<'_>,
4215 ) -> AnalysisApplyOutcome {
4216 let Some(entry) = self.try_entry(candidate.entry_id) else {
4217 return AnalysisApplyOutcome::Stale;
4218 };
4219 if entry.kind != EntryKind::File
4220 || entry.revision != candidate.revision
4221 || entry.attrs.fingerprint() != candidate.attrs.fingerprint()
4222 {
4223 return AnalysisApplyOutcome::Stale;
4224 }
4225 let Some(content) = self.content.as_mut() else {
4226 return AnalysisApplyOutcome::Stale;
4227 };
4228 if content.commit_without_rollup(candidate.relative_path, analysis) {
4229 AnalysisApplyOutcome::Applied
4230 } else {
4231 AnalysisApplyOutcome::Stale
4232 }
4233 }
4234
4235 pub(crate) fn rebuild_content_rollups(&mut self) {
4236 if let Some(content) = self.content.as_mut() {
4237 content.rebuild_rollups();
4238 }
4239 }
4240
4241 fn apply_analysis_record(&mut self, observation: AnalysisObservation) -> AnalysisApplyOutcome {
4242 let candidate = &observation.candidate;
4243 let Some(entry) = self.try_entry(candidate.entry_id) else {
4244 return AnalysisApplyOutcome::Stale;
4245 };
4246 if entry.kind != EntryKind::File
4247 || entry.revision != candidate.revision
4248 || entry.attrs.fingerprint() != candidate.attrs.fingerprint()
4249 || self.classify(&candidate.relative_path) != candidate.classification
4250 {
4251 return AnalysisApplyOutcome::Stale;
4252 }
4253 let Some(content) = self.content.as_mut() else {
4254 return AnalysisApplyOutcome::Stale;
4255 };
4256 if content.commit(
4257 candidate.relative_path.clone(),
4258 observation.profile,
4259 &observation.provenance,
4260 observation.analysis,
4261 ) {
4262 AnalysisApplyOutcome::Applied
4263 } else {
4264 AnalysisApplyOutcome::Stale
4265 }
4266 }
4267
4268 pub fn clear_content(&mut self) {
4270 self.content = None;
4271 }
4272
4273 fn try_entry(&self, id: EntryId) -> Option<&Entry> {
4276 match self.arena.get(id.idx())? {
4277 Slot::Occupied { generation, entry } if *generation == id.generation => Some(entry),
4278 Slot::Occupied { .. } | Slot::Free { .. } => None,
4279 }
4280 }
4281
4282 fn expectation_matches(&self, op: &Op, expected: PathExpectation) -> bool {
4283 let current = self.path_state(op.path());
4284 if self.holds_target(op, current) {
4290 return true;
4291 }
4292 if current != expected.state {
4293 return false;
4294 }
4295
4296 let require_structure = match (op, expected.state) {
4297 (Op::Remove { .. }, _) => true,
4298 (Op::Upsert { kind, .. }, PathState::Present { kind: baseline, .. }) => {
4299 *kind != baseline
4300 }
4301 (
4302 Op::Upsert { .. }
4303 | Op::ControlUpsert { .. }
4304 | Op::ControlRemove { .. }
4305 | Op::InvalidateSubtree { .. },
4306 _,
4307 ) => false,
4308 };
4309 if !same_target(self.entry_identity(op.path()), expected.entry(), require_structure) {
4310 return false;
4311 }
4312
4313 match expected.absence_guard() {
4314 Some(expected) => self
4315 .absence_guard_identity(op.path())
4316 .is_some_and(|current| current.same_absence_guard(expected)),
4317 None => true,
4318 }
4319 }
4320
4321 fn holds_target(&self, op: &Op, current: PathState) -> bool {
4330 match op {
4331 Op::Upsert { kind, attrs, .. } => {
4332 current == PathState::Present { kind: *kind, attrs: *attrs }
4333 }
4334 Op::Remove { .. } => current == PathState::Absent,
4335 Op::ControlUpsert { path, source } => self.controls.source_is(path, source),
4336 Op::ControlRemove { path } => !self.controls.contains(path),
4337 Op::InvalidateSubtree { .. } => false,
4338 }
4339 }
4340
4341 fn absence_guard_identity(&self, path: &Path) -> Option<EntryIdentity> {
4342 let parts = normalize(path)?;
4343 let (_, ancestors) = parts.split_last()?;
4344 let mut current = EntryId::ROOT;
4345 for part in ancestors {
4346 let Some(child) = self.child(current, part) else {
4347 break;
4348 };
4349 current = child;
4350 }
4351 Some(self.identity(current))
4352 }
4353
4354 fn validate_known_ancestry(
4360 &self,
4361 ops: &[ObservationOp],
4362 accepted: &[bool],
4363 ) -> crate::Result<()> {
4364 if let Some((path, reconcile_from)) =
4365 self.unknown_ancestry(ops, accepted).into_iter().next()
4366 {
4367 return Err(crate::Error::UnknownAncestry { path, reconcile_from });
4368 }
4369 Ok(())
4370 }
4371
4372 fn accepted_operations(&self, ops: &[ObservationOp]) -> Vec<bool> {
4373 ops.iter()
4374 .map(|observed| match observed.expectation {
4375 Expectation::Any => true,
4376 Expectation::State(expected) => self.expectation_matches(&observed.op, expected),
4377 })
4378 .collect()
4379 }
4380
4381 fn prepare_scanner_batch(
4397 &self,
4398 batch: crate::scan::ScannerBatch,
4399 ) -> crate::Result<PreparedObservation> {
4400 let ops = batch.into_ops();
4401 let mut parents = Vec::with_capacity(ops.len());
4402 let mut last_parent: Option<(&Path, ResolvedParent)> = None;
4403 let mut has_batch_parents = false;
4404 let mut path_comparisons = 0_u64;
4405 let mut replaces_kind = false;
4406
4407 for (op_index, observed) in ops.iter().enumerate() {
4408 if !matches!(observed.expectation, Expectation::Any) {
4409 return Err(crate::Error::UnsupportedScanConfig(
4410 "scanner batches contain unconditional discoveries only",
4411 ));
4412 }
4413 let op = &observed.op;
4414 let path = op.path();
4415 if path.as_os_str().is_empty() {
4416 return Err(crate::Error::UnsupportedScanConfig(
4417 "scanner batches cannot mutate the index root",
4418 ));
4419 }
4420 for component in path.components() {
4421 match component {
4422 Component::Normal(_) => {}
4423 Component::CurDir => {
4424 return Err(crate::Error::UnsupportedScanConfig(
4425 "scanner batches require canonical relative paths",
4426 ));
4427 }
4428 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
4429 return Err(crate::Error::PathEscapesRoot(path.to_path_buf()));
4430 }
4431 }
4432 }
4433 match op {
4434 Op::Upsert { .. } => {}
4435 Op::ControlUpsert { .. } | Op::ControlRemove { .. }
4436 if crate::control::is_control_file(path) => {}
4437 Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {
4438 return Err(crate::Error::InvalidControlPath(path.to_path_buf()));
4439 }
4440 Op::Remove { .. } | Op::InvalidateSubtree { .. } => {
4441 return Err(crate::Error::UnsupportedScanConfig(
4442 "scanner batches contain discoveries only",
4443 ));
4444 }
4445 }
4446 if replaces_kind {
4447 continue;
4450 }
4451
4452 let parent_path = path.parent().expect("a non-root relative path has a parent");
4453 if last_parent.is_some() {
4454 path_comparisons = path_comparisons.saturating_add(1);
4455 }
4456 let same_parent = last_parent
4457 .filter(|(previous, _)| *previous == parent_path)
4458 .map(|(_, parent)| parent);
4459 let parent = if let Some(parent) = same_parent {
4460 parent
4461 } else {
4462 self.lookup(parent_path)
4463 .filter(|id| self.entry(*id).kind.is_dir())
4464 .map(ResolvedParent::Existing)
4465 .or_else(|| Self::earlier_scanner_parent(&ops, op_index, parent_path))
4466 .ok_or_else(|| crate::Error::UnknownAncestry {
4467 path: path.to_path_buf(),
4468 reconcile_from: PathBuf::new(),
4469 })?
4470 };
4471 if let (Op::Upsert { kind, .. }, ResolvedParent::Existing(parent)) = (op, parent) {
4472 if path.file_name().is_some_and(|name| {
4473 self.child(parent, name).is_some_and(|child| self.entry(child).kind != *kind)
4474 }) {
4475 replaces_kind = true;
4476 continue;
4477 }
4478 }
4479 has_batch_parents |= matches!(parent, ResolvedParent::Earlier(_));
4480 parents.push(parent);
4481 last_parent = Some((parent_path, parent));
4482 }
4483
4484 if replaces_kind {
4485 return prepare_observation(&Observation::from_ops(ops));
4486 }
4487
4488 crate::counters::bump(|counts| {
4489 counts.ancestry_path_comparisons =
4490 counts.ancestry_path_comparisons.saturating_add(path_comparisons);
4491 counts.ancestry_parent_proofs = counts
4492 .ancestry_parent_proofs
4493 .saturating_add(u64::try_from(ops.len()).unwrap_or(u64::MAX));
4494 });
4495 Ok(PreparedObservation {
4496 ops,
4497 ancestry: PreparedAncestry::Scanner { parents, has_batch_parents },
4498 #[cfg(test)]
4499 reject_before_apply: false,
4500 })
4501 }
4502
4503 fn earlier_scanner_parent(
4505 ops: &[ObservationOp],
4506 before: usize,
4507 parent_path: &Path,
4508 ) -> Option<ResolvedParent> {
4509 let (op_index, op) = ops[..before].iter().enumerate().rev().find(
4510 |(_, observed)| matches!(&observed.op, Op::Upsert { path, .. } if path == parent_path),
4511 )?;
4512 let Op::Upsert { kind, .. } = &op.op else {
4513 unreachable!("the search selected an upsert");
4514 };
4515 kind.is_dir().then_some(ResolvedParent::Earlier(op_index))
4516 }
4517
4518 fn projected_controls(
4524 &self,
4525 ops: &[ObservationOp],
4526 accepted: &[bool],
4527 ) -> crate::Result<Option<crate::control::ControlTable>> {
4528 self.projected_controls_from(
4529 ops.iter()
4530 .zip(accepted)
4531 .filter_map(|(observed, accepted)| accepted.then_some(&observed.op)),
4532 )
4533 }
4534
4535 fn projected_controls_from<'a>(
4537 &self,
4538 ops: impl Iterator<Item = &'a Op> + Clone,
4539 ) -> crate::Result<Option<crate::control::ControlTable>> {
4540 if self.controls_unchanged_by(ops.clone()) {
4541 return Ok(None);
4542 }
4543 let mut projected = self.controls.clone();
4544 #[cfg(test)]
4545 CONTROL_PROJECTION_CLONES.with(|clones| clones.set(clones.get() + 1));
4546 let mut structure = StructuralOverlay::default();
4547 for op in ops {
4548 match op {
4549 Op::Upsert { path, kind, .. } => {
4550 if crate::control::is_control_file(path) && *kind != EntryKind::File {
4551 projected.remove(path)?;
4552 }
4553 if structure.kind(self, path) == Some(EntryKind::Dir) && !kind.is_dir() {
4554 projected.remove_subtree(path);
4555 }
4556 structure.upsert(self, path, *kind);
4557 }
4558 Op::Remove { path } => {
4559 if crate::control::is_control_file(path) {
4560 projected.remove(path)?;
4561 }
4562 projected.remove_subtree(path);
4563 structure.remove(self, path);
4564 }
4565 Op::ControlUpsert { path, source } => {
4566 projected.upsert(path, source.clone())?;
4567 }
4568 Op::ControlRemove { path } => {
4569 projected.remove(path)?;
4570 }
4571 Op::InvalidateSubtree { .. } => {}
4572 }
4573 }
4574 Ok(Some(projected))
4575 }
4576
4577 fn controls_unchanged_by<'a>(&self, ops: impl Iterator<Item = &'a Op>) -> bool {
4591 if self.controls.is_vacant() {
4597 return ops.into_iter().all(|op| match op {
4598 Op::ControlUpsert { .. } => false,
4599 Op::ControlRemove { path } => self.controls.remove_is_inert(path),
4600 Op::Upsert { .. } | Op::Remove { .. } | Op::InvalidateSubtree { .. } => true,
4601 });
4602 }
4603
4604 ops.into_iter().all(|op| match op {
4605 Op::ControlUpsert { path, source } => self.controls.upsert_is_inert(path, source),
4606 Op::ControlRemove { path } => self.controls.remove_is_inert(path),
4607 Op::Upsert { path, kind, .. } => {
4608 let drops_control = *kind != EntryKind::File
4611 && crate::control::is_control_file(path)
4612 && self.controls.contains(path);
4613 let prunes_subtree = !kind.is_dir() && self.controls.has_record_at_or_below(path);
4614 !drops_control && !prunes_subtree
4615 }
4616 Op::Remove { path } => {
4617 let drops_control =
4618 crate::control::is_control_file(path) && self.controls.contains(path);
4619 !drops_control && !self.controls.has_record_at_or_below(path)
4620 }
4621 Op::InvalidateSubtree { .. } => true,
4622 })
4623 }
4624
4625 fn apply_control_transition<C: ConsequenceSink>(
4626 &mut self,
4627 projected: Option<crate::control::ControlTable>,
4628 stats: &mut ApplyStats,
4629 effects: &mut C,
4630 ) {
4631 let Some(projected) = projected else {
4632 return;
4633 };
4634 let changes = projected.changes_from(&self.controls);
4635 let refusals = projected.refusal_changes_from(&self.controls);
4636 if changes.is_empty() && refusals.is_empty() {
4637 return;
4638 }
4639 let affected: Vec<PathBuf> = changes
4640 .iter()
4641 .filter_map(|(path, _, _)| crate::control::ControlTable::affected_subtree(path).ok())
4642 .collect();
4643 self.controls = projected;
4644 stats.controls = u64::try_from(changes.len() + refusals.len()).unwrap_or(u64::MAX);
4645 for (path, previous, current) in changes {
4646 effects.change(|| EffectiveChange::ControlUpdated { path, previous, current });
4647 }
4648 for (path, previous, current) in refusals {
4651 effects.change(|| EffectiveChange::ControlRefusalUpdated { path, previous, current });
4652 }
4653 self.reclassify_controlled_subtrees(&affected, stats, effects);
4654 }
4655
4656 fn reclassify_controlled_subtrees<C: ConsequenceSink>(
4659 &mut self,
4660 affected: &[PathBuf],
4661 stats: &mut ApplyStats,
4662 effects: &mut C,
4663 ) {
4664 let mut roots: Vec<PathBuf> = affected.to_vec();
4665 roots.sort();
4666 roots.dedup();
4667 let mut collapsed = Vec::new();
4668 for root in roots {
4669 if collapsed.iter().any(|ancestor: &PathBuf| root.starts_with(ancestor)) {
4670 continue;
4671 }
4672 collapsed.push(root);
4673 }
4674
4675 let mut moved = false;
4676 for root in collapsed {
4677 let Some(root_id) = self.lookup(&root) else {
4678 continue;
4679 };
4680 let children: Vec<(PathBuf, EntryId)> = self
4681 .children_of(root_id)
4682 .expect("controlled subtree root is live")
4683 .map(|(name, id)| (root.join(name), id))
4684 .collect();
4685 let mut queue = VecDeque::from(children);
4686 while let Some((path, id)) = queue.pop_front() {
4687 #[cfg(test)]
4688 RECLASSIFY_VISITS.with(|visits| visits.set(visits.get() + 1));
4689 let entry = self.entry(id);
4690 let parent_ignored = entry.parent.is_some_and(|parent| self.entry(parent).ignored);
4691 let current = entry.ignored;
4692 let next = parent_ignored
4693 || self.controls.matcher_for(&path).is_ignored(entry.kind.is_dir());
4694 let descendants: Vec<(PathBuf, EntryId)> = self
4695 .children_of(id)
4696 .expect("controlled subtree entry is live")
4697 .map(|(name, child)| (path.join(name), child))
4698 .collect();
4699 if current != next {
4700 self.move_serving_file_partition(&path, id, current, next);
4701 self.entry_mut(id).ignored = next;
4702 stats.reclassified += 1;
4703 effects.change(|| EffectiveChange::Reclassified {
4704 path: path.clone(),
4705 previous_ignored: current,
4706 current_ignored: next,
4707 });
4708 moved = true;
4709 }
4710 queue.extend(descendants);
4711 }
4712 }
4713 if moved {
4714 self.rebuild_unignored_rollups();
4715 }
4716 }
4717
4718 fn rebuild_unignored_rollups(&mut self) {
4719 let mut order = Vec::with_capacity(usize::try_from(self.live).unwrap_or(0));
4720 let mut stack = vec![EntryId::ROOT];
4721 while let Some(id) = stack.pop() {
4722 order.push(id);
4723 if self.entry(id).kind.is_dir() {
4724 stack.extend(self.child_ids(id));
4725 }
4726 if self.entry(id).kind.is_dir() {
4727 self.entry_mut(id).rollup_mut().unignored = InternedRollUp::default();
4728 }
4729 }
4730 for id in order.into_iter().rev() {
4731 let Some(parent) = self.entry(id).parent else {
4732 continue;
4733 };
4734 let contribution = self.contribution(id).unignored;
4735 self.entry_mut(parent).rollup_mut().unignored.merge(&contribution);
4736 }
4737 }
4738
4739 fn unknown_ancestry(
4740 &self,
4741 ops: &[ObservationOp],
4742 accepted: &[bool],
4743 ) -> Vec<(PathBuf, PathBuf)> {
4744 let mut structure = StructuralOverlay::default();
4745 let mut unknown = Vec::new();
4746 let mut overlay_inserts = 0_u64;
4747 let mut path_comparisons = 0_u64;
4748 let mut parent_proofs = 0_u64;
4749 let count_preflight = crate::counters::enabled();
4750 let mut proven_dir: Option<PathBuf> = None;
4759 let mut ancestor = PathBuf::new();
4760 for (observed, accepted) in ops.iter().zip(accepted) {
4761 if !accepted {
4762 continue;
4763 }
4764 match &observed.op {
4765 Op::Upsert { path, .. } | Op::ControlUpsert { path, .. }
4766 if !path.as_os_str().is_empty() =>
4767 {
4768 if count_preflight && proven_dir.is_some() {
4769 path_comparisons = path_comparisons.saturating_add(1);
4770 }
4771 let same_proven_parent = matches!(
4772 (path.parent(), proven_dir.as_deref()),
4773 (Some(parent), Some(proven)) if parent == proven
4774 );
4775 if same_proven_parent {
4776 if count_preflight {
4777 parent_proofs = parent_proofs.saturating_add(1);
4778 }
4779 } else {
4780 let mut reconcile_from = PathBuf::new();
4781 let mut ancestry_known = true;
4782 let parts = normalize(path).expect("prepared paths are canonical");
4783 let (_, ancestors) = parts.split_last().expect("non-root path has a name");
4784 ancestor.clear();
4785 for part in ancestors {
4786 ancestor.push(part);
4787 if structure.kind(self, &ancestor) != Some(EntryKind::Dir) {
4788 unknown.push((path.clone(), reconcile_from));
4789 ancestry_known = false;
4790 break;
4791 }
4792 reconcile_from.clone_from(&ancestor);
4793 }
4794 if !ancestry_known {
4795 proven_dir = None;
4796 continue;
4797 }
4798 if count_preflight {
4799 parent_proofs = parent_proofs.saturating_add(1);
4800 }
4801 match &mut proven_dir {
4802 Some(proven) => {
4803 proven.clear();
4804 path.parent().unwrap_or(Path::new("")).clone_into(proven);
4805 }
4806 None => {
4807 proven_dir =
4808 Some(path.parent().unwrap_or(Path::new("")).to_path_buf());
4809 }
4810 }
4811 }
4812 if let Op::Upsert { kind, .. } = &observed.op {
4813 structure.upsert(self, path, *kind);
4814 if count_preflight {
4815 overlay_inserts = overlay_inserts.saturating_add(1);
4816 }
4817 if !kind.is_dir() {
4818 if proven_dir.as_deref().is_some_and(|proven| proven.starts_with(path))
4822 {
4823 proven_dir = None;
4824 }
4825 }
4826 }
4827 }
4828 Op::Remove { path } if !path.as_os_str().is_empty() => {
4829 structure.remove(self, path);
4830 if proven_dir.as_deref().is_some_and(|proven| proven.starts_with(path)) {
4831 proven_dir = None;
4832 }
4833 }
4834 Op::Upsert { .. }
4835 | Op::Remove { .. }
4836 | Op::ControlUpsert { .. }
4837 | Op::ControlRemove { .. }
4838 | Op::InvalidateSubtree { .. } => {}
4839 }
4840 }
4841 if count_preflight {
4842 crate::counters::bump(|counts| {
4843 counts.ancestry_overlay_inserts =
4844 counts.ancestry_overlay_inserts.saturating_add(overlay_inserts);
4845 counts.ancestry_path_comparisons =
4846 counts.ancestry_path_comparisons.saturating_add(path_comparisons);
4847 counts.ancestry_parent_proofs =
4848 counts.ancestry_parent_proofs.saturating_add(parent_proofs);
4849 });
4850 }
4851 unknown
4852 }
4853
4854 fn entry_identity(&self, path: &Path) -> Option<EntryIdentity> {
4855 Some(self.identity(self.lookup(path)?))
4856 }
4857
4858 fn identity(&self, id: EntryId) -> EntryIdentity {
4859 let entry = self.entry(id);
4860 EntryIdentity::new(
4861 id.slot,
4862 id.generation,
4863 entry.revision,
4864 entry.directory.as_deref().map_or(0, |directory| directory.children_revision),
4865 entry.kind.is_dir(),
4866 )
4867 }
4868
4869 fn bump_revision(entry: &mut Entry) {
4870 entry.revision = entry.revision.checked_add(1).expect("entry revision exhausted");
4871 }
4872
4873 fn bump_children_revision(entry: &mut Entry) {
4874 let directory = entry.directory_mut();
4875 directory.children_revision =
4876 directory.children_revision.checked_add(1).expect("entry children revision exhausted");
4877 }
4878
4879 fn child(&self, parent: EntryId, name: &OsStr) -> Option<EntryId> {
4880 let children = &self.entry(parent).directory.as_deref()?.children;
4881 match children {
4882 DirectoryChildren::Sorted(ids) => ids
4883 .binary_search_by(|id| self.entry(*id).name.as_os_str().cmp(name))
4884 .ok()
4885 .map(|position| ids[position]),
4886 DirectoryChildren::Mutable(children) => children.get(name).copied(),
4887 }
4888 }
4889
4890 fn child_ids(&self, parent: EntryId) -> ChildIds<'_> {
4891 self.entry(parent).directory().children.ids()
4892 }
4893
4894 fn promote_children(&mut self, parent: EntryId) {
4900 let ids = match &mut self.entry_mut(parent).directory_mut().children {
4901 DirectoryChildren::Sorted(ids) => std::mem::take(ids),
4902 DirectoryChildren::Mutable(_) => return,
4903 };
4904 let expected = ids.len();
4905 let children =
4906 ids.into_iter().map(|id| (self.entry(id).name.clone(), id)).collect::<BTreeMap<_, _>>();
4907 assert_eq!(
4908 children.len(),
4909 expected,
4910 "compact child names must remain unique before promotion"
4911 );
4912 self.entry_mut(parent).directory_mut().children = DirectoryChildren::Mutable(children);
4913 }
4914
4915 fn insert_child(&mut self, parent: EntryId, name: OsString, child: EntryId) {
4916 self.promote_children(parent);
4917 let entry = self.entry_mut(parent);
4918 let DirectoryChildren::Mutable(children) = &mut entry.directory_mut().children else {
4919 unreachable!("child promotion produces mutable storage")
4920 };
4921 children.insert(name, child);
4922 Self::bump_children_revision(entry);
4923 }
4924
4925 fn reserve_detached_children(&mut self, parent: EntryId, additional: usize) {
4926 let entry = self.entry_mut(parent);
4927 let DirectoryChildren::Sorted(children) = &mut entry.directory_mut().children else {
4928 unreachable!("detached directories retain sorted child storage")
4929 };
4930 children.reserve(additional);
4931 }
4932
4933 fn push_detached_child(&mut self, parent: EntryId, child: EntryId) {
4934 let entry = self.entry_mut(parent);
4935 let DirectoryChildren::Sorted(children) = &mut entry.directory_mut().children else {
4936 unreachable!("detached directories retain sorted child storage")
4937 };
4938 children.push(child);
4939 Self::bump_children_revision(entry);
4940 }
4941
4942 fn merge_detached_descendants(&mut self, parent: EntryId, child: EntryId) {
4944 debug_assert!(parent.idx() < child.idx(), "cold parents must precede descendants");
4945 let (parents, children) = self.arena.split_at_mut(child.idx());
4946 let child_rollup = match &children[0] {
4947 Slot::Occupied { generation, entry } if *generation == child.generation => {
4948 entry.rollup()
4949 }
4950 Slot::Occupied { .. } | Slot::Free { .. } => {
4951 panic!("detached child handle must be live: {child:?}")
4952 }
4953 };
4954 let parent_entry = match &mut parents[parent.idx()] {
4955 Slot::Occupied { generation, entry } if *generation == parent.generation => entry,
4956 Slot::Occupied { .. } | Slot::Free { .. } => {
4957 panic!("detached parent handle must be live: {parent:?}")
4958 }
4959 };
4960 parent_entry.rollup_mut().merge(child_rollup);
4961 }
4962
4963 fn remove_child(&mut self, parent: EntryId, name: &OsStr) {
4964 self.promote_children(parent);
4965 let entry = self.entry_mut(parent);
4966 let DirectoryChildren::Mutable(children) = &mut entry.directory_mut().children else {
4967 unreachable!("child promotion produces mutable storage")
4968 };
4969 if children.remove(name).is_some() {
4970 Self::bump_children_revision(entry);
4971 }
4972 }
4973
4974 fn entry(&self, id: EntryId) -> &Entry {
4975 self.try_entry(id).expect("internal entry handle must be live")
4976 }
4977
4978 fn entry_mut(&mut self, id: EntryId) -> &mut Entry {
4979 match self.arena.get_mut(id.idx()) {
4980 Some(Slot::Occupied { generation, entry }) if *generation == id.generation => entry,
4981 Some(Slot::Occupied { .. } | Slot::Free { .. }) | None => {
4982 panic!("internal entry handle must be live: {id:?}")
4983 }
4984 }
4985 }
4986
4987 fn alloc(&mut self, entry: Entry) -> EntryId {
4988 crate::counters::bump(|c| c.entries_allocated += 1);
4989 self.live += 1;
4990 if let Some(free_slot) = self.free_head {
4991 let free_idx = free_slot as usize;
4992 let (generation, next) = match &self.arena[free_idx] {
4993 Slot::Free { generation, next_free } => (*generation, *next_free),
4994 Slot::Occupied { .. } => unreachable!("free list pointed at a live slot"),
4995 };
4996 self.free_head = next;
4997 self.arena[free_idx] = Slot::Occupied { generation, entry };
4998 return EntryId { slot: free_slot, generation };
4999 }
5000 let slot = u32::try_from(self.arena.len()).expect("index arena exceeded u32 capacity");
5001 let id = EntryId { slot, generation: 0 };
5002 self.arena.push(Slot::Occupied { generation: 0, entry });
5003 id
5004 }
5005
5006 fn free(&mut self, id: EntryId) {
5007 let next_generation = match &self.arena[id.idx()] {
5008 Slot::Occupied { generation, .. } if *generation == id.generation => {
5009 generation.checked_add(1).expect("entry generation exhausted")
5010 }
5011 Slot::Occupied { .. } | Slot::Free { .. } => {
5012 panic!("internal entry handle must be live: {id:?}")
5013 }
5014 };
5015 self.arena[id.idx()] =
5016 Slot::Free { generation: next_generation, next_free: self.free_head };
5017 self.free_head = Some(id.slot);
5018 self.live -= 1;
5019 }
5020
5021 fn now_unix_nanos() -> i64 {
5025 std::time::SystemTime::now()
5026 .duration_since(std::time::UNIX_EPOCH)
5027 .ok()
5028 .and_then(|since| i64::try_from(since.as_nanos()).ok())
5029 .unwrap_or(0)
5030 }
5031
5032 pub fn provenance(&self, path: &Path) -> Option<Provenance> {
5052 let id = self.lookup(path)?;
5053 Some(self.provenance_of(id))
5054 }
5055
5056 fn provenance_of(&self, id: EntryId) -> Provenance {
5057 let entry = self.entry(id);
5058 let status = self.status_of(id);
5059 if entry.source >= Source::Revalidated {
5080 if let Some(path) = self.path_of(id) {
5081 if self.freshness_at(&path) == Freshness::Fresh {
5082 if let Some(verified_at) = self.verified_at(&path) {
5083 return Provenance {
5084 source: Source::Revalidated,
5085 observed_at_ns: verified_at,
5086 status,
5087 };
5088 }
5089 }
5090 }
5091 }
5092 Provenance { source: entry.source, observed_at_ns: self.observed_at(entry.source), status }
5093 }
5094
5095 fn status_of(&self, id: EntryId) -> Status {
5108 let Some(path) = self.path_of(id) else {
5109 return Status::Complete;
5110 };
5111 match self.freshness_at(&path) {
5112 Freshness::Fresh | Freshness::Reconciling | Freshness::Stale => Status::Complete,
5113 Freshness::Partial => Status::Partial,
5114 }
5115 }
5116
5117 const fn observed_at(&self, source: Source) -> i64 {
5119 match source {
5120 Source::Cached | Source::JournalScoped => self.captured_at_ns,
5121 Source::Scanned | Source::Revalidated => self.scanned_at_ns,
5122 }
5123 }
5124
5125 pub(crate) const fn writing_pass_started_at_ns(&self) -> i64 {
5128 self.writing_pass_started_at_ns
5129 }
5130
5131 pub(crate) fn set_writing_pass_started_at_ns(&mut self, writing_pass_started_at_ns: i64) {
5133 self.writing_pass_started_at_ns = writing_pass_started_at_ns;
5134 }
5135
5136 pub(crate) const fn persistence_owed(&self) -> bool {
5138 self.persistence_owed
5139 }
5140
5141 pub(crate) fn set_persistence_owed(&mut self, owed: bool) {
5144 self.persistence_owed = owed;
5145 }
5146
5147 pub(crate) fn set_applying_source(&mut self, source: Source, captured_at_ns: i64) -> Source {
5153 let previous = self.applying_source;
5154 self.applying_source = source;
5155 if source == Source::Cached {
5156 self.state.source = Source::Cached;
5157 }
5158 if captured_at_ns != 0 {
5159 self.captured_at_ns = captured_at_ns;
5160 }
5161 previous
5162 }
5163
5164 fn intern_ext(&mut self, name: &str) -> ExtId {
5170 if let Some(&id) = self.ext_ids.get(name) {
5171 let refcount =
5172 self.ext_refcounts.get_mut(id as usize).expect("a live id has a refcount");
5173 *refcount = refcount.checked_add(1).expect("extension refcount exhausted");
5174 return id;
5175 }
5176 let id = if let Some(id) = self.free_ext_ids.pop() {
5177 let slot = id as usize;
5178 self.ext_names[slot] = Some(name.to_string());
5179 self.ext_refcounts[slot] = 1;
5180 id
5181 } else {
5182 let id = ExtId::try_from(self.ext_names.len()).expect("extension interner exhausted");
5183 self.ext_names.push(Some(name.to_string()));
5184 self.ext_refcounts.push(1);
5185 id
5186 };
5187 self.ext_ids.insert(name.to_string(), id);
5188 id
5189 }
5190
5191 fn release_ext(&mut self, id: ExtId) {
5193 let slot = id as usize;
5194 let refcount = self.ext_refcounts.get_mut(slot).expect("a live id has a refcount");
5195 debug_assert!(*refcount > 0, "extension reference released twice");
5196 *refcount -= 1;
5197 if *refcount != 0 {
5198 return;
5199 }
5200 let name = self.ext_names[slot].take().expect("a live id has a name");
5201 let removed = self.ext_ids.remove(&name);
5202 debug_assert_eq!(removed, Some(id), "the interner's two maps disagreed");
5203 self.free_ext_ids.push(id);
5204 }
5205
5206 fn named_rollup(&self, rollup: &InternedRollUp) -> RollUp {
5208 let by_ext = rollup
5209 .by_ext
5210 .iter()
5211 .map(|(id, tally)| {
5212 let name = self
5213 .ext_names
5214 .get(*id as usize)
5215 .and_then(Option::as_ref)
5216 .expect("a live roll-up's extension id has a name");
5217 (name.clone(), *tally)
5218 })
5219 .collect();
5220 RollUp {
5221 files: rollup.files,
5222 dirs: rollup.dirs,
5223 bytes: rollup.bytes,
5224 allocated: rollup.allocated,
5225 newest_mtime_ns: rollup.newest_mtime_ns,
5226 by_ext,
5227 }
5228 }
5229
5230 fn named_partitions(&self, rollup: &InternedPartitionRollUp) -> PartitionRollUp {
5231 PartitionRollUp {
5232 all: self.named_rollup(&rollup.all),
5233 unignored: self.named_rollup(&rollup.unignored),
5234 }
5235 }
5236
5237 fn contribution(&self, id: EntryId) -> InternedPartitionRollUp {
5239 let entry = self.entry(id);
5240 match entry.kind {
5241 EntryKind::Dir => {
5242 let mut all = entry.rollup().all.clone();
5243 all.dirs += 1;
5244 let mut unignored = InternedRollUp::default();
5245 if !entry.ignored {
5246 unignored = entry.rollup().unignored.clone();
5247 unignored.dirs += 1;
5248 }
5249 InternedPartitionRollUp { all, unignored }
5250 }
5251 EntryKind::File => {
5252 let mut all = InternedRollUp {
5253 files: 1,
5254 dirs: 0,
5255 bytes: entry.attrs.size,
5256 allocated: entry.attrs.allocated,
5257 newest_mtime_ns: entry.attrs.mtime_ns,
5258 by_ext: BTreeMap::new(),
5259 };
5260 if let Some(ext_id) = entry.ext_id {
5261 all.by_ext.insert(
5262 ext_id,
5263 ExtTally {
5264 files: 1,
5265 bytes: entry.attrs.size,
5266 allocated: entry.attrs.allocated,
5267 },
5268 );
5269 }
5270 let unignored = if entry.ignored { InternedRollUp::default() } else { all.clone() };
5271 InternedPartitionRollUp { all, unignored }
5272 }
5273 EntryKind::Symlink | EntryKind::Other => InternedPartitionRollUp::default(),
5274 }
5275 }
5276
5277 fn merge_upward(
5278 &mut self,
5279 from_parent: Option<EntryId>,
5280 contribution: &InternedPartitionRollUp,
5281 ) {
5282 let mut current = from_parent;
5283 while let Some(id) = current {
5284 crate::counters::bump(|c| c.rollup_merges += 1);
5287 let entry = self.entry_mut(id);
5288 entry.rollup_mut().merge(contribution);
5289 current = entry.parent;
5290 }
5291 }
5292
5293 fn unmerge_upward(
5294 &mut self,
5295 from_parent: Option<EntryId>,
5296 contribution: &InternedPartitionRollUp,
5297 ) {
5298 let mut current = from_parent;
5299 while let Some(id) = current {
5300 let entry = self.entry_mut(id);
5301 entry.rollup_mut().unmerge(contribution);
5302 current = entry.parent;
5303 }
5304 }
5305
5306 fn recompute_newest_upward(&mut self, from: Option<EntryId>) {
5314 let mut current = from;
5315 while let Some(id) = current {
5316 let mut newest: Option<i64> = None;
5317 for child in self.child_ids(id) {
5318 let child_entry = self.entry(child);
5319 let candidate = match child_entry.kind {
5320 EntryKind::Dir => (child_entry.rollup().files > 0)
5321 .then_some(child_entry.rollup().newest_mtime_ns),
5322 EntryKind::File => Some(child_entry.attrs.mtime_ns),
5323 EntryKind::Symlink | EntryKind::Other => None,
5324 };
5325 if let Some(candidate) = candidate {
5326 newest = Some(newest.map_or(candidate, |current| current.max(candidate)));
5327 }
5328 }
5329 let newest = newest.unwrap_or(0);
5330 self.entry_mut(id).rollup_mut().all.newest_mtime_ns = newest;
5331
5332 let mut newest_unignored: Option<i64> = None;
5333 for child in self.child_ids(id) {
5334 let child_entry = self.entry(child);
5335 let candidate = match child_entry.kind {
5336 EntryKind::Dir => (!child_entry.ignored
5337 && child_entry.rollup().unignored.files > 0)
5338 .then_some(child_entry.rollup().unignored.newest_mtime_ns),
5339 EntryKind::File => (!child_entry.ignored).then_some(child_entry.attrs.mtime_ns),
5340 EntryKind::Symlink | EntryKind::Other => None,
5341 };
5342 if let Some(candidate) = candidate {
5343 newest_unignored =
5344 Some(newest_unignored.map_or(candidate, |current| current.max(candidate)));
5345 }
5346 }
5347 let entry = self.entry_mut(id);
5348 entry.rollup_mut().unignored.newest_mtime_ns = newest_unignored.unwrap_or(0);
5349 current = entry.parent;
5350 }
5351 }
5352
5353 fn resolve_dir_chain(&self, parts: &[&OsStr]) -> EntryId {
5355 let mut current = EntryId::ROOT;
5356 for part in parts {
5357 current = self
5358 .child(current, part)
5359 .expect("validated ancestry remains present under the writer lock");
5360 debug_assert!(self.entry(current).kind.is_dir());
5361 }
5362 current
5363 }
5364
5365 fn apply_upsert<C: ConsequenceSink>(
5366 &mut self,
5367 path: &Path,
5368 kind: EntryKind,
5369 attrs: Attrs,
5370 stats: &mut ApplyStats,
5371 effects: &mut C,
5372 parent_memo: &mut ParentMemo,
5373 ) -> bool {
5374 crate::counters::bump(|c| c.upserts += 1);
5380 if let (Some(dir), Some(name)) = (path.parent(), path.file_name()) {
5381 if let Some(parent) = parent_memo.get(dir) {
5382 crate::counters::bump(|c| c.parent_memo_hits += 1);
5383 return self.upsert_beneath(parent, name, path, kind, attrs, stats, effects);
5384 }
5385 }
5386 crate::counters::bump(|c| c.parent_resolutions += 1);
5387
5388 let Some(parts) = normalize(path) else {
5389 return false;
5390 };
5391 let source = self.applying_source;
5392
5393 let Some((name, ancestors)) = parts.split_last() else {
5394 if self.entry(EntryId::ROOT).attrs == attrs {
5400 self.entry_mut(EntryId::ROOT).source = source;
5401 stats.unchanged += 1;
5402 return false;
5403 }
5404 let root = self.entry_mut(EntryId::ROOT);
5405 let previous = root.attrs;
5406 root.attrs = attrs;
5407 root.source = source;
5408 Self::bump_revision(root);
5409 stats.updated += 1;
5410 effects.change(|| EffectiveChange::Updated {
5411 path: PathBuf::new(),
5412 kind: EntryKind::Dir,
5413 previous,
5414 current: attrs,
5415 });
5416 return true;
5417 };
5418 let parent = self.resolve_dir_chain(ancestors);
5419 if let Some(dir) = path.parent() {
5420 parent_memo.set(dir, parent);
5421 }
5422 self.upsert_beneath(parent, name, path, kind, attrs, stats, effects)
5423 }
5424
5425 #[allow(clippy::too_many_arguments)]
5432 fn upsert_beneath<C: ConsequenceSink>(
5433 &mut self,
5434 parent: EntryId,
5435 name: &OsStr,
5436 path: &Path,
5437 kind: EntryKind,
5438 attrs: Attrs,
5439 stats: &mut ApplyStats,
5440 effects: &mut C,
5441 ) -> bool {
5442 let source = self.applying_source;
5443 let existing = self.child(parent, name);
5444
5445 if let Some(id) = existing {
5446 let entry = self.entry(id);
5447 if entry.kind == kind {
5448 if entry.attrs == attrs {
5449 self.entry_mut(id).source = source;
5455 stats.unchanged += 1;
5456 return false;
5457 }
5458 if kind.is_dir() {
5459 let entry = self.entry_mut(id);
5462 let previous = entry.attrs;
5463 entry.attrs = attrs;
5464 entry.source = source;
5465 Self::bump_revision(entry);
5466 stats.updated += 1;
5467 effects.change(|| EffectiveChange::Updated {
5468 path: path.to_path_buf(),
5469 kind,
5470 previous,
5471 current: attrs,
5472 });
5473 return true;
5474 }
5475 let previous_attrs = entry.attrs;
5476 self.invalidate_content(path);
5477 if kind == EntryKind::File {
5478 self.remove_serving_file_semantics(path, id, previous_attrs);
5479 }
5480 self.remove_serving_entry(path, kind, previous_attrs, id);
5481 let old = self.contribution(id);
5482 self.unmerge_upward(Some(parent), &old);
5483 let entry = self.entry_mut(id);
5484 let previous = entry.attrs;
5485 entry.attrs = attrs;
5486 entry.source = source;
5487 Self::bump_revision(entry);
5488 let new = self.contribution(id);
5489 self.merge_upward(Some(parent), &new);
5490 self.insert_serving_entry(path, kind, attrs, id);
5491 if new.newest_mtime_ns < old.newest_mtime_ns {
5492 self.recompute_newest_upward(Some(parent));
5493 }
5494 stats.updated += 1;
5495 effects.change(|| EffectiveChange::Updated {
5496 path: path.to_path_buf(),
5497 kind,
5498 previous,
5499 current: attrs,
5500 });
5501 return true;
5502 }
5503 self.remove_entry(id, stats, effects);
5511 }
5512
5513 let ext_id =
5514 (kind == EntryKind::File).then(|| self.intern_ext(&crate::classify::ext_bucket(name)));
5515 let ignored =
5516 self.entry(parent).ignored || self.controls.matcher_for(path).is_ignored(kind.is_dir());
5517 let id = self.alloc(Entry::new(
5518 NewEntry {
5519 parent: Some(parent),
5520 name: name.to_os_string(),
5521 ext_id,
5522 ignored,
5523 source,
5524 kind,
5525 attrs,
5526 },
5527 false,
5528 ));
5529 self.insert_child(parent, name.to_os_string(), id);
5530 let contribution = self.contribution(id);
5531 self.merge_upward(Some(parent), &contribution);
5532 stats.inserted += 1;
5533 effects.change(|| EffectiveChange::Inserted { path: path.to_path_buf(), kind, attrs });
5534 self.insert_serving_entry(path, kind, attrs, id);
5535 true
5536 }
5537
5538 pub(crate) fn insert_loaded_child(
5558 &mut self,
5559 parent: EntryId,
5560 name: OsString,
5561 kind: EntryKind,
5562 attrs: Attrs,
5563 ) -> Option<EntryId> {
5564 let parent_entry = self.try_entry(parent)?;
5565 if parent_entry.kind != EntryKind::Dir || self.child(parent, &name).is_some() {
5566 return None;
5567 }
5568 let source = self.applying_source;
5569 let ext_id =
5570 (kind == EntryKind::File).then(|| self.intern_ext(&crate::classify::ext_bucket(&name)));
5571 let id = self.alloc(Entry::new(
5572 NewEntry {
5573 parent: Some(parent),
5574 name: name.clone(),
5575 ext_id,
5576 ignored: false,
5577 source,
5578 kind,
5579 attrs,
5580 },
5581 true,
5582 ));
5583 self.insert_child(parent, name, id);
5584 let contribution = self.contribution(id);
5588 self.merge_upward(Some(parent), &contribution);
5589 if self.serving.is_some() {
5592 let path = self.path_of(id).expect("a newly loaded entry has a path");
5593 self.insert_serving_entry(&path, kind, attrs, id);
5594 }
5595 Some(id)
5596 }
5597
5598 fn apply_remove<C: ConsequenceSink>(
5599 &mut self,
5600 path: &Path,
5601 stats: &mut ApplyStats,
5602 effects: &mut C,
5603 ) -> bool {
5604 let Some(id) = self.lookup(path) else {
5605 stats.unchanged += 1;
5606 return false;
5607 };
5608 if id == EntryId::ROOT {
5609 stats.unchanged += 1;
5610 return false;
5611 }
5612 self.remove_entry(id, stats, effects);
5613 true
5614 }
5615
5616 fn remove_entry<C: ConsequenceSink>(
5617 &mut self,
5618 id: EntryId,
5619 stats: &mut ApplyStats,
5620 effects: &mut C,
5621 ) {
5622 let removed_root = self.path_of(id).expect("a live entry has a path");
5623 self.invalidate_content(&removed_root);
5624 self.remove_serving_subtree_semantics(id, &removed_root);
5625 let parent = self.entry(id).parent;
5626 let name = self.entry(id).name.clone();
5627 let contribution = self.contribution(id);
5628
5629 self.unmerge_upward(parent, &contribution);
5630 if let Some(parent) = parent {
5631 self.remove_child(parent, &name);
5632 }
5633
5634 let mut queue = VecDeque::from([(id, removed_root)]);
5637 while let Some((node, path)) = queue.pop_front() {
5638 let entry = self.entry(node);
5639 let kind = entry.kind;
5640 let attrs = entry.attrs;
5641 let children: Vec<(OsString, EntryId)> = self
5642 .children_of(node)
5643 .expect("removed subtree entry is live")
5644 .map(|(name, child)| (name.to_os_string(), child))
5645 .collect();
5646 let ext_id = entry.ext_id;
5647 for (name, child) in children {
5648 queue.push_back((child, path.join(name)));
5649 }
5650 self.remove_serving_entry(&path, kind, attrs, node);
5651 effects.change(|| EffectiveChange::Removed { path, kind, attrs });
5652 if let Some(ext_id) = ext_id {
5655 self.release_ext(ext_id);
5656 }
5657 self.free(node);
5658 stats.removed += 1;
5659 }
5660
5661 self.recompute_newest_upward(parent);
5663 }
5664
5665 fn invalidate_content(&mut self, path: &Path) {
5666 if let Some(content) = self.content.as_mut() {
5667 content.invalidate(path);
5668 }
5669 }
5670}
5671
5672pub(crate) fn collect_child_expectations(
5680 index: &Index,
5681 path: &Path,
5682) -> BTreeMap<OsString, PathExpectation> {
5683 index.children(path).map_or_else(BTreeMap::new, |children| {
5684 children
5685 .map(|(name, id)| {
5686 let entry = index.entry(id);
5687 let expectation = PathExpectation::new(
5688 PathState::Present { kind: entry.kind, attrs: entry.attrs },
5689 Some(index.identity(id)),
5690 None,
5691 );
5692 (name.to_os_string(), expectation)
5693 })
5694 .collect()
5695 })
5696}
5697
5698#[derive(Default)]
5726struct ParentMemo {
5727 entry: Option<(PathBuf, EntryId)>,
5728}
5729
5730impl ParentMemo {
5731 fn get(&self, dir: &Path) -> Option<EntryId> {
5733 self.entry.as_ref().filter(|(cached, _)| cached == dir).map(|&(_, id)| id)
5734 }
5735
5736 fn set(&mut self, dir: &Path, id: EntryId) {
5737 match &mut self.entry {
5738 Some((cached, cached_id)) => {
5741 cached.clear();
5742 cached.push(dir);
5743 *cached_id = id;
5744 }
5745 slot => *slot = Some((dir.to_path_buf(), id)),
5746 }
5747 }
5748
5749 fn clear(&mut self) {
5750 self.entry = None;
5751 }
5752}
5753
5754#[derive(Default)]
5756struct StructuralOverlay {
5757 entries: HashMap<PathBuf, EntryKind>,
5760 removed_roots: Vec<PathBuf>,
5761}
5762
5763impl StructuralOverlay {
5764 fn kind(&self, index: &Index, path: &Path) -> Option<EntryKind> {
5765 self.entries.get(path).copied().or_else(|| {
5766 (!self.removed_roots.iter().any(|removed| path.starts_with(removed)))
5767 .then(|| index.kind(path))
5768 .flatten()
5769 })
5770 }
5771
5772 fn upsert(&mut self, index: &Index, path: &Path, kind: EntryKind) {
5773 if self.kind(index, path).is_some_and(|current| current != kind) {
5774 self.remove(index, path);
5775 }
5776 self.entries.insert(path.to_path_buf(), kind);
5777 }
5778
5779 fn remove(&mut self, index: &Index, path: &Path) {
5780 if self.kind(index, path).is_none() {
5781 return;
5782 }
5783 self.entries.retain(|candidate, _| !candidate.starts_with(path));
5784 self.removed_roots.retain(|candidate| !candidate.starts_with(path));
5785 if !self.removed_roots.iter().any(|removed| path.starts_with(removed)) {
5786 self.removed_roots.push(path.to_path_buf());
5787 }
5788 }
5789}
5790
5791fn normalize(path: &Path) -> Option<Vec<&OsStr>> {
5792 let mut parts = Vec::new();
5793 for component in path.components() {
5794 match component {
5795 Component::Normal(part) => parts.push(part),
5796 Component::CurDir => {}
5797 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
5798 }
5799 }
5800 Some(parts)
5801}
5802
5803fn prepare_observation(observation: &Observation) -> crate::Result<PreparedObservation> {
5804 let mut ops = Vec::with_capacity(observation.len());
5805 for observed in &observation.ops {
5806 let path = canonical_relative_path(observed.op.path())?;
5807 let op = match &observed.op {
5808 Op::Upsert { kind, attrs, .. } => Op::Upsert { path, kind: *kind, attrs: *attrs },
5809 Op::Remove { .. } => Op::Remove { path },
5810 Op::ControlUpsert { source, .. } => {
5811 if !crate::control::is_control_file(&path) {
5812 return Err(crate::Error::InvalidControlPath(path));
5813 }
5814 Op::ControlUpsert { path, source: source.clone() }
5815 }
5816 Op::ControlRemove { .. } => {
5817 if !crate::control::is_control_file(&path) {
5818 return Err(crate::Error::InvalidControlPath(path));
5819 }
5820 Op::ControlRemove { path }
5821 }
5822 Op::InvalidateSubtree { reason, .. } => Op::InvalidateSubtree { path, reason: *reason },
5823 };
5824 ops.push(ObservationOp { op, expectation: observed.expectation });
5825 }
5826 Ok(PreparedObservation {
5827 ops,
5828 ancestry: PreparedAncestry::General,
5829 #[cfg(test)]
5830 reject_before_apply: false,
5831 })
5832}
5833
5834fn canonical_relative_path(path: &Path) -> crate::Result<PathBuf> {
5835 let mut canonical = PathBuf::with_capacity(path.as_os_str().as_encoded_bytes().len());
5836 for component in path.components() {
5837 match component {
5838 Component::Normal(part) => canonical.push(part),
5839 Component::CurDir => {}
5840 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
5841 return Err(crate::Error::PathEscapesRoot(path.to_path_buf()));
5842 }
5843 }
5844 }
5845 Ok(canonical)
5846}
5847
5848fn derive_impact(changes: &[EffectiveChange], state: &[StateTransition]) -> Impact {
5849 let mut domains = BTreeSet::new();
5850 let mut paths = BTreeSet::new();
5851 let mut all_dirty = false;
5852 let mut ancestor_visits = 0_u64;
5853 let count_impact = crate::counters::enabled();
5854
5855 for change in changes {
5856 match change {
5857 EffectiveChange::Inserted { .. } | EffectiveChange::Removed { .. } => {
5858 domains.extend([
5859 ImpactDomain::Topology,
5860 ImpactDomain::Metadata,
5861 ImpactDomain::Classification,
5862 ImpactDomain::Aggregates,
5863 ImpactDomain::Content,
5864 ]);
5865 }
5866 EffectiveChange::Updated { .. } => {
5867 domains.extend([
5868 ImpactDomain::Metadata,
5869 ImpactDomain::Aggregates,
5870 ImpactDomain::Content,
5871 ]);
5872 }
5873 EffectiveChange::ControlUpdated { .. } | EffectiveChange::Reclassified { .. } => {
5874 domains.extend([ImpactDomain::Classification, ImpactDomain::Aggregates]);
5875 }
5876 EffectiveChange::ControlRefusalUpdated { .. } => {
5877 domains.insert(ImpactDomain::Classification);
5878 }
5879 EffectiveChange::Invalidated { .. } => {
5880 domains.insert(ImpactDomain::State);
5881 }
5882 }
5883 insert_dirty_ancestors(
5884 change.path(),
5885 &mut paths,
5886 &mut all_dirty,
5887 count_impact,
5888 &mut ancestor_visits,
5889 );
5890 }
5891 for transition in state {
5892 domains.insert(ImpactDomain::State);
5893 insert_dirty_ancestors(
5894 transition.path(),
5895 &mut paths,
5896 &mut all_dirty,
5897 count_impact,
5898 &mut ancestor_visits,
5899 );
5900 }
5901
5902 if count_impact {
5903 let candidates =
5904 u64::try_from(changes.len().saturating_add(state.len())).unwrap_or(u64::MAX);
5905 let retained_dirty_paths = u64::try_from(paths.len()).unwrap_or(u64::MAX);
5906 crate::counters::bump(|counts| {
5907 counts.impact_candidates = counts.impact_candidates.saturating_add(candidates);
5908 counts.impact_ancestor_visits =
5909 counts.impact_ancestor_visits.saturating_add(ancestor_visits);
5910 counts.impact_retained_dirty_paths =
5911 counts.impact_retained_dirty_paths.saturating_add(retained_dirty_paths);
5912 counts.impact_all_dirty = counts.impact_all_dirty.saturating_add(u64::from(all_dirty));
5913 });
5914 }
5915
5916 Impact {
5917 domains: domains.into_iter().collect(),
5918 dirty_paths: if all_dirty { Vec::new() } else { paths.into_iter().collect() },
5919 all_dirty,
5920 }
5921}
5922
5923fn commit_work(observations: u64, stats: ApplyStats) -> Work {
5924 Work {
5925 observations,
5926 unchanged: stats.unchanged,
5927 stale: stats.stale,
5928 resource_refused: stats.resource_refused,
5929 ..Work::default()
5930 }
5931}
5932
5933fn insert_dirty_ancestors(
5934 path: &Path,
5935 paths: &mut BTreeSet<PathBuf>,
5936 all_dirty: &mut bool,
5937 count_impact: bool,
5938 ancestor_visits: &mut u64,
5939) {
5940 if *all_dirty {
5941 return;
5942 }
5943 for ancestor in path.ancestors() {
5944 if count_impact {
5945 *ancestor_visits = ancestor_visits.saturating_add(1);
5946 }
5947 paths.insert(ancestor.to_path_buf());
5948 if paths.len() > MAX_DIRTY_PATHS {
5949 paths.clear();
5950 *all_dirty = true;
5951 return;
5952 }
5953 }
5954}
5955
5956fn same_target(
5957 current: Option<EntryIdentity>,
5958 expected: Option<EntryIdentity>,
5959 require_structure: bool,
5960) -> bool {
5961 match (current, expected) {
5962 (Some(current), Some(expected)) => current.same_target(expected, require_structure),
5963 (None, None) => true,
5964 (Some(_), None) | (None, Some(_)) => false,
5965 }
5966}
5967
5968#[cfg(test)]
5969mod tests {
5970 use super::*;
5971 use crate::engine_contract::ObservationOp;
5972 use std::sync::{Arc, Barrier};
5973
5974 #[test]
5975 fn reusable_entry_keeps_directory_state_out_of_line() {
5976 let entry_bytes = std::mem::size_of::<Entry>();
5977 let slot_bytes = std::mem::size_of::<Slot>();
5978
5979 assert!(
5980 entry_bytes <= 136,
5981 "common entry storage must not inline directory-only maps and roll-ups: {entry_bytes} bytes"
5982 );
5983 assert!(
5984 slot_bytes <= entry_bytes + 16,
5985 "the arena slot must not add a second per-entry allocation: entry={entry_bytes}, slot={slot_bytes}"
5986 );
5987 }
5988
5989 #[test]
5990 fn detached_children_store_each_name_once_and_promote_on_mutation() {
5991 let mut builder = DetachedIndexBuilder::new(
5992 "/root",
5993 ScanScope::default(),
5994 crate::classify::TypeRegistry::compiled_shared(),
5995 );
5996 builder
5997 .push_directory(crate::scan::DetachedDirectory {
5998 path: PathBuf::new(),
5999 children: vec![
6000 crate::scan::DetachedChild {
6001 name: OsString::from("dir"),
6002 kind: EntryKind::Dir,
6003 attrs: Attrs::default(),
6004 position: 0,
6005 },
6006 crate::scan::DetachedChild {
6007 name: OsString::from("z.txt"),
6008 kind: EntryKind::File,
6009 attrs: file_attrs(1, 1),
6010 position: 1,
6011 },
6012 ],
6013 control: None,
6014 })
6015 .expect("detached root listing");
6016 builder
6017 .push_directory(crate::scan::DetachedDirectory {
6018 path: PathBuf::from("dir"),
6019 children: vec![
6020 crate::scan::DetachedChild {
6021 name: OsString::from("z.txt"),
6022 kind: EntryKind::File,
6023 attrs: file_attrs(2, 2),
6024 position: 0,
6025 },
6026 crate::scan::DetachedChild {
6027 name: OsString::from("a.txt"),
6028 kind: EntryKind::File,
6029 attrs: file_attrs(3, 3),
6030 position: 1,
6031 },
6032 ],
6033 control: None,
6034 })
6035 .expect("detached child listing");
6036 let mut index = builder.finish();
6037 let directory = index.lookup(Path::new("dir")).expect("detached directory");
6038
6039 assert!(index.entry(EntryId::ROOT).directory().children.is_sorted());
6040 assert!(index.entry(directory).directory().children.is_sorted());
6041 assert_eq!(
6042 index
6043 .children(Path::new("dir"))
6044 .expect("directory children")
6045 .map(|(name, _)| name.to_os_string())
6046 .collect::<Vec<_>>(),
6047 [OsString::from("a.txt"), OsString::from("z.txt")]
6048 );
6049
6050 index.apply_ok(&Observation::new(vec![upsert(
6051 "dir/m.txt",
6052 EntryKind::File,
6053 file_attrs(4, 4),
6054 )]));
6055
6056 assert!(index.entry(EntryId::ROOT).directory().children.is_sorted());
6057 assert!(index.entry(directory).directory().children.is_mutable());
6058 assert_eq!(
6059 index
6060 .children(Path::new("dir"))
6061 .expect("directory children")
6062 .map(|(name, _)| name.to_os_string())
6063 .collect::<Vec<_>>(),
6064 [OsString::from("a.txt"), OsString::from("m.txt"), OsString::from("z.txt")]
6065 );
6066 }
6067
6068 #[test]
6069 fn detached_builder_tolerates_a_duplicate_readdir_name() {
6070 let mut builder = DetachedIndexBuilder::new(
6071 "/root",
6072 ScanScope::default(),
6073 crate::classify::TypeRegistry::compiled_shared(),
6074 );
6075 let twice = |position, mtime_ns| crate::scan::DetachedChild {
6076 name: OsString::from("twice.txt"),
6077 kind: EntryKind::File,
6078 attrs: file_attrs(1, mtime_ns),
6079 position,
6080 };
6081 let result = builder.push_directory(crate::scan::DetachedDirectory {
6082 path: PathBuf::new(),
6083 children: vec![twice(0, 1), twice(1, 2)],
6084 control: None,
6085 });
6086 assert!(result.is_ok(), "a duplicate listing name must not fail the scan: {result:?}");
6087 let detached = builder.finish();
6088 assert_eq!(detached.total().files, 1);
6089 assert_eq!(detached.attrs(Path::new("twice.txt")), Some(&file_attrs(1, 2)));
6090
6091 let mut streaming = Index::new("/root");
6093 streaming
6094 .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
6095 upsert("twice.txt", EntryKind::File, file_attrs(1, 1)),
6096 upsert("twice.txt", EntryKind::File, file_attrs(1, 2)),
6097 ]))
6098 .expect("streaming tolerates a re-upsert");
6099 assert_eq!(streaming.total(), detached.total());
6100 assert_eq!(streaming.attrs(Path::new("twice.txt")), detached.attrs(Path::new("twice.txt")));
6101 }
6102
6103 #[test]
6104 fn detached_builder_accepts_the_repeated_walk_of_a_duplicated_directory() {
6105 let child = |name: &str, kind, attrs, position| crate::scan::DetachedChild {
6106 name: OsString::from(name),
6107 kind,
6108 attrs,
6109 position,
6110 };
6111 let listing = |path: &str, children| crate::scan::DetachedDirectory {
6112 path: PathBuf::from(path),
6113 children,
6114 control: None,
6115 };
6116 let mut builder = DetachedIndexBuilder::new(
6117 "/root",
6118 ScanScope::default(),
6119 crate::classify::TypeRegistry::compiled_shared(),
6120 );
6121 builder
6124 .push_directory(listing(
6125 "",
6126 vec![
6127 child("dir", EntryKind::Dir, file_attrs(0, 1), 0),
6128 child("swapped", EntryKind::Dir, file_attrs(0, 1), 1),
6129 child("dir", EntryKind::Dir, file_attrs(0, 2), 2),
6130 child("swapped", EntryKind::File, file_attrs(5, 2), 3),
6131 ],
6132 ))
6133 .expect("root listing with repeated names");
6134 for _ in 0..2 {
6136 builder
6137 .push_directory(listing(
6138 "dir",
6139 vec![child("nested", EntryKind::Dir, file_attrs(0, 3), 0)],
6140 ))
6141 .expect("each walk of the repeated directory");
6142 builder
6143 .push_directory(listing(
6144 "dir/nested",
6145 vec![child("file.txt", EntryKind::File, file_attrs(4, 4), 0)],
6146 ))
6147 .expect("each walk below the repeated directory");
6148 }
6149 builder
6151 .push_directory(listing(
6152 "swapped",
6153 vec![child("stale.txt", EntryKind::File, file_attrs(6, 5), 0)],
6154 ))
6155 .expect("the superseded directory's walk");
6156 let error = builder
6158 .push_directory(listing("elsewhere", Vec::new()))
6159 .expect_err("a listing whose parent was never listed");
6160 assert!(matches!(
6161 error,
6162 crate::Error::UnknownAncestry { path, .. } if path == Path::new("elsewhere")
6163 ));
6164
6165 let index = builder.finish();
6166 assert_eq!(index.attrs(Path::new("dir")), Some(&file_attrs(0, 2)));
6167 assert_eq!(index.kind(Path::new("swapped")), Some(EntryKind::File));
6168 assert!(index.lookup(Path::new("swapped/stale.txt")).is_none());
6169 let total = index.total();
6170 assert_eq!((total.files, total.dirs, total.bytes), (2, 2, 9));
6171 }
6172
6173 fn file_attrs(size: u64, mtime_ns: i64) -> Attrs {
6174 Attrs {
6175 size,
6176 allocated: size.div_ceil(512) * 512,
6177 mtime_ns,
6178 ctime_ns: mtime_ns,
6179 inode: size.wrapping_mul(31).wrapping_add(mtime_ns.unsigned_abs()),
6180 dev: 1,
6181 }
6182 }
6183
6184 fn upsert(path: &str, kind: EntryKind, attrs: Attrs) -> Op {
6185 Op::Upsert { path: PathBuf::from(path), kind, attrs }
6186 }
6187
6188 fn assert_serving_indexes(index: &Index) {
6189 let serving = index.serving.as_ref().expect("test index has serving state");
6190 let mut entries = BTreeMap::new();
6191 let mut children = BTreeMap::<PathBuf, PortableChildren>::new();
6192 let mut recent_files = BTreeSet::new();
6193 let mut semantic_by_directory =
6194 BTreeMap::<EntryId, (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>)>::new();
6195 let declared_exact_names: BTreeSet<_> =
6196 index.types.exact_filenames().map(str::to_ascii_lowercase).collect();
6197 let mut exact_name_by_directory =
6198 BTreeMap::<EntryId, (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>)>::new();
6199 let mut semantic_refcounts = BTreeMap::<String, u64>::new();
6200 let mut pending = vec![(EntryId::ROOT, PathBuf::new(), vec![EntryId::ROOT])];
6201 while let Some((parent_id, parent_path, ancestors)) = pending.pop() {
6202 let facts: Vec<_> = index
6203 .children_of(parent_id)
6204 .expect("live directory")
6205 .map(|(name, id)| (name.to_os_string(), id))
6206 .collect();
6207 for (name, id) in facts {
6208 let path = parent_path.join(&name);
6209 let kind = index.kind_of(id).expect("live child");
6210 let portable = crate::opened::read::portable_path(&path);
6211 entries.insert(portable.clone(), id);
6212 if kind == EntryKind::File {
6213 recent_files.insert(RecentKey {
6214 mtime_ns: index.attrs(&path).expect("live child has attributes").mtime_ns,
6215 portable_path: portable,
6216 id,
6217 });
6218 }
6219 if kind == EntryKind::File {
6220 let semantic = index.classify(&path).file_type.as_str().to_string();
6221 *semantic_refcounts.entry(semantic.clone()).or_default() += 1;
6222 let attrs = *index.attrs(&path).expect("live child has attributes");
6223 let ignored = index.entry(id).ignored;
6224 for ancestor in &ancestors {
6225 let partition = semantic_by_directory.entry(*ancestor).or_default();
6226 let all = partition.0.entry(semantic.clone()).or_default();
6227 all.files += 1;
6228 all.bytes += attrs.size;
6229 all.allocated += attrs.allocated;
6230 if !ignored {
6231 let unignored = partition.1.entry(semantic.clone()).or_default();
6232 unignored.files += 1;
6233 unignored.bytes += attrs.size;
6234 unignored.allocated += attrs.allocated;
6235 }
6236 }
6237 if let Some(exact_name) = name
6238 .to_str()
6239 .map(str::to_ascii_lowercase)
6240 .filter(|name| declared_exact_names.contains(name))
6241 {
6242 for ancestor in &ancestors {
6243 let partition = exact_name_by_directory.entry(*ancestor).or_default();
6244 let all = partition.0.entry(exact_name.clone()).or_default();
6245 all.files += 1;
6246 all.bytes += attrs.size;
6247 all.allocated += attrs.allocated;
6248 if !ignored {
6249 let unignored = partition.1.entry(exact_name.clone()).or_default();
6250 unignored.files += 1;
6251 unignored.bytes += attrs.size;
6252 unignored.allocated += attrs.allocated;
6253 }
6254 }
6255 }
6256 }
6257 let partition = children.entry(parent_path.clone()).or_default();
6258 let portable_name = crate::opened::read::portable_component(&name);
6259 if kind.is_dir() {
6260 partition.directories.insert(portable_name, id);
6261 } else {
6262 partition.nondirectories.insert(portable_name, id);
6263 }
6264 if kind.is_dir() {
6265 let mut child_ancestors = ancestors.clone();
6266 child_ancestors.insert(0, id);
6267 pending.push((id, path, child_ancestors));
6268 }
6269 }
6270 }
6271
6272 assert_eq!(serving.portable_entries, entries);
6273 assert_eq!(serving.recent_files, recent_files);
6274 let actual_semantics: BTreeMap<_, _> = serving
6275 .semantic_by_directory
6276 .iter()
6277 .map(|(directory, partitions)| {
6278 let named = |source: &BTreeMap<u32, ExtTally>| {
6279 source
6280 .iter()
6281 .map(|(semantic, tally)| {
6282 let name = serving.semantic_names[*semantic as usize]
6283 .as_ref()
6284 .expect("live semantic has a name")
6285 .clone();
6286 (name, *tally)
6287 })
6288 .collect()
6289 };
6290 (*directory, (named(&partitions.all), named(&partitions.unignored)))
6291 })
6292 .collect();
6293 assert_eq!(actual_semantics, semantic_by_directory);
6294 let actual_exact_names: BTreeMap<_, _> = serving
6295 .exact_name_by_directory
6296 .iter()
6297 .map(|(directory, partitions)| {
6298 let named = |source: &BTreeMap<u32, ExtTally>| {
6299 source
6300 .iter()
6301 .map(|(exact_name, tally)| {
6302 (serving.exact_names[*exact_name as usize].clone(), *tally)
6303 })
6304 .collect()
6305 };
6306 (*directory, (named(&partitions.all), named(&partitions.unignored)))
6307 })
6308 .collect();
6309 assert_eq!(actual_exact_names, exact_name_by_directory);
6310 assert_eq!(
6311 serving.exact_names.iter().cloned().collect::<BTreeSet<_>>(),
6312 declared_exact_names
6313 );
6314 assert_eq!(
6315 serving.exact_name_ids,
6316 serving
6317 .exact_names
6318 .iter()
6319 .enumerate()
6320 .map(|(position, name)| {
6321 (
6322 name.clone(),
6323 u32::try_from(position).expect("the exact-name vocabulary fits u32"),
6324 )
6325 })
6326 .collect()
6327 );
6328 assert!(serving.exact_name_by_directory.len() <= index.arena.len());
6329 assert!(serving.exact_name_by_directory.values().all(|partitions| {
6330 partitions.all.len() <= serving.exact_names.len()
6331 && partitions.unignored.len() <= serving.exact_names.len()
6332 && partitions
6333 .all
6334 .keys()
6335 .chain(partitions.unignored.keys())
6336 .all(|name| (*name as usize) < serving.exact_names.len())
6337 }));
6338 let actual_refcounts: BTreeMap<_, _> = serving
6339 .semantic_ids
6340 .iter()
6341 .map(|(name, semantic)| (name.clone(), serving.semantic_refcounts[*semantic as usize]))
6342 .collect();
6343 assert_eq!(actual_refcounts, semantic_refcounts);
6344 assert_eq!(serving.portable_children, children);
6345
6346 assert_eq!(
6350 u64::try_from(serving.portable_entries.len()).expect("entry count fits u64"),
6351 index.len().saturating_sub(1),
6352 "every retained non-root entry has exactly one portable name"
6353 );
6354 }
6355
6356 #[test]
6357 fn portable_indexes_conserve_insert_kind_change_and_subtree_removal() {
6358 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6359 "/root",
6360 ScanScope::default(),
6361 crate::classify::TypeRegistry::compiled_shared(),
6362 DEFAULT_JOURNAL_CAPACITY_BYTES,
6363 );
6364 index.apply_ok(&Observation::new(vec![
6365 upsert("dir", EntryKind::Dir, Attrs::default()),
6366 upsert("dir/a", EntryKind::File, file_attrs(1, 1)),
6367 upsert("replace", EntryKind::File, file_attrs(2, 2)),
6368 ]));
6369 assert_serving_indexes(&index);
6370
6371 index.apply_ok(&Observation::new(vec![
6372 upsert("replace", EntryKind::Dir, Attrs::default()),
6373 upsert("replace/child", EntryKind::File, file_attrs(3, 3)),
6374 ]));
6375 assert_serving_indexes(&index);
6376
6377 index.apply_ok(&Observation::new(vec![upsert("dir/a", EntryKind::File, file_attrs(4, 9))]));
6378 assert_serving_indexes(&index);
6379 assert_eq!(
6380 index
6381 .serving
6382 .as_ref()
6383 .expect("opened test index")
6384 .recent_files
6385 .iter()
6386 .map(|entry| entry.portable_path.as_str())
6387 .collect::<Vec<_>>(),
6388 vec!["dir/a", "replace/child"]
6389 );
6390
6391 index.apply_ok(&Observation::new(vec![
6397 upsert("dir/current", EntryKind::Symlink, file_attrs(5, 5)),
6398 upsert("dir/pipe", EntryKind::Other, file_attrs(6, 6)),
6399 ]));
6400 assert_serving_indexes(&index);
6401 index.apply_ok(&Observation::new(vec![
6402 upsert("dir/current", EntryKind::Symlink, file_attrs(7, 7)),
6403 upsert("dir/pipe", EntryKind::Other, file_attrs(8, 8)),
6404 ]));
6405 assert_serving_indexes(&index);
6406
6407 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("replace") }]));
6408 assert_serving_indexes(&index);
6409 assert_eq!(
6410 index.portable_entries().keys().map(crate::PortablePath::as_str).collect::<Vec<_>>(),
6411 vec!["dir", "dir/a", "dir/current", "dir/pipe"]
6412 );
6413 }
6414
6415 #[test]
6424 fn non_file_attrs_updates_leave_file_semantics_untouched() {
6425 for kind in [EntryKind::Symlink, EntryKind::Other] {
6426 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6427 "/root",
6428 ScanScope::default(),
6429 crate::classify::TypeRegistry::compiled_shared(),
6430 DEFAULT_JOURNAL_CAPACITY_BYTES,
6431 );
6432 index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(1, 1))]));
6433 assert_serving_indexes(&index);
6434 index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(1, 2))]));
6435 assert_serving_indexes(&index);
6436
6437 index.apply_ok(&Observation::new(vec![upsert(
6438 "notes",
6439 EntryKind::File,
6440 file_attrs(5, 3),
6441 )]));
6442 assert_serving_indexes(&index);
6443 index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(4, 4))]));
6444 assert_serving_indexes(&index);
6445
6446 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("notes") }]));
6447 assert_serving_indexes(&index);
6448 }
6449 }
6450
6451 #[test]
6464 fn escaping_touches_only_invalid_bytes_and_percent() {
6465 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6466 "/root",
6467 ScanScope::default(),
6468 crate::classify::TypeRegistry::compiled_shared(),
6469 DEFAULT_JOURNAL_CAPACITY_BYTES,
6470 );
6471 index.apply_ok(&Observation::new(vec![
6472 upsert("dir", EntryKind::Dir, Attrs::default()),
6473 upsert("dir/plain.txt", EntryKind::File, file_attrs(1, 1)),
6474 upsert("café", EntryKind::Dir, Attrs::default()),
6475 upsert("café/naïve.txt", EntryKind::File, file_attrs(2, 2)),
6476 upsert("日本語.md", EntryKind::File, file_attrs(3, 3)),
6477 upsert("a b", EntryKind::Dir, Attrs::default()),
6478 upsert("a b/c d.txt", EntryKind::File, file_attrs(5, 5)),
6479 upsert("100%.txt", EntryKind::File, file_attrs(4, 4)),
6480 ]));
6481
6482 let names: Vec<_> =
6483 index.portable_entries().keys().map(crate::PortablePath::as_str).collect();
6484 assert_eq!(
6485 names,
6486 vec![
6487 "100%25.txt",
6488 "a b",
6489 "a b/c d.txt",
6490 "café",
6491 "café/naïve.txt",
6492 "dir",
6493 "dir/plain.txt",
6494 "日本語.md",
6495 ],
6496 "only the literal percent is rewritten; separators, spaces and non-ASCII are not"
6497 );
6498 }
6499
6500 #[test]
6501 fn declared_exact_names_roll_up_by_ancestor_and_partition() {
6502 let types = Arc::new(
6503 crate::classify::TypeRegistry::from_manifest(
6504 "[[kind]]\nid = \"make\"\nfamily = \"code\"\nfilenames = [\"Makefile\"]\n",
6505 )
6506 .expect("custom registry"),
6507 );
6508 let scope =
6509 ScanScope { type_rules_fingerprint: types.fingerprint(), ..ScanScope::default() };
6510 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6511 "/root",
6512 scope,
6513 types,
6514 DEFAULT_JOURNAL_CAPACITY_BYTES,
6515 );
6516 index.apply_ok(&Observation::new(vec![
6517 upsert("Makefile", EntryKind::File, file_attrs(2, 1)),
6518 upsert("dir", EntryKind::Dir, Attrs::default()),
6519 upsert("dir/makefile", EntryKind::File, file_attrs(3, 2)),
6520 upsert("dir/notes", EntryKind::File, file_attrs(5, 3)),
6521 ]));
6522
6523 let serving = index.serving.as_ref().expect("opened test index");
6524 let exact_name = serving.exact_name_ids["makefile"];
6525 let root = &serving.exact_name_by_directory[&EntryId::ROOT];
6526 assert_eq!(root.all[&exact_name], ExtTally { files: 2, bytes: 5, allocated: 1_024 });
6527 assert_eq!(root.unignored, root.all);
6528
6529 let directory = index.lookup(Path::new("dir")).expect("directory");
6530 let nested = &serving.exact_name_by_directory[&directory];
6531 assert_eq!(nested.all[&exact_name], ExtTally { files: 1, bytes: 3, allocated: 512 });
6532 assert_eq!(nested.unignored, nested.all);
6533 }
6534
6535 #[test]
6536 #[ignore = "manual opened-root commit-cost evidence"]
6537 fn measure_opened_serving_commit_cost() {
6538 const DIRECTORY_COUNT: usize = 100;
6539 const FILES_PER_DIRECTORY: usize = 100;
6540 const SAMPLE_COUNT: usize = 7;
6541
6542 let mut operations = Vec::with_capacity(
6543 DIRECTORY_COUNT.saturating_mul(FILES_PER_DIRECTORY.saturating_add(1)),
6544 );
6545 for directory in 0..DIRECTORY_COUNT {
6546 let parent = format!("d{directory:03}");
6547 operations.push(upsert(&parent, EntryKind::Dir, Attrs::default()));
6548 for file in 0..FILES_PER_DIRECTORY {
6549 let size = u64::try_from(file).expect("the probe file count fits u64") + 1;
6550 let mtime = i64::try_from(file).expect("the probe file count fits i64");
6551 let name = if file == 0 {
6552 format!("{parent}/Makefile")
6553 } else {
6554 format!("{parent}/f{file:03}.rs")
6555 };
6556 operations.push(upsert(&name, EntryKind::File, file_attrs(size, mtime)));
6557 }
6558 }
6559 let observation = Observation::new(operations);
6560 let types = crate::classify::TypeRegistry::compiled_shared();
6561 let scope =
6562 ScanScope { type_rules_fingerprint: types.fingerprint(), ..ScanScope::default() };
6563 let measure = |opened: bool| {
6564 let mut index = if opened {
6565 Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6566 "/root",
6567 scope,
6568 Arc::clone(&types),
6569 DEFAULT_JOURNAL_CAPACITY_BYTES,
6570 )
6571 } else {
6572 Index::new_with_scope_types_and_journal_capacity_bytes(
6573 "/root",
6574 scope,
6575 Arc::clone(&types),
6576 DEFAULT_JOURNAL_CAPACITY_BYTES,
6577 )
6578 };
6579 let started = std::time::Instant::now();
6580 index.apply_ok(&observation);
6581 let elapsed = started.elapsed();
6582 std::hint::black_box(index.len());
6583 (elapsed, index)
6584 };
6585
6586 let _ = measure(false);
6587 let _ = measure(true);
6588 let mut detached = Vec::with_capacity(SAMPLE_COUNT);
6589 let mut opened = Vec::with_capacity(SAMPLE_COUNT);
6590 let mut last_opened = None;
6591 for sample in 0..SAMPLE_COUNT {
6592 if sample % 2 == 0 {
6593 detached.push(measure(false).0);
6594 let (duration, index) = measure(true);
6595 opened.push(duration);
6596 last_opened = Some(index);
6597 } else {
6598 let (duration, index) = measure(true);
6599 opened.push(duration);
6600 last_opened = Some(index);
6601 detached.push(measure(false).0);
6602 }
6603 }
6604 detached.sort_unstable();
6605 opened.sort_unstable();
6606 let detached_median = detached[SAMPLE_COUNT / 2];
6607 let opened_median = opened[SAMPLE_COUNT / 2];
6608 let ratio = opened_median.as_secs_f64() / detached_median.as_secs_f64();
6609
6610 let index = last_opened.expect("an opened sample ran");
6611 let serving = index.serving.as_ref().expect("opened sample has serving indexes");
6612 let semantic_rows: usize = serving
6613 .semantic_by_directory
6614 .values()
6615 .map(|partitions| partitions.all.len() + partitions.unignored.len())
6616 .sum();
6617 let exact_name_rows: usize = serving
6618 .exact_name_by_directory
6619 .values()
6620 .map(|partitions| partitions.all.len() + partitions.unignored.len())
6621 .sum();
6622 eprintln!(
6623 "entries={} detached_median_us={} opened_median_us={} ratio={ratio:.3} \
6624 portable_rows={} child_rows={} recent_rows={} semantic_rows={} exact_name_rows={} \
6625 exact_name_vocabulary={}",
6626 index.len(),
6627 detached_median.as_micros(),
6628 opened_median.as_micros(),
6629 serving.portable_entries.len(),
6630 serving
6631 .portable_children
6632 .values()
6633 .map(|children| children.directories.len() + children.nondirectories.len())
6634 .sum::<usize>(),
6635 serving.recent_files.len(),
6636 semantic_rows,
6637 exact_name_rows,
6638 serving.exact_names.len(),
6639 );
6640 }
6641
6642 #[test]
6643 fn detached_indexes_never_allocate_or_populate_serving_state() {
6644 let mut index = Index::new("/root");
6645 index.apply_ok(&Observation::new(vec![
6646 upsert("dir", EntryKind::Dir, Attrs::default()),
6647 upsert("dir/a", EntryKind::File, file_attrs(1, 1)),
6648 ]));
6649
6650 assert!(index.serving.is_none());
6651 assert!(index.portable_children(Path::new("dir")).is_none());
6652 }
6653
6654 #[test]
6655 fn insert_loaded_child_skips_serving_path_when_serving_is_off() {
6656 let mut index = Index::new("/root");
6657 let id = index
6658 .insert_loaded_child(
6659 EntryId::ROOT,
6660 OsString::from("a.rs"),
6661 EntryKind::File,
6662 file_attrs(4, 1),
6663 )
6664 .expect("parent is a live directory");
6665 assert!(!index.serving_indexes_enabled());
6666 assert_eq!(index.path_of(id), Some(PathBuf::from("a.rs")));
6667 assert_eq!(index.lookup(Path::new("a.rs")), Some(id));
6668 assert_eq!(index.total().files, 1);
6669 }
6670
6671 #[test]
6672 fn insert_loaded_child_fills_serving_when_enabled() {
6673 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6674 "/root",
6675 ScanScope::default(),
6676 crate::classify::TypeRegistry::compiled_shared(),
6677 DEFAULT_JOURNAL_CAPACITY_BYTES,
6678 );
6679 let id = index
6680 .insert_loaded_child(
6681 EntryId::ROOT,
6682 OsString::from("a.rs"),
6683 EntryKind::File,
6684 file_attrs(4, 1),
6685 )
6686 .expect("parent is a live directory");
6687 assert!(index.serving_indexes_enabled());
6688 assert_eq!(index.path_of(id), Some(PathBuf::from("a.rs")));
6689 let serving = index.serving.as_ref().expect("opened test index");
6690 assert!(serving.portable_entries.keys().any(|path| path.as_str() == "a.rs"));
6691 }
6692
6693 #[test]
6694 fn opened_entry_values_project_name_identity_without_retaining_it_on_detached_entries() {
6695 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6696 "/root",
6697 ScanScope::default(),
6698 crate::classify::TypeRegistry::compiled_shared(),
6699 DEFAULT_JOURNAL_CAPACITY_BYTES,
6700 );
6701 index.apply_ok(&Observation::new(vec![upsert(
6702 "bundle.umd.min.js",
6703 EntryKind::File,
6704 file_attrs(7, 1),
6705 )]));
6706
6707 let row = index.entry_value(Path::new("bundle.umd.min.js")).expect("entry value");
6708 let identity = row.classification.expect("regular files carry name identity");
6709 assert_eq!(identity.logical_extension(), Some(".min.js"));
6710 assert_eq!(identity.canonical_extension(), Some(".js"));
6711 assert_eq!(identity.kind_id(), Some("javascript"));
6712 assert_eq!(identity.content_family(), crate::classify::ContentFamily::Code);
6713 }
6714
6715 #[test]
6716 fn a_shared_snapshot_drops_opened_root_serving_state() {
6717 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6718 "/root",
6719 ScanScope::default(),
6720 crate::classify::TypeRegistry::compiled_shared(),
6721 DEFAULT_JOURNAL_CAPACITY_BYTES,
6722 );
6723 index.apply_ok(&Observation::new(vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))]));
6724 assert!(index.serving_indexes_enabled());
6725
6726 let snapshot = IndexHandle::new(index).snapshot().expect("detached snapshot");
6727
6728 assert!(!snapshot.serving_indexes_enabled());
6729 assert_eq!(snapshot.total().files, 1);
6730 }
6731
6732 #[test]
6733 #[should_panic(expected = "an index's registry must match its semantic scope")]
6734 fn an_index_cannot_claim_a_registry_different_from_its_scope() {
6735 let types = Arc::new(
6736 crate::classify::TypeRegistry::from_manifest(
6737 "[[kind]]\nid = \"notes\"\nfamily = \"prose\"\nextensions = [\"rs\"]\n",
6738 )
6739 .expect("custom registry"),
6740 );
6741
6742 let _ = Index::new_with_scope_and_types("/root", ScanScope::default(), types);
6743 }
6744
6745 #[test]
6752 fn parent_memo_does_not_survive_removing_the_directory_it_remembers() {
6753 let mut index = Index::new(PathBuf::from("/root"));
6754 index.apply_ok(&Observation::new(vec![
6755 upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
6756 upsert("dir/a.txt", EntryKind::File, file_attrs(10, 1)),
6757 ]));
6758
6759 index.apply_ok(&Observation::new(vec![
6763 upsert("dir/b.txt", EntryKind::File, file_attrs(20, 1)),
6764 Op::Remove { path: PathBuf::from("dir") },
6765 upsert("dir", EntryKind::Dir, file_attrs(0, 2)),
6766 upsert("dir/c.txt", EntryKind::File, file_attrs(30, 2)),
6767 ]));
6768
6769 let children = index.children(Path::new("dir")).expect("dir survives");
6770 let names: Vec<_> = children.map(|(name, _)| name.to_os_string()).collect();
6771 assert_eq!(names, vec![OsString::from("c.txt")], "only the re-added child remains");
6772 assert_eq!(index.total().bytes, 30, "totals match the surviving child");
6773 }
6774
6775 #[test]
6776 fn a_kind_change_mid_batch_leaves_the_memo_usable_for_the_next_sibling() {
6777 let mut index = Index::new(PathBuf::from("/root"));
6778 index.apply_ok(&Observation::new(vec![
6779 upsert("swap", EntryKind::Dir, file_attrs(0, 1)),
6780 upsert("swap/inner", EntryKind::Dir, file_attrs(0, 1)),
6781 upsert("swap/inner/deep.txt", EntryKind::File, file_attrs(40, 1)),
6782 ]));
6783
6784 index.apply_ok(&Observation::new(vec![
6790 upsert("swap/inner/other.txt", EntryKind::File, file_attrs(50, 1)),
6791 upsert("swap/inner", EntryKind::File, file_attrs(60, 2)),
6792 upsert("swap/sibling.txt", EntryKind::File, file_attrs(70, 2)),
6793 ]));
6794
6795 let children = index.children(Path::new("swap")).expect("swap survives");
6796 let names: Vec<_> = children.map(|(name, _)| name.to_os_string()).collect();
6797 assert_eq!(names, vec![OsString::from("inner"), OsString::from("sibling.txt")]);
6798 assert_eq!(index.total().files, 2, "inner counts once, as a file");
6799 assert_eq!(index.total().bytes, 130, "the dropped subtree's bytes are gone");
6800 }
6801
6802 #[test]
6803 fn parent_memo_distinguishes_directories_that_share_a_name_prefix() {
6804 let mut index = Index::new(PathBuf::from("/root"));
6805 index.apply_ok(&Observation::new(vec![
6808 upsert("src", EntryKind::Dir, file_attrs(0, 1)),
6809 upsert("src2", EntryKind::Dir, file_attrs(0, 1)),
6810 upsert("src/one.txt", EntryKind::File, file_attrs(11, 1)),
6811 upsert("src2/two.txt", EntryKind::File, file_attrs(22, 1)),
6812 upsert("src/three.txt", EntryKind::File, file_attrs(33, 1)),
6813 ]));
6814
6815 let in_src: Vec<_> = index
6816 .children(Path::new("src"))
6817 .expect("src")
6818 .map(|(name, _)| name.to_os_string())
6819 .collect();
6820 let in_src2: Vec<_> = index
6821 .children(Path::new("src2"))
6822 .expect("src2")
6823 .map(|(name, _)| name.to_os_string())
6824 .collect();
6825 assert_eq!(in_src, vec![OsString::from("one.txt"), OsString::from("three.txt")]);
6826 assert_eq!(in_src2, vec![OsString::from("two.txt")]);
6827 }
6828
6829 #[test]
6830 fn parent_memo_leaves_root_level_entries_alone() {
6831 let mut index = Index::new(PathBuf::from("/root"));
6834 index.apply_ok(&Observation::new(vec![
6835 upsert("a.txt", EntryKind::File, file_attrs(5, 1)),
6836 upsert("b.txt", EntryKind::File, file_attrs(6, 1)),
6837 upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
6838 upsert("dir/c.txt", EntryKind::File, file_attrs(7, 1)),
6839 upsert("d.txt", EntryKind::File, file_attrs(8, 1)),
6840 ]));
6841
6842 let top: Vec<_> = index
6843 .children(Path::new(""))
6844 .expect("root children")
6845 .map(|(name, _)| name.to_os_string())
6846 .collect();
6847 assert_eq!(
6848 top,
6849 vec![
6850 OsString::from("a.txt"),
6851 OsString::from("b.txt"),
6852 OsString::from("d.txt"),
6853 OsString::from("dir"),
6854 ]
6855 );
6856 assert_eq!(index.total().files, 4);
6857 assert_eq!(index.total().bytes, 26);
6858 }
6859
6860 #[test]
6861 fn shared_queries_return_owned_values_and_release_the_lock() {
6862 let handle = IndexHandle::new(index_with_sample_tree());
6863 let retained_total = handle.total().expect("total");
6864 let retained_history = handle.since(Clock::ZERO).expect("history");
6865 let retained_children = handle.children(Path::new("src")).expect("children");
6866 let retained_snapshot = handle.snapshot().expect("snapshot");
6867
6868 let writer = handle.clone();
6869 let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
6870 let thread = std::thread::spawn(move || {
6871 let result = writer.apply(&Observation::new(vec![upsert(
6872 "concurrent.txt",
6873 EntryKind::File,
6874 file_attrs(7, 30),
6875 )]));
6876 done_tx.send(result).expect("report writer result");
6877 });
6878
6879 let outcome = done_rx
6880 .recv_timeout(std::time::Duration::from_secs(5))
6881 .expect("owned query results must not retain the read lock")
6882 .expect("writer apply");
6883 thread.join().expect("writer thread");
6884
6885 assert_eq!(outcome.inserted, 1);
6886 assert_eq!(retained_total.files, 3);
6887 assert!(!retained_history.commits.is_empty());
6888 assert_eq!(retained_children.expect("src directory").len(), 2);
6889 assert!(retained_snapshot.lookup(Path::new("concurrent.txt")).is_none());
6890 assert!(handle.kind(Path::new("concurrent.txt")).expect("query").is_some());
6891 }
6892
6893 #[test]
6894 fn cloned_indexes_are_independent_detached_images() {
6895 let original = index_with_sample_tree();
6896 let original_clock = original.clock();
6897 let mut detached = original.clone();
6898
6899 detached.apply_ok(&Observation::new(vec![upsert(
6900 "detached-only.txt",
6901 EntryKind::File,
6902 file_attrs(7, 30),
6903 )]));
6904
6905 assert_eq!(original.clock(), original_clock);
6906 assert!(original.lookup(Path::new("detached-only.txt")).is_none());
6907 assert!(detached.lookup(Path::new("detached-only.txt")).is_some());
6908 assert_eq!(detached.total().files, original.total().files + 1);
6909 }
6910
6911 #[test]
6912 fn captured_child_expectations_match_individual_path_lookups() {
6913 let index = index_with_sample_tree();
6914 let captured = collect_child_expectations(&index, Path::new("src"));
6915
6916 assert!(!captured.is_empty());
6917 for (name, expectation) in captured {
6918 assert_eq!(expectation, index.expectation(&Path::new("src").join(name)));
6919 }
6920 }
6921
6922 #[test]
6923 fn index_and_shared_handle_are_send_and_sync() {
6924 fn assert_send_sync<T: Send + Sync>() {}
6925
6926 assert_send_sync::<Index>();
6927 assert_send_sync::<IndexHandle>();
6928 }
6929
6930 #[test]
6931 fn simultaneous_writers_commit_unique_contiguous_clocks_in_journal_order() {
6932 let writer_count: usize = 8;
6933 let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
6934 let barrier: Arc<Barrier> = Arc::new(Barrier::new(writer_count));
6935 let (result_tx, result_rx) = std::sync::mpsc::sync_channel(writer_count);
6936
6937 std::thread::scope(|scope| {
6938 for worker_id in 0..writer_count {
6939 let worker: IndexHandle = handle.clone();
6940 let start: Arc<Barrier> = Arc::clone(&barrier);
6941 let results = result_tx.clone();
6942 scope.spawn(move || {
6943 let ordinal: u64 = u64::try_from(worker_id + 1).expect("small worker count");
6944 let path: String = format!("writer-{ordinal}.txt");
6945 start.wait();
6946 let outcome: crate::Result<ApplyOutcome> =
6947 worker.apply(&Observation::new(vec![upsert(
6948 &path,
6949 EntryKind::File,
6950 file_attrs(ordinal, i64::try_from(ordinal).expect("small ordinal")),
6951 )]));
6952 results.send((path, outcome)).expect("report writer result");
6953 });
6954 }
6955 });
6956 drop(result_tx);
6957
6958 let mut committed_clocks: Vec<u64> = Vec::with_capacity(writer_count);
6959 for (path, outcome) in result_rx {
6960 let commit = outcome.expect("writer apply").commit.expect("unique upsert must commit");
6961 committed_clocks.push(commit.clock.0);
6962 assert!(handle.kind(Path::new(&path)).expect("query committed path").is_some());
6963 }
6964 committed_clocks.sort_unstable();
6965
6966 let last_clock: u64 = u64::try_from(writer_count).expect("small writer count");
6967 let expected_clocks: Vec<u64> = (1..=last_clock).collect();
6968 assert_eq!(committed_clocks, expected_clocks);
6969 assert_eq!(handle.clock().expect("clock"), Clock(last_clock));
6970
6971 let journal_clocks: Vec<u64> = handle
6972 .since(Clock::ZERO)
6973 .expect("journal")
6974 .commits
6975 .iter()
6976 .map(|commit| commit.clock.0)
6977 .collect();
6978 assert_eq!(journal_clocks, expected_clocks);
6979 }
6980
6981 #[test]
6982 fn readers_observe_only_complete_states_around_a_large_batch() {
6983 let file_count: u64 = 2_048;
6984 let expected_bytes: u64 = file_count * (file_count + 1) / 2;
6985 let operations: Vec<Op> =
6986 std::iter::once(upsert("batch", EntryKind::Dir, file_attrs(0, 1)))
6987 .chain((1..=file_count).map(|ordinal| {
6988 upsert(
6989 &format!("batch/file-{ordinal}.bin"),
6990 EntryKind::File,
6991 file_attrs(ordinal, i64::try_from(ordinal).expect("small ordinal")),
6992 )
6993 }))
6994 .collect();
6995 let observation: Observation = Observation::new(operations);
6996 let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
6997 let before: Index = handle.snapshot().expect("before snapshot");
6998 assert_eq!(before.total().files, 0);
6999
7000 let barrier: Arc<Barrier> = Arc::new(Barrier::new(2));
7001 let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
7002 std::thread::scope(|scope| {
7003 let writer: IndexHandle = handle.clone();
7004 let writer_start: Arc<Barrier> = Arc::clone(&barrier);
7005 scope.spawn(move || {
7006 writer_start.wait();
7007 let result: crate::Result<ApplyOutcome> = writer.apply(&observation);
7008 done_tx.send(result).expect("report batch result");
7009 });
7010
7011 let reader: IndexHandle = handle.clone();
7012 let reader_start: Arc<Barrier> = Arc::clone(&barrier);
7013 scope.spawn(move || {
7014 reader_start.wait();
7015 let deadline: std::time::Instant =
7016 std::time::Instant::now() + std::time::Duration::from_secs(10);
7017 loop {
7018 assert!(
7019 std::time::Instant::now() < deadline,
7020 "reader did not observe batch completion before the deadline"
7021 );
7022 let image: Index = reader.snapshot().expect("coherent reader snapshot");
7023 let total = image.total();
7024 match total.files {
7025 0 => {
7026 assert_eq!(total.bytes, 0);
7027 assert_eq!(image.len(), 1);
7028 }
7029 count if count == file_count => {
7030 assert_eq!(total.bytes, expected_bytes);
7031 assert_eq!(total.dirs, 1);
7032 assert_eq!(image.len(), file_count + 2);
7033 }
7034 partial => panic!("reader observed partial batch with {partial} files"),
7035 }
7036
7037 match done_rx.try_recv() {
7038 Ok(result) => {
7039 assert_eq!(result.expect("batch apply").inserted, file_count + 1);
7040 break;
7041 }
7042 Err(std::sync::mpsc::TryRecvError::Empty) => {}
7043 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
7044 panic!("batch writer disconnected")
7045 }
7046 }
7047 }
7048 });
7049 });
7050
7051 let after: Index = handle.snapshot().expect("after snapshot");
7052 assert_eq!(after.total().files, file_count);
7053 assert_eq!(after.total().bytes, expected_bytes);
7054 assert_eq!(after.len(), file_count + 2);
7055 }
7056
7057 #[test]
7058 fn poisoned_shared_lock_returns_typed_errors() {
7059 let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
7060 let poisoner: IndexHandle = handle.clone();
7061 let panic_result: std::thread::Result<()> = std::thread::spawn(move || {
7062 let _poison_guard = poisoner.write_index().expect("initial write lock");
7063 panic!("intentional lock poison");
7064 })
7065 .join();
7066 assert!(panic_result.is_err());
7067
7068 assert!(matches!(handle.total(), Err(crate::Error::IndexLockPoisoned)));
7069 assert!(matches!(
7070 handle.apply(&Observation::new(vec![upsert(
7071 "never-applied.txt",
7072 EntryKind::File,
7073 file_attrs(1, 1),
7074 )])),
7075 Err(crate::Error::IndexLockPoisoned)
7076 ));
7077 }
7078
7079 #[test]
7080 fn clock_exhaustion_rejects_before_any_mutation() {
7081 let mut index = index_with_sample_tree();
7082 index.clock = Clock(u64::MAX);
7083 let before_total = index.total();
7084 let before_len = index.len();
7085
7086 let error = index
7087 .apply(&Observation::new(vec![upsert(
7088 "too-late.txt",
7089 EntryKind::File,
7090 file_attrs(1, 1),
7091 )]))
7092 .expect_err("clock exhaustion must be typed");
7093
7094 assert!(matches!(error, crate::Error::ClockExhausted));
7095 assert_eq!(index.clock(), Clock(u64::MAX));
7096 assert_eq!(index.len(), before_len);
7097 assert_eq!(index.total(), before_total);
7098 assert!(index.lookup(Path::new("too-late.txt")).is_none());
7099 }
7100
7101 #[test]
7102 fn terminal_clock_still_accepts_no_op_and_stale_observations() {
7103 let mut index = index_with_sample_tree();
7104 let current = *index.attrs(Path::new("src/main.rs")).expect("sample attributes");
7105 let stale_baseline = index.expectation(Path::new("src/main.rs"));
7106 index.apply_ok(&Observation::new(vec![upsert(
7107 "src/main.rs",
7108 EntryKind::File,
7109 file_attrs(99, 99),
7110 )]));
7111 index.clock = Clock(u64::MAX);
7112 let before_total = index.total();
7113 let before_len = index.len();
7114 let before_journal = index.journal.clone();
7115
7116 let no_op = index
7117 .apply(&Observation::new(vec![upsert(
7118 "src/main.rs",
7119 EntryKind::File,
7120 file_attrs(99, 99),
7121 )]))
7122 .expect("a no-op needs no new clock");
7123 let stale = index
7124 .apply(&Observation::from_ops(vec![ObservationOp::if_state(
7125 upsert("src/main.rs", EntryKind::File, current),
7126 stale_baseline,
7127 )]))
7128 .expect("a rejected stale observation needs no new clock");
7129
7130 assert_eq!(no_op.unchanged, 1);
7131 assert!(no_op.commit.is_none());
7132 assert_eq!(stale.stale, 1);
7133 assert!(stale.commit.is_none());
7134 assert_eq!(index.clock(), Clock(u64::MAX));
7135 assert_eq!(index.len(), before_len);
7136 assert_eq!(index.total(), before_total);
7137 assert_eq!(index.journal, before_journal);
7138 }
7139
7140 #[test]
7141 fn delayed_conditional_observation_cannot_overwrite_newer_state() {
7142 let mut index = Index::new("/root");
7143 index.apply_ok(&Observation::new(vec![upsert(
7144 "file.txt",
7145 EntryKind::File,
7146 file_attrs(10, 1),
7147 )]));
7148
7149 let baseline = index.expectation(Path::new("file.txt"));
7150 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7151 upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
7152 baseline,
7153 )]);
7154
7155 index.apply_ok(&Observation::new(vec![upsert(
7156 "file.txt",
7157 EntryKind::File,
7158 file_attrs(30, 3),
7159 )]));
7160 let outcome = index.apply_ok(&delayed);
7161
7162 assert_eq!(outcome.stats.stale, 1);
7163 assert!(outcome.commit.is_none());
7164 assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 30);
7165 }
7166
7167 #[test]
7168 fn delayed_absent_child_cannot_replace_a_newer_parent_file() {
7169 let mut index = Index::new("/root");
7170 index.apply_ok(&Observation::new(vec![upsert("parent", EntryKind::Dir, file_attrs(0, 1))]));
7171 let child_baseline = index.expectation(Path::new("parent/child.txt"));
7172 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7173 upsert("parent/child.txt", EntryKind::File, file_attrs(10, 2)),
7174 child_baseline,
7175 )]);
7176
7177 index.apply_ok(&Observation::new(vec![upsert(
7178 "parent",
7179 EntryKind::File,
7180 file_attrs(20, 3),
7181 )]));
7182 let outcome = index.apply_ok(&delayed);
7183
7184 assert_eq!(outcome.stats.stale, 1);
7185 assert!(outcome.commit.is_none());
7186 assert_eq!(index.kind(Path::new("parent")), Some(EntryKind::File));
7187 assert!(index.lookup(Path::new("parent/child.txt")).is_none());
7188 }
7189
7190 #[test]
7191 fn conditional_observation_rejects_present_state_aba() {
7192 let mut index = Index::new("/root");
7193 index.apply_ok(&Observation::new(vec![upsert(
7194 "file.txt",
7195 EntryKind::File,
7196 file_attrs(10, 1),
7197 )]));
7198 let baseline = index.expectation(Path::new("file.txt"));
7199 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7200 upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
7201 baseline,
7202 )]);
7203
7204 index.apply_ok(&Observation::new(vec![upsert(
7205 "file.txt",
7206 EntryKind::File,
7207 file_attrs(30, 3),
7208 )]));
7209 index.apply_ok(&Observation::new(vec![upsert(
7210 "file.txt",
7211 EntryKind::File,
7212 file_attrs(10, 1),
7213 )]));
7214 let outcome = index.apply_ok(&delayed);
7215
7216 assert_eq!(outcome.stats.stale, 1);
7217 assert!(outcome.commit.is_none());
7218 assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 10);
7219 }
7220
7221 #[test]
7222 fn conditional_observation_rejects_absent_state_aba() {
7223 let mut index = Index::new("/root");
7224 let baseline = index.expectation(Path::new("file.txt"));
7225 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7226 upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
7227 baseline,
7228 )]);
7229
7230 index.apply_ok(&Observation::new(vec![upsert(
7231 "file.txt",
7232 EntryKind::File,
7233 file_attrs(30, 3),
7234 )]));
7235 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("file.txt") }]));
7236 let outcome = index.apply_ok(&delayed);
7237
7238 assert_eq!(outcome.stats.stale, 1);
7239 assert!(outcome.commit.is_none());
7240 assert!(index.lookup(Path::new("file.txt")).is_none());
7241 }
7242
7243 #[test]
7248 fn convergent_conditional_upsert_applies_as_unchanged_not_stale() {
7249 let mut index = Index::new("/root");
7250 index.apply_ok(&Observation::new(vec![upsert(
7251 "file.txt",
7252 EntryKind::File,
7253 file_attrs(10, 1),
7254 )]));
7255 let baseline = index.expectation(Path::new("file.txt"));
7256 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7257 upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
7258 baseline,
7259 )]);
7260
7261 index.apply_ok(&Observation::new(vec![upsert(
7262 "file.txt",
7263 EntryKind::File,
7264 file_attrs(20, 2),
7265 )]));
7266 let outcome = index.apply_ok(&delayed);
7267
7268 assert_eq!(outcome.stats.stale, 0);
7269 assert_eq!(outcome.stats.unchanged, 1);
7270 assert!(outcome.commit.is_none());
7271 assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 20);
7272 }
7273
7274 #[test]
7275 fn convergent_conditional_remove_applies_as_unchanged_not_stale() {
7276 let mut index = Index::new("/root");
7277 index.apply_ok(&Observation::new(vec![
7278 upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
7279 upsert("dir/file.txt", EntryKind::File, file_attrs(10, 1)),
7280 ]));
7281 let baseline = index.expectation(Path::new("dir/file.txt"));
7282 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7283 Op::Remove { path: PathBuf::from("dir/file.txt") },
7284 baseline,
7285 )]);
7286
7287 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("dir/file.txt") }]));
7288 let outcome = index.apply_ok(&delayed);
7289
7290 assert_eq!(outcome.stats.stale, 0);
7291 assert_eq!(outcome.stats.unchanged, 1);
7292 assert!(outcome.commit.is_none());
7293 assert!(index.lookup(Path::new("dir/file.txt")).is_none());
7294 }
7295
7296 #[test]
7301 fn convergent_conditional_control_ops_apply_as_unchanged_not_stale() {
7302 let path = PathBuf::from(".gitignore");
7303 let rules =
7304 |source: &[u8]| Op::ControlUpsert { path: path.clone(), source: source.to_vec() };
7305 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
7306 index.apply_ok(&Observation::new(vec![
7307 upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
7308 rules(b"before"),
7309 ]));
7310 let baseline = index.expectation(&path);
7311 index.apply_ok(&Observation::new(vec![
7312 upsert(".gitignore", EntryKind::File, file_attrs(7, 2)),
7313 rules(b"changed"),
7314 ]));
7315
7316 let outcome = index.apply_ok(&Observation::from_ops(vec![
7317 ObservationOp::if_state(
7318 upsert(".gitignore", EntryKind::File, file_attrs(7, 2)),
7319 baseline,
7320 ),
7321 ObservationOp::if_state(rules(b"changed"), baseline),
7322 ]));
7323 assert_eq!(outcome.stats.stale, 0);
7324 assert!(outcome.commit.is_none());
7325 let diverged = index.apply_ok(&Observation::from_ops(vec![ObservationOp::if_state(
7326 rules(b"other"),
7327 baseline,
7328 )]));
7329 assert_eq!(diverged.stats.stale, 1);
7330 assert!(index.controls().expect("control state observed").source_is(&path, b"changed"));
7331
7332 let baseline = index.expectation(&path);
7333 index.apply_ok(&Observation::new(vec![Op::Remove { path: path.clone() }]));
7334 let outcome = index.apply_ok(&Observation::from_ops(vec![
7335 ObservationOp::if_state(Op::Remove { path: path.clone() }, baseline),
7336 ObservationOp::if_state(Op::ControlRemove { path: path.clone() }, baseline),
7337 ]));
7338 assert_eq!(outcome.stats.stale, 0);
7339 assert!(outcome.commit.is_none());
7340 assert!(!index.controls().expect("control state observed").contains(&path));
7341 }
7342
7343 #[test]
7344 fn unrelated_mutation_does_not_stale_an_absent_path() {
7345 let mut index = Index::new("/root");
7346 index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
7347 let baseline = index.expectation(Path::new("dir/new.txt"));
7348 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7349 upsert("dir/new.txt", EntryKind::File, file_attrs(20, 2)),
7350 baseline,
7351 )]);
7352
7353 index.apply_ok(&Observation::new(vec![upsert(
7354 "other.txt",
7355 EntryKind::File,
7356 file_attrs(30, 3),
7357 )]));
7358 let outcome = index.apply_ok(&delayed);
7359
7360 assert_eq!(outcome.stats.stale, 0);
7361 assert_eq!(outcome.stats.inserted, 1);
7362 assert_eq!(index.attrs(Path::new("dir/new.txt")).expect("file").size, 20);
7363 }
7364
7365 #[test]
7366 fn directory_metadata_change_does_not_stale_an_absent_child() {
7367 let mut index = Index::new("/root");
7368 index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
7369 let baseline = index.expectation(Path::new("dir/new.txt"));
7370 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7371 upsert("dir/new.txt", EntryKind::File, file_attrs(20, 2)),
7372 baseline,
7373 )]);
7374
7375 index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 3))]));
7376 let outcome = index.apply_ok(&delayed);
7377
7378 assert_eq!(outcome.stats.stale, 0);
7379 assert_eq!(outcome.stats.inserted, 1);
7380 }
7381
7382 #[test]
7383 fn file_parent_metadata_change_stales_an_absent_child() {
7384 let mut index = Index::new("/root");
7385 index.apply_ok(&Observation::new(vec![upsert(
7386 "parent",
7387 EntryKind::File,
7388 file_attrs(10, 1),
7389 )]));
7390 let baseline = index.expectation(Path::new("parent/child.txt"));
7391 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7392 upsert("parent/child.txt", EntryKind::File, file_attrs(20, 2)),
7393 baseline,
7394 )]);
7395
7396 index.apply_ok(&Observation::new(vec![upsert(
7397 "parent",
7398 EntryKind::File,
7399 file_attrs(30, 3),
7400 )]));
7401 let outcome = index.apply_ok(&delayed);
7402
7403 assert_eq!(outcome.stats.stale, 1);
7404 assert_eq!(index.kind(Path::new("parent")), Some(EntryKind::File));
7405 assert!(index.lookup(Path::new("parent/child.txt")).is_none());
7406 }
7407
7408 #[test]
7409 fn delayed_directory_remove_cannot_delete_a_newer_child() {
7410 let mut index = Index::new("/root");
7411 index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
7412 let baseline = index.expectation(Path::new("dir"));
7413 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7414 Op::Remove { path: PathBuf::from("dir") },
7415 baseline,
7416 )]);
7417
7418 index.apply_ok(&Observation::new(vec![upsert(
7419 "dir/new.txt",
7420 EntryKind::File,
7421 file_attrs(20, 2),
7422 )]));
7423 let outcome = index.apply_ok(&delayed);
7424
7425 assert_eq!(outcome.stats.stale, 1);
7426 assert!(index.lookup(Path::new("dir/new.txt")).is_some());
7427 }
7428
7429 #[test]
7430 fn conditional_batch_is_validated_at_one_boundary() {
7431 let mut index = Index::new("/root");
7432 let first = index.expectation(Path::new("first.txt"));
7433 let second = index.expectation(Path::new("second.txt"));
7434
7435 let outcome = index.apply_ok(&Observation::from_ops(vec![
7436 ObservationOp::if_state(upsert("first.txt", EntryKind::File, file_attrs(10, 1)), first),
7437 ObservationOp::if_state(
7438 upsert("second.txt", EntryKind::File, file_attrs(20, 2)),
7439 second,
7440 ),
7441 ]));
7442
7443 assert_eq!(outcome.stats.inserted, 2);
7444 assert_eq!(outcome.stats.stale, 0);
7445 }
7446
7447 #[test]
7448 fn public_observation_outputs_use_canonical_encoded_paths() {
7449 let separator = std::path::MAIN_SEPARATOR;
7450 let dotted = PathBuf::from(format!("dotted{separator}.{separator}file.txt"));
7451 let dotted_canonical = PathBuf::from(format!("dotted{separator}file.txt"));
7452 let repeated = PathBuf::from(format!("repeated{separator}{separator}file.txt"));
7453 let repeated_canonical = PathBuf::from(format!("repeated{separator}file.txt"));
7454 let mut index = Index::new("/root");
7455
7456 let outcome = index.apply_ok(&Observation::new(vec![
7457 upsert("dotted", EntryKind::Dir, file_attrs(0, 1)),
7458 Op::Upsert { path: dotted, kind: EntryKind::File, attrs: file_attrs(10, 2) },
7459 upsert("repeated", EntryKind::Dir, file_attrs(0, 3)),
7460 Op::Upsert { path: repeated, kind: EntryKind::File, attrs: file_attrs(20, 4) },
7461 ]));
7462 let commit = outcome.commit.as_ref().expect("one exact commit");
7463
7464 for (actual, canonical) in [
7465 (commit.changes[1].path(), dotted_canonical.as_path()),
7466 (commit.changes[3].path(), repeated_canonical.as_path()),
7467 ] {
7468 assert_eq!(
7469 actual.as_os_str().as_encoded_bytes(),
7470 canonical.as_os_str().as_encoded_bytes()
7471 );
7472 }
7473
7474 for canonical in [&dotted_canonical, &repeated_canonical] {
7475 let dirty = commit
7476 .impact
7477 .dirty_paths
7478 .iter()
7479 .find(|candidate| candidate.as_path() == canonical)
7480 .expect("changed path is dirty");
7481 assert_eq!(
7482 dirty.as_os_str().as_encoded_bytes(),
7483 canonical.as_os_str().as_encoded_bytes()
7484 );
7485 }
7486 }
7487
7488 #[test]
7495 fn trailing_path_spellings_leave_errors_and_changes_canonical() {
7496 let separator = std::path::MAIN_SEPARATOR;
7497 let canonical = format!("a{separator}b");
7498 for spelling in [format!("a{separator}b{separator}"), format!("a{separator}b{separator}.")]
7499 {
7500 let file = |mtime_ns| Op::Upsert {
7501 path: PathBuf::from(&spelling),
7502 kind: EntryKind::File,
7503 attrs: file_attrs(1, mtime_ns),
7504 };
7505 let mut index = Index::new("/root");
7506
7507 let error = index
7508 .apply(&Observation::new(vec![file(1)]))
7509 .expect_err("a child of an unknown directory is refused");
7510 let crate::Error::UnknownAncestry { path, .. } = error else {
7511 panic!("expected unknown ancestry for {spelling:?}, got {error}");
7512 };
7513 assert_eq!(path.as_os_str().as_encoded_bytes(), canonical.as_bytes(), "{spelling:?}");
7514
7515 let outcome = index.apply_ok(&Observation::new(vec![
7516 upsert("a", EntryKind::Dir, file_attrs(0, 1)),
7517 file(2),
7518 ]));
7519 let commit = outcome.commit.as_ref().expect("one exact commit");
7520 assert_eq!(
7521 commit.changes[1].path().as_os_str().as_encoded_bytes(),
7522 canonical.as_bytes(),
7523 "{spelling:?}"
7524 );
7525 }
7526 }
7527
7528 #[test]
7529 fn malformed_batch_is_rejected_before_any_index_mutation() {
7530 let invalid_paths = [
7531 PathBuf::from("../escape"),
7532 PathBuf::from(format!("{}absolute", std::path::MAIN_SEPARATOR)),
7533 ];
7534
7535 for invalid_path in invalid_paths {
7536 for invalid_first in [false, true] {
7537 let mut index = index_with_sample_tree();
7538 let before_clock = index.clock;
7539 let before_live = index.live;
7540 let before_total = index.total();
7541 let before_journal = index.journal.clone();
7542 let before_journal_cost = index.journal_cost;
7543 let before_journal_floor = index.journal_floor;
7544 let before_invalidations = index.pending_invalidations.clone();
7545 let before_freshness_epoch = index.freshness_epoch;
7546 let before_freshness = index.freshness();
7547 let valid = upsert("new.txt", EntryKind::File, file_attrs(99, 99));
7548 let invalid = Op::InvalidateSubtree {
7549 path: invalid_path.clone(),
7550 reason: InvalidateReason::Requested,
7551 };
7552 let ops = if invalid_first { vec![invalid, valid] } else { vec![valid, invalid] };
7553
7554 let error = index.apply(&Observation::new(ops)).expect_err("malformed batch");
7555
7556 assert!(
7557 matches!(error, crate::Error::PathEscapesRoot(path) if path == invalid_path)
7558 );
7559 assert_eq!(index.clock, before_clock);
7560 assert_eq!(index.live, before_live);
7561 assert_eq!(index.total(), before_total);
7562 assert_eq!(index.journal, before_journal);
7563 assert_eq!(index.journal_cost, before_journal_cost);
7564 assert_eq!(index.journal_floor, before_journal_floor);
7565 assert_eq!(index.pending_invalidations, before_invalidations);
7566 assert_eq!(index.freshness_epoch, before_freshness_epoch);
7567 assert_eq!(index.freshness(), before_freshness);
7568 assert!(index.lookup(Path::new("new.txt")).is_none());
7569 }
7570 }
7571 }
7572
7573 #[cfg(windows)]
7574 #[test]
7575 fn windows_prefix_is_rejected_before_mutation() {
7576 let mut index = index_with_sample_tree();
7577 let before_clock = index.clock();
7578
7579 let error = index
7580 .apply(&Observation::new(vec![Op::Remove { path: PathBuf::from(r"C:\escape") }]))
7581 .expect_err("prefixed path");
7582
7583 assert!(
7584 matches!(error, crate::Error::PathEscapesRoot(path) if path == Path::new(r"C:\escape"))
7585 );
7586 assert_eq!(index.clock(), before_clock);
7587 }
7588
7589 fn index_with_sample_tree() -> Index {
7590 let mut index = Index::new("/root");
7591 index.apply_ok(&Observation::new(vec![
7592 upsert("src", EntryKind::Dir, Attrs::default()),
7593 upsert("src/main.rs", EntryKind::File, file_attrs(100, 10)),
7594 upsert("src/lib.rs", EntryKind::File, file_attrs(200, 20)),
7595 upsert("docs", EntryKind::Dir, Attrs::default()),
7596 upsert("docs/guide.md", EntryKind::File, file_attrs(300, 30)),
7597 ]));
7598 index
7599 }
7600
7601 #[test]
7602 fn the_extension_interner_reclaims_ids_after_churn() {
7603 let mut index = Index::new("/root");
7607 for sequence in 0..128 {
7608 let path = PathBuf::from(format!("build.out-{sequence}"));
7609 index.apply_ok(&Observation::new(vec![Op::Upsert {
7610 path: path.clone(),
7611 kind: EntryKind::File,
7612 attrs: file_attrs(1, sequence),
7613 }]));
7614 index.apply_ok(&Observation::new(vec![Op::Remove { path }]));
7615 }
7616
7617 assert!(index.ext_ids.is_empty(), "no extension survives the file that named it");
7618 assert_eq!(index.ext_names.len(), 1, "128 dead extensions reuse one interner slot");
7619 assert!(index.total().by_ext.is_empty());
7620 }
7621
7622 #[test]
7623 fn a_reclaimed_extension_id_does_not_alias_a_live_tally() {
7624 let mut index = Index::new("/root");
7627 index.apply_ok(&Observation::new(vec![
7628 upsert("keep.rs", EntryKind::File, file_attrs(10, 1)),
7629 upsert("drop.tmp", EntryKind::File, file_attrs(20, 2)),
7630 ]));
7631 let retained = index.total();
7632 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("drop.tmp") }]));
7633 index.apply_ok(&Observation::new(vec![upsert(
7634 "next.bak",
7635 EntryKind::File,
7636 file_attrs(30, 3),
7637 )]));
7638
7639 let tallies = index.total().by_ext;
7640 assert_eq!(tallies[".rs"], ExtTally { files: 1, bytes: 10, allocated: 512 });
7641 assert_eq!(tallies[".bak"], ExtTally { files: 1, bytes: 30, allocated: 512 });
7642 assert!(!tallies.contains_key(".tmp"), "the removed extension is gone");
7643 assert_eq!(
7644 retained.by_ext[".tmp"],
7645 ExtTally { files: 1, bytes: 20, allocated: 512 },
7646 "an owned roll-up stays self-describing after its interner slot is reused"
7647 );
7648 assert!(!retained.by_ext.contains_key(".bak"));
7649 }
7650
7651 #[test]
7652 fn rollups_aggregate_up_the_tree() {
7653 let index = index_with_sample_tree();
7654
7655 let total = index.total();
7656 assert_eq!(total.files, 3);
7657 assert_eq!(total.dirs, 2);
7658 assert_eq!(total.bytes, 600);
7659 assert_eq!(total.newest_mtime_ns, 30);
7660
7661 let src = index.rollup(Path::new("src")).expect("src is a directory");
7662 assert_eq!(src.files, 2);
7663 assert_eq!(src.dirs, 0);
7664 assert_eq!(src.bytes, 300);
7665 assert_eq!(src.newest_mtime_ns, 20);
7666 }
7667
7668 #[test]
7669 fn rollups_conserve_hand_counted_populations_through_updates_and_subtree_replacement() {
7670 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
7671 let attrs =
7672 |size, allocated, inode| Attrs { size, allocated, inode, dev: 1, ..Attrs::default() };
7673 index.apply_ok(&Observation::new(vec![
7674 upsert(".gitignore", EntryKind::File, attrs(6, 512, 1)),
7675 upsert("a", EntryKind::Dir, Attrs::default()),
7676 upsert("a/keep.rs", EntryKind::File, attrs(7, 512, 99)),
7677 upsert("a/drop.log", EntryKind::File, attrs(11, 1_024, 3)),
7678 upsert("z.rs", EntryKind::File, attrs(5, 4_096, 99)),
7679 Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
7680 ]));
7681
7682 let total = index.partition_total().expect("control state observed");
7683 assert_eq!(
7684 (total.all.files, total.all.dirs, total.all.bytes, total.all.allocated),
7685 (4, 1, 29, 6_144)
7686 );
7687 assert_eq!(
7688 (total.unignored.files, total.unignored.bytes, total.unignored.allocated),
7689 (3, 18, 5_120)
7690 );
7691 assert_eq!(index.rollup(Path::new("a")).expect("directory").bytes, 18);
7692 assert_eq!(
7693 total.all.by_ext[".rs"].files, 2,
7694 "two paths with one inode each contribute a file"
7695 );
7696
7697 index.apply_ok(&Observation::new(vec![upsert(
7698 "a/drop.log",
7699 EntryKind::File,
7700 attrs(13, 1_536, 3),
7701 )]));
7702 let total = index.partition_total().expect("control state observed");
7703 assert_eq!((total.all.files, total.all.bytes, total.all.allocated), (4, 31, 6_656));
7704 assert_eq!(
7705 (total.unignored.files, total.unignored.bytes, total.unignored.allocated),
7706 (3, 18, 5_120)
7707 );
7708
7709 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("a") }]));
7710 let total = index.partition_total().expect("control state observed");
7711 assert_eq!(
7712 (total.all.files, total.all.dirs, total.all.bytes, total.all.allocated),
7713 (2, 0, 11, 4_608)
7714 );
7715 assert_eq!(total.all, total.unignored);
7716
7717 index.apply_ok(&Observation::new(vec![
7718 upsert("a", EntryKind::Dir, Attrs::default()),
7719 upsert("a/final.log", EntryKind::File, attrs(17, 4_096, 4)),
7720 ]));
7721 let total = index.partition_total().expect("control state observed");
7722 assert_eq!(
7723 (total.all.files, total.all.dirs, total.all.bytes, total.all.allocated),
7724 (3, 1, 28, 8_704)
7725 );
7726 assert_eq!(
7727 (
7728 total.unignored.files,
7729 total.unignored.dirs,
7730 total.unignored.bytes,
7731 total.unignored.allocated
7732 ),
7733 (2, 1, 11, 4_608)
7734 );
7735 assert_eq!(index.rollup(Path::new("a")).expect("directory").bytes, 17);
7736 }
7737
7738 #[test]
7739 fn symlinks_and_special_nodes_do_not_contribute_regular_file_tallies() {
7740 let mut index = Index::new("/root");
7741 index.apply_ok(&Observation::new(vec![
7742 upsert("regular.txt", EntryKind::File, file_attrs(10, 10)),
7743 upsert("link.rs", EntryKind::Symlink, file_attrs(99, 99)),
7744 upsert("socket.md", EntryKind::Other, file_attrs(88, 88)),
7745 ]));
7746
7747 let total = index.total();
7748 assert_eq!(total.files, 1);
7749 assert_eq!(total.bytes, 10);
7750 assert_eq!(total.allocated, 512);
7751 assert_eq!(total.newest_mtime_ns, 10);
7752 assert_eq!(total.by_ext[".txt"], ExtTally { files: 1, bytes: 10, allocated: 512 });
7753 assert!(!total.by_ext.contains_key(".rs"));
7754 assert!(!total.by_ext.contains_key(".md"));
7755
7756 index.apply_ok(&Observation::new(vec![upsert(
7757 "link.rs",
7758 EntryKind::File,
7759 file_attrs(99, 99),
7760 )]));
7761 assert_eq!(index.total().files, 2);
7762 assert_eq!(index.total().bytes, 109);
7763
7764 index.apply_ok(&Observation::new(vec![upsert(
7765 "link.rs",
7766 EntryKind::Symlink,
7767 file_attrs(99, 99),
7768 )]));
7769 assert_eq!(index.total().files, 1);
7770 assert_eq!(index.total().bytes, 10);
7771 }
7772
7773 #[test]
7774 fn per_extension_tallies_roll_up_hierarchically() {
7775 let index = index_with_sample_tree();
7776
7777 let total = index.total();
7778 assert_eq!(total.by_ext[".rs"], ExtTally { files: 2, bytes: 300, allocated: 1024 });
7779 assert_eq!(total.by_ext[".md"], ExtTally { files: 1, bytes: 300, allocated: 512 });
7780
7781 let src = index.rollup(Path::new("src")).expect("src is a directory");
7783 assert_eq!(src.by_ext[".rs"], ExtTally { files: 2, bytes: 300, allocated: 1024 });
7784 assert!(!src.by_ext.contains_key(".md"));
7785 }
7786
7787 #[test]
7788 fn per_extension_allocated_tracks_apparent_bytes_separately() {
7789 let mut index = Index::new("/root");
7792 index.apply_ok(&Observation::new(vec![
7793 upsert("a.rs", EntryKind::File, file_attrs(1, 10)),
7795 upsert("b.rs", EntryKind::File, file_attrs(2, 20)),
7796 ]));
7797
7798 let rs = index.total().by_ext[".rs"];
7799 assert_eq!((rs.files, rs.bytes), (2, 3));
7800 assert_eq!(rs.allocated, 1024, "each file occupies one 512-byte block");
7801
7802 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("b.rs") }]));
7804 let rs = index.total().by_ext[".rs"];
7805 assert_eq!((rs.files, rs.bytes, rs.allocated), (1, 1, 512));
7806 }
7807
7808 #[test]
7809 fn upsert_with_matching_fingerprint_is_a_no_op() {
7810 let mut index = index_with_sample_tree();
7811 let before = index.total();
7812 let mark = index.clock();
7813
7814 let stats = index.apply_ok(&Observation::new(vec![upsert(
7815 "src/main.rs",
7816 EntryKind::File,
7817 file_attrs(100, 10),
7818 )]));
7819
7820 assert_eq!(stats.unchanged, 1);
7821 assert_eq!(stats.updated, 0);
7822 assert_eq!(index.total(), before);
7823 assert_eq!(index.clock(), mark);
7824 assert!(index.since(mark).commits.is_empty());
7825 }
7826
7827 #[test]
7828 fn commit_contains_only_effective_mutations() {
7829 let mut index = index_with_sample_tree();
7830 let outcome = index.apply_ok(&Observation::new(vec![
7831 upsert("src/main.rs", EntryKind::File, file_attrs(100, 10)),
7832 upsert("new.txt", EntryKind::File, file_attrs(4, 4)),
7833 Op::Remove { path: PathBuf::from("missing.txt") },
7834 ]));
7835
7836 assert_eq!(outcome.stats.unchanged, 2);
7837 let commit = outcome.commit.expect("one effective insert");
7838 assert_eq!(commit.changes.len(), 1);
7839 assert_eq!(commit.changes[0].path(), Path::new("new.txt"));
7840 }
7841
7842 #[test]
7843 fn mutation_counters_match_exact_batch_and_consequence_totals() {
7844 struct DisableCounters;
7845
7846 impl Drop for DisableCounters {
7847 fn drop(&mut self) {
7848 crate::counters::enable(false);
7849 crate::counters::test_thread_reset();
7850 }
7851 }
7852
7853 let _serial = crate::counters::test_serial();
7854 crate::counters::enable(true);
7855 let _disable = DisableCounters;
7856 let observation = Observation::new(vec![
7857 upsert("a", EntryKind::Dir, Attrs::default()),
7858 upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
7859 upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
7860 ]);
7861
7862 crate::counters::test_thread_reset();
7863 let mut baseline = Index::new("/root");
7864 baseline.apply_baseline_ok(&observation);
7865 let counts = crate::counters::test_thread_snapshot();
7866 assert_eq!(counts.baseline_batches, 1);
7867 assert_eq!(counts.baseline_accepted_ops, 3);
7868 assert_eq!(counts.opened_batches, 0);
7869 assert_eq!(counts.public_batches, 0);
7870 assert_eq!(counts.ancestry_overlay_inserts, 3);
7871 assert_eq!(counts.ancestry_path_comparisons, 2);
7872 assert_eq!(counts.ancestry_parent_proofs, 3);
7873 assert_eq!(counts.effect_paths, 0);
7874 assert_eq!(counts.effect_path_bytes, 0);
7875 assert_eq!(counts.impact_candidates, 0);
7876 assert_eq!(counts.impact_ancestor_visits, 0);
7877 assert_eq!(counts.impact_retained_dirty_paths, 0);
7878 assert_eq!(counts.impact_all_dirty, 0);
7879 assert_eq!(counts.journal_cloned_commits, 0);
7880 assert_eq!(counts.journal_retained_commits, 0);
7881
7882 crate::counters::test_thread_reset();
7883 let mut public = Index::new("/root");
7884 public.apply_ok(&observation);
7885 let counts = crate::counters::test_thread_snapshot();
7886 assert_eq!(counts.baseline_batches, 0);
7887 assert_eq!(counts.opened_batches, 0);
7888 assert_eq!(counts.public_batches, 1);
7889 assert_eq!(counts.public_accepted_ops, 3);
7890 assert_eq!(counts.effect_paths, 3);
7891 assert_eq!(counts.journal_cloned_commits, 1);
7892 assert_eq!(counts.journal_retained_commits, 1);
7893 assert_eq!(counts.journal_oversized_commits, 0);
7894 assert_eq!(counts.journal_dropped_commits, 0);
7895
7896 crate::counters::test_thread_reset();
7897 let opened = IndexHandle::new(Index::new("/root"));
7898 opened
7899 .apply_discovery(&observation, DiscoveryCommit::default())
7900 .expect("opened discovery batch");
7901 let counts = crate::counters::test_thread_snapshot();
7902 assert_eq!(counts.baseline_batches, 0);
7903 assert_eq!(counts.opened_batches, 1);
7904 assert_eq!(counts.opened_accepted_ops, 3);
7905 assert_eq!(counts.public_batches, 0);
7906
7907 crate::counters::test_thread_reset();
7908 let mut scanner = Index::new("/root");
7909 scanner
7910 .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
7911 upsert("a", EntryKind::Dir, Attrs::default()),
7912 upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
7913 upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
7914 ]))
7915 .expect("private scanner batch");
7916 let counts = crate::counters::test_thread_snapshot();
7917 assert_eq!(counts.baseline_batches, 1);
7918 assert_eq!(counts.baseline_accepted_ops, 3);
7919 assert_eq!(counts.ancestry_overlay_inserts, 0);
7920 assert_eq!(counts.ancestry_path_comparisons, 2);
7921 assert_eq!(counts.ancestry_parent_proofs, 3);
7922 assert_eq!(counts.parent_resolutions, 0);
7923 assert_eq!(counts.parent_memo_hits, 0);
7924 assert_eq!(scanner.total().dirs, 1);
7925 assert_eq!(scanner.total().files, 2);
7926
7927 crate::counters::test_thread_reset();
7928 let opened_scanner = IndexHandle::new(Index::new("/root"));
7929 opened_scanner
7930 .apply_scanner_discovery_bounded(
7931 crate::scan::ScannerBatch::from_ops(vec![
7932 upsert("a", EntryKind::Dir, Attrs::default()),
7933 upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
7934 upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
7935 ]),
7936 DiscoveryCommit::default(),
7937 None,
7938 )
7939 .expect("opened scanner batch");
7940 let counts = crate::counters::test_thread_snapshot();
7941 assert_eq!(counts.opened_batches, 1);
7942 assert_eq!(counts.opened_accepted_ops, 3);
7943 assert_eq!(counts.ancestry_overlay_inserts, 0);
7944 assert_eq!(counts.effect_paths, 3);
7945 assert_eq!(counts.journal_retained_commits, 1);
7946
7947 let two_commits = 2 * commit_cost(vec![upsert("one", EntryKind::File, file_attrs(1, 1))]);
7950 crate::counters::test_thread_reset();
7951 let mut bounded = Index::new("/root");
7952 bounded.journal_capacity_bytes = two_commits;
7953 bounded.apply_ok(&Observation::new(vec![upsert("one", EntryKind::File, file_attrs(1, 1))]));
7954 bounded.apply_ok(&Observation::new(vec![upsert("two", EntryKind::File, file_attrs(2, 2))]));
7955 bounded.journal_capacity_bytes = 1;
7956 bounded.apply_ok(&Observation::new(vec![upsert(
7957 "three",
7958 EntryKind::File,
7959 file_attrs(3, 3),
7960 )]));
7961 let counts = crate::counters::test_thread_snapshot();
7962 assert_eq!(counts.journal_cloned_commits, 2);
7963 assert_eq!(counts.journal_retained_commits, 2);
7964 assert_eq!(counts.journal_oversized_commits, 1);
7965 assert_eq!(counts.journal_dropped_commits, 2);
7966 }
7967
7968 #[test]
7969 fn detached_and_exact_reducers_produce_the_same_facts_and_stats() {
7970 fn fact_image(index: &Index) -> Vec<(PathBuf, EntryKind, Attrs, bool, Source, bool)> {
7971 let mut image = Vec::new();
7972 let mut frontier = VecDeque::from([EntryId::ROOT]);
7973 while let Some(id) = frontier.pop_front() {
7974 let entry = index.entry(id);
7975 if entry.kind.is_dir() {
7976 frontier.extend(index.child_ids(id));
7977 }
7978 image.push((
7979 index.path_of(id).expect("live entry path"),
7980 entry.kind,
7981 entry.attrs,
7982 entry.ignored,
7983 entry.source,
7984 entry.directory.as_deref().is_none_or(|directory| directory.children_complete),
7985 ));
7986 }
7987 image.sort_by(|left, right| left.0.cmp(&right.0));
7988 image
7989 }
7990
7991 fn assert_same_facts(detached: &Index, exact: &Index) {
7992 assert_eq!(fact_image(detached), fact_image(exact));
7993 assert_eq!(detached.total(), exact.total());
7994 let partitions =
7995 |index: &Index| index.named_partitions(index.entry(EntryId::ROOT).rollup());
7996 assert_eq!(partitions(detached), partitions(exact));
7997 let controls = |index: &Index| {
7998 index
7999 .controls
8000 .sources()
8001 .map(|(path, source)| (path, source.to_vec()))
8002 .collect::<Vec<_>>()
8003 };
8004 assert_eq!(controls(detached), controls(exact));
8005 assert_eq!(detached.state, exact.state);
8006 assert_eq!(detached.issues, exact.issues);
8007 let freshness = |index: &Index| {
8008 index
8009 .freshness_marks
8010 .iter()
8011 .map(|(path, mark)| (path.clone(), mark.state, mark.epoch))
8012 .collect::<Vec<_>>()
8013 };
8014 assert_eq!(freshness(detached), freshness(exact));
8015 assert_eq!(detached.verified, exact.verified);
8016 assert_eq!(detached.pending_invalidations, exact.pending_invalidations);
8017 }
8018
8019 let mut detached = Index::new("/root");
8020 let mut exact = detached.clone();
8021 let batches = [
8022 Observation::new(vec![
8023 upsert("a", EntryKind::Dir, file_attrs(0, 1)),
8024 upsert("a/one.rs", EntryKind::File, file_attrs(10, 2)),
8025 upsert("a/two.txt", EntryKind::File, file_attrs(20, 3)),
8026 ]),
8027 Observation::new(vec![
8028 upsert("a/one.rs", EntryKind::File, file_attrs(30, 4)),
8029 Op::Remove { path: PathBuf::from("a/two.txt") },
8030 Op::InvalidateSubtree {
8031 path: PathBuf::from("a"),
8032 reason: InvalidateReason::VerificationFailed,
8033 },
8034 ]),
8035 ];
8036
8037 for batch in batches {
8038 let detached_stats = detached.apply_baseline(&batch).expect("detached batch");
8039 let exact_outcome = exact.apply(&batch).expect("exact batch");
8040 assert_eq!(detached_stats, exact_outcome.stats);
8041 exact.establish_baseline();
8042 assert_same_facts(&detached, &exact);
8043 }
8044 }
8045
8046 #[test]
8047 fn exact_commit_records_verified_ancestry_and_kind_replacement() {
8048 let mut index = Index::new("/root");
8049 let inserted = index.apply_ok(&Observation::new(vec![
8050 upsert("unknown", EntryKind::Dir, file_attrs(0, 1)),
8051 upsert("unknown/deep", EntryKind::Dir, file_attrs(0, 2)),
8052 upsert("unknown/deep/file.txt", EntryKind::File, file_attrs(10, 3)),
8053 ]));
8054 let inserted = inserted.commit.expect("ancestry commit");
8055 assert_eq!(
8056 inserted.changes.iter().map(EffectiveChange::path).collect::<Vec<_>>(),
8057 [Path::new("unknown"), Path::new("unknown/deep"), Path::new("unknown/deep/file.txt")]
8058 );
8059 assert!(
8060 inserted
8061 .changes
8062 .iter()
8063 .all(|change| matches!(change, EffectiveChange::Inserted { .. }))
8064 );
8065
8066 let replaced = index.apply_ok(&Observation::new(vec![upsert(
8067 "unknown",
8068 EntryKind::File,
8069 file_attrs(20, 2),
8070 )]));
8071 let replaced = replaced.commit.expect("replacement commit");
8072 assert_eq!(
8073 replaced.changes,
8074 vec![
8075 EffectiveChange::Removed {
8076 path: "unknown".into(),
8077 kind: EntryKind::Dir,
8078 attrs: file_attrs(0, 1),
8079 },
8080 EffectiveChange::Removed {
8081 path: "unknown/deep".into(),
8082 kind: EntryKind::Dir,
8083 attrs: file_attrs(0, 2),
8084 },
8085 EffectiveChange::Removed {
8086 path: "unknown/deep/file.txt".into(),
8087 kind: EntryKind::File,
8088 attrs: file_attrs(10, 3),
8089 },
8090 EffectiveChange::Inserted {
8091 path: "unknown".into(),
8092 kind: EntryKind::File,
8093 attrs: file_attrs(20, 2),
8094 },
8095 ]
8096 );
8097 assert_eq!(
8098 replaced.impact.domains,
8099 vec![
8100 ImpactDomain::Topology,
8101 ImpactDomain::Metadata,
8102 ImpactDomain::Classification,
8103 ImpactDomain::Aggregates,
8104 ImpactDomain::Content,
8105 ]
8106 );
8107 assert_eq!(
8108 replaced.impact.dirty_paths,
8109 vec![
8110 PathBuf::new(),
8111 "unknown".into(),
8112 "unknown/deep".into(),
8113 "unknown/deep/file.txt".into(),
8114 ]
8115 );
8116 }
8117
8118 #[test]
8119 fn rejected_prepared_commit_is_fault_atomic() {
8120 let mut index = index_with_sample_tree();
8121 let before_clock = index.clock();
8122 let before_total = index.total();
8123 let before_len = index.len();
8124 let before_history = index.since(Clock::ZERO);
8125 let mut prepared = prepare_observation(&Observation::new(vec![upsert(
8126 "new/deep.txt",
8127 EntryKind::File,
8128 file_attrs(99, 99),
8129 )]))
8130 .expect("valid preparation");
8131 prepared.reject_before_apply = true;
8132
8133 let error = index.commit_prepared(prepared, true).expect_err("injected preflight");
8134
8135 assert!(matches!(error, crate::Error::CommitRejected("injected reducer preflight")));
8136 assert_eq!(index.clock(), before_clock);
8137 assert_eq!(index.total(), before_total);
8138 assert_eq!(index.len(), before_len);
8139 assert_eq!(index.since(Clock::ZERO), before_history);
8140 assert!(index.lookup(Path::new("new")).is_none());
8141 }
8142
8143 #[test]
8144 fn reconciliation_state_moves_through_exact_commits() {
8145 let mut index = Index::new("/root");
8146 let (started, start) = index.begin_reconcile(Path::new("src")).expect("begin");
8147 let start = start.expect("start commit");
8148 assert!(start.changes.is_empty());
8149 assert_eq!(
8150 start.state,
8151 vec![
8152 StateTransition::Freshness {
8153 path: "src".into(),
8154 previous: Freshness::Fresh,
8155 current: Freshness::Reconciling,
8156 },
8157 StateTransition::IndexState {
8158 previous: IndexState::default(),
8159 current: IndexState {
8160 freshness: Freshness::Reconciling,
8161 ..IndexState::default()
8162 },
8163 },
8164 ]
8165 );
8166
8167 let finish = index
8168 .finish_reconcile(
8169 Path::new("src"),
8170 started,
8171 true,
8172 &[],
8173 &[],
8174 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
8175 )
8176 .expect("finish")
8177 .commit
8178 .expect("finish commit");
8179 assert!(finish.changes.is_empty());
8180 assert_eq!(
8181 finish.state,
8182 vec![
8183 StateTransition::Verified { path: "src".into() },
8184 StateTransition::Freshness {
8185 path: "src".into(),
8186 previous: Freshness::Reconciling,
8187 current: Freshness::Fresh,
8188 },
8189 StateTransition::IndexState {
8190 previous: IndexState {
8191 freshness: Freshness::Reconciling,
8192 ..IndexState::default()
8193 },
8194 current: IndexState::default(),
8195 },
8196 ]
8197 );
8198 }
8199
8200 #[cfg(feature = "watch")]
8201 #[test]
8202 fn observation_failure_cannot_overwrite_a_terminal_resource_stop() {
8203 let handle = IndexHandle::new(Index::new("/root"));
8204 handle
8205 .transition_discovery(DiscoveryTransition::BudgetRefused(Issue::resource_budget(0)))
8206 .expect("stop for budget");
8207 let stopped = handle.state().expect("stopped state");
8208 let clock = handle.clock().expect("stopped clock");
8209
8210 let outcome = handle
8211 .transition_observation(ObservationTransition::Failed(Issue::from_error(
8212 &crate::Error::WatchStopped,
8213 )))
8214 .expect("late observer failure is ignored");
8215
8216 assert_eq!(outcome.commit, None);
8217 assert_eq!(handle.state().expect("terminal state"), stopped);
8218 assert_eq!(handle.clock().expect("terminal clock"), clock);
8219 }
8220
8221 #[test]
8223 fn a_terminal_root_refuses_every_discovery_commit() {
8224 let terminals = [
8225 DiscoveryTransition::BudgetRefused(Issue::resource_budget(1)),
8226 DiscoveryTransition::Failed(Issue::from_error(&crate::Error::OpenedIndexClosed)),
8227 ];
8228 for terminal in terminals {
8229 let handle = IndexHandle::new(Index::new("/root"));
8230 handle.transition_discovery(DiscoveryTransition::Begin).expect("begin");
8231 handle
8232 .apply_discovery(
8233 &Observation::new(vec![upsert("dir", EntryKind::Dir, Attrs::default())]),
8234 DiscoveryCommit::default(),
8235 )
8236 .expect("listing before the stop");
8237 handle.transition_discovery(terminal.clone()).expect("terminal transition");
8238 let state = handle.state().expect("terminal state");
8239 let clock = handle.clock().expect("terminal clock");
8240
8241 let late = [
8242 (
8243 vec![upsert("dir/late.txt", EntryKind::File, file_attrs(1, 1))],
8244 DiscoveryCommit {
8245 directory_complete: Some(PathBuf::from("dir")),
8246 transition: None,
8247 },
8248 ),
8249 (
8250 Vec::new(),
8251 DiscoveryCommit {
8252 directory_complete: None,
8253 transition: Some(DiscoveryTransition::Finish),
8254 },
8255 ),
8256 (
8257 Vec::new(),
8258 DiscoveryCommit {
8259 directory_complete: None,
8260 transition: Some(DiscoveryTransition::Inaccessible {
8261 issues: vec![Issue::resource_budget(2)],
8262 omitted: 0,
8263 }),
8264 },
8265 ),
8266 ];
8267 for (ops, discovery) in late {
8268 assert!(
8269 matches!(
8270 handle.apply_discovery(&Observation::new(ops), discovery.clone()),
8271 Err(crate::Error::OpenedIndexStopped)
8272 ),
8273 "after {terminal:?}, {discovery:?} was accepted"
8274 );
8275 }
8276 assert_eq!(handle.state().expect("state"), state, "after {terminal:?}");
8277 assert_eq!(handle.clock().expect("clock"), clock, "after {terminal:?}");
8278 assert_eq!(handle.kind(Path::new("dir/late.txt")).expect("lookup"), None);
8279 }
8280 }
8281
8282 #[test]
8283 fn replaying_the_same_delta_twice_changes_nothing() {
8284 let mut index = Index::new("/root");
8285 let delta = Observation::new(vec![
8286 upsert("a", EntryKind::Dir, Attrs::default()),
8287 upsert("a/f.txt", EntryKind::File, file_attrs(10, 1)),
8288 ]);
8289 index.apply_ok(&delta);
8290 let after_first = index.total();
8291 let stats = index.apply_ok(&delta);
8292
8293 assert_eq!(stats.unchanged, 2);
8294 assert_eq!(index.total(), after_first);
8295 assert_eq!(index.len(), 3);
8296 }
8297
8298 #[test]
8299 fn changed_size_updates_every_ancestor() {
8300 let mut index = index_with_sample_tree();
8301 index.apply_ok(&Observation::new(vec![upsert(
8302 "src/main.rs",
8303 EntryKind::File,
8304 file_attrs(150, 11),
8305 )]));
8306
8307 assert_eq!(index.rollup(Path::new("src")).expect("dir").bytes, 350);
8308 assert_eq!(index.total().bytes, 650);
8309 assert_eq!(index.total().by_ext[".rs"], ExtTally { files: 2, bytes: 350, allocated: 1024 });
8310 }
8311
8312 #[test]
8313 fn allocated_size_change_updates_rollups_even_when_fingerprint_matches() {
8314 let mut index = Index::new("/root");
8315 let original = Attrs { allocated: 512, ..file_attrs(100, 10) };
8316 index.apply_ok(&Observation::new(vec![upsert("file.bin", EntryKind::File, original)]));
8317
8318 let repacked = Attrs { allocated: 4096, ..original };
8319 let outcome =
8320 index.apply_ok(&Observation::new(vec![upsert("file.bin", EntryKind::File, repacked)]));
8321
8322 assert_eq!(outcome.stats.updated, 1);
8323 assert_eq!(outcome.stats.unchanged, 0);
8324 assert_eq!(index.total().allocated, 4096);
8325 assert_eq!(index.attrs(Path::new("file.bin")), Some(&repacked));
8326 }
8327
8328 #[test]
8329 fn newest_mtime_preserves_pre_epoch_values_through_updates_and_removals() {
8330 let mut index = Index::new("/root");
8331 index.apply_ok(&Observation::new(vec![
8332 upsert("newer.txt", EntryKind::File, file_attrs(10, -10)),
8333 upsert("older.txt", EntryKind::File, file_attrs(20, -20)),
8334 ]));
8335
8336 assert_eq!(index.total().newest_mtime_ns, -10);
8337
8338 index.apply_ok(&Observation::new(vec![upsert(
8339 "newer.txt",
8340 EntryKind::File,
8341 file_attrs(10, -30),
8342 )]));
8343 assert_eq!(index.total().newest_mtime_ns, -20);
8344
8345 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("older.txt") }]));
8346 assert_eq!(index.total().newest_mtime_ns, -30);
8347 }
8348
8349 #[test]
8350 fn removing_a_file_corrects_sums_and_rebuilds_the_max() {
8351 let mut index = index_with_sample_tree();
8352 let stats = index
8354 .apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("docs/guide.md") }]));
8355
8356 assert_eq!(stats.removed, 1);
8357 let total = index.total();
8358 assert_eq!(total.files, 2);
8359 assert_eq!(total.bytes, 300);
8360 assert_eq!(total.newest_mtime_ns, 20, "max must fall back to src/lib.rs");
8361 assert!(!total.by_ext.contains_key(".md"), "emptied tallies are dropped");
8362 }
8363
8364 #[test]
8365 fn removing_a_directory_cascades_to_descendants() {
8366 let mut index = index_with_sample_tree();
8367 let stats = index
8368 .apply(&Observation::new(vec![Op::Remove { path: PathBuf::from("src") }]))
8369 .expect("valid observation");
8370
8371 assert_eq!(stats.removed, 3, "the directory and both files");
8372 let total = index.total();
8373 assert_eq!(total.files, 1);
8374 assert_eq!(total.dirs, 1);
8375 assert_eq!(total.bytes, 300);
8376 assert!(index.lookup(Path::new("src/main.rs")).is_none());
8377 assert!(!total.by_ext.contains_key(".rs"));
8378 }
8379
8380 #[test]
8381 fn freed_slots_are_reused() {
8382 let mut index = index_with_sample_tree();
8383 let before = index.len();
8384 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("src") }]));
8385 index.apply_ok(&Observation::new(vec![
8386 upsert("other", EntryKind::Dir, Attrs::default()),
8387 upsert("other/x.rs", EntryKind::File, file_attrs(1, 1)),
8388 upsert("other/y.rs", EntryKind::File, file_attrs(1, 1)),
8389 ]));
8390 assert_eq!(index.len(), before, "three freed slots, three new entries");
8391 }
8392
8393 #[test]
8394 fn stale_entry_handle_does_not_alias_a_reused_slot() {
8395 let mut index = Index::new("/root");
8396 index.apply_ok(&Observation::new(vec![upsert(
8397 "first.txt",
8398 EntryKind::File,
8399 file_attrs(1, 1),
8400 )]));
8401 let stale = index.lookup(Path::new("first.txt")).expect("first id");
8402 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("first.txt") }]));
8403 index.apply_ok(&Observation::new(vec![upsert(
8404 "second.txt",
8405 EntryKind::File,
8406 file_attrs(2, 2),
8407 )]));
8408 let current = index.lookup(Path::new("second.txt")).expect("second id");
8409
8410 assert_ne!(stale, current, "generation participates in handle identity");
8411 assert!(index.attrs_of(stale).is_none());
8412 assert!(index.kind_of(stale).is_none());
8413 assert!(index.name_of(stale).is_none());
8414 assert!(index.path_of(stale).is_none());
8415 assert!(index.children_of(stale).is_none());
8416 assert!(index.rollup_of(stale).is_none());
8417 assert_eq!(index.attrs_of(current).map(|attrs| attrs.size), Some(2));
8418 }
8419
8420 #[test]
8421 fn parent_first_batch_establishes_exact_ancestry() {
8422 let mut index = Index::new("/root");
8423 let deep = file_attrs(0, 1);
8424 let nested = file_attrs(0, 2);
8425 let tree = file_attrs(0, 3);
8426 index.apply_ok(&Observation::new(vec![
8427 upsert("deep", EntryKind::Dir, deep),
8428 upsert("deep/nested", EntryKind::Dir, nested),
8429 upsert("deep/nested/tree", EntryKind::Dir, tree),
8430 upsert("deep/nested/tree/file.txt", EntryKind::File, file_attrs(42, 7)),
8431 ]));
8432
8433 assert_eq!(index.total().files, 1);
8434 assert_eq!(index.total().dirs, 3);
8435 assert_eq!(index.total().bytes, 42);
8436 assert_eq!(index.rollup(Path::new("deep/nested")).expect("created").files, 1);
8437 assert_eq!(index.attrs(Path::new("deep")), Some(&deep));
8438 assert_eq!(index.attrs(Path::new("deep/nested")), Some(&nested));
8439 assert_eq!(index.attrs(Path::new("deep/nested/tree")), Some(&tree));
8440 }
8441
8442 #[test]
8443 fn scanner_parent_proof_matches_public_parent_first_application() {
8444 let ops = vec![
8445 upsert("deep", EntryKind::Dir, file_attrs(0, 1)),
8446 upsert("deep/nested", EntryKind::Dir, file_attrs(0, 2)),
8447 upsert("deep/nested/file.txt", EntryKind::File, file_attrs(42, 3)),
8448 ];
8449 let mut scanner = Index::new("/root");
8450 let scanner_stats = scanner
8451 .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(ops.clone()))
8452 .expect("scanner proof");
8453 let mut public = Index::new("/root");
8454 let public_stats = public.apply(&Observation::new(ops)).expect("public proof").stats;
8455
8456 assert_eq!(scanner_stats, public_stats);
8457 assert_eq!(scanner.total(), public.total());
8458 assert_eq!(scanner.len(), public.len());
8459 for path in ["deep", "deep/nested", "deep/nested/file.txt"] {
8460 assert_eq!(scanner.kind(Path::new(path)), public.kind(Path::new(path)));
8461 assert_eq!(scanner.attrs(Path::new(path)), public.attrs(Path::new(path)));
8462 }
8463 }
8464
8465 #[test]
8466 fn scanner_parent_proof_rejects_unknown_ancestry_before_mutation() {
8467 let mut index = Index::new("/root");
8468 let before = index.total();
8469 let error = index
8470 .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![upsert(
8471 "missing/child.txt",
8472 EntryKind::File,
8473 file_attrs(1, 1),
8474 )]))
8475 .expect_err("scanner proof must reject an unknown parent");
8476
8477 assert!(matches!(
8478 error,
8479 crate::Error::UnknownAncestry { path, .. }
8480 if path == Path::new("missing/child.txt")
8481 ));
8482 assert_eq!(index.total(), before);
8483 assert_eq!(index.len(), 1);
8484 assert_eq!(index.clock(), Clock::ZERO);
8485 }
8486
8487 #[test]
8488 fn scanner_kind_replacement_is_proved_by_the_general_lane() {
8489 let mut index = Index::new("/root");
8490 index.apply_ok(&Observation::new(vec![
8491 upsert("a", EntryKind::Dir, file_attrs(0, 1)),
8492 upsert("a/old.txt", EntryKind::File, file_attrs(7, 1)),
8493 ]));
8494 let before = index.total();
8495 let before_clock = index.clock();
8496
8497 let error = index
8500 .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
8501 upsert("a", EntryKind::File, file_attrs(2, 2)),
8502 upsert("a/new.txt", EntryKind::File, file_attrs(3, 2)),
8503 ]))
8504 .expect_err("a child cannot attach after its parent became a file");
8505
8506 assert!(matches!(
8507 error,
8508 crate::Error::UnknownAncestry { path, .. } if path == Path::new("a/new.txt")
8509 ));
8510 assert_eq!(index.total(), before);
8511 assert_eq!(index.clock(), before_clock);
8512 assert_eq!(index.kind(Path::new("a")), Some(EntryKind::Dir));
8513 assert!(index.lookup(Path::new("a/old.txt")).is_some());
8514 assert!(index.lookup(Path::new("a/new.txt")).is_none());
8515
8516 index
8518 .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![upsert(
8519 "a",
8520 EntryKind::File,
8521 file_attrs(2, 2),
8522 )]))
8523 .expect("a scanner batch can replace an entry's kind");
8524 assert_eq!(index.kind(Path::new("a")), Some(EntryKind::File));
8525 assert!(index.lookup(Path::new("a/old.txt")).is_none());
8526 let total = index.total();
8527 assert_eq!((total.files, total.dirs, total.bytes), (1, 0, 2));
8528 }
8529
8530 #[test]
8531 fn scanner_discovery_survives_a_kind_changed_by_a_concurrent_refresh() {
8532 let handle = IndexHandle::new(Index::new("/root"));
8533 handle
8534 .apply_scanner_discovery_bounded(
8535 crate::scan::ScannerBatch::from_ops(vec![upsert(
8536 "p",
8537 EntryKind::Dir,
8538 Attrs::default(),
8539 )]),
8540 DiscoveryCommit::default(),
8541 None,
8542 )
8543 .expect("root listing");
8544 handle
8546 .apply(&Observation::new(vec![upsert("p/d", EntryKind::File, file_attrs(1, 1))]))
8547 .expect("refresh insert");
8548 let outcome = handle.apply_scanner_discovery_bounded(
8550 crate::scan::ScannerBatch::from_ops(vec![upsert(
8551 "p/d",
8552 EntryKind::Dir,
8553 Attrs::default(),
8554 )]),
8555 DiscoveryCommit { directory_complete: Some(PathBuf::from("p")), transition: None },
8556 None,
8557 );
8558 assert!(outcome.is_ok(), "discovery must not die on a kind race: {outcome:?}");
8559 assert_eq!(handle.kind(Path::new("p/d")).expect("kind read"), Some(EntryKind::Dir));
8562 assert_eq!(handle.directory_complete(Path::new("p")).expect("read"), Some(true));
8563 }
8564
8565 #[test]
8566 fn live_upsert_refuses_unknown_ancestry_without_mutation() {
8567 let mut index = Index::new("/root");
8568 let before = index.clock();
8569
8570 let error = index
8571 .apply(&Observation::new(vec![upsert(
8572 "unknown/deep/file.txt",
8573 EntryKind::File,
8574 file_attrs(10, 1),
8575 )]))
8576 .expect_err("live input must not invent parent metadata");
8577
8578 assert!(matches!(
8579 error,
8580 crate::Error::UnknownAncestry { path, reconcile_from }
8581 if path == Path::new("unknown/deep/file.txt")
8582 && reconcile_from.as_os_str().is_empty()
8583 ));
8584 assert_eq!(index.clock(), before);
8585 assert_eq!(index.len(), 1);
8586 assert!(index.since(before).commits.is_empty());
8587 }
8588
8589 #[test]
8590 fn explicit_kind_replacement_precedes_attaching_a_child() {
8591 let mut index = Index::new("/root");
8592 index.apply_ok(&Observation::new(vec![upsert(
8593 "conflict",
8594 EntryKind::File,
8595 file_attrs(9, 1),
8596 )]));
8597
8598 let outcome = index.apply_ok(&Observation::new(vec![
8599 upsert("conflict", EntryKind::Dir, file_attrs(0, 2)),
8600 upsert("conflict/child.txt", EntryKind::File, file_attrs(4, 2)),
8601 ]));
8602
8603 assert_eq!(index.kind(Path::new("conflict")), Some(EntryKind::Dir));
8604 assert!(index.lookup(Path::new("conflict/child.txt")).is_some());
8605 assert_eq!(index.total().files, 1);
8606 assert_eq!(index.total().dirs, 1);
8607 assert_eq!(index.total().bytes, 4);
8608 assert_eq!(outcome.removed, 1);
8609 }
8610
8611 #[test]
8612 fn kind_change_replaces_the_entry() {
8613 let mut index = Index::new("/root");
8614 index.apply_ok(&Observation::new(vec![upsert(
8615 "thing",
8616 EntryKind::File,
8617 file_attrs(50, 5),
8618 )]));
8619 assert_eq!(index.total().files, 1);
8620
8621 index.apply_ok(&Observation::new(vec![upsert("thing", EntryKind::Dir, Attrs::default())]));
8622 let total = index.total();
8623 assert_eq!(total.files, 0);
8624 assert_eq!(total.dirs, 1);
8625 assert_eq!(total.bytes, 0);
8626 }
8627
8628 #[test]
8629 fn paths_are_reconstructed_from_parent_pointers() {
8630 let index = index_with_sample_tree();
8631 let id = index.lookup(Path::new("src/main.rs")).expect("present");
8632 assert_eq!(index.path_of(id), Some(PathBuf::from("src/main.rs")));
8633 assert_eq!(index.path_of(EntryId::ROOT), Some(PathBuf::new()));
8634 }
8635
8636 #[test]
8637 fn since_returns_commits_after_a_clock() {
8638 let mut index = Index::new("/root");
8639 index.apply_ok(&Observation::new(vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))]));
8640 let mark = index.clock();
8641 index.apply_ok(&Observation::new(vec![upsert("b.txt", EntryKind::File, file_attrs(2, 2))]));
8642
8643 let since = index.since(mark);
8644 assert!(!since.truncated);
8645 assert_eq!(since.commits.len(), 1);
8646 assert_eq!(since.commits[0].changes[0].path(), Path::new("b.txt"));
8647
8648 assert_eq!(index.since(index.clock()).commits.len(), 0);
8649 }
8650
8651 fn commit_cost(ops: Vec<Op>) -> usize {
8653 let mut probe = Index::new("/root");
8654 probe.apply_ok(&Observation::new(ops)).commit.expect("effective commit").retained_cost()
8655 }
8656
8657 #[test]
8658 fn oversized_single_batch_is_not_retained() {
8659 let batch = || {
8660 vec![
8661 upsert("a.txt", EntryKind::File, file_attrs(1, 1)),
8662 upsert("b.txt", EntryKind::File, file_attrs(2, 2)),
8663 upsert("c.txt", EntryKind::File, file_attrs(3, 3)),
8664 ]
8665 };
8666 let mut index = Index::with_journal_capacity_bytes("/root", commit_cost(batch()) - 1);
8667 let outcome = index.apply_ok(&Observation::new(batch()));
8668
8669 assert_eq!(outcome.commit.as_ref().expect("committed").changes.len(), 3);
8670 let since = index.since(Clock::ZERO);
8671 assert!(since.truncated);
8672 assert!(since.commits.is_empty());
8673 }
8674
8675 #[test]
8676 fn journal_eviction_charges_the_complete_retained_payload() {
8677 let first = || {
8678 vec![
8679 upsert("a.txt", EntryKind::File, file_attrs(1, 1)),
8680 upsert("b.txt", EntryKind::File, file_attrs(2, 2)),
8681 ]
8682 };
8683 let second = || {
8684 vec![
8685 upsert("c.txt", EntryKind::File, file_attrs(3, 3)),
8686 upsert("d.txt", EntryKind::File, file_attrs(4, 4)),
8687 ]
8688 };
8689 let capacity = commit_cost(first()) + commit_cost(second()) - 1;
8691 let mut index = Index::with_journal_capacity_bytes("/root", capacity);
8692 index.apply_ok(&Observation::new(first()));
8693 index.apply_ok(&Observation::new(second()));
8694
8695 let since = index.since(Clock::ZERO);
8696 assert!(since.truncated);
8697 assert_eq!(since.commits.len(), 1);
8698 assert_eq!(since.commits[0].changes.len(), 2);
8699 assert_eq!(since.commits[0].changes[0].path(), Path::new("c.txt"));
8700 }
8701
8702 #[test]
8707 fn journal_eviction_is_charged_in_path_bytes() {
8708 let long_name = format!("{}.txt", "n".repeat(4096));
8709 let short = || vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))];
8710 let long = || vec![upsert(&long_name, EntryKind::File, file_attrs(2, 2))];
8711 let short_cost = commit_cost(short());
8712 let long_cost = commit_cost(long());
8713 assert!(
8714 long_cost > short_cost + 4096,
8715 "path bytes must be charged: short {short_cost}, long {long_cost}"
8716 );
8717
8718 let mut index = Index::with_journal_capacity_bytes("/root", short_cost + long_cost - 1);
8719 index.apply_ok(&Observation::new(short()));
8720 index.apply_ok(&Observation::new(long()));
8721
8722 let since = index.since(Clock::ZERO);
8723 assert!(since.truncated, "an item budget kept both commits; a byte budget cannot");
8724 assert_eq!(since.commits.len(), 1);
8725 assert_eq!(since.commits[0].changes[0].path(), Path::new(&long_name));
8726 }
8727
8728 #[test]
8729 fn impact_drops_an_overflowing_path_set_instead_of_truncating_it() {
8730 let mut index = Index::new("/root");
8731 let ops = (0..=MAX_DIRTY_PATHS)
8732 .map(|which| {
8733 upsert(
8734 &format!("file-{which}.txt"),
8735 EntryKind::File,
8736 file_attrs(u64::try_from(which).expect("bounded"), 1),
8737 )
8738 })
8739 .collect();
8740
8741 let commit =
8742 index.apply_ok(&Observation::new(ops)).commit.expect("overflowing impact commit");
8743
8744 assert!(commit.impact.all_dirty);
8745 assert!(commit.impact.dirty_paths.is_empty(), "a partial path list must not escape");
8746 assert_eq!(commit.changes.len(), MAX_DIRTY_PATHS + 1);
8747 }
8748
8749 #[test]
8750 fn invalidations_are_queued_for_the_scan_layer() {
8751 let mut index = Index::new("/root");
8752 let stats = index.apply_ok(&Observation::new(vec![Op::InvalidateSubtree {
8753 path: PathBuf::from("src"),
8754 reason: InvalidateReason::WatchOverflow,
8755 }]));
8756
8757 assert_eq!(stats.invalidated, 1);
8758 let pending = index.take_pending_invalidations();
8759 assert_eq!(pending.len(), 1);
8760 assert_eq!(pending[0].0, PathBuf::from("src"));
8761 assert_eq!(pending[0].1, InvalidateReason::WatchOverflow);
8762 assert!(index.take_pending_invalidations().is_empty(), "drained once");
8763 }
8764
8765 #[test]
8766 fn paths_escaping_the_root_are_rejected() {
8767 assert!(normalize(Path::new("../escape")).is_none());
8768 assert!(normalize(Path::new("/absolute")).is_none());
8769 assert_eq!(
8770 normalize(Path::new("./a/b")).expect("relative"),
8771 vec![OsString::from("a"), OsString::from("b")]
8772 );
8773
8774 let mut index = Index::new("/root");
8775 let upsert_error = index
8776 .apply(&Observation::new(vec![upsert("../escape", EntryKind::File, file_attrs(1, 1))]))
8777 .expect_err("escaping upsert");
8778 assert!(matches!(upsert_error, crate::Error::PathEscapesRoot(_)));
8779 assert_eq!(index.total().files, 0);
8780
8781 let invalidation_error = index
8782 .apply(&Observation::new(vec![Op::InvalidateSubtree {
8783 path: PathBuf::from("../outside"),
8784 reason: InvalidateReason::Requested,
8785 }]))
8786 .expect_err("escaping invalidation");
8787 assert!(matches!(invalidation_error, crate::Error::PathEscapesRoot(_)));
8788 assert!(index.take_pending_invalidations().is_empty());
8789 assert_eq!(index.freshness(), Freshness::Fresh);
8790 }
8791
8792 #[cfg(unix)]
8793 #[test]
8794 fn distinct_non_utf8_names_have_distinct_identity() {
8795 use std::ffi::OsString;
8796 use std::os::unix::ffi::OsStringExt;
8797
8798 let first = PathBuf::from(OsString::from_vec(vec![b'n', 0x80]));
8799 let second = PathBuf::from(OsString::from_vec(vec![b'n', 0x81]));
8800 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
8801 "/root",
8802 ScanScope::default(),
8803 crate::classify::TypeRegistry::compiled_shared(),
8804 DEFAULT_JOURNAL_CAPACITY_BYTES,
8805 );
8806 index.apply_ok(&Observation::new(vec![
8807 Op::Upsert { path: first.clone(), kind: EntryKind::File, attrs: file_attrs(10, 1) },
8808 Op::Upsert { path: second.clone(), kind: EntryKind::File, attrs: file_attrs(20, 2) },
8809 ]));
8810
8811 assert_eq!(index.total().files, 2);
8812 assert_eq!(index.total().bytes, 30);
8813 assert!(index.lookup(&first).is_some());
8814 assert!(index.lookup(&second).is_some());
8815 assert_serving_indexes(&index);
8816 }
8817
8818 #[cfg(unix)]
8819 #[test]
8820 fn a_non_utf8_parent_still_lists_its_children() {
8821 use std::ffi::OsString;
8822 use std::os::unix::ffi::OsStringExt;
8823
8824 let directory = PathBuf::from(OsString::from_vec(vec![b'd', 0x80]));
8825 let child = directory.join("child");
8826 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
8827 "/root",
8828 ScanScope::default(),
8829 crate::classify::TypeRegistry::compiled_shared(),
8830 DEFAULT_JOURNAL_CAPACITY_BYTES,
8831 );
8832 index.apply_ok(&Observation::new(vec![
8833 Op::Upsert { path: directory.clone(), kind: EntryKind::Dir, attrs: Attrs::default() },
8834 Op::Upsert { path: child, kind: EntryKind::File, attrs: file_attrs(1, 1) },
8835 ]));
8836
8837 assert_serving_indexes(&index);
8838 assert_eq!(
8842 index.portable_children(&directory).map(|children| children.nondirectories.len()),
8843 Some(1)
8844 );
8845 assert!(
8846 index.portable_entries().keys().any(|portable| portable.as_str() == "d%80/child"),
8847 "a child under a non-utf8 directory is listed at its escaped path"
8848 );
8849 }
8850
8851 #[cfg(unix)]
8852 #[test]
8853 fn non_utf8_stem_keeps_ascii_extension_tally() {
8854 use std::ffi::OsString;
8855 use std::os::unix::ffi::OsStringExt;
8856
8857 let path = PathBuf::from(OsString::from_vec(vec![b'n', 0x80, b'.', b'R', b'S']));
8858 let mut index = Index::new("/root");
8859 index.apply_ok(&Observation::new(vec![Op::Upsert {
8860 path,
8861 kind: EntryKind::File,
8862 attrs: file_attrs(10, 1),
8863 }]));
8864
8865 let tallies = index.total().by_ext;
8866 assert_eq!(
8867 tallies.get(".rs"),
8868 Some(&ExtTally { files: 1, bytes: 10, allocated: file_attrs(10, 1).allocated })
8869 );
8870 }
8871
8872 #[test]
8873 fn restore_candidates_count_visited_files_not_pathbuf_aliases() {
8874 use crate::content::AnalysisSet;
8875
8876 let mut index = Index::new("/root");
8877 let attrs = file_attrs(10, 1);
8878 assert!(
8879 index
8880 .insert_loaded_child(EntryId::ROOT, OsString::from("a"), EntryKind::File, attrs)
8881 .is_some()
8882 );
8883 assert!(
8884 index
8885 .insert_loaded_child(EntryId::ROOT, OsString::from("a/"), EntryKind::File, attrs)
8886 .is_some()
8887 );
8888 let (candidates, visited) =
8889 index.restore_analysis_candidates(AnalysisSet::NONE.with_lines());
8890 assert_eq!(visited, 2, "each visited regular file is a completeness slot");
8891 assert_eq!(candidates.len(), 1, "PathBuf keys merge trailing-separator aliases");
8892 }
8893
8894 #[test]
8895 fn restore_candidates_match_analysis_file_identities() {
8896 use crate::content::AnalysisSet;
8897
8898 let mut index = Index::new("/root");
8899 index.apply_ok(&Observation::new(vec![
8900 upsert("src", EntryKind::Dir, file_attrs(0, 1)),
8901 upsert("src/nested", EntryKind::Dir, file_attrs(0, 2)),
8902 upsert("src/nested/lib.rs", EntryKind::File, file_attrs(10, 1)),
8903 upsert("README.md", EntryKind::File, file_attrs(20, 2)),
8904 ]));
8905 let profile = AnalysisSet::NONE.with_lines();
8906 let live = index.analysis_candidates(profile);
8907 let (restore, visited) = index.restore_analysis_candidates(profile);
8908 assert_eq!(restore.len(), live.len());
8909 assert_eq!(visited, u64::try_from(live.len()).expect("candidate count fits u64"));
8910 assert_eq!(restore.len(), 2);
8911 for candidate in &live {
8912 assert_eq!(
8913 index.path_of(candidate.entry_id).as_deref(),
8914 Some(candidate.relative_path.as_path())
8915 );
8916 let restored = restore.get(&candidate.relative_path).expect("same relative path");
8917 assert_eq!(restored.entry_id, candidate.entry_id);
8918 assert_eq!(restored.revision, candidate.revision);
8919 assert_eq!(restored.attrs.fingerprint(), candidate.attrs.fingerprint());
8920 }
8921 }
8922
8923 #[test]
8924 fn content_results_commit_conditionally_and_metadata_changes_invalidate_them() {
8925 use crate::content::{
8926 AnalysisApplyOutcome, AnalysisRequest, AnalysisSet, AnalyzerOutcome, BasicMetrics,
8927 ContentProvenance, FileAnalysis,
8928 };
8929
8930 let mut index = Index::new("/root");
8931 index.apply_ok(&Observation::new(vec![
8932 upsert("src", EntryKind::Dir, file_attrs(0, 1)),
8933 upsert("src/lib.rs", EntryKind::File, file_attrs(10, 1)),
8934 ]));
8935 let profile = AnalysisSet::NONE.with_lines();
8936 let candidate =
8937 index.analysis_candidates(profile).into_iter().next().expect("file candidate");
8938 let analysis = FileAnalysis {
8939 fingerprint: candidate.attrs.fingerprint(),
8940 bytes: candidate.attrs.size,
8941 detection: candidate.classification.clone().into(),
8942 lines: AnalyzerOutcome::analyzed(BasicMetrics {
8943 physical_lines: 2,
8944 nonblank_lines: 2,
8945 ..BasicMetrics::default()
8946 }),
8947 code: None,
8948 words: None,
8949 error: None,
8950 };
8951 let provenance = ContentProvenance::for_request(
8952 AnalysisRequest { profile, ..AnalysisRequest::default() },
8953 crate::classify::type_rule_fingerprint(),
8954 );
8955 let observation = AnalysisObservation {
8956 candidate: candidate.clone(),
8957 profile,
8958 provenance: provenance.clone(),
8959 analysis: analysis.clone(),
8960 };
8961 assert_eq!(
8962 index.apply_analysis(observation.clone()),
8963 AnalysisApplyOutcome::Stale,
8964 "an index prepared for no content identity holds no record"
8965 );
8966 assert!(index.content().is_none());
8967
8968 index.prepare_content_analysis(AnalysisRequest { profile, ..AnalysisRequest::default() });
8969 assert_eq!(index.content_set(), profile);
8970 assert_eq!(index.apply_analysis(observation), AnalysisApplyOutcome::Applied);
8971 assert_eq!(
8972 index
8973 .content_rollup(Path::new(""))
8974 .expect("content root")
8975 .total
8976 .lines
8977 .metrics
8978 .physical_lines,
8979 2
8980 );
8981
8982 index.apply_ok(&Observation::new(vec![upsert(
8983 "src/lib.rs",
8984 EntryKind::File,
8985 file_attrs(20, 2),
8986 )]));
8987 assert!(index.content_rollup(Path::new("")).is_none());
8988 assert_eq!(
8989 index.apply_analysis(AnalysisObservation { candidate, profile, provenance, analysis }),
8990 AnalysisApplyOutcome::Stale
8991 );
8992 }
8993
8994 #[test]
8995 fn operational_content_failures_are_retained_but_retried_until_recovery() {
8996 use crate::content::{
8997 AnalysisApplyOutcome, AnalysisRequest, AnalysisSet, AnalyzerOutcome, BasicMetrics,
8998 ContentProvenance, CoverageReason, FileAnalysis,
8999 };
9000
9001 let mut index = Index::new("/root");
9002 index.apply_ok(&Observation::new(vec![
9003 upsert("src", EntryKind::Dir, file_attrs(0, 1)),
9004 upsert("src/lib.rs", EntryKind::File, file_attrs(10, 1)),
9005 ]));
9006 let profile = AnalysisSet::LINES_ONLY;
9007 let request = AnalysisRequest { profile, ..AnalysisRequest::default() };
9008 index.prepare_content_analysis(request);
9009 let candidate = index.pending_analysis_candidates(request).pop().expect("candidate");
9010 let provenance =
9011 ContentProvenance::for_request(request, crate::classify::type_rule_fingerprint());
9012 let record = |reason, error: &str| FileAnalysis {
9013 fingerprint: candidate.attrs.fingerprint(),
9014 bytes: candidate.attrs.size,
9015 detection: candidate.classification.clone().into(),
9016 lines: AnalyzerOutcome::unavailable(reason),
9017 code: None,
9018 words: None,
9019 error: Some(error.to_owned()),
9020 };
9021
9022 for (reason, error) in [
9023 (CoverageReason::IoError, "read failed"),
9024 (CoverageReason::ChangedDuringRead, "changed during read"),
9025 ] {
9026 assert_eq!(
9027 index.apply_analysis(AnalysisObservation {
9028 candidate: candidate.clone(),
9029 profile,
9030 provenance: provenance.clone(),
9031 analysis: record(reason, error),
9032 }),
9033 AnalysisApplyOutcome::Applied
9034 );
9035 let retained = index.content().expect("content").file(Path::new("src/lib.rs"));
9036 assert_eq!(retained.and_then(FileAnalysis::operational_failure), Some(reason));
9037 assert_eq!(
9038 index.pending_analysis_candidates(request).len(),
9039 1,
9040 "an operational failure must remain pending"
9041 );
9042 }
9043
9044 let recovered = FileAnalysis {
9045 fingerprint: candidate.attrs.fingerprint(),
9046 bytes: candidate.attrs.size,
9047 detection: candidate.classification.clone().into(),
9048 lines: AnalyzerOutcome::analyzed(BasicMetrics {
9049 physical_lines: 1,
9050 nonblank_lines: 1,
9051 raw_words: 1,
9052 ..BasicMetrics::default()
9053 }),
9054 code: None,
9055 words: None,
9056 error: None,
9057 };
9058 assert_eq!(
9059 index.apply_analysis(AnalysisObservation {
9060 candidate,
9061 profile,
9062 provenance,
9063 analysis: recovered,
9064 }),
9065 AnalysisApplyOutcome::Applied
9066 );
9067 assert!(index.pending_analysis_candidates(request).is_empty());
9068 assert_eq!(
9069 index
9070 .content()
9071 .expect("content")
9072 .file(Path::new("src/lib.rs"))
9073 .and_then(FileAnalysis::operational_failure),
9074 None
9075 );
9076 }
9077
9078 #[test]
9081 fn an_index_that_did_not_observe_controls_refuses_ignore_questions() {
9082 let mut index =
9083 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
9084 assert!(!index.observes_controls());
9085 index.apply_ok(&Observation::new(vec![upsert(
9086 "debug.log",
9087 EntryKind::File,
9088 file_attrs(10, 1),
9089 )]));
9090 for path in ["debug.log", "absent.log"] {
9091 assert!(
9092 matches!(
9093 index.is_ignored(Path::new(path)),
9094 Err(crate::Error::ControlStateNotObserved)
9095 ),
9096 "{path}"
9097 );
9098 }
9099 assert!(matches!(index.controls(), Err(crate::Error::ControlStateNotObserved)));
9100
9101 let mut observed =
9102 Index::new_with_scope("/root", crate::test_support::observing_controls());
9103 assert!(observed.observes_controls());
9104 observed.apply_ok(&Observation::new(vec![upsert(
9105 "debug.log",
9106 EntryKind::File,
9107 file_attrs(10, 1),
9108 )]));
9109 assert_eq!(observed.is_ignored(Path::new("debug.log")).ok(), Some(Some(false)));
9110 assert_eq!(observed.is_ignored(Path::new("absent.log")).ok(), Some(None));
9111 assert!(observed.controls().is_ok_and(crate::control::ControlTable::is_empty));
9112 }
9113
9114 #[test]
9121 fn an_index_that_does_not_observe_controls_refuses_control_input() {
9122 let mut index =
9123 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
9124 index.apply_ok(&Observation::new(vec![
9125 upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9126 upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
9127 ]));
9128 let stale_baseline = index.expectation(Path::new(".gitignore"));
9129 index.apply_ok(&Observation::new(vec![upsert(
9130 ".gitignore",
9131 EntryKind::File,
9132 file_attrs(7, 3),
9133 )]));
9134 let clock = index.clock();
9135 let total = index.total();
9136 let controls = || {
9137 [
9138 Op::ControlUpsert {
9139 path: PathBuf::from(".gitignore"),
9140 source: b"*.log\n".to_vec(),
9141 },
9142 Op::ControlRemove { path: PathBuf::from(".gitignore") },
9143 ]
9144 };
9145
9146 for control in controls() {
9147 let batch = Observation::new(vec![
9148 upsert("new.txt", EntryKind::File, file_attrs(1, 4)),
9149 control.clone(),
9150 ]);
9151 assert!(
9152 matches!(index.apply(&batch), Err(crate::Error::ControlStateNotObserved)),
9153 "{control:?}"
9154 );
9155 assert!(
9156 matches!(index.apply_baseline(&batch), Err(crate::Error::ControlStateNotObserved)),
9157 "{control:?}"
9158 );
9159 let stale = Observation::from_ops(vec![ObservationOp::if_state(
9160 control.clone(),
9161 stale_baseline,
9162 )]);
9163 assert!(
9164 matches!(index.apply(&stale), Err(crate::Error::ControlStateNotObserved)),
9165 "stale {control:?}"
9166 );
9167 }
9168 assert_eq!(index.clock(), clock, "a refused batch commits nothing");
9169 assert_eq!(index.total(), total);
9170 assert!(index.lookup(Path::new("new.txt")).is_none());
9171 assert!(index.control_table().is_empty());
9172
9173 let mut table = crate::control::ControlTable::default();
9174 table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control source");
9175 assert!(matches!(
9176 index.install_controls(table),
9177 Err(crate::Error::ControlStateNotObserved)
9178 ));
9179 assert!(index.control_table().is_empty());
9180
9181 let mut observed =
9182 Index::new_with_scope("/root", crate::test_support::observing_controls());
9183 for control in controls() {
9184 observed
9185 .apply(&Observation::new(vec![
9186 upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9187 control,
9188 ]))
9189 .expect("an observing index accepts control input");
9190 }
9191 }
9192
9193 #[test]
9198 fn an_index_that_did_not_observe_controls_states_no_partition_or_child_ignore_fact() {
9199 let tree = Observation::new(vec![
9200 upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
9201 upsert("dir/debug.log", EntryKind::File, file_attrs(10, 2)),
9202 ]);
9203 let mut unobserved =
9204 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
9205 unobserved.apply_ok(&tree);
9206 assert!(matches!(unobserved.partition_total(), Err(crate::Error::ControlStateNotObserved)));
9207 for path in ["", "dir", "dir/debug.log", "absent"] {
9208 assert!(
9209 matches!(
9210 unobserved.partition_rollup(Path::new(path)),
9211 Err(crate::Error::ControlStateNotObserved)
9212 ),
9213 "{path}"
9214 );
9215 assert!(
9216 matches!(
9217 unobserved.partition_rollup_summary(Path::new(path)),
9218 Err(crate::Error::ControlStateNotObserved)
9219 ),
9220 "{path}"
9221 );
9222 }
9223 assert_eq!(unobserved.total().files, 1, "the all partition still answers");
9224 let children = IndexHandle::new(unobserved)
9225 .children(Path::new(""))
9226 .expect("children read")
9227 .expect("root directory");
9228 assert_eq!(children.len(), 1);
9229 assert_eq!(children[0].ignored, None);
9230 assert_eq!(children[0].partitions, None);
9231 assert_eq!(children[0].rollup.as_ref().map(|rollup| rollup.files), Some(1));
9232
9233 let mut observed =
9234 Index::new_with_scope("/root", crate::test_support::observing_controls());
9235 observed.apply_ok(&tree);
9236 assert_eq!(observed.partition_total().expect("control state observed").unignored.files, 1);
9237 assert!(
9238 observed.partition_rollup(Path::new("dir")).expect("control state observed").is_some()
9239 );
9240 assert_eq!(
9241 observed
9242 .partition_rollup_summary(Path::new("dir/debug.log"))
9243 .expect("control state observed"),
9244 None,
9245 "a file has no partitions"
9246 );
9247 let children = IndexHandle::new(observed)
9248 .children(Path::new(""))
9249 .expect("children read")
9250 .expect("root directory");
9251 assert_eq!(children[0].ignored, Some(false));
9252 assert_eq!(
9253 children[0].partitions.as_ref().map(|partitions| partitions.unignored.files),
9254 Some(1)
9255 );
9256 }
9257
9258 #[test]
9259 fn control_changes_atomically_move_fixed_partitions_without_changing_all() {
9260 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9261 index.apply_ok(&Observation::new(vec![
9262 upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9263 upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
9264 upsert("keep.rs", EntryKind::File, file_attrs(20, 3)),
9265 upsert("docs", EntryKind::Dir, file_attrs(0, 4)),
9266 upsert("docs/other.log", EntryKind::File, file_attrs(30, 5)),
9267 upsert("docs/keep.log", EntryKind::File, file_attrs(40, 6)),
9268 ]));
9269 let before = index.partition_total().expect("control state observed");
9270
9271 let outcome = index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
9272 path: PathBuf::from(".gitignore"),
9273 source: b"*.log\n".to_vec(),
9274 }]));
9275 let partitions = index.partition_total().expect("control state observed");
9276
9277 assert_eq!(partitions.all, before.all, "classification never changes all facts");
9278 assert_eq!(partitions.all.files, 5);
9279 assert_eq!(partitions.unignored.files, 2);
9280 assert_eq!(partitions.unignored.bytes, 26);
9281 assert_eq!(outcome.controls, 1);
9282 assert_eq!(outcome.reclassified, 3);
9283 assert_eq!(
9284 index.is_ignored(Path::new("debug.log")).expect("control state observed"),
9285 Some(true)
9286 );
9287 assert_eq!(
9288 index.is_ignored(Path::new("keep.rs")).expect("control state observed"),
9289 Some(false)
9290 );
9291
9292 let commit = outcome.commit.expect("control and classification commit together");
9293 assert!(matches!(
9294 commit.changes.first(),
9295 Some(EffectiveChange::ControlUpdated { path, previous: None, current: Some(_) })
9296 if path == Path::new(".gitignore")
9297 ));
9298 assert_eq!(
9299 commit
9300 .changes
9301 .iter()
9302 .filter(|change| matches!(change, EffectiveChange::Reclassified { .. }))
9303 .count(),
9304 3
9305 );
9306 }
9307
9308 #[test]
9309 fn serving_semantics_follow_ignore_reclassification_exactly() {
9310 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
9311 "/root",
9312 crate::test_support::observing_controls(),
9313 crate::classify::TypeRegistry::compiled_shared(),
9314 DEFAULT_JOURNAL_CAPACITY_BYTES,
9315 );
9316 index.apply_ok(&Observation::new(vec![
9317 upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9318 upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
9319 upsert("keep.rs", EntryKind::File, file_attrs(20, 3)),
9320 upsert("Makefile", EntryKind::File, file_attrs(30, 4)),
9321 ]));
9322 assert_serving_indexes(&index);
9323
9324 index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
9325 path: PathBuf::from(".gitignore"),
9326 source: b"*.log\nMakefile\n".to_vec(),
9327 }]));
9328 assert_serving_indexes(&index);
9329
9330 index.apply_ok(&Observation::new(vec![Op::ControlRemove {
9331 path: PathBuf::from(".gitignore"),
9332 }]));
9333 assert_serving_indexes(&index);
9334 }
9335
9336 #[test]
9337 fn nested_negation_edit_and_last_control_deletion_reclassify_exactly() {
9338 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9339 index.apply_ok(&Observation::new(vec![
9340 upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9341 upsert("docs", EntryKind::Dir, file_attrs(0, 2)),
9342 upsert("docs/.gitignore", EntryKind::File, file_attrs(10, 3)),
9343 upsert("docs/keep.log", EntryKind::File, file_attrs(40, 4)),
9344 Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
9345 Op::ControlUpsert {
9346 path: PathBuf::from("docs/.gitignore"),
9347 source: b"!keep.log\n".to_vec(),
9348 },
9349 ]));
9350 assert_eq!(
9351 index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
9352 Some(false)
9353 );
9354
9355 let edited = index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
9356 path: PathBuf::from("docs/.gitignore"),
9357 source: b"# no exception\n".to_vec(),
9358 }]));
9359 assert_eq!(edited.reclassified, 1);
9360 assert_eq!(
9361 index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
9362 Some(true)
9363 );
9364
9365 let removed = index
9366 .apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from(".gitignore") }]));
9367 assert_eq!(removed.controls, 1, "removing the retained row removes its control state");
9368 assert_eq!(removed.reclassified, 1);
9369 assert_eq!(
9370 index.controls().expect("control state observed").len(),
9371 1,
9372 "the nested control remains"
9373 );
9374 assert_eq!(
9375 index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
9376 Some(false)
9377 );
9378
9379 index.apply_ok(&Observation::new(vec![Op::Remove {
9380 path: PathBuf::from("docs/.gitignore"),
9381 }]));
9382 assert!(index.controls().expect("control state observed").is_empty());
9383 assert_eq!(
9384 index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
9385 Some(false)
9386 );
9387 }
9388
9389 #[test]
9390 fn replacing_batch_ancestors_prunes_retained_and_transient_controls() {
9391 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9392 index
9393 .apply(&Observation::new(vec![
9394 upsert("docs", EntryKind::Dir, file_attrs(0, 1)),
9395 upsert("docs/.gitignore", EntryKind::File, file_attrs(6, 2)),
9396 Op::ControlUpsert { path: "docs/.gitignore".into(), source: b"*.log\n".to_vec() },
9397 ]))
9398 .expect("retained control");
9399
9400 let outcome = index
9401 .apply(&Observation::new(vec![
9402 upsert("scratch", EntryKind::Dir, file_attrs(0, 3)),
9403 upsert("scratch/.gitignore", EntryKind::File, file_attrs(6, 4)),
9404 Op::ControlUpsert {
9405 path: "scratch/.gitignore".into(),
9406 source: b"*.log\n".to_vec(),
9407 },
9408 upsert("scratch", EntryKind::File, file_attrs(1, 5)),
9409 upsert("scratch", EntryKind::Dir, file_attrs(0, 6)),
9410 upsert("scratch/new.log", EntryKind::File, file_attrs(7, 7)),
9411 Op::Remove { path: "docs".into() },
9412 upsert("docs", EntryKind::Dir, file_attrs(0, 8)),
9413 upsert("docs/new.log", EntryKind::File, file_attrs(9, 9)),
9414 ]))
9415 .expect("mixed control and structural batch");
9416
9417 assert!(index.controls().expect("control state observed").is_empty());
9418 assert_eq!(
9419 index.is_ignored(Path::new("scratch/new.log")).expect("control state observed"),
9420 Some(false)
9421 );
9422 assert_eq!(
9423 index.is_ignored(Path::new("docs/new.log")).expect("control state observed"),
9424 Some(false)
9425 );
9426 assert_eq!(outcome.stats.controls, 1, "only the retained control has a net change");
9427 let controls = outcome
9428 .commit
9429 .as_ref()
9430 .expect("one exact commit")
9431 .changes
9432 .iter()
9433 .filter(|change| matches!(change, EffectiveChange::ControlUpdated { .. }))
9434 .collect::<Vec<_>>();
9435 assert!(matches!(
9436 controls.as_slice(),
9437 [EffectiveChange::ControlUpdated { path, previous: Some(_), current: None }]
9438 if path == Path::new("docs/.gitignore")
9439 ));
9440 }
9441
9442 #[test]
9446 fn an_over_budget_control_is_refused_while_its_batch_commits() {
9447 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
9448 "/root",
9449 crate::test_support::observing_controls(),
9450 crate::classify::TypeRegistry::compiled_shared(),
9451 DEFAULT_JOURNAL_CAPACITY_BYTES,
9452 );
9453 index.apply_ok(&Observation::new(vec![
9454 Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
9455 upsert("debug.log", EntryKind::File, file_attrs(10, 1)),
9456 ]));
9457 assert_eq!(index.is_ignored(Path::new("debug.log")).expect("observed"), Some(true));
9458 let mut oversized = crate::control::source_at_test_limit();
9459 oversized.push(b'a');
9460
9461 let outcome = index.apply_ok(&Observation::new(vec![
9462 upsert("ordinary.txt", EntryKind::File, file_attrs(1, 2)),
9463 Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: oversized },
9464 ]));
9465
9466 assert!(index.lookup(Path::new("ordinary.txt")).is_some(), "ordinary work commits");
9467 assert_eq!(index.total().files, 2);
9468 assert_eq!(
9469 index.is_ignored(Path::new("debug.log")).expect("observed"),
9470 None,
9471 "the refused replacement leaves classification unknown"
9472 );
9473 let changes = &outcome.commit.as_ref().expect("one commit").changes;
9474 let refused = Some(crate::control::ControlRefusalReason::Budget);
9475 assert!(changes.iter().any(|change| matches!(
9476 change,
9477 EffectiveChange::ControlUpdated { previous: Some(_), current: None, .. }
9478 )));
9479 assert!(changes.iter().any(|change| matches!(
9480 change,
9481 EffectiveChange::ControlRefusalUpdated { previous: None, current, .. }
9482 if *current == refused
9483 )));
9484 let crate::control::ControlCoverage::Observed(coverage) = index.control_coverage() else {
9485 panic!("an observing index reports observed coverage");
9486 };
9487 assert_eq!((coverage.applied, coverage.refused), (0, 1));
9488 assert_eq!(coverage.refusals[0].path, Path::new(".gitignore"));
9489
9490 let lifted = index.apply_ok(&Observation::new(vec![Op::ControlRemove {
9492 path: PathBuf::from(".gitignore"),
9493 }]));
9494 assert!(matches!(
9495 lifted.commit.as_ref().expect("lifting a refusal commits").changes.as_slice(),
9496 [EffectiveChange::ControlRefusalUpdated { previous, current: None, .. }]
9497 if *previous == refused
9498 ));
9499 assert_eq!(
9500 index.control_coverage(),
9501 crate::control::ControlCoverage::Observed(crate::control::ControlObservation {
9502 limits: crate::control::ControlLimits::default(),
9503 applied: 0,
9504 rules: 0,
9505 refused: 0,
9506 refusals: Vec::new(),
9507 })
9508 );
9509 }
9510
9511 #[test]
9518 fn a_batch_that_cannot_change_the_control_table_does_not_copy_it() {
9519 let projection_clones = |index: &mut Index, ops: Vec<Op>| {
9520 CONTROL_PROJECTION_CLONES.with(|clones| clones.set(0));
9521 let outcome = index.apply_ok(&Observation::new(ops));
9522 (CONTROL_PROJECTION_CLONES.with(std::cell::Cell::get), outcome)
9523 };
9524 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9525
9526 let (clones, _) = projection_clones(
9529 &mut index,
9530 vec![
9531 upsert("cold", EntryKind::Dir, file_attrs(0, 1)),
9532 upsert("cold/file.txt", EntryKind::File, file_attrs(1, 1)),
9533 ],
9534 );
9535 assert_eq!(clones, 0, "an empty table has nothing a structural batch can change");
9536
9537 let mut over_budget = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
9538 over_budget.push(b'\n');
9539 index.apply_ok(&Observation::new(vec![
9540 upsert("keep", EntryKind::Dir, file_attrs(0, 1)),
9541 upsert("keep/file.txt", EntryKind::File, file_attrs(3, 1)),
9542 upsert("vendor", EntryKind::Dir, file_attrs(0, 1)),
9543 Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
9544 Op::ControlUpsert {
9545 path: PathBuf::from("vendor/.gitignore"),
9546 source: over_budget.clone(),
9547 },
9548 ]));
9549 let coverage = index.control_coverage();
9550
9551 let (clones, outcome) = projection_clones(
9554 &mut index,
9555 vec![
9556 upsert("keep/file.txt", EntryKind::File, file_attrs(4, 1)),
9557 Op::ControlUpsert {
9558 path: PathBuf::from(".gitignore"),
9559 source: b"*.log\n".to_vec(),
9560 },
9561 Op::ControlUpsert { path: PathBuf::from("vendor/.gitignore"), source: over_budget },
9562 ],
9563 );
9564 assert_eq!(clones, 0, "no control op changes anything");
9565 assert_eq!(index.control_coverage(), coverage);
9566 assert!(!outcome.commit.expect("the file's size changed").changes.iter().any(
9567 |change| matches!(
9568 change,
9569 EffectiveChange::ControlUpdated { .. }
9570 | EffectiveChange::ControlRefusalUpdated { .. }
9571 )
9572 ));
9573
9574 let (clones, _) = projection_clones(
9576 &mut index,
9577 vec![Op::ControlRemove { path: PathBuf::from("vendor/.gitignore") }],
9578 );
9579 assert_eq!(clones, 1);
9580 assert_eq!(index.controls().expect("observed").refused_len(), 0);
9581 }
9582
9583 #[test]
9585 fn removing_a_subtree_lifts_the_refusals_beneath_it() {
9586 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9587 let mut line = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
9588 line.push(b'\n');
9589 index.apply_ok(&Observation::new(vec![
9590 upsert("vendor", EntryKind::Dir, file_attrs(0, 1)),
9591 upsert("vendor/.gitignore", EntryKind::File, file_attrs(16_386, 1)),
9592 Op::ControlUpsert { path: PathBuf::from("vendor/.gitignore"), source: line },
9593 ]));
9594 assert_eq!(index.controls().expect("observed").refused_len(), 1);
9595
9596 let outcome =
9597 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("vendor") }]));
9598
9599 assert_eq!(index.controls().expect("observed").refused_len(), 0);
9600 assert!(outcome.commit.expect("commit").changes.iter().any(|change| matches!(
9601 change,
9602 EffectiveChange::ControlRefusalUpdated { current: None, .. }
9603 )));
9604 }
9605
9606 #[test]
9607 fn one_sweep_reports_one_as_of_time_for_everything_it_verified() {
9608 let mut index = Index::new("/root");
9616 index.set_applying_source(Source::Cached, 1_000);
9617 index.apply_ok(&Observation::new(vec![
9618 Op::Upsert { path: "a".into(), kind: EntryKind::Dir, attrs: file_attrs(0, 1) },
9619 Op::Upsert {
9620 path: "a/kept.txt".into(),
9621 kind: EntryKind::File,
9622 attrs: file_attrs(1, 1),
9623 },
9624 Op::Upsert {
9625 path: "a/changed.txt".into(),
9626 kind: EntryKind::File,
9627 attrs: file_attrs(2, 2),
9628 },
9629 ]));
9630
9631 index.set_applying_source(Source::Revalidated, 2_000);
9633 index.begin_reconcile(Path::new("")).expect("begin reconciliation");
9634 index.apply_ok(&Observation::new(vec![
9635 Op::Upsert {
9636 path: "a/kept.txt".into(),
9637 kind: EntryKind::File,
9638 attrs: file_attrs(1, 1),
9639 },
9640 Op::Upsert {
9641 path: "a/changed.txt".into(),
9642 kind: EntryKind::File,
9643 attrs: file_attrs(9, 2),
9644 },
9645 ]));
9646 index
9647 .finish_reconcile(
9648 Path::new(""),
9649 0,
9650 true,
9651 &[],
9652 &[],
9653 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9654 )
9655 .expect("finish reconciliation");
9656
9657 let kept = index.provenance(Path::new("a/kept.txt")).expect("present");
9658 let changed = index.provenance(Path::new("a/changed.txt")).expect("present");
9659 assert!(kept.is_verified() && changed.is_verified(), "the sweep covered both");
9660 assert_eq!(
9661 kept.observed_at_ns, changed.observed_at_ns,
9662 "one sweep, one as-of time: {kept:?} vs {changed:?}"
9663 );
9664 }
9665
9666 #[test]
9667 fn withdrawn_trust_beats_a_verification_interval() {
9668 let mut index = Index::new("/root");
9673 index.set_applying_source(Source::Cached, 1_000);
9675 index.apply_baseline_ok(&Observation::new(vec![
9676 Op::Upsert { path: PathBuf::from("a"), kind: EntryKind::Dir, attrs: Attrs::default() },
9677 Op::Upsert {
9678 path: PathBuf::from("a/file.txt"),
9679 kind: EntryKind::File,
9680 attrs: Attrs { size: 1, ..Attrs::default() },
9681 },
9682 ]));
9683 assert_eq!(
9684 index.provenance(Path::new("a/file.txt")).expect("present").source,
9685 Source::Cached,
9686 "nothing has checked it yet"
9687 );
9688 index
9690 .finish_reconcile(
9691 Path::new(""),
9692 0,
9693 true,
9694 &[],
9695 &[],
9696 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9697 )
9698 .expect("finish reconciliation");
9699 let path = Path::new("a/file.txt");
9700 assert_eq!(
9701 index.provenance(path).expect("present").source,
9702 Source::Revalidated,
9703 "a completed sweep covers this path"
9704 );
9705
9706 index.mark_unfresh(Path::new("a"), Freshness::Stale);
9708 let provenance = index.provenance(path).expect("present");
9709 assert!(
9710 !provenance.is_verified(),
9711 "an invalidated path must not read as verified: {provenance:?}"
9712 );
9713 assert_eq!(
9714 provenance.status,
9715 Status::Complete,
9716 "withdrawing trust changes how far to believe the value, not how much of \
9717 the subtree it covers: the cached total still accounts for every entry \
9718 beneath this path, and reporting it as Partial would tell a consumer the \
9719 number is still being built when it is merely unverified"
9720 );
9721 }
9722
9723 #[test]
9724 fn verification_intervals_stay_bounded() {
9725 let mut index = Index::new("/root");
9728 for which in 0..(MAX_VERIFIED_INTERVALS * 2) {
9729 index
9730 .finish_reconcile(
9731 &PathBuf::from(format!("dir-{which}")),
9732 0,
9733 true,
9734 &[],
9735 &[],
9736 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9737 )
9738 .expect("finish reconciliation");
9739 }
9740 assert!(
9741 index.verified.len() <= MAX_VERIFIED_INTERVALS,
9742 "interval list grew to {}",
9743 index.verified.len()
9744 );
9745 }
9746
9747 #[test]
9748 fn retained_walk_issues_are_the_same_first_paths_for_every_arrival_order() {
9749 let make = |order: Vec<usize>| {
9750 order
9751 .into_iter()
9752 .map(|number| {
9753 crate::Error::io(
9754 PathBuf::from(format!("/root/file-{number:02}")),
9755 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
9756 )
9757 })
9758 .collect::<Vec<_>>()
9759 };
9760 let mut reverse_order: Vec<_> = (0..66).rev().collect();
9761 reverse_order.push(65);
9762 let mut shuffled_order: Vec<_> = (0..66).step_by(2).collect();
9763 shuffled_order.extend((0..66).skip(1).step_by(2));
9764 shuffled_order.push(65);
9765
9766 let mut reverse = Index::new("/root");
9767 let mut reverse_errors = make(reverse_order);
9768 reverse.record_walk_errors(&mut reverse_errors);
9769 let mut shuffled = Index::new("/root");
9770 let mut shuffled_errors = make(shuffled_order);
9771 shuffled.record_walk_errors(&mut shuffled_errors);
9772
9773 let reverse_paths: Vec<_> =
9774 reverse.issues().iter().map(|issue| issue.path.clone().expect("path")).collect();
9775 let shuffled_paths: Vec<_> =
9776 shuffled.issues().iter().map(|issue| issue.path.clone().expect("path")).collect();
9777 assert_eq!(reverse_paths, shuffled_paths);
9778 assert_eq!(reverse_paths.len(), MAX_RETAINED_ISSUES);
9779 assert_eq!(reverse_paths.first().map(PathBuf::as_path), Some(Path::new("file-00")));
9780 assert_eq!(reverse_paths.last().map(PathBuf::as_path), Some(Path::new("file-63")));
9781 assert_eq!(reverse.state().issues.omitted, 2);
9782 assert_eq!(shuffled.state().issues.omitted, 2);
9783 assert_eq!(reverse.issue_epochs.len(), reverse.issues.len());
9784 assert_eq!(shuffled.issue_epochs.len(), shuffled.issues.len());
9785 }
9786
9787 #[test]
9788 fn repeated_partial_root_pass_replaces_bounded_issues_and_omitted_count() {
9789 let root = Path::new("/root");
9790 let mut index = Index::new(root);
9791 let run = |index: &mut Index, range: std::ops::Range<usize>| {
9792 let errors: Vec<_> = range
9793 .clone()
9794 .map(|number| {
9795 crate::Error::io(
9796 root.join(format!("file-{number:02}")),
9797 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
9798 )
9799 })
9800 .collect();
9801 let failed: Vec<_> =
9802 range.map(|number| PathBuf::from(format!("file-{number:02}"))).collect();
9803 let (started, _) = index.begin_reconcile(Path::new("")).expect("begin");
9804 index
9805 .finish_reconcile(
9806 Path::new(""),
9807 started,
9808 false,
9809 &[],
9810 &failed,
9811 ReconcileErrors { errors: &errors, terminal: None, disproves_old: true },
9812 )
9813 .expect("finish");
9814 };
9815
9816 run(&mut index, 0..66);
9817 assert_eq!(index.state().issues.omitted, 2);
9818 run(&mut index, 10..76);
9819
9820 assert_eq!(index.state().issues.omitted, 2, "a retry replaces the old omission count");
9821 assert_eq!(index.omitted_issue_epochs.len(), 1, "sequential failures use one bucket");
9822 assert_eq!(index.issues().len(), crate::MAX_RETAINED_ISSUES);
9823 assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("file-10")));
9824 assert_eq!(index.issues()[63].path.as_deref(), Some(Path::new("file-73")));
9825 }
9826
9827 #[test]
9828 fn partial_pass_preserves_an_issue_published_after_it_began() {
9829 let root = Path::new("/root");
9830 let mut index = Index::new(root);
9831 let (started, _) = index.begin_reconcile(Path::new("")).expect("begin");
9832 index.mark_unfresh(Path::new("concurrent"), Freshness::Stale);
9833 index.retain_issue(Issue::from_error_under(
9834 root,
9835 &crate::Error::io(
9836 root.join("concurrent"),
9837 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "concurrent"),
9838 ),
9839 ));
9840 let pass_error = crate::Error::io(
9841 root.join("pass"),
9842 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "pass"),
9843 );
9844
9845 index
9846 .finish_reconcile(
9847 Path::new(""),
9848 started,
9849 false,
9850 &[],
9851 &[PathBuf::from("pass")],
9852 ReconcileErrors { errors: &[pass_error], terminal: None, disproves_old: true },
9853 )
9854 .expect("finish");
9855
9856 let paths: Vec<_> =
9857 index.issues().iter().filter_map(|issue| issue.path.as_deref()).collect();
9858 assert_eq!(paths, [Path::new("concurrent"), Path::new("pass")]);
9859 }
9860
9861 #[test]
9862 fn older_root_closer_preserves_newer_pass_omissions_and_partial_coverage() {
9863 let root = Path::new("/root");
9864 let mut index = Index::new(root);
9865 for number in 0..66 {
9866 let path = PathBuf::from(format!("a-{number:02}"));
9867 index.retain_issue(Issue::observation_gap(
9868 &path,
9869 crate::InvalidateReason::WatchOverflow,
9870 ));
9871 }
9872 index.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
9873 assert_eq!(index.state.issues.omitted, 2);
9874
9875 let (older, _) = index.begin_reconcile(Path::new("")).expect("begin older pass");
9876 let (newer, _) = index.begin_reconcile(Path::new("")).expect("begin newer pass");
9877 let newer_errors = ["z-one", "z-two"].map(|path| {
9878 crate::Error::io(
9879 root.join(path),
9880 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
9881 )
9882 });
9883 index
9884 .finish_reconcile(
9885 Path::new(""),
9886 newer,
9887 false,
9888 &[],
9889 &[PathBuf::from("z-one"), PathBuf::from("z-two")],
9890 ReconcileErrors { errors: &newer_errors, terminal: None, disproves_old: true },
9891 )
9892 .expect("finish newer pass");
9893 assert_eq!(index.state.issues.omitted, 2);
9894
9895 index
9896 .finish_reconcile(
9897 Path::new(""),
9898 older,
9899 true,
9900 &[],
9901 &[],
9902 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9903 )
9904 .expect("finish older pass");
9905
9906 assert_eq!(index.state.issues.omitted, 2, "the older closer cannot erase newer omissions");
9907 assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
9908 }
9909
9910 #[test]
9911 fn an_unvisited_aborted_scope_does_not_disprove_its_old_issue() {
9912 let root = Path::new("/root");
9913 let mut index = Index::new(root);
9914 index.retain_issue(Issue::from_error_under(
9915 root,
9916 &crate::Error::io(
9917 root.join("later/blocked"),
9918 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "old failure"),
9919 ),
9920 ));
9921 let (started, _) = index.begin_reconcile(Path::new("later")).expect("begin later scope");
9922
9923 index
9924 .finish_reconcile(
9925 Path::new("later"),
9926 started,
9927 false,
9928 &[],
9929 &[],
9930 ReconcileErrors { errors: &[], terminal: None, disproves_old: false },
9931 )
9932 .expect("close skipped scope");
9933
9934 assert_eq!(index.issues().len(), 1);
9935 assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("later/blocked")));
9936 }
9937 #[test]
9938 fn older_pass_cannot_publish_errors_after_newer_clean_verification() {
9939 let root = Path::new("/root");
9940 let mut index = Index::new(root);
9941 let (older, _) = index.begin_reconcile(Path::new("")).expect("begin older pass");
9942 let (newer, _) = index.begin_reconcile(Path::new("")).expect("begin newer pass");
9943 index
9944 .finish_reconcile(
9945 Path::new(""),
9946 newer,
9947 true,
9948 &[],
9949 &[],
9950 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9951 )
9952 .expect("finish newer pass");
9953 let stale_error = crate::Error::io(
9954 root.join("stale"),
9955 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "older failure"),
9956 );
9957
9958 index
9959 .finish_reconcile(
9960 Path::new(""),
9961 older,
9962 false,
9963 &[],
9964 &[PathBuf::from("stale")],
9965 ReconcileErrors { errors: &[stale_error], terminal: None, disproves_old: true },
9966 )
9967 .expect("finish superseded older pass");
9968
9969 assert!(index.issues().is_empty());
9970 assert_eq!(index.state.coverage, Coverage::Complete);
9971 assert_eq!(index.state.freshness, Freshness::Fresh);
9972 }
9973
9974 #[test]
9975 fn newer_unchanged_verification_refuses_an_older_conditional_fact() {
9976 let mut index = Index::new("/root");
9977 index
9978 .apply(&Observation::new(vec![Op::Upsert {
9979 path: PathBuf::from("same"),
9980 kind: EntryKind::File,
9981 attrs: file_attrs(1, 1),
9982 }]))
9983 .expect("fixture");
9984 let handle = IndexHandle::new(index);
9985 let baseline = handle.expectation(Path::new("same")).expect("baseline");
9986 let (older, _) = handle.begin_reconcile(Path::new("")).expect("begin older pass");
9987 let (newer, _) = handle.begin_reconcile(Path::new("")).expect("begin newer pass");
9988 handle
9989 .finish_reconcile(
9990 Path::new(""),
9991 newer,
9992 true,
9993 &[],
9994 &[],
9995 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9996 )
9997 .expect("finish unchanged newer pass");
9998
9999 let stale = handle
10000 .apply_reconcile(
10001 older,
10002 &Observation::from_ops(vec![ObservationOp::if_state(
10003 Op::Remove { path: PathBuf::from("same") },
10004 baseline,
10005 )]),
10006 )
10007 .expect("arbitrate older fact");
10008
10009 assert_eq!(stale.stats.stale, 1);
10010 assert!(stale.commit.is_none());
10011 assert!(handle.attrs(Path::new("same")).expect("attrs").is_some());
10012 }
10013
10014 #[test]
10015 fn newer_disjoint_verification_does_not_refuse_an_older_fact() {
10016 let mut index = Index::new("/root");
10017 index
10018 .apply(&Observation::new(vec![
10019 Op::Upsert {
10020 path: PathBuf::from("a"),
10021 kind: EntryKind::File,
10022 attrs: file_attrs(1, 1),
10023 },
10024 Op::Upsert {
10025 path: PathBuf::from("b"),
10026 kind: EntryKind::File,
10027 attrs: file_attrs(1, 1),
10028 },
10029 ]))
10030 .expect("fixture");
10031 let handle = IndexHandle::new(index);
10032 let baseline = handle.expectation(Path::new("a")).expect("baseline");
10033 let (older, _) = handle.begin_reconcile(Path::new("a")).expect("begin a");
10034 let (newer, _) = handle.begin_reconcile(Path::new("b")).expect("begin b");
10035 handle
10036 .finish_reconcile(
10037 Path::new("b"),
10038 newer,
10039 true,
10040 &[],
10041 &[],
10042 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10043 )
10044 .expect("finish b");
10045
10046 let applied = handle
10047 .apply_reconcile(
10048 older,
10049 &Observation::from_ops(vec![ObservationOp::if_state(
10050 Op::Remove { path: PathBuf::from("a") },
10051 baseline,
10052 )]),
10053 )
10054 .expect("apply disjoint a fact");
10055
10056 assert_eq!(applied.stats.removed, 1);
10057 assert_eq!(applied.stats.stale, 0);
10058 assert!(handle.attrs(Path::new("a")).expect("attrs").is_none());
10059 }
10060 #[test]
10061 fn newer_child_verification_preserves_older_sibling_failure() {
10062 let root = Path::new("/root");
10063 let mut index = Index::new(root);
10064 index.apply_ok(&Observation::new(
10065 ["a", "a/old", "b", "b/blocked", "healthy"]
10066 .map(|path| Op::Upsert {
10067 path: PathBuf::from(path),
10068 kind: EntryKind::Dir,
10069 attrs: Attrs::default(),
10070 })
10071 .to_vec(),
10072 ));
10073 index.set_initial_scan_freshness(&[]);
10074 let (older, _) = index.begin_reconcile(Path::new("")).expect("older root");
10075 let (newer, _) = index.begin_reconcile(Path::new("a")).expect("newer child");
10076 index
10077 .finish_reconcile(
10078 Path::new("a"),
10079 newer,
10080 true,
10081 &[],
10082 &[],
10083 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10084 )
10085 .expect("verify a");
10086 let errors = ["a/old", "b/blocked"].map(|path| {
10087 crate::Error::io(
10088 root.join(path),
10089 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed read"),
10090 )
10091 });
10092 index
10093 .finish_reconcile(
10094 Path::new(""),
10095 older,
10096 false,
10097 &[],
10098 &[PathBuf::from("a/old"), PathBuf::from("b/blocked")],
10099 ReconcileErrors { errors: &errors, terminal: None, disproves_old: true },
10100 )
10101 .expect("finish older root");
10102 for path in ["", "a", "a/old", "b", "healthy"] {
10103 assert_eq!(index.directory_complete(Path::new(path)), Some(true), "{path}");
10104 }
10105 assert_eq!(index.directory_complete(Path::new("b/blocked")), Some(false));
10106 assert_eq!(index.issues().len(), 1);
10107 assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("b/blocked")));
10108 assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10109 assert_eq!(index.freshness_at(Path::new("a")), Freshness::Fresh);
10110 assert_eq!(index.freshness_at(Path::new("b/blocked")), Freshness::Partial);
10111 }
10112 #[test]
10113 fn failure_published_after_a_newer_pass_began_keeps_its_issue_with_its_mark() {
10114 let root = Path::new("/root");
10120 let mut index = Index::new(root);
10121 index.apply_ok(&Observation::new(
10122 ["x", "x/deep", "healthy"]
10123 .map(|path| Op::Upsert {
10124 path: PathBuf::from(path),
10125 kind: EntryKind::Dir,
10126 attrs: Attrs::default(),
10127 })
10128 .to_vec(),
10129 ));
10130 index.set_initial_scan_freshness(&[]);
10131 let (older_root, _) = index.begin_reconcile(Path::new("")).expect("older root");
10132 let (newer_child, _) = index.begin_reconcile(Path::new("x")).expect("newer child");
10133 let error = crate::Error::io(
10134 root.join("x"),
10135 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed read"),
10136 );
10137 index
10138 .finish_reconcile(
10139 Path::new(""),
10140 older_root,
10141 false,
10142 &[],
10143 &[PathBuf::from("x")],
10144 ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
10145 )
10146 .expect("older root fails on x");
10147 assert_eq!(index.freshness_at(Path::new("x")), Freshness::Partial);
10148 assert_eq!(index.issues().len(), 1);
10149
10150 index
10151 .finish_reconcile(
10152 Path::new("x"),
10153 newer_child,
10154 true,
10155 &[],
10156 &[],
10157 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10158 )
10159 .expect("newer child verifies clean");
10160
10161 let explained = !index.issues().is_empty();
10162 let partial = index.freshness_at(Path::new("x")) == Freshness::Partial;
10163 assert_eq!(
10164 partial,
10165 explained,
10166 "a surviving partial mark and its issue must be kept or dropped together: \
10167 partial={partial}, issues={:?}",
10168 index.issues()
10169 );
10170 assert!(partial, "the mark minted after the child pass began is the newer claim");
10171 assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("x")));
10172 assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10173 assert_eq!(index.state.freshness, Freshness::Partial);
10174 let request = crate::query::Request::new(
10175 crate::query::Basis::held_by(&index),
10176 crate::query::Query::default(),
10177 std::time::UNIX_EPOCH,
10178 );
10179 let status = crate::query::TreeStatus::of(&index, &request);
10180 assert!(!status.complete);
10181 assert_eq!(status.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10182 assert_eq!(status.errors.len(), 1, "an incomplete status names its cause");
10183 }
10184 #[test]
10185 fn cold_scan_failure_does_not_verify_unknown_descendants_or_unscoped_work() {
10186 let mut index = Index::new("/root");
10187 index.apply_ok(&Observation::new(
10188 ["blocked", "blocked/nested", "healthy"]
10189 .map(|path| Op::Upsert {
10190 path: PathBuf::from(path),
10191 kind: EntryKind::Dir,
10192 attrs: Attrs::default(),
10193 })
10194 .to_vec(),
10195 ));
10196 let error = crate::Error::io(
10197 PathBuf::from("/root/blocked"),
10198 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed listing"),
10199 );
10200 index.set_initial_scan_freshness(&[error]);
10201 assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
10202 assert_eq!(index.directory_complete(Path::new("")), Some(true));
10203 assert_eq!(index.freshness_at(Path::new("")), Freshness::Partial);
10204 for path in ["blocked", "blocked/nested"] {
10205 assert_eq!(index.directory_complete(Path::new(path)), Some(false), "{path}");
10206 assert_eq!(index.freshness_at(Path::new(path)), Freshness::Partial, "{path}");
10207 }
10208 index.set_initial_scan_freshness(&[crate::Error::Snapshot("unscoped failure".into())]);
10209 assert_eq!(index.directory_complete(Path::new("healthy")), Some(false));
10210 assert_eq!(index.freshness_at(Path::new("healthy")), Freshness::Partial);
10211 }
10212
10213 #[test]
10214 fn unscoped_failure_publishes_listing_withdrawal_when_root_state_is_unchanged() {
10215 let mut index = Index::new("/root");
10216 index.apply_ok(&Observation::new(vec![Op::Upsert {
10217 path: PathBuf::from("healthy"),
10218 kind: EntryKind::Dir,
10219 attrs: Attrs::default(),
10220 }]));
10221 index.set_initial_scan_freshness(&[]);
10222 index.mark_unfresh(Path::new("elsewhere"), Freshness::Partial);
10223 index.state.freshness = Freshness::Partial;
10224 index.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
10225 let error = crate::Error::Snapshot("unscoped failure".into());
10226 index.retain_issue(Issue::from_error_under(&index.root_path, &error));
10227 let progress = index.state.progress;
10228 let (epoch, _) = index.begin_reconcile(Path::new("")).expect("begin partial root");
10229 let before = index.state;
10230 let clock = index.clock;
10231 let finished = index
10232 .finish_reconcile(
10233 Path::new(""),
10234 epoch,
10235 false,
10236 &[],
10237 &[],
10238 ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
10239 )
10240 .expect("finish failure");
10241 assert_eq!(index.state, before, "aggregate root state remains identical");
10242 assert_eq!(index.state.progress, progress, "discovery progress is cumulative");
10243 assert_eq!(index.directory_complete(Path::new("healthy")), Some(false));
10244 let commit = finished.commit.expect("withdrawal must publish even without another effect");
10245 assert!(index.clock > clock);
10246 assert_eq!(commit.clock, index.clock);
10247 assert!(
10248 commit
10249 .state
10250 .iter()
10251 .all(|effect| matches!(effect, StateTransition::DirectoryIncomplete { .. }))
10252 );
10253 assert!(
10254 commit
10255 .state
10256 .contains(&StateTransition::DirectoryIncomplete { path: PathBuf::from("healthy") })
10257 );
10258 assert!(commit.impact.dirty_paths.contains(&PathBuf::from("healthy")));
10259 }
10260
10261 #[test]
10262 fn omitted_failed_entry_withdraws_parent_listing_without_tainting_siblings() {
10263 let mut index = Index::new("/root");
10264 index.apply_ok(&Observation::new(vec![Op::Upsert {
10265 path: PathBuf::from("healthy"),
10266 kind: EntryKind::Dir,
10267 attrs: Attrs::default(),
10268 }]));
10269 let error = crate::Error::io(
10270 PathBuf::from("/root/missing"),
10271 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "metadata failed"),
10272 );
10273 index.set_initial_scan_freshness(&[error]);
10274 assert_eq!(index.directory_complete(Path::new("")), Some(false));
10275 assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
10276 index.set_initial_scan_freshness(&[]);
10277 let (epoch, _) = index.begin_reconcile(Path::new("")).expect("begin root");
10278 let error = crate::Error::io(
10279 PathBuf::from("/root/missing"),
10280 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "metadata failed"),
10281 );
10282 index
10283 .finish_reconcile(
10284 Path::new(""),
10285 epoch,
10286 false,
10287 &[],
10288 &[PathBuf::from("missing")],
10289 ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
10290 )
10291 .expect("partial root");
10292 assert_eq!(index.directory_complete(Path::new("")), Some(false));
10293 assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
10294 }
10295
10296 #[test]
10297 fn complete_older_root_does_not_verify_a_newer_failed_child() {
10298 let root = Path::new("/root");
10299 let mut index = Index::new(root);
10300 let (older, _) = index.begin_reconcile(Path::new("")).expect("older root");
10301 let (newer, _) = index.begin_reconcile(Path::new("child")).expect("newer child");
10302 let error = crate::Error::io(
10303 root.join("child"),
10304 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "new failure"),
10305 );
10306 index
10307 .finish_reconcile(
10308 Path::new("child"),
10309 newer,
10310 false,
10311 &[],
10312 &[PathBuf::from("child")],
10313 ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
10314 )
10315 .expect("failed child");
10316 let finish = index
10317 .finish_reconcile(
10318 Path::new(""),
10319 older,
10320 true,
10321 &[],
10322 &[],
10323 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10324 )
10325 .expect("complete older walk");
10326 assert_eq!(index.issues().len(), 1);
10327 assert_eq!(index.freshness_at(Path::new("child")), Freshness::Partial);
10328 assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10329 assert!(!finish.commit.iter().flat_map(|commit| commit.state.iter()).any(|state| {
10330 matches!(state, StateTransition::Verified { path } if path.as_os_str().is_empty())
10331 }));
10332 }
10333
10334 #[test]
10335 fn reconciliation_scope_budget_preserves_issues_and_newer_facts() {
10336 let mut index = Index::new("/root");
10337 index.apply_ok(&Observation::new(vec![Op::Upsert {
10338 path: PathBuf::from("kept"),
10339 kind: EntryKind::File,
10340 attrs: file_attrs(1, 1),
10341 }]));
10342 index.retain_issue(Issue::provider_failure(
10343 Some(Path::new("unvisited")),
10344 "earlier failure".into(),
10345 ));
10346 let (older, _) = index.begin_reconcile(Path::new("")).expect("older pass");
10347 let budget = index.active_reconciles[&older].scope_budget;
10348 assert_eq!(budget, 2, "root and kept file define the evidence budget");
10349 for number in 0..100 {
10350 let path = PathBuf::from(format!("missing-{number}"));
10351 let (newer, _) = index.begin_reconcile(&path).expect("newer pass");
10352 index
10353 .finish_reconcile(
10354 &path,
10355 newer,
10356 true,
10357 &[],
10358 &[],
10359 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10360 )
10361 .expect("newer verification");
10362 if let ReconcileEvidence::Scopes(scopes) = &index.active_reconciles[&older].evidence {
10363 assert!(scopes.len() <= budget);
10364 }
10365 }
10366 assert!(matches!(index.active_reconciles[&older].evidence, ReconcileEvidence::Retry));
10367 index.apply_ok(&Observation::new(vec![Op::Upsert {
10368 path: PathBuf::from("kept"),
10369 kind: EntryKind::File,
10370 attrs: file_attrs(9, 2),
10371 }]));
10372 let finished = index
10373 .finish_reconcile(
10374 Path::new(""),
10375 older,
10376 true,
10377 &[],
10378 &[],
10379 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10380 )
10381 .expect("close interrupted pass");
10382 assert!(finished.retry);
10383 assert!(finished.commit.is_some());
10384 assert_eq!(index.total_scalars().bytes, 9);
10385 assert!(
10386 index
10387 .issues()
10388 .iter()
10389 .any(|issue| issue.path.as_deref() == Some(Path::new("unvisited")))
10390 );
10391 assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10392 assert!(index.active_reconciles.is_empty(), "closed passes retain no shadow history");
10393 }
10394}