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 pub(crate) static ANALYSIS_CHILD_STEPS: std::cell::Cell<u64> =
99 const { std::cell::Cell::new(0) };
100}
101
102pub const DEFAULT_JOURNAL_CAPACITY_BYTES: usize = 8 * 1024 * 1024;
113
114#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
116pub struct EntryId {
117 slot: u32,
118 generation: u64,
119}
120
121impl EntryId {
122 pub const ROOT: EntryId = EntryId { slot: 0, generation: 0 };
124
125 #[inline]
126 const fn idx(self) -> usize {
127 self.slot as usize
128 }
129}
130
131type ExtId = u32;
133
134#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
136pub struct ExtTally {
137 pub files: u64,
139 pub bytes: u64,
141 pub allocated: u64,
147}
148
149#[derive(Clone, PartialEq, Eq, Debug, Default)]
163pub struct RollUp {
164 pub files: u64,
166 pub dirs: u64,
168 pub bytes: u64,
170 pub allocated: u64,
172 pub newest_mtime_ns: i64,
174 pub by_ext: BTreeMap<String, ExtTally>,
176}
177
178#[derive(Clone, PartialEq, Eq, Debug, Default)]
180pub struct PartitionRollUp {
181 pub all: RollUp,
183 pub unignored: RollUp,
185}
186
187#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
189pub struct RollUpSummary {
190 pub files: u64,
192 pub dirs: u64,
194 pub bytes: u64,
196 pub allocated: u64,
198 pub newest_mtime_ns: Option<i64>,
200}
201
202#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
204pub struct PartitionRollUpSummary {
205 pub all: RollUpSummary,
207 pub unignored: RollUpSummary,
209}
210
211#[derive(Clone, PartialEq, Eq, Debug, Default)]
218struct InternedRollUp {
219 files: u64,
220 dirs: u64,
221 bytes: u64,
222 allocated: u64,
223 newest_mtime_ns: i64,
224 by_ext: BTreeMap<ExtId, ExtTally>,
225}
226
227#[derive(Clone, PartialEq, Eq, Debug, Default)]
232struct InternedPartitionRollUp {
233 all: InternedRollUp,
234 unignored: InternedRollUp,
235}
236
237impl std::ops::Deref for InternedPartitionRollUp {
238 type Target = InternedRollUp;
239
240 fn deref(&self) -> &Self::Target {
241 &self.all
242 }
243}
244
245impl std::ops::DerefMut for InternedPartitionRollUp {
246 fn deref_mut(&mut self) -> &mut Self::Target {
247 &mut self.all
248 }
249}
250
251impl InternedPartitionRollUp {
252 fn merge(&mut self, other: &Self) {
253 self.all.merge(&other.all);
254 self.unignored.merge(&other.unignored);
255 }
256
257 fn unmerge(&mut self, other: &Self) {
258 self.all.unmerge(&other.all);
259 self.unignored.unmerge(&other.unignored);
260 }
261}
262
263fn rollup_summary(rollup: &InternedRollUp) -> RollUpSummary {
264 RollUpSummary {
265 files: rollup.files,
266 dirs: rollup.dirs,
267 bytes: rollup.bytes,
268 allocated: rollup.allocated,
269 newest_mtime_ns: (rollup.files > 0).then_some(rollup.newest_mtime_ns),
270 }
271}
272
273fn partition_summary(rollup: &InternedPartitionRollUp) -> PartitionRollUpSummary {
274 PartitionRollUpSummary {
275 all: rollup_summary(&rollup.all),
276 unignored: rollup_summary(&rollup.unignored),
277 }
278}
279
280#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
285pub(crate) struct RollUpScalars {
286 pub(crate) files: u64,
287 pub(crate) dirs: u64,
288 pub(crate) bytes: u64,
289 pub(crate) allocated: u64,
290 pub(crate) newest_mtime_ns: i64,
291}
292
293impl From<&InternedRollUp> for RollUpScalars {
294 fn from(rollup: &InternedRollUp) -> Self {
295 Self {
296 files: rollup.files,
297 dirs: rollup.dirs,
298 bytes: rollup.bytes,
299 allocated: rollup.allocated,
300 newest_mtime_ns: rollup.newest_mtime_ns,
301 }
302 }
303}
304
305impl RollUpScalars {
306 fn leaf(kind: EntryKind, attrs: &Attrs) -> Self {
309 match kind {
310 EntryKind::File => {
311 Self { files: 1, bytes: attrs.size, allocated: attrs.allocated, ..Self::default() }
312 }
313 EntryKind::Dir => Self { dirs: 1, ..Self::default() },
314 EntryKind::Symlink | EntryKind::Other => Self::default(),
315 }
316 }
317
318 fn checked_add(self, other: &Self) -> Result<Self, &'static str> {
320 Ok(Self {
321 files: self.files.checked_add(other.files).ok_or("files")?,
322 dirs: self.dirs.checked_add(other.dirs).ok_or("directories")?,
323 bytes: self.bytes.checked_add(other.bytes).ok_or("bytes")?,
324 allocated: self.allocated.checked_add(other.allocated).ok_or("allocated bytes")?,
325 newest_mtime_ns: self.newest_mtime_ns,
326 })
327 }
328
329 fn saturating_sub(self, other: &Self) -> Self {
331 Self {
332 files: self.files.saturating_sub(other.files),
333 dirs: self.dirs.saturating_sub(other.dirs),
334 bytes: self.bytes.saturating_sub(other.bytes),
335 allocated: self.allocated.saturating_sub(other.allocated),
336 newest_mtime_ns: self.newest_mtime_ns,
337 }
338 }
339}
340
341#[inline]
356pub(crate) fn add_file_sizes(
357 bytes: &mut u64,
358 allocated: &mut u64,
359 attrs: &Attrs,
360 path: impl FnOnce() -> PathBuf,
361) -> crate::Result<()> {
362 match (bytes.checked_add(attrs.size), allocated.checked_add(attrs.allocated)) {
363 (Some(next_bytes), Some(next_allocated)) => {
364 *bytes = next_bytes;
365 *allocated = next_allocated;
366 Ok(())
367 }
368 (None, _) => Err(unrepresentable_file(path(), "bytes")),
369 (Some(_), None) => Err(unrepresentable_file(path(), "allocated bytes")),
370 }
371}
372
373#[cold]
374#[inline(never)]
375fn unrepresentable_file(path: PathBuf, counter: &'static str) -> crate::Error {
376 crate::Error::UnrepresentableTotal { path, counter }
377}
378
379#[derive(Default)]
388struct TotalsOverlay {
389 cuts: BTreeMap<PathBuf, RollUpScalars>,
390 live: BTreeMap<PathBuf, (EntryKind, RollUpScalars)>,
391}
392
393impl TotalsOverlay {
394 fn index_alive(&self, index: &Index, path: &Path) -> Option<EntryId> {
396 let id = index.lookup(path)?;
397 let mut ancestor = Some(path);
398 while let Some(current) = ancestor {
399 if self.cuts.contains_key(current) {
400 return None;
401 }
402 ancestor = current.parent();
403 }
404 Some(id)
405 }
406
407 fn kind_at(&self, index: &Index, path: &Path) -> Option<EntryKind> {
409 if let Some((kind, _)) = self.live.get(path) {
410 return Some(*kind);
411 }
412 self.index_alive(index, path).map(|id| index.entry(id).kind)
413 }
414
415 fn beneath<'a, V>(
418 map: &'a BTreeMap<PathBuf, V>,
419 path: &'a Path,
420 ) -> impl Iterator<Item = (&'a PathBuf, &'a V)> {
421 map.range::<Path, _>((std::ops::Bound::Excluded(path), std::ops::Bound::Unbounded))
422 .take_while(move |(candidate, _)| candidate.starts_with(path))
423 }
424
425 fn subtree_at(&self, index: &Index, path: &Path) -> RollUpScalars {
429 let mut total = RollUpScalars::default();
430 if let Some(id) = self.index_alive(index, path) {
431 total = index.subtree_scalars(id);
432 for (_, cut) in Self::beneath(&self.cuts, path) {
433 total = total.saturating_sub(cut);
434 }
435 }
436 let live = self
437 .live
438 .range::<Path, _>((std::ops::Bound::Included(path), std::ops::Bound::Unbounded))
439 .take_while(|(candidate, _)| candidate.starts_with(path));
440 for (_, (_, own)) in live {
441 total = total.checked_add(own).unwrap_or_else(|_| {
442 unreachable!("a subtree the tree holds is within its representable total")
443 });
444 }
445 total
446 }
447
448 fn cut(&mut self, index: &Index, path: &Path) {
450 if let Some(id) = self.index_alive(index, path) {
451 let covered: Vec<PathBuf> =
452 Self::beneath(&self.cuts, path).map(|(cut, _)| cut.clone()).collect();
453 for cut in covered {
454 self.cuts.remove(&cut);
455 }
456 self.cuts.insert(path.to_path_buf(), index.subtree_scalars(id));
457 }
458 let gone: Vec<PathBuf> = self
459 .live
460 .range::<Path, _>((std::ops::Bound::Included(path), std::ops::Bound::Unbounded))
461 .take_while(|(candidate, _)| candidate.starts_with(path))
462 .map(|(live, _)| live.clone())
463 .collect();
464 for live in gone {
465 self.live.remove(&live);
466 }
467 }
468
469 fn replace(&mut self, index: &Index, path: &Path, kind: EntryKind, own: RollUpScalars) {
471 self.cut(index, path);
472 self.live.insert(path.to_path_buf(), (kind, own));
473 }
474}
475
476#[derive(Debug)]
484pub(crate) struct AnalysisWalk {
485 pending: Vec<(EntryId, PathBuf)>,
486 listing: Option<(EntryId, PathBuf, ListingResume)>,
487}
488
489impl AnalysisWalk {
490 pub(crate) fn start() -> Self {
492 Self { pending: vec![(EntryId::ROOT, PathBuf::new())], listing: None }
493 }
494}
495
496#[derive(Debug)]
504struct ListingResume {
505 position: usize,
506 last: OsString,
507}
508
509enum ResumedChildren<'a> {
512 Empty,
513 Sorted { index: &'a Index, ids: std::slice::Iter<'a, EntryId> },
514 Mutable(std::collections::btree_map::Range<'a, OsString, EntryId>),
515}
516
517impl<'a> Iterator for ResumedChildren<'a> {
518 type Item = (&'a OsStr, EntryId);
519
520 fn next(&mut self) -> Option<Self::Item> {
521 match self {
522 Self::Empty => None,
523 Self::Sorted { index, ids } => {
524 let id = *ids.next()?;
525 Some((index.entry(id).name.as_os_str(), id))
526 }
527 Self::Mutable(children) => children.next().map(|(name, id)| (name.as_os_str(), *id)),
528 }
529 }
530}
531
532#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
541pub(crate) struct FoldedFiles {
542 pub(crate) files: u64,
544 pub(crate) bytes: u64,
546 pub(crate) allocated: u64,
548 pub(crate) ignored: crate::query::IgnoredSize,
552}
553
554impl FoldedFiles {
555 fn add(&mut self, attrs: &Attrs, ignored: bool) {
556 self.files += 1;
557 self.bytes += attrs.size;
558 self.allocated += attrs.allocated;
559 if ignored {
560 self.ignored.bytes += attrs.size;
561 self.ignored.allocated += attrs.allocated;
562 }
563 }
564}
565
566impl InternedRollUp {
567 fn merge(&mut self, other: &InternedRollUp) {
575 let had_files = self.files > 0;
576 self.files += other.files;
577 self.dirs += other.dirs;
578 self.bytes += other.bytes;
579 self.allocated += other.allocated;
580 if other.files > 0 {
581 self.newest_mtime_ns = if had_files {
582 self.newest_mtime_ns.max(other.newest_mtime_ns)
583 } else {
584 other.newest_mtime_ns
585 };
586 }
587 for (ext, tally) in &other.by_ext {
588 let slot = self.by_ext.entry(*ext).or_default();
589 slot.files += tally.files;
590 slot.bytes += tally.bytes;
591 slot.allocated += tally.allocated;
592 }
593 }
594
595 fn unmerge(&mut self, other: &InternedRollUp) {
601 self.files = self.files.saturating_sub(other.files);
602 self.dirs = self.dirs.saturating_sub(other.dirs);
603 self.bytes = self.bytes.saturating_sub(other.bytes);
604 self.allocated = self.allocated.saturating_sub(other.allocated);
605 for (ext, tally) in &other.by_ext {
606 if let Some(slot) = self.by_ext.get_mut(ext) {
607 slot.files = slot.files.saturating_sub(tally.files);
608 slot.bytes = slot.bytes.saturating_sub(tally.bytes);
609 slot.allocated = slot.allocated.saturating_sub(tally.allocated);
610 if slot.files == 0 && slot.bytes == 0 && slot.allocated == 0 {
611 self.by_ext.remove(ext);
612 }
613 }
614 }
615 }
616}
617
618#[derive(Clone, Debug)]
619enum DirectoryChildren {
620 Sorted(Vec<EntryId>),
622 Mutable(BTreeMap<OsString, EntryId>),
624}
625
626impl DirectoryChildren {
627 #[cfg(test)]
628 fn is_sorted(&self) -> bool {
629 matches!(self, Self::Sorted(_))
630 }
631
632 #[cfg(test)]
633 fn is_mutable(&self) -> bool {
634 matches!(self, Self::Mutable(_))
635 }
636
637 fn ids(&self) -> ChildIds<'_> {
638 match self {
639 Self::Sorted(ids) => ChildIds::Sorted(ids.iter()),
640 Self::Mutable(children) => ChildIds::Mutable(children.values()),
641 }
642 }
643}
644
645#[derive(Clone, Debug)]
646struct DirectoryEntry {
647 children: DirectoryChildren,
648 rollup: InternedPartitionRollUp,
649 children_revision: u64,
650 children_complete: bool,
651}
652
653impl DirectoryEntry {
654 fn new(children_complete: bool) -> Self {
655 Self {
656 children: DirectoryChildren::Mutable(BTreeMap::new()),
657 rollup: InternedPartitionRollUp::default(),
658 children_revision: 0,
659 children_complete,
660 }
661 }
662}
663
664#[derive(Clone, Debug)]
665struct Entry {
666 parent: Option<EntryId>,
667 name: OsString,
668 ext_id: Option<ExtId>,
672 ignored: bool,
674 source: Source,
680 kind: EntryKind,
681 attrs: Attrs,
682 revision: u64,
685 directory: Option<Box<DirectoryEntry>>,
689}
690
691struct NewEntry {
692 parent: Option<EntryId>,
693 name: OsString,
694 ext_id: Option<ExtId>,
695 ignored: bool,
696 source: Source,
697 kind: EntryKind,
698 attrs: Attrs,
699}
700
701impl Entry {
702 fn new(entry: NewEntry, children_complete: bool) -> Self {
703 let NewEntry { parent, name, ext_id, ignored, source, kind, attrs } = entry;
704 Self {
705 parent,
706 name,
707 ext_id,
708 ignored,
709 source,
710 kind,
711 attrs,
712 revision: 0,
713 directory: kind.is_dir().then(|| Box::new(DirectoryEntry::new(children_complete))),
714 }
715 }
716
717 fn new_detached(new_entry: NewEntry, children_complete: bool) -> Self {
718 let mut entry = Self::new(new_entry, children_complete);
719 if let Some(directory) = entry.directory.as_deref_mut() {
720 directory.children = DirectoryChildren::Sorted(Vec::new());
721 }
722 entry
723 }
724
725 fn directory(&self) -> &DirectoryEntry {
726 self.directory.as_deref().expect("directory entry must retain directory state")
727 }
728
729 fn directory_mut(&mut self) -> &mut DirectoryEntry {
730 self.directory.as_deref_mut().expect("directory entry must retain directory state")
731 }
732
733 fn rollup(&self) -> &InternedPartitionRollUp {
734 &self.directory().rollup
735 }
736
737 fn rollup_mut(&mut self) -> &mut InternedPartitionRollUp {
738 &mut self.directory_mut().rollup
739 }
740}
741
742#[derive(Clone, PartialEq, Eq, Debug, Default)]
744pub(crate) struct PortableChildren {
745 pub(crate) directories: BTreeMap<String, EntryId>,
746 pub(crate) nondirectories: BTreeMap<String, EntryId>,
747}
748
749#[derive(Clone, PartialEq, Eq, Debug, Default)]
756struct ServingIndexes {
757 portable_children: BTreeMap<PathBuf, PortableChildren>,
758 portable_entries: BTreeMap<crate::PortablePath, EntryId>,
759 recent_files: BTreeSet<RecentKey>,
760 semantic_names: Vec<Option<String>>,
761 semantic_ids: BTreeMap<String, u32>,
762 semantic_refcounts: Vec<u64>,
763 free_semantic_ids: Vec<u32>,
764 semantic_by_directory: BTreeMap<EntryId, InternedSemanticPartitions>,
765 exact_name_ids: BTreeMap<String, u32>,
766 exact_names: Vec<String>,
767 exact_name_by_directory: BTreeMap<EntryId, InternedSemanticPartitions>,
768}
769
770#[derive(Clone, PartialEq, Eq, Debug, Default)]
771struct InternedSemanticPartitions {
772 all: BTreeMap<u32, ExtTally>,
773 unignored: BTreeMap<u32, ExtTally>,
774}
775
776#[derive(Clone, PartialEq, Eq, Debug)]
778struct RecentKey {
779 mtime_ns: i64,
780 portable_path: crate::PortablePath,
781 id: EntryId,
782}
783
784impl PartialOrd for RecentKey {
785 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
786 Some(self.cmp(other))
787 }
788}
789
790impl Ord for RecentKey {
791 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
792 other
793 .mtime_ns
794 .cmp(&self.mtime_ns)
795 .then_with(|| self.portable_path.cmp(&other.portable_path))
796 .then_with(|| self.id.cmp(&other.id))
797 }
798}
799
800impl ServingIndexes {
801 fn for_types(types: &crate::classify::TypeRegistry) -> Self {
802 let exact_names: Vec<_> = types
803 .exact_filenames()
804 .map(str::to_ascii_lowercase)
805 .collect::<BTreeSet<_>>()
806 .into_iter()
807 .collect();
808 let exact_name_ids = exact_names
809 .iter()
810 .enumerate()
811 .map(|(index, name)| {
812 let id = u32::try_from(index)
813 .expect("a registry declares fewer than four billion exact filenames");
814 (name.clone(), id)
815 })
816 .collect();
817 Self { exact_name_ids, exact_names, ..Self::default() }
818 }
819
820 fn exact_name_id(&self, name: &OsStr) -> Option<u32> {
821 let name = name.to_str()?;
822 if let Some(id) = self.exact_name_ids.get(name) {
823 return Some(*id);
824 }
825 name.bytes()
826 .any(|byte| byte.is_ascii_uppercase())
827 .then(|| name.to_ascii_lowercase())
828 .and_then(|name| self.exact_name_ids.get(&name).copied())
829 }
830
831 fn intern_semantic(&mut self, name: &str) -> u32 {
832 if let Some(id) = self.semantic_ids.get(name).copied() {
833 let refcount = self
834 .semantic_refcounts
835 .get_mut(id as usize)
836 .expect("a live semantic id has a refcount");
837 *refcount = refcount.checked_add(1).expect("semantic refcount exhausted");
838 return id;
839 }
840 let id = if let Some(id) = self.free_semantic_ids.pop() {
841 self.semantic_names[id as usize] = Some(name.to_string());
842 self.semantic_refcounts[id as usize] = 1;
843 id
844 } else {
845 let id = u32::try_from(self.semantic_names.len())
846 .expect("fewer than four billion semantic types are live");
847 self.semantic_names.push(Some(name.to_string()));
848 self.semantic_refcounts.push(1);
849 id
850 };
851 self.semantic_ids.insert(name.to_string(), id);
852 id
853 }
854
855 fn release_semantic(&mut self, id: u32, count: u64) {
856 let slot = self
857 .semantic_refcounts
858 .get_mut(id as usize)
859 .expect("a live semantic id has a refcount");
860 *slot = slot.checked_sub(count).expect("semantic reference released twice");
861 if *slot != 0 {
862 return;
863 }
864 let name =
865 self.semantic_names[id as usize].take().expect("a referenced semantic id has a name");
866 let removed = self.semantic_ids.remove(&name);
867 debug_assert_eq!(removed, Some(id), "the semantic interner's two maps disagreed");
868 self.free_semantic_ids.push(id);
869 }
870}
871
872fn merge_semantic(map: &mut BTreeMap<u32, ExtTally>, id: u32, attrs: Attrs) {
873 let tally = map.entry(id).or_default();
874 tally.files = tally.files.saturating_add(1);
875 tally.bytes = tally.bytes.saturating_add(attrs.size);
876 tally.allocated = tally.allocated.saturating_add(attrs.allocated);
877}
878
879fn unmerge_semantic(map: &mut BTreeMap<u32, ExtTally>, id: u32, attrs: Attrs) {
880 let tally = map.get_mut(&id).expect("a semantic contribution must exist before removal");
881 tally.files = tally.files.saturating_sub(1);
882 tally.bytes = tally.bytes.saturating_sub(attrs.size);
883 tally.allocated = tally.allocated.saturating_sub(attrs.allocated);
884 if tally.files == 0 && tally.bytes == 0 && tally.allocated == 0 {
885 map.remove(&id);
886 }
887}
888
889fn unmerge_semantic_map(
890 destination: &mut BTreeMap<u32, ExtTally>,
891 contribution: &BTreeMap<u32, ExtTally>,
892) {
893 for (id, removed) in contribution {
894 let tally = destination
895 .get_mut(id)
896 .expect("a semantic subtree contribution must exist before removal");
897 tally.files = tally.files.saturating_sub(removed.files);
898 tally.bytes = tally.bytes.saturating_sub(removed.bytes);
899 tally.allocated = tally.allocated.saturating_sub(removed.allocated);
900 if tally.files == 0 && tally.bytes == 0 && tally.allocated == 0 {
901 destination.remove(id);
902 }
903 }
904}
905
906#[derive(Clone, Debug)]
907enum Slot {
908 Occupied { generation: u64, entry: Entry },
909 Free { generation: u64, next_free: Option<u32> },
910}
911
912fn retained_parent(arena: &[Slot], id: EntryId) -> Option<EntryId> {
913 match arena.get(id.idx()) {
914 Some(Slot::Occupied { generation, entry }) if *generation == id.generation => entry.parent,
915 Some(Slot::Occupied { .. } | Slot::Free { .. }) | None => {
916 panic!("internal entry handle must be live: {id:?}")
917 }
918 }
919}
920
921#[derive(Clone, PartialEq, Eq, Debug, Default)]
923#[must_use]
924pub struct Since {
925 pub commits: Vec<Commit>,
927 pub clock: Clock,
929 pub state: IndexState,
931 pub truncated: bool,
934}
935
936#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
938pub struct ApplyStats {
939 pub inserted: u64,
941 pub updated: u64,
943 pub removed: u64,
945 pub unchanged: u64,
947 pub invalidated: u64,
949 pub controls: u64,
951 pub reclassified: u64,
953 pub stale: u64,
956 pub resource_refused: u64,
959}
960
961impl ApplyStats {
962 pub const fn mutated(&self) -> bool {
964 self.inserted > 0
965 || self.updated > 0
966 || self.removed > 0
967 || self.invalidated > 0
968 || self.controls > 0
969 || self.reclassified > 0
970 }
971}
972
973#[derive(Clone, PartialEq, Eq, Debug, Default)]
975pub struct ApplyOutcome {
976 pub stats: ApplyStats,
978 pub commit: Option<Commit>,
980}
981
982#[derive(Clone, PartialEq, Eq, Debug)]
987pub struct ChildSnapshot {
988 pub id: EntryId,
990 pub name: OsString,
992 pub kind: EntryKind,
994 pub attrs: Attrs,
996 pub ignored: Option<bool>,
1002 pub rollup: Option<RollUp>,
1004 pub partitions: Option<PartitionRollUp>,
1008}
1009
1010impl std::ops::Deref for ApplyOutcome {
1011 type Target = ApplyStats;
1012
1013 fn deref(&self) -> &Self::Target {
1014 &self.stats
1015 }
1016}
1017
1018impl ApplyOutcome {
1019 fn from_commit(stats: ApplyStats, commit: Option<Commit>) -> Self {
1020 Self { stats, commit }
1021 }
1022}
1023
1024#[derive(Clone, Copy)]
1025enum BatchProvenance {
1026 Baseline,
1027 Opened,
1028 Public,
1029}
1030
1031fn record_batch(provenance: BatchProvenance, observed: usize, stats: ApplyStats) {
1032 let observed = u64::try_from(observed).unwrap_or(u64::MAX);
1033 let accepted = observed.saturating_sub(stats.stale);
1034 crate::counters::bump(|counts| match provenance {
1035 BatchProvenance::Baseline => {
1036 counts.baseline_batches = counts.baseline_batches.saturating_add(1);
1037 counts.baseline_accepted_ops = counts.baseline_accepted_ops.saturating_add(accepted);
1038 }
1039 BatchProvenance::Opened => {
1040 counts.opened_batches = counts.opened_batches.saturating_add(1);
1041 counts.opened_accepted_ops = counts.opened_accepted_ops.saturating_add(accepted);
1042 }
1043 BatchProvenance::Public => {
1044 counts.public_batches = counts.public_batches.saturating_add(1);
1045 counts.public_accepted_ops = counts.public_accepted_ops.saturating_add(accepted);
1046 }
1047 });
1048}
1049
1050fn elapsed_micros(started: std::time::Instant) -> u64 {
1051 u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX)
1052}
1053
1054#[derive(Clone, Debug)]
1056struct PreparedObservation {
1057 ops: Vec<ObservationOp>,
1058 ancestry: PreparedAncestry,
1059 #[cfg(test)]
1060 reject_before_apply: bool,
1061}
1062
1063#[derive(Clone, Debug)]
1064enum PreparedAncestry {
1065 General,
1066 Scanner { parents: Vec<ResolvedParent>, has_batch_parents: bool },
1067}
1068
1069#[derive(Clone, Copy, Debug)]
1071enum ResolvedParent {
1072 Existing(EntryId),
1074 Earlier(usize),
1076}
1077
1078#[derive(Default)]
1079struct ExactConsequences {
1080 changes: Vec<EffectiveChange>,
1081 state: Vec<StateTransition>,
1082}
1083
1084impl ExactConsequences {
1085 fn is_empty(&self) -> bool {
1086 self.changes.is_empty() && self.state.is_empty()
1087 }
1088}
1089
1090#[derive(Default)]
1091struct NoConsequences;
1092
1093trait ConsequenceSink {
1099 fn change(&mut self, change: impl FnOnce() -> EffectiveChange);
1100 fn state(&mut self, transition: impl FnOnce() -> StateTransition);
1101}
1102
1103impl ConsequenceSink for ExactConsequences {
1104 #[inline]
1105 fn change(&mut self, change: impl FnOnce() -> EffectiveChange) {
1106 self.changes.push(change());
1107 }
1108
1109 #[inline]
1110 fn state(&mut self, transition: impl FnOnce() -> StateTransition) {
1111 self.state.push(transition());
1112 }
1113}
1114
1115impl ConsequenceSink for NoConsequences {
1116 #[inline]
1117 fn change(&mut self, _change: impl FnOnce() -> EffectiveChange) {}
1118
1119 #[inline]
1120 fn state(&mut self, _transition: impl FnOnce() -> StateTransition) {}
1121}
1122
1123#[derive(Clone, Debug)]
1125pub struct Index {
1126 root_path: PathBuf,
1127 scope: ScanScope,
1128 arena: Vec<Slot>,
1129 free_head: Option<u32>,
1130 live: u64,
1131 clock: Clock,
1132 journal: VecDeque<Commit>,
1133 journal_cost: usize,
1134 journal_capacity_bytes: usize,
1135 journal_floor: Clock,
1137 pending_invalidations: Vec<(PathBuf, InvalidateReason)>,
1138 applying_source: Source,
1144 scanned_at_ns: i64,
1146 captured_at_ns: i64,
1149 writing_pass_started_at_ns: i64,
1159 persistence_owed: bool,
1167 active_root_reconciles: BTreeMap<u64, i64>,
1169 active_reconciles: BTreeMap<u64, ActiveReconcile>,
1171 verified: Vec<(PathBuf, i64)>,
1179 ext_names: Vec<Option<String>>,
1182 ext_ids: BTreeMap<String, ExtId>,
1184 ext_refcounts: Vec<u64>,
1191 free_ext_ids: Vec<ExtId>,
1193 content: Option<Box<ContentIndex>>,
1195 types: std::sync::Arc<crate::classify::TypeRegistry>,
1202 controls: crate::control::ControlTable,
1204 unreadable_control_paths: BTreeSet<PathBuf>,
1207 freshness_epoch: u64,
1208 freshness_marks: BTreeMap<PathBuf, FreshnessMark>,
1209 state: IndexState,
1212 issues: Vec<Issue>,
1214 issue_epochs: Vec<u64>,
1217 omitted_issue_epochs: BTreeMap<u64, u64>,
1221 serving: Option<Box<ServingIndexes>>,
1226 folded: Option<Vec<FoldedFiles>>,
1233}
1234
1235#[derive(Clone, Copy)]
1236pub(crate) struct ReconcileErrors<'a> {
1237 pub(crate) errors: &'a [crate::Error],
1238 pub(crate) terminal: Option<&'a crate::Error>,
1239 pub(crate) disproves_old: bool,
1240}
1241
1242#[derive(Clone, Debug)]
1243struct ActiveReconcile {
1244 path: PathBuf,
1245 scope_budget: usize,
1248 evidence: ReconcileEvidence,
1249}
1250
1251#[derive(Clone, Debug)]
1252enum ReconcileEvidence {
1253 Scopes(BTreeSet<PathBuf>),
1254 Retry,
1255}
1256
1257impl ActiveReconcile {
1258 fn supersede(&mut self, path: &Path) {
1259 if self.path.starts_with(path) {
1260 self.evidence = ReconcileEvidence::Scopes(BTreeSet::from([path.to_path_buf()]));
1261 return;
1262 }
1263 let ReconcileEvidence::Scopes(scopes) = &mut self.evidence else {
1264 return;
1265 };
1266 if scopes.iter().any(|newer| path.starts_with(newer)) {
1267 return;
1268 }
1269 scopes.retain(|newer| !newer.starts_with(path));
1270 if scopes.len() == self.scope_budget {
1271 self.evidence = ReconcileEvidence::Retry;
1273 } else {
1274 scopes.insert(path.to_path_buf());
1275 }
1276 }
1277
1278 fn refuses(&self, observation: &Observation, index: &Index) -> bool {
1279 match &self.evidence {
1280 ReconcileEvidence::Retry => true,
1281 ReconcileEvidence::Scopes(scopes) => observation.ops.iter().any(|op| {
1282 scopes
1283 .iter()
1284 .any(|path| op.op.path().starts_with(path) || path.starts_with(op.op.path()))
1285 && !index.holds_target(&op.op, index.path_state(op.op.path()))
1286 }),
1287 }
1288 }
1289}
1290
1291pub(crate) struct ReconcileFinish {
1292 pub(crate) commit: Option<Commit>,
1293 pub(crate) retry: bool,
1294}
1295
1296enum ChildIds<'a> {
1297 Sorted(std::slice::Iter<'a, EntryId>),
1298 Mutable(std::collections::btree_map::Values<'a, OsString, EntryId>),
1299}
1300
1301impl Iterator for ChildIds<'_> {
1302 type Item = EntryId;
1303
1304 fn next(&mut self) -> Option<Self::Item> {
1305 match self {
1306 Self::Sorted(ids) => ids.next().copied(),
1307 Self::Mutable(ids) => ids.next().copied(),
1308 }
1309 }
1310
1311 fn size_hint(&self) -> (usize, Option<usize>) {
1312 let len = self.len();
1313 (len, Some(len))
1314 }
1315}
1316
1317impl DoubleEndedIterator for ChildIds<'_> {
1318 fn next_back(&mut self) -> Option<Self::Item> {
1319 match self {
1320 Self::Sorted(ids) => ids.next_back().copied(),
1321 Self::Mutable(ids) => ids.next_back().copied(),
1322 }
1323 }
1324}
1325
1326impl ExactSizeIterator for ChildIds<'_> {
1327 fn len(&self) -> usize {
1328 match self {
1329 Self::Sorted(ids) => ids.len(),
1330 Self::Mutable(ids) => ids.len(),
1331 }
1332 }
1333}
1334
1335enum IndexChildren<'a> {
1336 Empty,
1337 Sorted { index: &'a Index, ids: std::slice::Iter<'a, EntryId> },
1338 Mutable(std::collections::btree_map::Iter<'a, OsString, EntryId>),
1339}
1340
1341impl<'a> IndexChildren<'a> {
1342 fn new(index: &'a Index, entry: &'a Entry) -> Self {
1343 match entry.directory.as_deref().map(|directory| &directory.children) {
1344 None => Self::Empty,
1345 Some(DirectoryChildren::Sorted(ids)) => Self::Sorted { index, ids: ids.iter() },
1346 Some(DirectoryChildren::Mutable(children)) => Self::Mutable(children.iter()),
1347 }
1348 }
1349}
1350
1351impl<'a> Iterator for IndexChildren<'a> {
1352 type Item = (&'a OsStr, EntryId);
1353
1354 fn next(&mut self) -> Option<Self::Item> {
1355 match self {
1356 Self::Empty => None,
1357 Self::Sorted { index, ids } => {
1358 let id = *ids.next()?;
1359 Some((index.entry(id).name.as_os_str(), id))
1360 }
1361 Self::Mutable(children) => children.next().map(|(name, id)| (name.as_os_str(), *id)),
1362 }
1363 }
1364
1365 fn size_hint(&self) -> (usize, Option<usize>) {
1366 let len = self.len();
1367 (len, Some(len))
1368 }
1369}
1370
1371impl DoubleEndedIterator for IndexChildren<'_> {
1372 fn next_back(&mut self) -> Option<Self::Item> {
1373 match self {
1374 Self::Empty => None,
1375 Self::Sorted { index, ids } => {
1376 let id = *ids.next_back()?;
1377 Some((index.entry(id).name.as_os_str(), id))
1378 }
1379 Self::Mutable(children) => {
1380 children.next_back().map(|(name, id)| (name.as_os_str(), *id))
1381 }
1382 }
1383 }
1384}
1385
1386impl ExactSizeIterator for IndexChildren<'_> {
1387 fn len(&self) -> usize {
1388 match self {
1389 Self::Empty => 0,
1390 Self::Sorted { ids, .. } => ids.len(),
1391 Self::Mutable(children) => children.len(),
1392 }
1393 }
1394}
1395
1396#[derive(Clone, Copy, Debug)]
1397struct FreshnessMark {
1398 state: Freshness,
1399 epoch: u64,
1400}
1401
1402#[derive(Clone, Debug)]
1404pub struct IndexHandle {
1405 inner: Arc<RwLock<Index>>,
1406}
1407
1408#[derive(Clone, Debug, Default)]
1413pub(crate) struct DiscoveryCommit {
1414 pub(crate) directory_complete: Option<PathBuf>,
1415 pub(crate) transition: Option<DiscoveryTransition>,
1416}
1417
1418#[derive(Clone, Debug)]
1419pub(crate) enum DiscoveryTransition {
1420 Begin,
1421 Finish,
1422 BudgetRefused(Issue),
1423 Inaccessible { issues: Vec<Issue>, omitted: u64 },
1424 Cancelled,
1425 Failed(Issue),
1426}
1427
1428#[derive(Clone, Debug)]
1430#[cfg_attr(not(feature = "watch"), allow(dead_code))]
1431pub(crate) enum ObservationTransition {
1432 Reconciling,
1434 Watching { issues: Vec<Issue>, omitted: u64 },
1439 Unreadable { issues: Vec<Issue>, omitted: u64 },
1445 Failed(Issue),
1447}
1448
1449impl IndexHandle {
1450 pub fn new(index: Index) -> Self {
1452 Self { inner: Arc::new(RwLock::new(index)) }
1453 }
1454
1455 fn read_index(&self) -> crate::Result<std::sync::RwLockReadGuard<'_, Index>> {
1456 self.inner.read().map_err(|_| crate::Error::IndexLockPoisoned)
1457 }
1458
1459 fn write_index(&self) -> crate::Result<std::sync::RwLockWriteGuard<'_, Index>> {
1460 self.inner.write().map_err(|_| crate::Error::IndexLockPoisoned)
1461 }
1462
1463 pub(crate) fn read_with<T>(&self, read: impl FnOnce(&Index) -> T) -> crate::Result<T> {
1465 let index = self.read_index()?;
1466 Ok(read(&index))
1467 }
1468
1469 #[cfg(test)]
1470 pub(crate) fn poison_for_test(&self) {
1471 let handle = self.clone();
1472 std::thread::spawn(move || handle.panic_holding_the_write_lock_for_test())
1473 .join()
1474 .expect_err("injected index panic");
1475 }
1476
1477 #[cfg(test)]
1480 pub(crate) fn panic_holding_the_write_lock_for_test(&self) -> ! {
1481 let _guard = self.inner.write().expect("test index write lock");
1482 panic!("inject index poison");
1483 }
1484
1485 pub fn apply(&self, observation: &Observation) -> crate::Result<ApplyOutcome> {
1487 let prepared = prepare_observation(observation)?;
1488 let outcome = self.write_index()?.commit_prepared(prepared, true)?;
1489 record_batch(BatchProvenance::Public, observation.len(), outcome.stats);
1490 Ok(outcome)
1491 }
1492
1493 pub(crate) fn apply_discovery(
1494 &self,
1495 observation: &Observation,
1496 discovery: DiscoveryCommit,
1497 ) -> crate::Result<ApplyOutcome> {
1498 let prepared = prepare_observation(observation)?;
1499 let outcome = self.write_index()?.commit_prepared_with(
1500 prepared,
1501 true,
1502 Some(discovery),
1503 None,
1504 None,
1505 true,
1506 )?;
1507 record_batch(BatchProvenance::Opened, observation.len(), outcome.stats);
1508 Ok(outcome)
1509 }
1510
1511 #[cfg(feature = "watch")]
1512 pub(crate) fn apply_opened(
1513 &self,
1514 observation: &Observation,
1515 max_files: Option<u64>,
1516 ) -> crate::Result<ApplyOutcome> {
1517 let prepared = prepare_observation(observation)?;
1518 let outcome = self
1519 .write_index()?
1520 .commit_prepared_with(prepared, true, None, None, max_files, true)?;
1521 record_batch(BatchProvenance::Opened, observation.len(), outcome.stats);
1522 Ok(outcome)
1523 }
1524
1525 pub(crate) fn apply_scanner_discovery_bounded(
1526 &self,
1527 batch: crate::scan::ScannerBatch,
1528 discovery: DiscoveryCommit,
1529 max_files: Option<u64>,
1530 ) -> crate::Result<ApplyOutcome> {
1531 let observed = batch.len();
1532 let mut index = self.write_index()?;
1533 let prepared = index.prepare_scanner_batch(batch)?;
1534 let outcome =
1535 index.commit_prepared_with(prepared, true, Some(discovery), None, max_files, true)?;
1536 record_batch(BatchProvenance::Opened, observed, outcome.stats);
1537 Ok(outcome)
1538 }
1539
1540 pub(crate) fn transition_discovery(
1541 &self,
1542 transition: DiscoveryTransition,
1543 ) -> crate::Result<ApplyOutcome> {
1544 self.apply_discovery(
1545 &Observation::new(Vec::new()),
1546 DiscoveryCommit { directory_complete: None, transition: Some(transition) },
1547 )
1548 }
1549
1550 #[cfg(feature = "watch")]
1551 pub(crate) fn transition_observation(
1552 &self,
1553 transition: ObservationTransition,
1554 ) -> crate::Result<ApplyOutcome> {
1555 let prepared = prepare_observation(&Observation::default())?;
1556 let outcome = self.write_index()?.commit_prepared_with(
1557 prepared,
1558 true,
1559 None,
1560 Some(transition),
1561 None,
1562 true,
1563 )?;
1564 record_batch(BatchProvenance::Opened, 0, outcome.stats);
1565 Ok(outcome)
1566 }
1567
1568 pub fn root_path(&self) -> crate::Result<PathBuf> {
1570 Ok(self.read_index()?.root_path().to_path_buf())
1571 }
1572
1573 pub fn scope(&self) -> crate::Result<ScanScope> {
1575 Ok(self.read_index()?.scope())
1576 }
1577
1578 pub fn freshness(&self) -> crate::Result<Freshness> {
1580 Ok(self.read_index()?.freshness())
1581 }
1582
1583 pub fn freshness_at(&self, path: &Path) -> crate::Result<Freshness> {
1585 Ok(self.read_index()?.freshness_at(path))
1586 }
1587
1588 pub fn clock(&self) -> crate::Result<Clock> {
1590 Ok(self.read_index()?.clock())
1591 }
1592
1593 pub fn len(&self) -> crate::Result<u64> {
1595 Ok(self.read_index()?.len())
1596 }
1597
1598 pub fn is_empty(&self) -> crate::Result<bool> {
1600 Ok(self.read_index()?.is_empty())
1601 }
1602
1603 pub fn total(&self) -> crate::Result<RollUp> {
1605 Ok(self.read_index()?.total())
1606 }
1607
1608 pub(crate) fn state(&self) -> crate::Result<IndexState> {
1610 Ok(self.read_index()?.state())
1611 }
1612
1613 #[allow(dead_code)] pub(crate) fn issues(&self) -> crate::Result<Vec<Issue>> {
1615 Ok(self.read_index()?.issues().to_vec())
1616 }
1617
1618 #[allow(dead_code)] pub(crate) fn directory_complete(&self, path: &Path) -> crate::Result<Option<bool>> {
1621 Ok(self.read_index()?.directory_complete(path))
1622 }
1623
1624 pub fn rollup(&self, path: &Path) -> crate::Result<Option<RollUp>> {
1626 Ok(self.read_index()?.rollup(path))
1627 }
1628
1629 pub fn attrs(&self, path: &Path) -> crate::Result<Option<Attrs>> {
1631 Ok(self.read_index()?.attrs(path).copied())
1632 }
1633
1634 pub fn kind(&self, path: &Path) -> crate::Result<Option<EntryKind>> {
1636 Ok(self.read_index()?.kind(path))
1637 }
1638
1639 pub fn path_state(&self, path: &Path) -> crate::Result<PathState> {
1641 Ok(self.read_index()?.path_state(path))
1642 }
1643
1644 pub fn expectation(&self, path: &Path) -> crate::Result<PathExpectation> {
1646 Ok(self.read_index()?.expectation(path))
1647 }
1648
1649 pub fn since(&self, clock: Clock) -> crate::Result<Since> {
1651 Ok(self.read_index()?.since(clock))
1652 }
1653
1654 pub fn children(&self, path: &Path) -> crate::Result<Option<Vec<ChildSnapshot>>> {
1660 let index = self.read_index()?;
1661 let Some(children) = index.children(path) else {
1662 return Ok(None);
1663 };
1664 let observed = index.observes_controls();
1665 Ok(Some(
1666 children
1667 .map(|(name, id)| {
1668 let entry = index.entry(id);
1669 ChildSnapshot {
1670 id,
1671 name: name.to_os_string(),
1672 kind: entry.kind,
1673 attrs: entry.attrs,
1674 ignored: observed.then_some(entry.ignored),
1675 rollup: entry
1676 .kind
1677 .is_dir()
1678 .then(|| index.named_rollup(&entry.rollup().all)),
1679 partitions: (observed && entry.kind.is_dir())
1680 .then(|| index.named_partitions(entry.rollup())),
1681 }
1682 })
1683 .collect(),
1684 ))
1685 }
1686
1687 pub fn snapshot(&self) -> crate::Result<Index> {
1690 let mut snapshot = self.read_index()?.clone();
1691 snapshot.serving = None;
1692 Ok(snapshot)
1693 }
1694
1695 pub(crate) fn child_states(
1696 &self,
1697 path: &Path,
1698 ) -> crate::Result<BTreeMap<OsString, PathExpectation>> {
1699 let index = self.read_index()?;
1700 Ok(collect_child_expectations(&index, path))
1701 }
1702
1703 pub(crate) fn listing_baseline(
1706 &self,
1707 path: &Path,
1708 ) -> crate::Result<(BTreeMap<OsString, PathExpectation>, bool)> {
1709 let index = self.read_index()?;
1710 Ok((collect_child_expectations(&index, path), index.directory_complete(path) != Some(true)))
1711 }
1712
1713 pub(crate) fn has_control(&self, path: &Path) -> crate::Result<bool> {
1714 Ok(self.read_index()?.control_table().contains(path))
1715 }
1716
1717 pub(crate) fn take_pending_invalidations(
1718 &self,
1719 ) -> crate::Result<Vec<(PathBuf, InvalidateReason)>> {
1720 Ok(self.write_index()?.take_pending_invalidations())
1721 }
1722
1723 pub(crate) fn restore_pending_invalidations(
1724 &self,
1725 invalidations: Vec<(PathBuf, InvalidateReason)>,
1726 ) -> crate::Result<()> {
1727 self.write_index()?.restore_pending_invalidations(invalidations);
1728 Ok(())
1729 }
1730
1731 pub(crate) fn begin_reconcile(&self, path: &Path) -> crate::Result<(u64, Option<Commit>)> {
1732 self.write_index()?.begin_reconcile(path)
1733 }
1734
1735 pub(crate) fn finish_reconcile(
1736 &self,
1737 path: &Path,
1738 started_at: u64,
1739 complete: bool,
1740 listed_incomplete: &[PathBuf],
1741 failed_paths: &[PathBuf],
1742 errors: ReconcileErrors<'_>,
1743 ) -> crate::Result<ReconcileFinish> {
1744 self.write_index()?.finish_reconcile(
1745 path,
1746 started_at,
1747 complete,
1748 listed_incomplete,
1749 failed_paths,
1750 errors,
1751 )
1752 }
1753
1754 pub(crate) fn apply_reconcile(
1755 &self,
1756 started_at: u64,
1757 observation: &Observation,
1758 ) -> crate::Result<ApplyOutcome> {
1759 self.apply_reconcile_with(started_at, observation, None, BatchProvenance::Public)
1760 }
1761
1762 pub(crate) fn apply_opened_reconcile(
1763 &self,
1764 started_at: u64,
1765 observation: &Observation,
1766 max_files: Option<u64>,
1767 ) -> crate::Result<ApplyOutcome> {
1768 self.apply_reconcile_with(started_at, observation, max_files, BatchProvenance::Opened)
1769 }
1770
1771 fn apply_reconcile_with(
1772 &self,
1773 started_at: u64,
1774 observation: &Observation,
1775 max_files: Option<u64>,
1776 provenance: BatchProvenance,
1777 ) -> crate::Result<ApplyOutcome> {
1778 let prepared = prepare_observation(observation)?;
1779 let mut index = self.write_index()?;
1780 if index
1781 .active_reconciles
1782 .get(&started_at)
1783 .is_some_and(|active| active.refuses(observation, &index))
1784 {
1785 let stats = ApplyStats {
1786 stale: u64::try_from(observation.len()).unwrap_or(u64::MAX),
1787 ..ApplyStats::default()
1788 };
1789 record_batch(provenance, observation.len(), stats);
1790 return Ok(ApplyOutcome { stats, commit: None });
1791 }
1792 let outcome = index.commit_prepared_with(
1793 prepared,
1794 true,
1795 None,
1796 None,
1797 max_files,
1798 matches!(provenance, BatchProvenance::Opened),
1799 )?;
1800 record_batch(provenance, observation.len(), outcome.stats);
1801 Ok(outcome)
1802 }
1803
1804 #[cfg(feature = "watch")]
1805 pub(crate) fn apply_if_clock(
1806 &self,
1807 clock: Clock,
1808 observation: &Observation,
1809 ) -> crate::Result<Option<ApplyOutcome>> {
1810 let prepared = prepare_observation(observation)?;
1811 let mut index = self.write_index()?;
1812 if index.clock() != clock {
1813 return Ok(None);
1814 }
1815 index.commit_prepared(prepared, true).map(Some)
1816 }
1817
1818 #[cfg(feature = "watch")]
1819 pub(crate) fn apply_opened_if_clock(
1820 &self,
1821 clock: Clock,
1822 observation: &Observation,
1823 max_files: Option<u64>,
1824 ) -> crate::Result<Option<ApplyOutcome>> {
1825 let prepared = prepare_observation(observation)?;
1826 let mut index = self.write_index()?;
1827 if index.clock() != clock {
1828 return Ok(None);
1829 }
1830 index.commit_prepared_with(prepared, true, None, None, max_files, true).map(Some)
1831 }
1832
1833 #[cfg(feature = "watch")]
1834 pub(crate) fn unknown_ancestry(
1835 &self,
1836 observation: &Observation,
1837 ) -> crate::Result<Vec<(PathBuf, PathBuf)>> {
1838 let prepared = prepare_observation(observation)?;
1839 let index = self.read_index()?;
1840 let accepted = index.accepted_operations(&prepared.ops);
1841 Ok(index.unknown_ancestry(&prepared.ops, &accepted))
1842 }
1843
1844 #[cfg(feature = "watch")]
1845 pub(crate) fn watch_boundary(&self) -> crate::Result<(PathBuf, ScanScope, Clock)> {
1846 let index = self.read_index()?;
1847 Ok((index.root_path().to_path_buf(), index.scope(), index.clock()))
1848 }
1849
1850 #[cfg(feature = "watch")]
1851 pub(crate) fn invalidate_root(&self, reason: InvalidateReason) -> crate::Result<ApplyOutcome> {
1852 self.apply(&Observation::new(vec![Op::InvalidateSubtree { path: PathBuf::new(), reason }]))
1853 }
1854}
1855
1856pub(crate) struct DetachedIndexBuilder {
1862 index: Index,
1863 directory_ids: HashMap<PathBuf, (EntryId, Arc<crate::control::ControlChain>)>,
1867 repeated_directories: Vec<PathBuf>,
1870 inserted: u64,
1871 total_bytes: u64,
1874 total_allocated: u64,
1875 tree: Option<TreeFold>,
1877}
1878
1879struct TreeFold {
1887 retention: crate::execution::TreeRetention,
1888 largest: std::collections::BinaryHeap<std::cmp::Reverse<KeptFile>>,
1890 folded: Vec<FoldedFiles>,
1892}
1893
1894struct KeptFile {
1898 value: u64,
1899 parent: EntryId,
1900 name: OsString,
1901 attrs: Attrs,
1902 ignored: bool,
1903}
1904
1905impl PartialEq for KeptFile {
1906 fn eq(&self, other: &Self) -> bool {
1907 self.value == other.value
1908 }
1909}
1910
1911impl Eq for KeptFile {}
1912
1913impl PartialOrd for KeptFile {
1914 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
1915 Some(self.cmp(other))
1916 }
1917}
1918
1919impl Ord for KeptFile {
1920 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
1921 self.value.cmp(&other.value)
1922 }
1923}
1924
1925impl TreeFold {
1926 fn fold(folded: &mut Vec<FoldedFiles>, parent: EntryId, attrs: &Attrs, ignored: bool) {
1928 let slot = parent.idx();
1929 if folded.len() <= slot {
1930 folded.resize(slot + 1, FoldedFiles::default());
1931 }
1932 folded[slot].add(attrs, ignored);
1933 }
1934
1935 fn offer(&mut self, parent: EntryId, name: &mut OsString, attrs: Attrs, ignored: bool) {
1939 let value = self.retention.size.of(&attrs);
1940 let capacity = usize::try_from(self.retention.largest_files).unwrap_or(usize::MAX);
1941 if self.largest.len() < capacity {
1942 let name = std::mem::take(name);
1943 self.largest.push(std::cmp::Reverse(KeptFile { value, parent, name, attrs, ignored }));
1944 return;
1945 }
1946 match self.largest.peek_mut() {
1947 Some(mut smallest) if value > smallest.0.value => {
1948 let name = std::mem::take(name);
1949 let displaced = std::mem::replace(
1950 &mut smallest.0,
1951 KeptFile { value, parent, name, attrs, ignored },
1952 );
1953 drop(smallest);
1954 Self::fold(&mut self.folded, displaced.parent, &displaced.attrs, displaced.ignored);
1955 }
1956 _ => Self::fold(&mut self.folded, parent, &attrs, ignored),
1957 }
1958 }
1959}
1960
1961impl DetachedIndexBuilder {
1962 pub(crate) fn new(
1963 root_path: impl Into<PathBuf>,
1964 scope: ScanScope,
1965 types: std::sync::Arc<crate::classify::TypeRegistry>,
1966 ) -> Self {
1967 let mut index = Index::new_with_scope_and_types(root_path, scope, types);
1968 index.entry_mut(EntryId::ROOT).directory_mut().children =
1969 DirectoryChildren::Sorted(Vec::new());
1970 Self {
1971 index,
1972 directory_ids: HashMap::from([(PathBuf::new(), (EntryId::ROOT, Arc::default()))]),
1973 repeated_directories: Vec::new(),
1974 inserted: 0,
1975 total_bytes: 0,
1976 total_allocated: 0,
1977 tree: None,
1978 }
1979 }
1980
1981 pub(crate) fn with_control_limits(mut self, limits: crate::control::ControlLimits) -> Self {
1983 self.index.set_control_limits(limits);
1984 self
1985 }
1986
1987 pub(crate) fn folding(mut self, retention: crate::execution::TreeRetention) -> Self {
1991 self.tree = Some(TreeFold {
1992 retention,
1993 largest: std::collections::BinaryHeap::new(),
1994 folded: Vec::new(),
1995 });
1996 self
1997 }
1998
1999 pub(crate) fn push_directory(
2018 &mut self,
2019 directory: &mut crate::scan::DetachedDirectory,
2020 ) -> crate::Result<()> {
2021 let crate::scan::DetachedDirectory { path, children, control } = directory;
2022 let Some((parent, above)) = self.directory_ids.remove(path.as_path()) else {
2026 if self.repeated_directories.iter().any(|repeated| path.starts_with(repeated)) {
2027 return Ok(());
2028 }
2029 return Err(crate::Error::UnknownAncestry {
2030 path: path.clone(),
2031 reconcile_from: PathBuf::new(),
2032 });
2033 };
2034
2035 let listed_control = control.is_some();
2040 if let Some(control) = control.take() {
2041 debug_assert!(
2042 matches!(&control, Op::ControlUpsert { path: control_path, .. }
2043 | Op::ControlRemove { path: control_path }
2044 if control_path.parent() == Some(path.as_path())),
2045 "a listing carries only its own directory's control"
2046 );
2047 match control {
2048 Op::ControlUpsert { path, source } => {
2049 self.index.controls.upsert(&path, source)?;
2050 }
2051 Op::ControlRemove { path } => {
2052 self.index.controls.remove(&path)?;
2053 }
2054 _ => unreachable!("detached directory retains only fixed-control operations"),
2055 }
2056 self.inserted = self.inserted.saturating_add(1);
2057 }
2058
2059 children.sort_unstable_by(|left, right| {
2063 left.name.cmp(&right.name).then(left.position.cmp(&right.position))
2064 });
2065 let repeated_directories = &mut self.repeated_directories;
2066 children.dedup_by(|later, kept| {
2067 if later.name != kept.name {
2068 return false;
2069 }
2070 if later.kind.is_dir() || kept.kind.is_dir() {
2071 let repeated = path.join(&kept.name);
2072 if repeated_directories.last() != Some(&repeated) {
2073 repeated_directories.push(repeated);
2074 }
2075 }
2076 std::mem::swap(later, kept);
2077 true
2078 });
2079
2080 let parent_ignored = self.index.entry(parent).ignored;
2081 let chain =
2088 if listed_control { self.index.controls.chain_below(&above, path) } else { above };
2089 debug_assert!(
2090 chain.same_as(&self.index.controls.chain_for(path)),
2091 "a derived control chain is the one the table resolves for {}",
2092 path.display()
2093 );
2094 let classifying = !parent_ignored && !chain.is_empty();
2095 let entries = match self.tree {
2096 None => children.len(),
2097 Some(_) => children.iter().filter(|child| child.kind != EntryKind::File).count(),
2099 };
2100 self.index.reserve_detached_children(parent, entries);
2101 let path: &Path = path;
2102 let mut classify_children = |directory: &[&[u8]]| -> crate::Result<()> {
2103 for child in children.iter_mut() {
2104 let &mut crate::scan::DetachedChild { ref mut name, kind, attrs, .. } = child;
2105 crate::counters::bump(|counts| counts.upserts += 1);
2106 if kind == EntryKind::File {
2107 add_file_sizes(
2108 &mut self.total_bytes,
2109 &mut self.total_allocated,
2110 &attrs,
2111 || path.join(&*name),
2112 )?;
2113 }
2114 let ext_id = (kind == EntryKind::File && self.tree.is_none())
2115 .then(|| self.index.intern_ext(&crate::classify::ext_bucket(name)));
2116 let ignored = if classifying {
2117 chain.is_ignored_within(directory, name.as_encoded_bytes(), kind.is_dir())
2118 } else {
2119 parent_ignored
2120 };
2121 if let (EntryKind::File, Some(tree)) = (kind, &mut self.tree) {
2122 let direct = Index::file_contribution(&attrs, None, ignored);
2124 crate::counters::bump(|counts| counts.rollup_merges += 1);
2125 self.index.entry_mut(parent).rollup_mut().merge(&direct);
2126 tree.offer(parent, name, attrs, ignored);
2127 self.inserted = self.inserted.saturating_add(1);
2128 continue;
2129 }
2130 let name = std::mem::take(name);
2131 let child_path = kind.is_dir().then(|| path.join(&name));
2132 let child_id = self.index.alloc(Entry::new_detached(
2135 NewEntry {
2136 parent: Some(parent),
2137 name,
2138 ext_id,
2139 ignored,
2140 source: Source::Scanned,
2141 kind,
2142 attrs,
2143 },
2144 true,
2145 ));
2146 self.index.push_detached_child(parent, child_id);
2147 let direct = self.index.contribution(child_id);
2151 crate::counters::bump(|counts| counts.rollup_merges += 1);
2152 self.index.entry_mut(parent).rollup_mut().merge(&direct);
2153 if let Some(child_path) = child_path {
2154 self.directory_ids.insert(child_path, (child_id, Arc::clone(&chain)));
2155 }
2156 self.inserted = self.inserted.saturating_add(1);
2157 }
2158 Ok(())
2159 };
2160 if classifying {
2161 crate::control::with_directory_components(path, classify_children)?;
2162 } else {
2163 classify_children(&[])?;
2164 }
2165 if self.tree.is_none() {
2167 children.clear();
2168 }
2169 Ok(())
2170 }
2171
2172 fn keep_largest_files(&mut self, tree: TreeFold) {
2179 let TreeFold { largest, folded, .. } = tree;
2180 let mut gained = Vec::with_capacity(largest.len());
2181 for std::cmp::Reverse(file) in largest {
2182 let KeptFile { parent, name, attrs, ignored, .. } = file;
2183 let id = self.index.alloc(Entry::new_detached(
2184 NewEntry {
2185 parent: Some(parent),
2186 name,
2187 ext_id: None,
2188 ignored,
2189 source: Source::Scanned,
2190 kind: EntryKind::File,
2191 attrs,
2192 },
2193 true,
2194 ));
2195 self.index.push_detached_child(parent, id);
2196 gained.push(parent.idx());
2197 }
2198 gained.sort_unstable();
2199 gained.dedup();
2200 for slot in gained {
2201 let parent = EntryId {
2202 slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
2203 generation: 0,
2204 };
2205 self.index.sort_detached_children(parent);
2206 }
2207 self.index.folded = Some(folded);
2208 }
2209
2210 pub(crate) fn finish(mut self) -> Index {
2212 if let Some(tree) = self.tree.take() {
2213 self.keep_largest_files(tree);
2214 }
2215 for slot in (1..self.index.arena.len()).rev() {
2219 let id = EntryId {
2220 slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
2221 generation: 0,
2222 };
2223 if !self.index.entry(id).kind.is_dir() {
2224 continue;
2225 }
2226 let parent = self.index.entry(id).parent.expect("every non-root entry has a parent");
2227 crate::counters::bump(|counts| counts.rollup_merges += 1);
2229 self.index.merge_detached_descendants(parent, id);
2230 }
2231
2232 crate::counters::bump(|counts| {
2233 counts.baseline_batches = counts.baseline_batches.saturating_add(1);
2234 counts.baseline_accepted_ops =
2235 counts.baseline_accepted_ops.saturating_add(self.inserted);
2236 });
2237 self.index.establish_baseline();
2238 self.index
2239 }
2240}
2241
2242impl Index {
2243 pub fn new(root_path: impl Into<PathBuf>) -> Self {
2252 Self::new_with_scope(root_path, ScanScope::default())
2253 }
2254
2255 pub fn new_with_scope(root_path: impl Into<PathBuf>, scope: ScanScope) -> Self {
2262 Self::new_with_scope_and_types(
2263 root_path,
2264 scope,
2265 crate::classify::TypeRegistry::compiled_shared(),
2266 )
2267 }
2268
2269 pub fn new_with_config(root_path: impl Into<PathBuf>, config: &crate::ScanConfig) -> Self {
2275 let mut index =
2276 Self::new_with_scope_and_types(root_path, config.scope(), config.types_shared());
2277 index.set_control_limits(config.control_limits);
2278 index
2279 }
2280
2281 pub(crate) fn new_with_scope_and_types(
2283 root_path: impl Into<PathBuf>,
2284 scope: ScanScope,
2285 types: std::sync::Arc<crate::classify::TypeRegistry>,
2286 ) -> Self {
2287 Self::new_with_scope_types_and_journal_capacity_bytes(
2288 root_path,
2289 scope,
2290 types,
2291 DEFAULT_JOURNAL_CAPACITY_BYTES,
2292 )
2293 }
2294
2295 pub(crate) fn new_with_scope_types_and_journal_capacity_bytes(
2296 root_path: impl Into<PathBuf>,
2297 scope: ScanScope,
2298 types: std::sync::Arc<crate::classify::TypeRegistry>,
2299 journal_capacity_bytes: usize,
2300 ) -> Self {
2301 assert_eq!(
2302 scope.type_rules_fingerprint,
2303 types.fingerprint(),
2304 "an index's registry must match its semantic scope"
2305 );
2306 Self::new_with_journal_capacity_bytes(root_path, scope, journal_capacity_bytes, types, None)
2307 }
2308
2309 pub(crate) fn new_opened_with_scope_types_and_journal_capacity_bytes(
2311 root_path: impl Into<PathBuf>,
2312 scope: ScanScope,
2313 types: std::sync::Arc<crate::classify::TypeRegistry>,
2314 journal_capacity_bytes: usize,
2315 ) -> Self {
2316 assert_eq!(
2317 scope.type_rules_fingerprint,
2318 types.fingerprint(),
2319 "an index's registry must match its semantic scope"
2320 );
2321 let serving = ServingIndexes::for_types(&types);
2322 Self::new_with_journal_capacity_bytes(
2323 root_path,
2324 scope,
2325 journal_capacity_bytes,
2326 types,
2327 Some(Box::new(serving)),
2328 )
2329 }
2330
2331 fn new_with_journal_capacity_bytes(
2332 root_path: impl Into<PathBuf>,
2333 scope: ScanScope,
2334 journal_capacity_bytes: usize,
2335 types: std::sync::Arc<crate::classify::TypeRegistry>,
2336 serving: Option<Box<ServingIndexes>>,
2337 ) -> Self {
2338 let root = Entry::new(
2339 NewEntry {
2340 parent: None,
2341 name: OsString::new(),
2342 ext_id: None,
2343 ignored: false,
2344 source: Source::Scanned,
2345 kind: EntryKind::Dir,
2346 attrs: Attrs::default(),
2347 },
2348 true,
2349 );
2350 let constructed_at_ns = Self::now_unix_nanos();
2351 Self {
2352 root_path: root_path.into(),
2353 scope,
2354 arena: vec![Slot::Occupied { generation: 0, entry: root }],
2355 free_head: None,
2356 live: 1,
2357 clock: Clock::ZERO,
2358 journal: VecDeque::new(),
2359 journal_cost: 0,
2360 journal_capacity_bytes,
2361 journal_floor: Clock::ZERO,
2362 pending_invalidations: Vec::new(),
2363 freshness_epoch: 0,
2364 freshness_marks: BTreeMap::new(),
2365 state: IndexState::default(),
2366 issues: Vec::new(),
2367 issue_epochs: Vec::new(),
2368 omitted_issue_epochs: BTreeMap::new(),
2369 serving,
2370 applying_source: Source::Scanned,
2371 scanned_at_ns: constructed_at_ns,
2372 captured_at_ns: 0,
2373 writing_pass_started_at_ns: constructed_at_ns,
2374 persistence_owed: true,
2375 active_root_reconciles: BTreeMap::new(),
2376 active_reconciles: BTreeMap::new(),
2377 verified: Vec::new(),
2378 ext_names: Vec::new(),
2379 ext_ids: BTreeMap::new(),
2380 ext_refcounts: Vec::new(),
2381 free_ext_ids: Vec::new(),
2382 content: None,
2383 types,
2384 controls: crate::control::ControlTable::default(),
2385 unreadable_control_paths: BTreeSet::new(),
2386 folded: None,
2387 }
2388 }
2389
2390 pub fn types(&self) -> &crate::classify::TypeRegistry {
2392 self.types.as_ref()
2393 }
2394
2395 pub fn controls(&self) -> crate::Result<&crate::control::ControlTable> {
2404 self.require_observed_controls()?;
2405 Ok(&self.controls)
2406 }
2407
2408 pub const fn observes_controls(&self) -> bool {
2411 self.scope.observes_controls()
2412 }
2413
2414 pub fn control_identity(&self) -> crate::ControlTierIdentity {
2416 if self.observes_controls() {
2417 crate::ControlTierIdentity::Observed { limits: self.controls.limits() }
2418 } else {
2419 crate::ControlTierIdentity::NotObserved
2420 }
2421 }
2422
2423 pub fn snapshot_identity(&self) -> crate::SnapshotIdentity {
2425 crate::SnapshotIdentity {
2426 entries: crate::EntryTierIdentity::of_scope(self.scope),
2427 controls: self.control_identity(),
2428 }
2429 }
2430
2431 fn require_observed_controls(&self) -> crate::Result<()> {
2432 if self.observes_controls() { Ok(()) } else { Err(crate::Error::ControlStateNotObserved) }
2433 }
2434
2435 fn carries_unobserved_control_input(&self, ops: &[ObservationOp]) -> bool {
2444 !self.observes_controls()
2445 && ops.iter().any(|observed| {
2446 matches!(observed.op, Op::ControlUpsert { .. } | Op::ControlRemove { .. })
2447 })
2448 }
2449
2450 pub(crate) fn control_table(&self) -> &crate::control::ControlTable {
2455 &self.controls
2456 }
2457
2458 pub fn control_coverage(&self) -> crate::control::ControlCoverage {
2465 if self.observes_controls() {
2466 crate::control::ControlCoverage::Observed(self.controls.observation())
2467 } else {
2468 crate::control::ControlCoverage::NotObserved
2469 }
2470 }
2471
2472 pub(crate) fn set_control_limits(&mut self, limits: crate::control::ControlLimits) {
2476 debug_assert!(self.controls.is_vacant(), "the control limits are set before any control");
2477 self.controls = crate::control::ControlTable::with_limits(limits);
2478 }
2479
2480 pub(crate) fn require_control_limits_in_scope(
2492 &self,
2493 limits: crate::control::ControlLimits,
2494 ) -> crate::Result<()> {
2495 if self.scope.observes_controls()
2496 && (crate::ControlTierIdentity::Observed { limits }).ignore_rules_fingerprint()
2497 != self.scope.ignore_rules_fingerprint
2498 {
2499 return Err(crate::Error::ControlLimitsOutsideScope { limits });
2500 }
2501 Ok(())
2502 }
2503
2504 pub(crate) fn install_controls(
2506 &mut self,
2507 controls: crate::control::ControlTable,
2508 ) -> crate::Result<()> {
2509 self.require_control_limits_in_scope(controls.limits())?;
2510 if controls.limits().budget.is_some_and(|budget| controls.retained_cost() > budget) {
2513 return Err(crate::Error::Snapshot(
2514 "a snapshot's control table exceeds its own control budget".into(),
2515 ));
2516 }
2517 if !controls.is_vacant() {
2521 self.require_observed_controls()?;
2522 }
2523 let unchanged = controls.is_empty() && self.controls.is_empty();
2527 self.controls = controls;
2528 if unchanged {
2529 return Ok(());
2530 }
2531 let mut stats = ApplyStats::default();
2532 let mut effects = NoConsequences;
2533 self.reclassify_controlled_subtrees(&[PathBuf::new()], &mut stats, &mut effects);
2534 Ok(())
2535 }
2536
2537 pub(crate) fn types_shared(&self) -> std::sync::Arc<crate::classify::TypeRegistry> {
2539 std::sync::Arc::clone(&self.types)
2540 }
2541
2542 pub fn classify(&self, relative_path: &Path) -> crate::classify::Classification {
2544 crate::classify::classify_with(&self.types, relative_path, None)
2545 }
2546
2547 #[cfg(test)]
2548 fn with_journal_capacity_bytes(
2549 root_path: impl Into<PathBuf>,
2550 journal_capacity_bytes: usize,
2551 ) -> Self {
2552 Self::new_with_journal_capacity_bytes(
2553 root_path,
2554 ScanScope::default(),
2555 journal_capacity_bytes,
2556 crate::classify::TypeRegistry::compiled_shared(),
2557 None,
2558 )
2559 }
2560
2561 pub fn root_path(&self) -> &Path {
2563 &self.root_path
2564 }
2565
2566 pub const fn scope(&self) -> ScanScope {
2568 self.scope
2569 }
2570
2571 pub fn freshness(&self) -> Freshness {
2573 self.freshness_at(Path::new(""))
2574 }
2575
2576 fn published_freshness(&self) -> Freshness {
2585 let derived = self.freshness();
2586 if self.state.phase == LifecyclePhase::Reconciling && derived == Freshness::Fresh {
2587 Freshness::Reconciling
2588 } else {
2589 derived
2590 }
2591 }
2592
2593 pub(crate) const fn state(&self) -> IndexState {
2595 self.state
2596 }
2597
2598 #[allow(dead_code)] pub(crate) fn issues(&self) -> &[Issue] {
2600 &self.issues
2601 }
2602
2603 #[allow(dead_code)] pub(crate) fn directory_complete(&self, path: &Path) -> Option<bool> {
2606 let id = self.lookup(path)?;
2607 let entry = self.entry(id);
2608 (entry.kind == EntryKind::Dir).then(|| entry.directory().children_complete)
2609 }
2610
2611 pub fn freshness_at(&self, path: &Path) -> Freshness {
2613 self.freshness_marks
2614 .iter()
2615 .filter(|(marked, _)| path.starts_with(marked) || marked.starts_with(path))
2616 .map(|(_, mark)| mark.state)
2617 .max_by_key(|state| state.rank())
2618 .unwrap_or(Freshness::Fresh)
2619 }
2620
2621 pub fn clock(&self) -> Clock {
2623 self.clock
2624 }
2625
2626 pub fn len(&self) -> u64 {
2628 self.live
2629 }
2630
2631 pub fn is_empty(&self) -> bool {
2633 self.live <= 1
2634 }
2635
2636 pub fn total(&self) -> RollUp {
2638 self.named_rollup(&self.entry(EntryId::ROOT).rollup().all)
2639 }
2640
2641 pub fn partition_total(&self) -> crate::Result<PartitionRollUp> {
2649 self.require_observed_controls()?;
2650 Ok(self.named_partitions(self.entry(EntryId::ROOT).rollup()))
2651 }
2652
2653 pub(crate) fn total_scalars(&self) -> RollUpScalars {
2655 RollUpScalars::from(&self.entry(EntryId::ROOT).rollup().all)
2656 }
2657
2658 pub fn apply(&mut self, observation: &Observation) -> crate::Result<ApplyOutcome> {
2667 let prepared = prepare_observation(observation)?;
2668 let outcome = self.commit_prepared(prepared, true)?;
2669 record_batch(BatchProvenance::Public, observation.len(), outcome.stats);
2670 Ok(outcome)
2671 }
2672
2673 fn commit_prepared(
2675 &mut self,
2676 prepared: PreparedObservation,
2677 journal: bool,
2678 ) -> crate::Result<ApplyOutcome> {
2679 self.commit_prepared_with(prepared, journal, None, None, None, false)
2680 }
2681
2682 fn commit_prepared_with(
2683 &mut self,
2684 prepared: PreparedObservation,
2685 journal: bool,
2686 discovery: Option<DiscoveryCommit>,
2687 observation: Option<ObservationTransition>,
2688 max_files: Option<u64>,
2689 track_file_progress: bool,
2690 ) -> crate::Result<ApplyOutcome> {
2691 debug_assert!(!self.is_folded(), "a folded index answers one report and is never mutated");
2692 if prepared.ops.is_empty()
2693 && discovery.as_ref().is_none_or(|discovery| {
2694 discovery.directory_complete.is_none() && discovery.transition.is_none()
2695 })
2696 && observation.is_none()
2697 {
2698 return Ok(ApplyOutcome::default());
2699 }
2700
2701 if discovery.is_some()
2707 && matches!(self.state.phase, LifecyclePhase::Stopped | LifecyclePhase::Failed)
2708 {
2709 return Err(crate::Error::OpenedIndexStopped);
2710 }
2711
2712 let mut discovery = discovery;
2713 if let Some(path) =
2714 discovery.as_mut().and_then(|discovery| discovery.directory_complete.as_mut())
2715 {
2716 let canonical = canonical_relative_path(path)?;
2720 let Some(id) = self.lookup(&canonical) else {
2721 return Err(crate::Error::InvalidDirectoryCompletion(canonical));
2722 };
2723 if self.entry(id).kind != EntryKind::Dir {
2724 return Err(crate::Error::InvalidDirectoryCompletion(canonical));
2725 }
2726 *path = canonical;
2727 }
2728
2729 #[cfg(test)]
2730 if prepared.reject_before_apply {
2731 return Err(crate::Error::CommitRejected("injected reducer preflight"));
2732 }
2733
2734 let Some(next_clock) = self.clock.checked_next() else {
2735 let mut probe = self.clone();
2739 probe.clock = Clock(self.clock.0 - 1);
2740 let outcome = probe.commit_prepared_with(
2741 prepared,
2742 false,
2743 discovery,
2744 observation,
2745 max_files,
2746 track_file_progress,
2747 )?;
2748 return if outcome.commit.is_some() {
2749 Err(crate::Error::ClockExhausted)
2750 } else {
2751 Ok(outcome)
2752 };
2753 };
2754
2755 let observed = u64::try_from(prepared.ops.len()).unwrap_or(u64::MAX);
2756 let mut effects = ExactConsequences::default();
2757 let stats = self.reduce_prepared(
2758 &prepared,
2759 discovery,
2760 observation,
2761 max_files,
2762 track_file_progress,
2763 &mut effects,
2764 )?;
2765
2766 if effects.is_empty() {
2767 return Ok(ApplyOutcome::from_commit(stats, None));
2768 }
2769
2770 let commit =
2771 self.publish_effects(next_clock, effects, commit_work(observed, stats), journal);
2772 Ok(ApplyOutcome::from_commit(stats, Some(commit)))
2773 }
2774
2775 fn reduce_prepared<C: ConsequenceSink>(
2780 &mut self,
2781 prepared: &PreparedObservation,
2782 discovery: Option<DiscoveryCommit>,
2783 observation: Option<ObservationTransition>,
2784 max_files: Option<u64>,
2785 track_file_progress: bool,
2786 effects: &mut C,
2787 ) -> crate::Result<ApplyStats> {
2788 if self.carries_unobserved_control_input(&prepared.ops) {
2789 return Err(crate::Error::ControlStateNotObserved);
2790 }
2791 if matches!(prepared.ancestry, PreparedAncestry::Scanner { .. }) {
2792 debug_assert!(observation.is_none());
2793 return self.reduce_scanner_prepared(
2794 prepared,
2795 discovery,
2796 max_files,
2797 track_file_progress,
2798 effects,
2799 );
2800 }
2801 let mut stats = ApplyStats::default();
2802 let mut parent_memo = ParentMemo::default();
2803 let accepted = self.accepted_operations(&prepared.ops);
2804 stats.stale = u64::try_from(accepted.iter().filter(|accepted| !**accepted).count())
2805 .unwrap_or(u64::MAX);
2806 self.validate_known_ancestry(&prepared.ops, &accepted)?;
2807 let projected_controls = self.projected_controls(&prepared.ops, &accepted)?;
2808 self.preflight_totals(&prepared.ops, Some(&accepted), max_files)?;
2809
2810 for (observed, accepted) in prepared.ops.iter().zip(accepted) {
2811 if !accepted {
2812 continue;
2813 }
2814 let op = &observed.op;
2815 if let (Some(max_files), Op::Upsert { path, kind, .. }) = (max_files, op) {
2816 if self.files_after_upsert(path, *kind) > max_files {
2817 stats.resource_refused = stats.resource_refused.saturating_add(1);
2818 continue;
2819 }
2820 }
2821 match op {
2822 Op::Upsert { path, kind, attrs } => {
2823 self.apply_upsert(path, *kind, *attrs, &mut stats, effects, &mut parent_memo);
2824 }
2825 Op::Remove { path } => {
2826 parent_memo.clear();
2832 self.apply_remove(path, &mut stats, effects);
2833 }
2834 Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {
2835 parent_memo.clear();
2839 }
2840 Op::InvalidateSubtree { path, reason } => {
2841 parent_memo.clear();
2842 let previous_index_state = self.state;
2843 let previous = self.freshness_at(path);
2844 self.pending_invalidations.push((path.clone(), *reason));
2845 self.mark_unfresh(path, Freshness::Stale);
2846 let current = self.freshness_at(path);
2847 self.state.freshness = self.published_freshness();
2848 if matches!(
2849 reason,
2850 InvalidateReason::WatchOverflow
2851 | InvalidateReason::UnpairedRename
2852 | InvalidateReason::WatchSetupRace
2853 | InvalidateReason::VerificationFailed
2854 | InvalidateReason::UnknownAncestry
2855 | InvalidateReason::WatchContention
2856 ) {
2857 self.retain_issue(Issue::observation_gap(path, *reason));
2858 }
2859 stats.invalidated += 1;
2860 effects.change(|| EffectiveChange::Invalidated {
2861 path: path.clone(),
2862 reason: *reason,
2863 });
2864 if previous != current {
2865 effects.state(|| StateTransition::Freshness {
2866 path: path.clone(),
2867 previous,
2868 current,
2869 });
2870 }
2871 if previous_index_state != self.state {
2872 effects.state(|| StateTransition::IndexState {
2873 previous: previous_index_state,
2874 current: self.state,
2875 });
2876 }
2877 }
2878 }
2879 }
2880
2881 self.finish_reduction(
2882 projected_controls,
2883 &mut stats,
2884 discovery,
2885 observation,
2886 max_files,
2887 track_file_progress,
2888 effects,
2889 );
2890
2891 Ok(stats)
2892 }
2893
2894 fn reduce_scanner_prepared<C: ConsequenceSink>(
2896 &mut self,
2897 prepared: &PreparedObservation,
2898 discovery: Option<DiscoveryCommit>,
2899 max_files: Option<u64>,
2900 track_file_progress: bool,
2901 effects: &mut C,
2902 ) -> crate::Result<ApplyStats> {
2903 let PreparedAncestry::Scanner { parents, has_batch_parents } = &prepared.ancestry else {
2904 unreachable!("scanner reduction requires scanner ancestry");
2905 };
2906 debug_assert_eq!(prepared.ops.len(), parents.len());
2907 let projection_started = crate::counters::enabled().then(std::time::Instant::now);
2908 let projected_controls =
2909 self.projected_controls_from(prepared.ops.iter().map(|observed| &observed.op))?;
2910 if let Some(started) = projection_started {
2911 let elapsed = elapsed_micros(started);
2912 crate::counters::bump(|counts| {
2913 counts.scanner_control_projection_us =
2914 counts.scanner_control_projection_us.saturating_add(elapsed);
2915 });
2916 }
2917 self.preflight_totals(&prepared.ops, None, max_files)?;
2918 let mut stats = ApplyStats::default();
2919 let mut applied_ids = has_batch_parents.then(|| vec![None; prepared.ops.len()]);
2920
2921 for (op_index, (observed, parent)) in prepared.ops.iter().zip(parents.iter()).enumerate() {
2922 match &observed.op {
2923 Op::Upsert { path, kind, attrs } => {
2924 if let Some(max_files) = max_files {
2925 if self.files_after_upsert(path, *kind) > max_files {
2926 stats.resource_refused = stats.resource_refused.saturating_add(1);
2927 continue;
2928 }
2929 }
2930 let parent = match parent {
2931 ResolvedParent::Existing(parent) => *parent,
2932 ResolvedParent::Earlier(parent_op) => applied_ids
2933 .as_ref()
2934 .and_then(|ids| ids.get(*parent_op))
2935 .copied()
2936 .flatten()
2937 .expect("a proved parent directory was applied earlier"),
2938 };
2939 let name = path.file_name().expect("scanner upserts are not root mutations");
2940 crate::counters::bump(|counts| counts.upserts += 1);
2941 self.upsert_beneath(parent, name, path, *kind, *attrs, &mut stats, effects);
2942 if kind.is_dir() {
2943 if let Some(ids) = &mut applied_ids {
2944 ids[op_index] = self.child(parent, name);
2945 }
2946 }
2947 }
2948 Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {}
2949 Op::Remove { .. } | Op::InvalidateSubtree { .. } => {
2950 unreachable!("scanner preparation rejects non-discovery operations");
2951 }
2952 }
2953 }
2954
2955 self.finish_reduction(
2956 projected_controls,
2957 &mut stats,
2958 discovery,
2959 None,
2960 max_files,
2961 track_file_progress,
2962 effects,
2963 );
2964
2965 Ok(stats)
2966 }
2967
2968 #[allow(clippy::too_many_arguments)] fn finish_reduction<C: ConsequenceSink>(
2970 &mut self,
2971 projected_controls: Option<crate::control::ControlTable>,
2972 stats: &mut ApplyStats,
2973 discovery: Option<DiscoveryCommit>,
2974 observation: Option<ObservationTransition>,
2975 max_files: Option<u64>,
2976 track_file_progress: bool,
2977 effects: &mut C,
2978 ) {
2979 self.apply_control_transition(projected_controls, stats, effects);
2980 let mut discovery = discovery;
2981 if stats.resource_refused > 0 {
2982 let max_files = max_files.expect("resource refusal requires a file limit");
2983 let discovery = discovery.get_or_insert_with(DiscoveryCommit::default);
2984 discovery.directory_complete = None;
2985 discovery.transition =
2986 Some(DiscoveryTransition::BudgetRefused(Issue::resource_budget(max_files)));
2987 }
2988 self.apply_opened_state(discovery, observation, track_file_progress, effects);
2989 }
2990
2991 fn apply_opened_state<C: ConsequenceSink>(
2992 &mut self,
2993 discovery: Option<DiscoveryCommit>,
2994 observation: Option<ObservationTransition>,
2995 track_file_progress: bool,
2996 effects: &mut C,
2997 ) {
2998 if discovery.is_none() && observation.is_none() && !track_file_progress {
2999 return;
3000 }
3001 let previous = self.state;
3002 if track_file_progress {
3003 self.state.progress.files_retained = self.total_scalars().files;
3004 }
3005
3006 if let Some(discovery) = discovery {
3007 if let Some(path) = discovery.directory_complete {
3008 let id = self.lookup(&path).expect("discovery completion was preflighted");
3009 if !self.entry(id).directory().children_complete {
3010 self.entry_mut(id).directory_mut().children_complete = true;
3011 self.state.progress.directories_complete =
3012 self.state.progress.directories_complete.saturating_add(1);
3013 effects.state(|| StateTransition::DirectoryComplete { path });
3014 }
3015 }
3016
3017 if let Some(transition) = discovery.transition {
3018 match transition {
3019 DiscoveryTransition::Begin => {
3020 for slot in &mut self.arena {
3021 if let Slot::Occupied { entry, .. } = slot {
3022 if entry.kind == EntryKind::Dir {
3023 entry.directory_mut().children_complete = false;
3024 }
3025 }
3026 }
3027 self.state = IndexState {
3028 phase: LifecyclePhase::Discovering,
3029 coverage: Coverage::Partial(CoverageReason::Building),
3030 freshness: Freshness::Fresh,
3031 source: Source::Scanned,
3032 progress: DiscoveryProgress::default(),
3033 issues: crate::IssueSummary::default(),
3034 };
3035 self.issues.clear();
3036 self.issue_epochs.clear();
3037 self.omitted_issue_epochs.clear();
3038 }
3039 DiscoveryTransition::Finish => {
3040 self.state.phase = LifecyclePhase::Ready;
3041 if self.state.coverage == Coverage::Partial(CoverageReason::Building) {
3042 self.state.coverage = Coverage::Complete;
3043 }
3044 self.state.freshness = if self.state.coverage == Coverage::Complete {
3045 Freshness::Fresh
3046 } else {
3047 Freshness::Partial
3048 };
3049 }
3050 DiscoveryTransition::BudgetRefused(issue) => {
3051 let already_stopped_for_budget = self.state.phase
3052 == LifecyclePhase::Stopped
3053 && self.state.coverage == Coverage::Partial(CoverageReason::Budget);
3054 self.state.phase = LifecyclePhase::Stopped;
3055 self.state.coverage = Coverage::Partial(CoverageReason::Budget);
3056 self.state.freshness = Freshness::Fresh;
3057 if !already_stopped_for_budget {
3058 self.retain_issue(issue);
3059 }
3060 }
3061 DiscoveryTransition::Inaccessible { issues, omitted } => {
3062 if self.state.coverage != Coverage::Partial(CoverageReason::Budget) {
3063 self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
3064 self.state.freshness = Freshness::Partial;
3065 }
3066 for issue in issues {
3067 self.retain_issue(issue);
3068 }
3069 self.retain_omitted(omitted);
3070 }
3071 DiscoveryTransition::Cancelled => {
3072 self.state.phase = LifecyclePhase::Stopped;
3073 if self.state.coverage != Coverage::Complete {
3074 self.state.coverage = Coverage::Partial(CoverageReason::Cancelled);
3075 }
3076 }
3077 DiscoveryTransition::Failed(issue) => {
3078 self.state.phase = LifecyclePhase::Failed;
3079 self.state.coverage = Coverage::Partial(CoverageReason::Failed);
3080 self.state.freshness = Freshness::Partial;
3081 self.retain_issue(issue);
3082 }
3083 }
3084 }
3085 }
3086
3087 if let Some(observation) = observation {
3088 match observation {
3089 ObservationTransition::Reconciling => {
3090 if self.state.phase == LifecyclePhase::Ready {
3091 self.state.phase = LifecyclePhase::Reconciling;
3092 self.state.freshness = Freshness::Reconciling;
3093 }
3094 }
3095 ObservationTransition::Watching { issues, omitted } => {
3096 if self.state.phase == LifecyclePhase::Reconciling {
3097 self.state.phase = LifecyclePhase::Watching;
3098 if !issues.is_empty() || omitted > 0 {
3099 self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
3100 for issue in issues {
3101 self.retain_issue(issue);
3102 }
3103 self.retain_omitted(omitted);
3104 } else if self.state.coverage
3105 == Coverage::Partial(CoverageReason::Inaccessible)
3106 {
3107 self.state.coverage = Coverage::Complete;
3113 }
3114 self.state.freshness = self.freshness();
3115 if self.state.coverage != Coverage::Complete {
3116 self.state.freshness = Freshness::Partial;
3117 }
3118 }
3119 }
3120 ObservationTransition::Unreadable { issues, omitted } => {
3121 if matches!(self.state.phase, LifecyclePhase::Watching | LifecyclePhase::Ready)
3124 {
3125 for issue in issues {
3126 self.retain_issue(issue);
3127 }
3128 self.retain_omitted(omitted);
3129 }
3130 }
3131 ObservationTransition::Failed(issue) => {
3132 if self.state.phase != LifecyclePhase::Stopped {
3133 self.state.phase = LifecyclePhase::Failed;
3134 self.state.freshness = Freshness::Partial;
3135 self.retain_issue(issue);
3136 }
3137 }
3138 }
3139 }
3140
3141 if previous != self.state {
3142 effects.state(|| StateTransition::IndexState { previous, current: self.state });
3143 }
3144 }
3145
3146 fn subtree_scalars(&self, id: EntryId) -> RollUpScalars {
3148 let entry = self.entry(id);
3149 match entry.kind {
3150 EntryKind::Dir => {
3151 let mut scalars = RollUpScalars::from(&entry.rollup().all);
3152 scalars.dirs = scalars.dirs.saturating_add(1);
3153 scalars
3154 }
3155 EntryKind::File => RollUpScalars::leaf(EntryKind::File, &entry.attrs),
3156 EntryKind::Symlink | EntryKind::Other => RollUpScalars::default(),
3157 }
3158 }
3159
3160 fn preflight_totals(
3176 &self,
3177 ops: &[ObservationOp],
3178 accepted: Option<&[bool]>,
3179 max_files: Option<u64>,
3180 ) -> crate::Result<()> {
3181 let mut bound = self.total_scalars();
3182 for (index, observed) in ops.iter().enumerate() {
3183 if accepted.is_some_and(|accepted| !accepted[index]) {
3184 continue;
3185 }
3186 if let Op::Upsert { kind, attrs, .. } = &observed.op {
3187 match bound.checked_add(&RollUpScalars::leaf(*kind, attrs)) {
3188 Ok(next) => bound = next,
3189 Err(_) => return self.replay_totals(ops, accepted, max_files),
3190 }
3191 }
3192 }
3193 Ok(())
3194 }
3195
3196 fn replay_totals(
3200 &self,
3201 ops: &[ObservationOp],
3202 accepted: Option<&[bool]>,
3203 max_files: Option<u64>,
3204 ) -> crate::Result<()> {
3205 let mut total = self.total_scalars();
3206 let mut overlay = TotalsOverlay::default();
3207 for (index, observed) in ops.iter().enumerate() {
3208 if accepted.is_some_and(|accepted| !accepted[index]) {
3209 continue;
3210 }
3211 match &observed.op {
3212 Op::Upsert { path, kind, attrs } => {
3213 if path.as_os_str().is_empty()
3217 || (overlay.kind_at(self, path) == Some(*kind) && *kind != EntryKind::File)
3218 {
3219 continue;
3220 }
3221 let old = overlay.subtree_at(self, path);
3222 let new = RollUpScalars::leaf(*kind, attrs);
3223 let after =
3224 total.saturating_sub(&old).checked_add(&new).map_err(|counter| {
3225 crate::Error::UnrepresentableTotal { path: path.clone(), counter }
3226 })?;
3227 if max_files.is_some_and(|max_files| after.files > max_files) {
3228 continue;
3230 }
3231 overlay.replace(self, path, *kind, new);
3232 total = after;
3233 }
3234 Op::Remove { path } => {
3235 if path.as_os_str().is_empty() {
3236 continue;
3237 }
3238 let old = overlay.subtree_at(self, path);
3239 overlay.cut(self, path);
3240 total = total.saturating_sub(&old);
3241 }
3242 Op::ControlUpsert { .. }
3243 | Op::ControlRemove { .. }
3244 | Op::InvalidateSubtree { .. } => {}
3245 }
3246 }
3247 Ok(())
3248 }
3249
3250 fn files_after_upsert(&self, path: &Path, kind: EntryKind) -> u64 {
3253 let current_total = self.total_scalars().files;
3254 let Some(id) = self.lookup(path) else {
3255 return current_total.saturating_add(u64::from(kind == EntryKind::File));
3256 };
3257 let current = self.entry(id);
3258 if current.kind == kind {
3259 return current_total;
3260 }
3261 let removed = match current.kind {
3262 EntryKind::File => 1,
3263 EntryKind::Dir => current.rollup().all.files,
3264 _ => 0,
3265 };
3266 current_total.saturating_sub(removed).saturating_add(u64::from(kind == EntryKind::File))
3267 }
3268
3269 fn retain_issue(&mut self, issue: Issue) {
3280 self.retain_issue_at(issue, self.freshness_epoch);
3281 }
3282
3283 fn retain_issue_at(&mut self, issue: Issue, epoch: u64) {
3284 let repeat = self.issues.iter().position(|retained| same_issue_cause(retained, &issue));
3285 if let Some(position) = repeat {
3286 self.issue_epochs[position] = epoch;
3287 } else {
3288 let position = self
3289 .issues
3290 .binary_search_by(|retained| compare_issues(retained, &issue))
3291 .unwrap_or_else(|position| position);
3292 if position < MAX_RETAINED_ISSUES {
3293 self.issues.insert(position, issue);
3294 self.issue_epochs.insert(position, epoch);
3295 if self.issues.len() > MAX_RETAINED_ISSUES {
3296 self.issues.pop();
3297 let omitted_epoch =
3298 self.issue_epochs.pop().expect("issue epochs stay parallel");
3299 self.retain_omitted_at(1, omitted_epoch);
3300 }
3301 } else {
3302 self.retain_omitted_at(1, epoch);
3303 }
3304 self.state.issues.retained = u64::try_from(self.issues.len()).unwrap_or(u64::MAX);
3305 }
3306 }
3307
3308 fn retain_omitted(&mut self, count: u64) {
3309 if count == 0 {
3310 return;
3311 }
3312 let epoch =
3313 self.active_reconciles.keys().next_back().copied().unwrap_or(self.freshness_epoch);
3314 self.retain_omitted_at(count, epoch);
3315 }
3316
3317 fn retain_omitted_at(&mut self, count: u64, epoch: u64) {
3318 let retained = self.omitted_issue_epochs.entry(epoch).or_default();
3319 *retained = retained.saturating_add(count);
3320 self.state.issues.omitted = self.state.issues.omitted.saturating_add(count);
3321 }
3322
3323 fn drop_disproven_omitted(&mut self, started_at: u64) {
3324 self.omitted_issue_epochs.retain(|epoch, _| *epoch >= started_at);
3325 self.state.issues.omitted =
3326 self.omitted_issue_epochs.values().fold(0_u64, |sum, count| sum.saturating_add(*count));
3327 }
3328
3329 fn compact_omitted_epochs(&mut self) {
3330 let mut compact = BTreeMap::new();
3331 for (epoch, count) in std::mem::take(&mut self.omitted_issue_epochs) {
3332 let owner =
3333 self.active_reconciles.range(..=epoch).next_back().map_or(0, |(epoch, _)| *epoch);
3334 let retained = compact.entry(owner).or_insert(0_u64);
3335 *retained = retained.saturating_add(count);
3336 }
3337 self.omitted_issue_epochs = compact;
3338 }
3339
3340 fn drop_disproven_issues(&mut self, path: &Path, started_at: u64) {
3353 let mut position = 0;
3354 while position < self.issues.len() {
3355 let issue = &self.issues[position];
3356 let disproven = issue.kind != crate::IssueKind::ObservationGap
3357 && issue.path.as_deref().is_some_and(|issue_path| issue_path.starts_with(path))
3358 && self.issue_epochs[position] < started_at;
3359 if disproven {
3360 self.issues.remove(position);
3361 self.issue_epochs.remove(position);
3362 } else {
3363 position += 1;
3364 }
3365 }
3366 self.state.issues.retained = u64::try_from(self.issues.len()).unwrap_or(u64::MAX);
3367 }
3368
3369 fn publish_effects(
3374 &mut self,
3375 next_clock: Clock,
3376 effects: ExactConsequences,
3377 work: Work,
3378 journal: bool,
3379 ) -> Commit {
3380 debug_assert!(!effects.is_empty());
3381 if crate::counters::enabled() {
3382 let effect_paths = u64::try_from(effects.changes.len()).unwrap_or(u64::MAX);
3383 let effect_path_bytes = effects.changes.iter().fold(0_u64, |total, change| {
3384 total.saturating_add(
3385 u64::try_from(change.path().as_os_str().as_encoded_bytes().len())
3386 .unwrap_or(u64::MAX),
3387 )
3388 });
3389 crate::counters::bump(|counts| {
3390 counts.effect_paths = counts.effect_paths.saturating_add(effect_paths);
3391 counts.effect_path_bytes =
3392 counts.effect_path_bytes.saturating_add(effect_path_bytes);
3393 });
3394 }
3395 let commit = Commit {
3396 clock: next_clock,
3397 impact: derive_impact(&effects.changes, &effects.state),
3398 changes: effects.changes,
3399 state: effects.state,
3400 work,
3401 };
3402 self.clock = next_clock;
3403 if journal {
3404 self.retain_commit(&commit);
3405 }
3406 commit
3407 }
3408
3409 fn retain_commit(&mut self, commit: &Commit) {
3410 let cost = commit.retained_cost();
3411 if cost > self.journal_capacity_bytes {
3412 let dropped = u64::try_from(self.journal.len()).unwrap_or(u64::MAX);
3413 crate::counters::bump(|counts| {
3414 counts.journal_oversized_commits =
3415 counts.journal_oversized_commits.saturating_add(1);
3416 counts.journal_dropped_commits =
3417 counts.journal_dropped_commits.saturating_add(dropped);
3418 });
3419 self.journal.clear();
3420 self.journal_cost = 0;
3421 self.journal_floor = commit.clock;
3422 return;
3423 }
3424
3425 while self.journal_cost + cost > self.journal_capacity_bytes {
3426 if let Some(dropped) = self.journal.pop_front() {
3427 crate::counters::bump(|counts| {
3428 counts.journal_dropped_commits =
3429 counts.journal_dropped_commits.saturating_add(1);
3430 });
3431 self.journal_cost -= dropped.retained_cost();
3432 self.journal_floor = dropped.clock;
3433 }
3434 }
3435 self.journal_cost += cost;
3436 self.journal.push_back(commit.clone());
3437 crate::counters::bump(|counts| {
3438 counts.journal_cloned_commits = counts.journal_cloned_commits.saturating_add(1);
3439 counts.journal_retained_commits = counts.journal_retained_commits.saturating_add(1);
3440 });
3441 }
3442
3443 pub(crate) fn apply_baseline(
3445 &mut self,
3446 observation: &Observation,
3447 ) -> crate::Result<ApplyStats> {
3448 let prepared = prepare_observation(observation)?;
3449 #[cfg(test)]
3450 if prepared.reject_before_apply {
3451 return Err(crate::Error::CommitRejected("injected reducer preflight"));
3452 }
3453 let mut effects = NoConsequences;
3454 let stats = self.reduce_prepared(&prepared, None, None, None, false, &mut effects)?;
3455 record_batch(BatchProvenance::Baseline, observation.len(), stats);
3456 self.establish_baseline();
3457 Ok(stats)
3458 }
3459
3460 pub(crate) fn apply_scanner_baseline(
3462 &mut self,
3463 batch: crate::scan::ScannerBatch,
3464 ) -> crate::Result<ApplyStats> {
3465 let observed = batch.len();
3466 let prepare_started = crate::counters::enabled().then(std::time::Instant::now);
3467 let prepared = self.prepare_scanner_batch(batch)?;
3468 if let Some(started) = prepare_started {
3469 let elapsed = elapsed_micros(started);
3470 crate::counters::bump(|counts| {
3471 counts.scanner_prepare_us = counts.scanner_prepare_us.saturating_add(elapsed);
3472 });
3473 }
3474 let mut effects = NoConsequences;
3475 let reduce_started = crate::counters::enabled().then(std::time::Instant::now);
3476 let stats = self.reduce_prepared(&prepared, None, None, None, false, &mut effects)?;
3478 if let Some(started) = reduce_started {
3479 let elapsed = elapsed_micros(started);
3480 crate::counters::bump(|counts| {
3481 counts.scanner_reduce_us = counts.scanner_reduce_us.saturating_add(elapsed);
3482 });
3483 }
3484 record_batch(BatchProvenance::Baseline, observed, stats);
3485 self.establish_baseline();
3486 Ok(stats)
3487 }
3488
3489 #[cfg(test)]
3490 pub(crate) fn apply_ok(&mut self, observation: &Observation) -> ApplyOutcome {
3491 self.apply(observation).expect("test observation must be valid")
3492 }
3493
3494 #[cfg(test)]
3495 pub(crate) fn apply_baseline_ok(&mut self, observation: &Observation) -> ApplyStats {
3496 self.apply_baseline(observation).expect("test baseline must be valid")
3497 }
3498
3499 pub(crate) fn establish_baseline(&mut self) {
3501 self.clock = Clock::ZERO;
3502 self.journal.clear();
3503 self.journal_cost = 0;
3504 self.journal_floor = Clock::ZERO;
3505 self.pending_invalidations.clear();
3506 }
3507
3508 pub(crate) fn mark_unverified(&mut self) {
3514 self.freshness_marks.clear();
3515 self.mark_unfresh(Path::new(""), Freshness::Stale);
3516 self.state.source = Source::Cached;
3517 self.state.freshness = Freshness::Stale;
3518 }
3519
3520 pub(crate) fn set_initial_freshness(&mut self, complete: bool) {
3521 if complete {
3522 for slot in &mut self.arena {
3523 if let Slot::Occupied { entry, .. } = slot {
3524 if entry.kind == EntryKind::Dir {
3525 entry.directory_mut().children_complete = true;
3526 }
3527 }
3528 }
3529 }
3530 self.set_initial_state(complete);
3531 }
3532
3533 pub(crate) fn set_initial_detached_scan_freshness(&mut self, errors: &[crate::Error]) {
3541 if !errors.is_empty() {
3542 self.set_initial_scan_freshness(errors);
3543 return;
3544 }
3545 debug_assert!(
3546 self.arena.iter().all(|slot| match slot {
3547 Slot::Occupied { entry, .. } if entry.kind.is_dir() => {
3548 entry.directory().children_complete
3549 }
3550 Slot::Occupied { .. } | Slot::Free { .. } => true,
3551 }),
3552 "a detached builder allocates every directory as listed in full"
3553 );
3554 self.set_initial_state(true);
3555 }
3556
3557 fn set_initial_state(&mut self, complete: bool) {
3560 self.freshness_marks.clear();
3561 if complete {
3562 self.state.phase = LifecyclePhase::Ready;
3563 self.state.coverage = Coverage::Complete;
3564 self.state.freshness = Freshness::Fresh;
3565 } else {
3566 self.mark_unfresh(Path::new(""), Freshness::Partial);
3567 self.state.phase = LifecyclePhase::Ready;
3568 self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
3569 self.state.freshness = Freshness::Partial;
3570 }
3571 }
3572
3573 pub(crate) fn set_initial_scan_freshness(&mut self, errors: &[crate::Error]) {
3578 self.set_initial_freshness(errors.is_empty());
3579 if errors.is_empty() {
3580 return;
3581 }
3582 for slot in &mut self.arena {
3583 if let Slot::Occupied { entry, .. } = slot {
3584 if entry.kind.is_dir() {
3585 entry.directory_mut().children_complete = false;
3586 }
3587 }
3588 }
3589 let mut failed = Vec::with_capacity(errors.len());
3590 for error in errors {
3591 let Some(path) = Issue::from_error_under(&self.root_path, error).path else {
3592 return;
3593 };
3594 if path.is_absolute() || path.as_os_str().is_empty() {
3595 return;
3596 }
3597 failed.push(path);
3598 }
3599 failed.sort();
3600 failed.dedup();
3601 let listings: Vec<_> =
3602 failed.iter().map(|path| self.failed_listing_boundary(path)).collect();
3603 self.freshness_marks.clear();
3604 for path in &failed {
3605 self.mark_unfresh(path, Freshness::Partial);
3606 }
3607 for slot in 0..self.arena.len() {
3611 let Slot::Occupied { generation, entry } = &self.arena[slot] else {
3612 continue;
3613 };
3614 if !entry.kind.is_dir() {
3615 continue;
3616 }
3617 let id = EntryId {
3618 slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
3619 generation: *generation,
3620 };
3621 let Some(path) = self.path_of(id) else {
3622 continue;
3623 };
3624 self.entry_mut(id).directory_mut().children_complete =
3625 !listings.iter().zip(&failed).any(|(boundary, failure)| {
3626 path == *boundary || (boundary == failure && path.starts_with(boundary))
3627 });
3628 }
3629 }
3630
3631 fn failed_listing_boundary(&self, failed: &Path) -> PathBuf {
3635 let mut boundary = failed.to_path_buf();
3636 loop {
3637 if self.lookup(&boundary).is_some_and(|id| self.entry(id).kind.is_dir()) {
3638 return boundary;
3639 }
3640 if !boundary.pop() {
3641 return PathBuf::new();
3642 }
3643 }
3644 }
3645
3646 pub(crate) fn record_walk_errors(&mut self, errors: &mut Vec<crate::Error>) {
3647 self.issues.clear();
3648 self.issue_epochs.clear();
3649 self.omitted_issue_epochs.clear();
3650 self.state.issues = crate::IssueSummary::default();
3651 let root = self.root_path.clone();
3652 crate::scan::normalize_walk_errors(&root, errors);
3653 self.unreadable_control_paths.clear();
3654 for error in errors {
3655 if let Some(path) = crate::control::unreadable_control(&root, error) {
3656 self.unreadable_control_paths.insert(path);
3657 }
3658 self.retain_issue(Issue::from_error_under(&root, error));
3659 }
3660 }
3661
3662 pub(crate) fn begin_reconcile(&mut self, path: &Path) -> crate::Result<(u64, Option<Commit>)> {
3663 let path = canonical_relative_path(path)?;
3664 let next_clock = self.clock.checked_next().ok_or(crate::Error::ClockExhausted)?;
3665 let previous_index_state = self.state;
3666 let previous = self.freshness_at(&path);
3667 let epoch = self.mark_unfresh(&path, Freshness::Reconciling);
3668 self.active_reconciles.insert(
3669 epoch,
3670 ActiveReconcile {
3671 path: path.clone(),
3672 scope_budget: usize::try_from(self.len()).unwrap_or(usize::MAX),
3673 evidence: ReconcileEvidence::Scopes(BTreeSet::new()),
3674 },
3675 );
3676 if path.as_os_str().is_empty() {
3677 self.active_root_reconciles.insert(epoch, Self::now_unix_nanos());
3678 }
3679 let current = self.freshness_at(&path);
3680 self.state.freshness = self.published_freshness();
3681 let commit = if previous == current && previous_index_state == self.state {
3682 None
3683 } else {
3684 let mut state = Vec::new();
3685 if previous != current {
3686 state.push(StateTransition::Freshness { path, previous, current });
3687 }
3688 if previous_index_state != self.state {
3689 state.push(StateTransition::IndexState {
3690 previous: previous_index_state,
3691 current: self.state,
3692 });
3693 }
3694 let effects = ExactConsequences { state, ..ExactConsequences::default() };
3695 Some(self.publish_effects(next_clock, effects, Work::default(), true))
3696 };
3697 Ok((epoch, commit))
3698 }
3699
3700 pub(crate) fn finish_reconcile(
3709 &mut self,
3710 path: &Path,
3711 started_at: u64,
3712 complete: bool,
3713 listed_incomplete: &[PathBuf],
3714 failed_paths: &[PathBuf],
3715 errors: ReconcileErrors<'_>,
3716 ) -> crate::Result<ReconcileFinish> {
3717 let path = canonical_relative_path(path)?;
3718 let next_clock = self.clock.checked_next().ok_or(crate::Error::ClockExhausted)?;
3719 let previous_index_state = self.state;
3720 let previous = self.freshness_at(&path);
3721 let evidence = self.active_reconciles.get(&started_at).map_or_else(
3723 || ReconcileEvidence::Scopes(BTreeSet::new()),
3724 |active| active.evidence.clone(),
3725 );
3726 let superseded = match evidence {
3727 ReconcileEvidence::Scopes(scopes) => scopes,
3728 ReconcileEvidence::Retry => {
3729 self.active_root_reconciles.remove(&started_at);
3730 self.active_reconciles.remove(&started_at);
3731 self.compact_omitted_epochs();
3732 self.mark_unfresh(&path, Freshness::Partial);
3733 if self.state.coverage == Coverage::Complete {
3734 self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
3735 }
3736 self.state.freshness = self.published_freshness();
3737 self.retain_issue(Issue::provider_failure(
3738 Some(&path),
3739 "reconciliation interrupted by newer verification; retry this scope"
3740 .to_string(),
3741 ));
3742 let current = self.freshness_at(&path);
3743 let mut state = Vec::new();
3744 if previous != current {
3745 state.push(StateTransition::Freshness { path, previous, current });
3746 }
3747 if previous_index_state != self.state {
3748 state.push(StateTransition::IndexState {
3749 previous: previous_index_state,
3750 current: self.state,
3751 });
3752 }
3753 let commit = if state.is_empty() {
3754 None
3755 } else {
3756 let effects = ExactConsequences { state, ..ExactConsequences::default() };
3757 Some(self.publish_effects(next_clock, effects, Work::default(), true))
3758 };
3759 return Ok(ReconcileFinish { commit, retry: true });
3760 }
3761 };
3762 let fully_superseded = superseded.iter().any(|newer| path.starts_with(newer));
3763 if errors.disproves_old && !fully_superseded {
3764 for (epoch, active) in &mut self.active_reconciles {
3765 if *epoch < started_at
3766 && (active.path.starts_with(&path) || path.starts_with(&active.path))
3767 {
3768 active.supersede(&path);
3769 }
3770 }
3771 }
3772 self.freshness_marks
3773 .retain(|marked, mark| !marked.starts_with(&path) || mark.epoch > started_at);
3774 if fully_superseded {
3775 self.active_root_reconciles.remove(&started_at);
3776 self.active_reconciles.remove(&started_at);
3777 self.compact_omitted_epochs();
3778 let current = self.freshness_at(&path);
3779 self.state.freshness = self.published_freshness();
3780 if self.state.coverage == Coverage::Partial(CoverageReason::Inaccessible) {
3781 self.state.freshness = Freshness::Partial;
3782 }
3783 let mut state = Vec::new();
3784 if previous != current {
3785 state.push(StateTransition::Freshness { path, previous, current });
3786 }
3787 if previous_index_state != self.state {
3788 state.push(StateTransition::IndexState {
3789 previous: previous_index_state,
3790 current: self.state,
3791 });
3792 }
3793 if state.is_empty() {
3794 return Ok(ReconcileFinish { commit: None, retry: false });
3795 }
3796 let effects = ExactConsequences { state, ..ExactConsequences::default() };
3797 return Ok(ReconcileFinish {
3798 commit: Some(self.publish_effects(next_clock, effects, Work::default(), true)),
3799 retry: false,
3800 });
3801 }
3802 let still_owned =
3803 |candidate: &Path| !superseded.iter().any(|newer| candidate.starts_with(newer));
3804 let verified_control = crate::control::governing_control(&path);
3807 let in_scope = |control: &Path| {
3808 control.starts_with(&path) || verified_control.as_deref() == Some(control)
3809 };
3810 if errors.disproves_old {
3811 self.unreadable_control_paths
3812 .retain(|control| !in_scope(control) || !still_owned(control));
3813 }
3814 for error in errors.errors.iter().chain(errors.terminal) {
3815 if let Some(control) = crate::control::unreadable_control(&self.root_path, error) {
3816 if in_scope(&control) && still_owned(&control) {
3817 self.unreadable_control_paths.insert(control);
3818 }
3819 }
3820 }
3821 let recordable: Vec<&PathBuf> = listed_incomplete
3828 .iter()
3829 .filter(|directory| {
3830 directory.starts_with(&path)
3831 && still_owned(directory)
3832 && !self.freshness_marks.iter().any(|(marked, mark)| {
3833 mark.epoch > started_at && directory.starts_with(marked)
3834 })
3835 })
3836 .collect();
3837 let scoped_failures: Vec<&PathBuf> = failed_paths
3838 .iter()
3839 .filter(|failed| failed.starts_with(&path) && still_owned(failed))
3840 .collect();
3841 let complete = complete
3845 || (errors.disproves_old
3846 && failed_paths.iter().any(|failed| failed.starts_with(&path))
3847 && scoped_failures.is_empty()
3848 && errors.errors.iter().chain(errors.terminal).all(|error| {
3849 Issue::from_error_under(&self.root_path, error)
3850 .path
3851 .is_some_and(|failed| !failed.starts_with(&path) || !still_owned(&failed))
3852 }));
3853 if errors.disproves_old && self.state.phase != LifecyclePhase::Failed {
3854 self.drop_disproven_issues(&path, started_at);
3855 }
3856 if errors.disproves_old
3857 && self.state.phase != LifecyclePhase::Failed
3858 && path.as_os_str().is_empty()
3859 {
3860 self.drop_disproven_omitted(started_at);
3861 }
3862 let mut state = Vec::new();
3863 if !complete && (!errors.errors.is_empty() || errors.terminal.is_some()) {
3868 let boundaries: Vec<_> = if scoped_failures.is_empty() {
3869 vec![(path.clone(), true)]
3870 } else {
3871 scoped_failures
3872 .iter()
3873 .map(|failed| {
3874 let boundary = self.failed_listing_boundary(failed);
3875 let subtree = boundary == **failed;
3876 (boundary, subtree)
3877 })
3878 .collect()
3879 };
3880 for slot in 0..self.arena.len() {
3881 let Slot::Occupied { generation, entry } = &self.arena[slot] else {
3882 continue;
3883 };
3884 if !entry.kind.is_dir() || !entry.directory().children_complete {
3885 continue;
3886 }
3887 let id = EntryId {
3888 slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
3889 generation: *generation,
3890 };
3891 let Some(directory) = self.path_of(id) else {
3892 continue;
3893 };
3894 if boundaries.iter().any(|(boundary, subtree)| {
3895 directory == *boundary || (*subtree && directory.starts_with(boundary))
3896 }) && still_owned(&directory)
3897 && !self.freshness_marks.iter().any(|(marked, mark)| {
3898 mark.epoch > started_at && directory.starts_with(marked)
3899 })
3900 {
3901 self.entry_mut(id).directory_mut().children_complete = false;
3902 state.push(StateTransition::DirectoryIncomplete { path: directory });
3903 }
3904 }
3905 }
3906 let verified_scope = superseded.is_empty()
3909 || (complete
3910 && superseded.iter().all(|newer| self.freshness_at(newer) == Freshness::Fresh));
3911 if verified_scope && (complete || !scoped_failures.is_empty()) {
3912 let now = Self::now_unix_nanos();
3916 self.verified.retain(|(verified_path, _)| !verified_path.starts_with(&path));
3917 self.verified.push((path.clone(), now));
3918 if self.verified.len() > MAX_VERIFIED_INTERVALS {
3919 let excess = self.verified.len() - MAX_VERIFIED_INTERVALS;
3920 self.verified.sort_by_key(|(_, at)| *at);
3921 self.verified.drain(..excess);
3922 }
3923 state.push(StateTransition::Verified { path: path.clone() });
3924 }
3925 if complete {
3926 if path.as_os_str().is_empty() {
3927 if let Some(started) = self.active_root_reconciles.remove(&started_at) {
3928 self.writing_pass_started_at_ns = started;
3929 }
3930 self.state.source = self.applying_source;
3931 }
3932 } else {
3933 if scoped_failures.is_empty() {
3934 self.mark_unfresh(&path, Freshness::Partial);
3935 } else {
3936 for failed in scoped_failures {
3937 self.mark_unfresh(failed, Freshness::Partial);
3938 }
3939 }
3940 if !errors.errors.is_empty() || errors.terminal.is_some() {
3941 self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
3942 }
3943 if path.as_os_str().is_empty() {
3944 if let Some(started) = self.active_root_reconciles.remove(&started_at) {
3945 self.writing_pass_started_at_ns = started;
3946 }
3947 self.state.source = self.applying_source;
3948 }
3949 }
3950 for error in errors.errors.iter().chain(errors.terminal) {
3956 let issue = Issue::from_error_under(&self.root_path, error);
3957 if issue
3958 .path
3959 .as_deref()
3960 .is_none_or(|issue_path| issue_path.starts_with(&path) && still_owned(issue_path))
3961 {
3962 self.retain_issue(issue);
3963 }
3964 }
3965 for directory in recordable {
3966 let Some(id) = self.lookup(directory) else {
3967 continue;
3968 };
3969 let entry = self.entry_mut(id);
3970 if entry.kind != EntryKind::Dir || entry.directory().children_complete {
3971 continue;
3972 }
3973 entry.directory_mut().children_complete = true;
3974 self.state.progress.directories_complete =
3975 self.state.progress.directories_complete.saturating_add(1);
3976 state.push(StateTransition::DirectoryComplete { path: directory.clone() });
3977 }
3978
3979 if complete
3980 && self.state.coverage == Coverage::Partial(CoverageReason::Inaccessible)
3981 && !self.issues.iter().any(|issue| issue.kind != crate::IssueKind::ObservationGap)
3982 && self.state.issues.omitted == 0
3983 && self.arena.iter().all(|slot| {
3984 let Slot::Occupied { entry, .. } = slot else {
3985 return true;
3986 };
3987 entry.kind != EntryKind::Dir || entry.directory().children_complete
3988 })
3989 {
3990 self.state.coverage = Coverage::Complete;
3991 }
3992
3993 let current = self.freshness_at(&path);
3994 self.state.freshness = self.published_freshness();
3995 if self.state.coverage == Coverage::Partial(CoverageReason::Inaccessible) {
3996 self.state.freshness = Freshness::Partial;
3997 }
3998 self.active_reconciles.remove(&started_at);
3999 self.compact_omitted_epochs();
4000 if previous != current {
4001 state.push(StateTransition::Freshness { path: path.clone(), previous, current });
4002 }
4003 if previous_index_state != self.state {
4004 state.push(StateTransition::IndexState {
4005 previous: previous_index_state,
4006 current: self.state,
4007 });
4008 }
4009 if state.is_empty() {
4010 return Ok(ReconcileFinish { commit: None, retry: false });
4011 }
4012 let effects = ExactConsequences { state, ..ExactConsequences::default() };
4013 Ok(ReconcileFinish {
4014 commit: Some(self.publish_effects(next_clock, effects, Work::default(), true)),
4015 retry: false,
4016 })
4017 }
4018
4019 fn verified_at(&self, path: &Path) -> Option<i64> {
4021 self.verified
4022 .iter()
4023 .filter(|(covered, _)| path.starts_with(covered))
4024 .map(|(_, at)| *at)
4025 .max()
4026 }
4027
4028 fn mark_unfresh(&mut self, path: &Path, state: Freshness) -> u64 {
4029 self.freshness_epoch =
4030 self.freshness_epoch.checked_add(1).expect("freshness epoch exhausted");
4031 let epoch = self.freshness_epoch;
4032 self.freshness_marks.insert(path.to_path_buf(), FreshnessMark { state, epoch });
4033 epoch
4034 }
4035
4036 pub fn path_state(&self, path: &Path) -> PathState {
4041 let Some(id) = self.lookup(path) else {
4042 return PathState::Absent;
4043 };
4044 let entry = self.entry(id);
4045 PathState::Present { kind: entry.kind, attrs: entry.attrs }
4046 }
4047
4048 pub fn expectation(&self, path: &Path) -> PathExpectation {
4050 let entry = self.entry_identity(path);
4051 PathExpectation::new(
4052 self.path_state(path),
4053 entry,
4054 entry.is_none().then(|| self.absence_guard_identity(path)).flatten(),
4055 )
4056 }
4057
4058 pub(crate) fn relaxed_expectation(&self, path: &Path) -> PathExpectation {
4059 PathExpectation::new(self.path_state(path), self.entry_identity(path), None)
4060 }
4061
4062 pub fn since(&self, clock: Clock) -> Since {
4064 let commits: Vec<Commit> =
4065 self.journal.iter().filter(|commit| commit.clock > clock).cloned().collect();
4066 Since {
4067 commits,
4068 clock: self.clock,
4069 state: self.state,
4070 truncated: clock < self.journal_floor,
4071 }
4072 }
4073
4074 pub fn take_pending_invalidations(&mut self) -> Vec<(PathBuf, InvalidateReason)> {
4080 std::mem::take(&mut self.pending_invalidations)
4081 }
4082
4083 pub(crate) fn restore_pending_invalidations(
4085 &mut self,
4086 invalidations: Vec<(PathBuf, InvalidateReason)>,
4087 ) {
4088 self.pending_invalidations.extend(invalidations);
4089 }
4090
4091 pub fn lookup(&self, path: &Path) -> Option<EntryId> {
4093 let mut current = EntryId::ROOT;
4094 for part in normalize(path)? {
4095 current = self.child(current, part)?;
4096 }
4097 Some(current)
4098 }
4099
4100 pub fn rollup(&self, path: &Path) -> Option<RollUp> {
4103 let id = self.lookup(path)?;
4104 let entry = self.entry(id);
4105 entry.kind.is_dir().then(|| self.named_rollup(&entry.rollup().all))
4106 }
4107
4108 pub fn partition_rollup(&self, path: &Path) -> crate::Result<Option<PartitionRollUp>> {
4117 self.require_observed_controls()?;
4118 Ok(self
4119 .lookup(path)
4120 .map(|id| self.entry(id))
4121 .filter(|entry| entry.kind.is_dir())
4122 .map(|entry| self.named_partitions(entry.rollup())))
4123 }
4124
4125 pub fn partition_rollup_summary(
4134 &self,
4135 path: &Path,
4136 ) -> crate::Result<Option<PartitionRollUpSummary>> {
4137 self.require_observed_controls()?;
4138 Ok(self
4139 .lookup(path)
4140 .map(|id| self.entry(id))
4141 .filter(|entry| entry.kind.is_dir())
4142 .map(|entry| partition_summary(entry.rollup())))
4143 }
4144
4145 pub(crate) fn opened_is_ignored(&self, id: EntryId) -> bool {
4151 debug_assert!(self.observes_controls(), "an opened root observes control state");
4152 self.entry(id).ignored
4153 }
4154
4155 pub(crate) fn entry_value(&self, path: &Path) -> Option<crate::EntryValue> {
4157 let id = self.lookup(path)?;
4158 Some(self.entry_value_of(id, path))
4159 }
4160
4161 pub(crate) fn entry_value_of(&self, id: EntryId, path: &Path) -> crate::EntryValue {
4162 let entry = self.entry(id);
4163 crate::EntryValue {
4164 path: path.to_path_buf(),
4165 portable_path: crate::opened::read::portable_path(path),
4166 kind: entry.kind,
4167 attrs: entry.attrs,
4168 ignored: entry.ignored,
4169 classification: (entry.kind == EntryKind::File)
4170 .then(|| self.types.classify_name(path.file_name().unwrap_or_default())),
4171 rollup: entry.kind.is_dir().then(|| partition_summary(entry.rollup())),
4172 children_complete: entry.kind.is_dir().then(|| entry.directory().children_complete),
4173 }
4174 }
4175
4176 pub(crate) fn portable_children(&self, path: &Path) -> Option<&PortableChildren> {
4177 self.serving.as_ref()?.portable_children.get(path)
4178 }
4179
4180 pub(crate) fn portable_entries(&self) -> &BTreeMap<crate::PortablePath, EntryId> {
4181 &self.serving.as_ref().expect("opened-root reads require serving indexes").portable_entries
4182 }
4183
4184 #[cfg(test)]
4185 pub(crate) const fn serving_indexes_enabled(&self) -> bool {
4186 self.serving.is_some()
4187 }
4188
4189 fn insert_serving_entry(&mut self, path: &Path, kind: EntryKind, attrs: Attrs, id: EntryId) {
4190 if path.as_os_str().is_empty() || self.serving.is_none() {
4191 return;
4192 }
4193 let file = (kind == EntryKind::File).then(|| {
4194 (
4195 self.classify(path).file_type.as_str().to_string(),
4196 path.file_name(),
4197 self.entry(id).ignored,
4198 self.entry(id).parent,
4199 )
4200 });
4201 let arena = &self.arena;
4202 let Some(serving) = self.serving.as_mut() else {
4203 return;
4204 };
4205 if let Some((name, exact_name, ignored, parent)) = file {
4206 let semantic = serving.intern_semantic(&name);
4207 let mut ancestor = parent;
4208 while let Some(directory) = ancestor {
4209 let partition = serving.semantic_by_directory.entry(directory).or_default();
4210 merge_semantic(&mut partition.all, semantic, attrs);
4211 if !ignored {
4212 merge_semantic(&mut partition.unignored, semantic, attrs);
4213 }
4214 ancestor = retained_parent(arena, directory);
4215 }
4216 if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
4217 let mut ancestor = parent;
4218 while let Some(directory) = ancestor {
4219 let partition = serving.exact_name_by_directory.entry(directory).or_default();
4220 merge_semantic(&mut partition.all, exact_name, attrs);
4221 if !ignored {
4222 merge_semantic(&mut partition.unignored, exact_name, attrs);
4223 }
4224 ancestor = retained_parent(arena, directory);
4225 }
4226 }
4227 }
4228 let portable = crate::opened::read::portable_path(path);
4229 serving.portable_entries.insert(portable.clone(), id);
4230 if kind == EntryKind::File {
4231 serving.recent_files.insert(RecentKey {
4232 mtime_ns: attrs.mtime_ns,
4233 portable_path: portable,
4234 id,
4235 });
4236 }
4237 let parent = path.parent().unwrap_or_else(|| Path::new("")).to_path_buf();
4238 let Some(name) = path.file_name().map(crate::opened::read::portable_component) else {
4239 return;
4240 };
4241 let children = serving.portable_children.entry(parent).or_default();
4242 if kind.is_dir() {
4243 children.directories.insert(name, id);
4244 } else {
4245 children.nondirectories.insert(name, id);
4246 }
4247 }
4248
4249 fn remove_serving_entry(&mut self, path: &Path, kind: EntryKind, attrs: Attrs, id: EntryId) {
4250 if path.as_os_str().is_empty() {
4251 return;
4252 }
4253 let Some(serving) = self.serving.as_mut() else {
4254 return;
4255 };
4256 let portable = crate::opened::read::portable_path(path);
4257 serving.portable_entries.remove(&portable);
4258 if kind == EntryKind::File {
4259 serving.recent_files.remove(&RecentKey {
4260 mtime_ns: attrs.mtime_ns,
4261 portable_path: portable,
4262 id,
4263 });
4264 }
4265 let parent = path.parent().unwrap_or_else(|| Path::new("")).to_path_buf();
4266 let remove_parent = if let Some(children) = serving.portable_children.get_mut(&parent) {
4267 if let Some(name) = path.file_name().map(crate::opened::read::portable_component) {
4268 if kind.is_dir() {
4269 children.directories.remove(&name);
4270 } else {
4271 children.nondirectories.remove(&name);
4272 }
4273 }
4274 children.directories.is_empty() && children.nondirectories.is_empty()
4275 } else {
4276 false
4277 };
4278 if remove_parent {
4279 serving.portable_children.remove(&parent);
4280 }
4281 if kind.is_dir() {
4282 serving.portable_children.remove(path);
4283 }
4284 }
4285
4286 fn remove_serving_file_semantics(&mut self, path: &Path, id: EntryId, attrs: Attrs) {
4287 if self.serving.is_none() || self.entry(id).kind != EntryKind::File {
4291 return;
4292 }
4293 let name = self.classify(path).file_type.as_str().to_string();
4294 let exact_name = path.file_name();
4295 let ignored = self.entry(id).ignored;
4296 let parent = self.entry(id).parent;
4297 let arena = &self.arena;
4298 let serving = self.serving.as_mut().expect("checked above");
4299 let semantic = *serving
4300 .semantic_ids
4301 .get(&name)
4302 .expect("every served file has an interned semantic type");
4303 let mut empty = Vec::new();
4304 let mut ancestor = parent;
4305 while let Some(directory) = ancestor {
4306 let partition = serving
4307 .semantic_by_directory
4308 .get_mut(&directory)
4309 .expect("every served file contributes to every ancestor");
4310 unmerge_semantic(&mut partition.all, semantic, attrs);
4311 if !ignored {
4312 unmerge_semantic(&mut partition.unignored, semantic, attrs);
4313 }
4314 if partition.all.is_empty() && partition.unignored.is_empty() {
4315 empty.push(directory);
4316 }
4317 ancestor = retained_parent(arena, directory);
4318 }
4319 for ancestor in empty {
4320 serving.semantic_by_directory.remove(&ancestor);
4321 }
4322 serving.release_semantic(semantic, 1);
4323 if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
4324 let mut exact_empty = Vec::new();
4325 let mut ancestor = parent;
4326 while let Some(directory) = ancestor {
4327 let partition = serving
4328 .exact_name_by_directory
4329 .get_mut(&directory)
4330 .expect("every declared exact-name file contributes to every ancestor");
4331 unmerge_semantic(&mut partition.all, exact_name, attrs);
4332 if !ignored {
4333 unmerge_semantic(&mut partition.unignored, exact_name, attrs);
4334 }
4335 if partition.all.is_empty() && partition.unignored.is_empty() {
4336 exact_empty.push(directory);
4337 }
4338 ancestor = retained_parent(arena, directory);
4339 }
4340 for ancestor in exact_empty {
4341 serving.exact_name_by_directory.remove(&ancestor);
4342 }
4343 }
4344 }
4345
4346 fn remove_serving_subtree_semantics(&mut self, root: EntryId, path: &Path) {
4347 if self.serving.is_none() {
4348 return;
4349 }
4350 match self.entry(root).kind {
4351 EntryKind::File => {
4352 let attrs = self.entry(root).attrs;
4353 self.remove_serving_file_semantics(path, root, attrs);
4354 }
4355 EntryKind::Dir => {
4356 let parent = self.entry(root).parent;
4357 let mut stack = vec![root];
4358 let mut directories = Vec::new();
4359 while let Some(id) = stack.pop() {
4360 let entry = self.entry(id);
4361 if !entry.kind.is_dir() {
4362 continue;
4363 }
4364 directories.push(id);
4365 stack.extend(self.child_ids(id));
4366 }
4367 let arena = &self.arena;
4368 let serving = self.serving.as_mut().expect("checked above");
4369 let contribution =
4370 serving.semantic_by_directory.get(&root).cloned().unwrap_or_default();
4371 let exact_contribution =
4372 serving.exact_name_by_directory.get(&root).cloned().unwrap_or_default();
4373 let mut empty = Vec::new();
4374 if !contribution.all.is_empty() || !contribution.unignored.is_empty() {
4375 let mut ancestor = parent;
4376 while let Some(directory) = ancestor {
4377 let partition = serving
4378 .semantic_by_directory
4379 .get_mut(&directory)
4380 .expect("a semantic subtree contributes to every ancestor");
4381 unmerge_semantic_map(&mut partition.all, &contribution.all);
4382 unmerge_semantic_map(&mut partition.unignored, &contribution.unignored);
4383 if partition.all.is_empty() && partition.unignored.is_empty() {
4384 empty.push(directory);
4385 }
4386 ancestor = retained_parent(arena, directory);
4387 }
4388 }
4389 let mut exact_empty = Vec::new();
4390 if !exact_contribution.all.is_empty() || !exact_contribution.unignored.is_empty() {
4391 let mut ancestor = parent;
4392 while let Some(directory) = ancestor {
4393 let partition = serving
4394 .exact_name_by_directory
4395 .get_mut(&directory)
4396 .expect("an exact-name subtree contributes to every ancestor");
4397 unmerge_semantic_map(&mut partition.all, &exact_contribution.all);
4398 unmerge_semantic_map(
4399 &mut partition.unignored,
4400 &exact_contribution.unignored,
4401 );
4402 if partition.all.is_empty() && partition.unignored.is_empty() {
4403 exact_empty.push(directory);
4404 }
4405 ancestor = retained_parent(arena, directory);
4406 }
4407 }
4408 for ancestor in empty {
4409 serving.semantic_by_directory.remove(&ancestor);
4410 }
4411 for ancestor in exact_empty {
4412 serving.exact_name_by_directory.remove(&ancestor);
4413 }
4414 for directory in directories {
4415 serving.semantic_by_directory.remove(&directory);
4416 serving.exact_name_by_directory.remove(&directory);
4417 }
4418 for (semantic, tally) in contribution.all {
4419 serving.release_semantic(semantic, tally.files);
4420 }
4421 }
4422 EntryKind::Symlink | EntryKind::Other => {}
4423 }
4424 }
4425
4426 fn move_serving_file_partition(
4427 &mut self,
4428 path: &Path,
4429 id: EntryId,
4430 previous_ignored: bool,
4431 current_ignored: bool,
4432 ) {
4433 if previous_ignored == current_ignored
4434 || self.serving.is_none()
4435 || self.entry(id).kind != EntryKind::File
4436 {
4437 return;
4438 }
4439 let name = self.classify(path).file_type.as_str().to_string();
4440 let exact_name = path.file_name();
4441 let attrs = self.entry(id).attrs;
4442 let parent = self.entry(id).parent;
4443 let arena = &self.arena;
4444 let serving = self.serving.as_mut().expect("checked above");
4445 let semantic = *serving
4446 .semantic_ids
4447 .get(&name)
4448 .expect("every served file has an interned semantic type");
4449 let mut ancestor = parent;
4450 while let Some(directory) = ancestor {
4451 let partition = serving
4452 .semantic_by_directory
4453 .get_mut(&directory)
4454 .expect("every served file contributes to every ancestor");
4455 if current_ignored {
4456 unmerge_semantic(&mut partition.unignored, semantic, attrs);
4457 } else {
4458 merge_semantic(&mut partition.unignored, semantic, attrs);
4459 }
4460 ancestor = retained_parent(arena, directory);
4461 }
4462 if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
4463 let mut ancestor = parent;
4464 while let Some(directory) = ancestor {
4465 let partition = serving
4466 .exact_name_by_directory
4467 .get_mut(&directory)
4468 .expect("every declared exact-name file contributes to every ancestor");
4469 if current_ignored {
4470 unmerge_semantic(&mut partition.unignored, exact_name, attrs);
4471 } else {
4472 merge_semantic(&mut partition.unignored, exact_name, attrs);
4473 }
4474 ancestor = retained_parent(arena, directory);
4475 }
4476 }
4477 }
4478
4479 pub fn attrs(&self, path: &Path) -> Option<&Attrs> {
4481 Some(&self.entry(self.lookup(path)?).attrs)
4482 }
4483
4484 pub fn kind(&self, path: &Path) -> Option<EntryKind> {
4486 Some(self.entry(self.lookup(path)?).kind)
4487 }
4488
4489 pub fn is_ignored(&self, path: &Path) -> crate::Result<Option<bool>> {
4501 self.require_observed_controls()?;
4502 Ok(self.ignored_classification(path))
4503 }
4504
4505 pub fn ignored_classification(&self, path: &Path) -> Option<bool> {
4510 let id = self.lookup(path)?;
4511 self.ignored_classification_of(path, id)
4512 }
4513
4514 pub(crate) fn ignored_classification_of(&self, path: &Path, id: EntryId) -> Option<bool> {
4516 if !self.observes_controls() || !self.control_classification_known(path) {
4517 return None;
4518 }
4519 self.try_entry(id).map(|entry| entry.ignored)
4520 }
4521
4522 pub(crate) fn entry_ignored(&self, id: EntryId) -> Option<bool> {
4526 self.try_entry(id).map(|entry| entry.ignored)
4527 }
4528
4529 pub(crate) fn control_classification_known(&self, path: &Path) -> bool {
4530 path.parent().is_none_or(|directory| self.controls_known_in(directory))
4531 }
4532
4533 pub(crate) fn children_classification_known(&self, directory: &Path) -> bool {
4538 self.observes_controls() && self.controls_known_in(directory)
4539 }
4540
4541 fn controls_known_in(&self, directory: &Path) -> bool {
4544 self.controls.children_classification_known(directory)
4545 && (self.unreadable_control_paths.is_empty()
4546 || !directory.ancestors().any(|ancestor| {
4547 self.unreadable_control_paths
4548 .iter()
4549 .any(|control| control.parent() == Some(ancestor))
4550 }))
4551 }
4552
4553 pub fn ignored_classification_complete_below(&self, path: &Path) -> bool {
4555 self.observes_controls()
4556 && self.controls.classification_known(path)
4557 && !self.unreadable_control_paths.iter().any(|control| {
4558 control.starts_with(path)
4559 || path
4560 .parent()
4561 .into_iter()
4562 .flat_map(Path::ancestors)
4563 .any(|directory| control.parent() == Some(directory))
4564 })
4565 && !self.controls.refusals().any(|refusal| {
4566 refusal.path.starts_with(path)
4567 || path
4568 .parent()
4569 .into_iter()
4570 .flat_map(Path::ancestors)
4571 .any(|directory| refusal.path.parent() == Some(directory))
4572 })
4573 }
4574
4575 pub fn children(
4579 &self,
4580 path: &Path,
4581 ) -> Option<impl DoubleEndedIterator<Item = (&OsStr, EntryId)> + ExactSizeIterator + '_> {
4582 let id = self.lookup(path)?;
4583 let entry = self.entry(id);
4584 entry.kind.is_dir().then(|| IndexChildren::new(self, entry))
4585 }
4586
4587 pub fn children_of(
4591 &self,
4592 id: EntryId,
4593 ) -> Option<impl DoubleEndedIterator<Item = (&OsStr, EntryId)> + ExactSizeIterator + '_> {
4594 Some(IndexChildren::new(self, self.try_entry(id)?))
4595 }
4596
4597 pub fn path_of(&self, id: EntryId) -> Option<PathBuf> {
4599 let mut parts = Vec::new();
4600 let mut current = Some(id);
4601 while let Some(node) = current {
4602 let entry = self.try_entry(node)?;
4603 if entry.parent.is_some() {
4604 parts.push(entry.name.as_os_str());
4605 }
4606 current = entry.parent;
4607 }
4608 parts.reverse();
4609 Some(parts.iter().collect())
4610 }
4611
4612 pub fn rollup_of(&self, id: EntryId) -> Option<RollUp> {
4614 let entry = self.try_entry(id)?;
4615 entry.kind.is_dir().then(|| self.named_rollup(&entry.rollup().all))
4616 }
4617
4618 pub(crate) fn partition_scalars_of(
4625 &self,
4626 id: EntryId,
4627 ) -> Option<(RollUpScalars, RollUpScalars)> {
4628 let entry = self.try_entry(id)?;
4629 entry.kind.is_dir().then(|| {
4630 let rollup = entry.rollup();
4631 (RollUpScalars::from(&rollup.all), RollUpScalars::from(&rollup.unignored))
4632 })
4633 }
4634
4635 pub(crate) const fn is_folded(&self) -> bool {
4645 self.folded.is_some()
4646 }
4647
4648 pub(crate) fn folded_children(&self, id: EntryId) -> Option<FoldedFiles> {
4653 let folded = self.folded.as_ref()?;
4654 self.try_entry(id)?;
4655 folded.get(id.idx()).copied().filter(|folded| folded.files > 0)
4656 }
4657
4658 pub(crate) fn directory_complete_of(&self, id: EntryId) -> Option<bool> {
4662 let entry = self.try_entry(id)?;
4663 (entry.kind == EntryKind::Dir).then(|| entry.directory().children_complete)
4664 }
4665
4666 pub fn attrs_of(&self, id: EntryId) -> Option<&Attrs> {
4668 Some(&self.try_entry(id)?.attrs)
4669 }
4670
4671 pub fn kind_of(&self, id: EntryId) -> Option<EntryKind> {
4673 Some(self.try_entry(id)?.kind)
4674 }
4675
4676 pub fn name_of(&self, id: EntryId) -> Option<&OsStr> {
4678 Some(&self.try_entry(id)?.name)
4679 }
4680
4681 pub fn content(&self) -> Option<&ContentIndex> {
4683 self.content.as_deref()
4684 }
4685
4686 pub fn content_rollup(&self, path: &Path) -> Option<&ContentRollUp> {
4688 self.content()?.rollup(path)
4689 }
4690
4691 pub fn content_set(&self) -> AnalysisSet {
4694 self.content().and_then(ContentIndex::profile).unwrap_or(AnalysisSet::NONE)
4695 }
4696
4697 pub(crate) fn content_has_pending(&self, profile: AnalysisSet) -> bool {
4703 if !profile.is_enabled() {
4704 return false;
4705 }
4706 let wanted = self.content_identity(profile);
4707 let Some(content) = self.content().and_then(|content| content.admit(&wanted)) else {
4708 return true;
4709 };
4710 if self.scope.population == crate::query::IgnoredEntries::Include {
4711 return u64::try_from(content.len()).unwrap_or(u64::MAX)
4712 < self.entry(EntryId::ROOT).rollup().files;
4713 }
4714 let mut pending = false;
4719 self.for_each_analysis_file(profile, |_, _, attrs, path| {
4720 pending |=
4721 content.file(&path).is_none_or(|record| record.fingerprint != attrs.fingerprint());
4722 });
4723 pending
4724 }
4725
4726 pub fn content_identity(&self, analysis: AnalysisSet) -> crate::ContentTierIdentity {
4729 crate::ContentTierIdentity::for_request(
4730 crate::EntryTierIdentity::of_scope(self.scope),
4731 analysis,
4732 )
4733 }
4734
4735 pub(crate) fn prepare_content_analysis(&mut self, request: crate::content::AnalysisRequest) {
4738 if !request.profile.is_enabled() {
4739 return;
4740 }
4741 let identity = self.content_identity(request.profile);
4742 self.content.get_or_insert_with(|| Box::new(ContentIndex::default())).prepare(identity);
4743 }
4744
4745 pub(crate) fn set_content_tier_state(
4746 &mut self,
4747 source: Source,
4748 freshness: Freshness,
4749 observed_at_ns: Option<i64>,
4750 ) {
4751 if let Some(content) = self.content.as_deref_mut() {
4752 content.set_state(crate::content::ContentTierState {
4753 source,
4754 freshness,
4755 observed_at_ns,
4756 });
4757 }
4758 }
4759
4760 #[cfg(test)]
4770 pub(crate) fn analysis_candidates(&self, profile: AnalysisSet) -> Vec<AnalysisCandidate> {
4771 let root_files = self.entry(EntryId::ROOT).rollup().files;
4772 let mut candidates = Vec::with_capacity(usize::try_from(root_files).unwrap_or(0));
4773 self.walk_analysis_files(
4774 profile,
4775 &mut AnalysisWalk::start(),
4776 |id, revision, attrs, relative_path| {
4777 candidates.push(self.analysis_candidate(id, revision, attrs, relative_path));
4778 true
4779 },
4780 );
4781 candidates
4782 }
4783
4784 fn analysis_candidate(
4785 &self,
4786 entry_id: EntryId,
4787 revision: u64,
4788 attrs: Attrs,
4789 relative_path: PathBuf,
4790 ) -> AnalysisCandidate {
4791 AnalysisCandidate {
4792 entry_id,
4793 revision,
4794 absolute_path: self.root_path.join(&relative_path),
4795 classification: self.classify(&relative_path),
4796 relative_path,
4797 attrs,
4798 }
4799 }
4800
4801 pub(crate) fn restore_analysis_candidates(
4807 &self,
4808 profile: AnalysisSet,
4809 ) -> (HashMap<PathBuf, RestoreCandidate>, u64) {
4810 let root_files = self.entry(EntryId::ROOT).rollup().files;
4811 let mut candidates = HashMap::with_capacity(usize::try_from(root_files).unwrap_or(0));
4812 let mut visited = 0_u64;
4813 self.for_each_analysis_file(profile, |id, revision, attrs, relative_path| {
4814 visited = visited.saturating_add(1);
4815 candidates.insert(
4816 relative_path.clone(),
4817 RestoreCandidate { entry_id: id, revision, relative_path, attrs },
4818 );
4819 });
4820 (candidates, visited)
4821 }
4822
4823 fn for_each_analysis_file(
4824 &self,
4825 profile: AnalysisSet,
4826 mut visit: impl FnMut(EntryId, u64, Attrs, PathBuf),
4827 ) {
4828 self.walk_analysis_files(
4829 profile,
4830 &mut AnalysisWalk::start(),
4831 |id, revision, attrs, path| {
4832 visit(id, revision, attrs, path);
4833 true
4834 },
4835 );
4836 }
4837
4838 fn walk_analysis_files(
4846 &self,
4847 profile: AnalysisSet,
4848 walk: &mut AnalysisWalk,
4849 mut visit: impl FnMut(EntryId, u64, Attrs, PathBuf) -> bool,
4850 ) {
4851 if !profile.is_enabled() {
4852 return;
4853 }
4854 loop {
4855 let (parent, parent_path, resume) = match walk.listing.take() {
4856 Some((parent, parent_path, resume)) => (parent, parent_path, Some(resume)),
4857 None => match walk.pending.pop() {
4858 Some((parent, parent_path)) => (parent, parent_path, None),
4859 None => return,
4860 },
4861 };
4862 let visited = resume.as_ref().map_or(0, |resume| resume.position);
4863 let children = self.resumed_children(parent, resume.as_ref());
4864 #[cfg(test)]
4865 let children = children.inspect(|_| {
4866 ANALYSIS_CHILD_STEPS.with(|steps| steps.set(steps.get() + 1));
4867 });
4868 for (position, (name, id)) in (visited + 1..).zip(children) {
4871 let entry = self.entry(id);
4872 if entry.kind == EntryKind::Dir {
4873 walk.pending.push((id, parent_path.join(name)));
4874 continue;
4875 }
4876 if entry.kind != EntryKind::File {
4877 continue;
4878 }
4879 let relative_path = parent_path.join(name);
4880 if !self.scope.population.admits(entry.ignored)
4881 || (self.scope.population != crate::query::IgnoredEntries::Include
4882 && !self.control_classification_known(&relative_path))
4883 {
4884 continue;
4885 }
4886 if !visit(id, entry.revision, entry.attrs, relative_path) {
4887 let resume = ListingResume { position, last: name.to_os_string() };
4888 walk.listing = Some((parent, parent_path, resume));
4889 return;
4890 }
4891 }
4892 }
4893 }
4894
4895 fn resumed_children(
4899 &self,
4900 parent: EntryId,
4901 resume: Option<&ListingResume>,
4902 ) -> ResumedChildren<'_> {
4903 use std::ops::Bound::{Excluded, Unbounded};
4904
4905 let Some(entry) = self.try_entry(parent) else { return ResumedChildren::Empty };
4906 match entry.directory.as_deref().map(|directory| &directory.children) {
4907 None => ResumedChildren::Empty,
4908 Some(DirectoryChildren::Sorted(ids)) => {
4909 let from = resume.map_or(0, |resume| resume.position).min(ids.len());
4910 ResumedChildren::Sorted { index: self, ids: ids[from..].iter() }
4911 }
4912 Some(DirectoryChildren::Mutable(children)) => ResumedChildren::Mutable(match resume {
4913 Some(resume) => {
4914 children.range::<OsStr, _>((Excluded(resume.last.as_os_str()), Unbounded))
4915 }
4916 None => children.range::<OsStr, _>(..),
4917 }),
4918 }
4919 }
4920
4921 #[cfg(test)]
4929 pub(crate) fn pending_analysis_candidates(
4930 &self,
4931 request: crate::content::AnalysisRequest,
4932 ) -> Vec<AnalysisCandidate> {
4933 self.next_analysis_candidates(request, &mut AnalysisWalk::start(), usize::MAX)
4934 }
4935
4936 fn pending_analysis(
4939 held: Option<crate::stored_state::ContentProjection<'_>>,
4940 relative_path: &Path,
4941 attrs: &Attrs,
4942 ) -> bool {
4943 held.and_then(|content| content.file(relative_path))
4944 .is_none_or(|record| record.fingerprint != attrs.fingerprint() || !record.is_reusable())
4945 }
4946
4947 pub(crate) fn next_analysis_candidates(
4956 &self,
4957 request: crate::content::AnalysisRequest,
4958 walk: &mut AnalysisWalk,
4959 limit: usize,
4960 ) -> Vec<AnalysisCandidate> {
4961 let wanted = self.content_identity(request.profile);
4962 let held = self.content().and_then(|content| content.admit(&wanted));
4965 let root_files = usize::try_from(self.entry(EntryId::ROOT).rollup().files).unwrap_or(0);
4966 let mut candidates = Vec::with_capacity(limit.min(root_files));
4967 self.walk_analysis_files(request.profile, walk, |id, revision, attrs, relative_path| {
4968 if Self::pending_analysis(held, &relative_path, &attrs) {
4969 candidates.push(self.analysis_candidate(id, revision, attrs, relative_path));
4970 }
4971 candidates.len() < limit
4972 });
4973 candidates
4974 }
4975
4976 pub(crate) fn count_pending_analysis_candidates(
4980 &self,
4981 request: crate::content::AnalysisRequest,
4982 ) -> u64 {
4983 let wanted = self.content_identity(request.profile);
4984 let held = self.content().and_then(|content| content.admit(&wanted));
4985 let mut count = 0_u64;
4986 self.walk_analysis_files(
4987 request.profile,
4988 &mut AnalysisWalk::start(),
4989 |_, _, attrs, path| {
4990 count += u64::from(Self::pending_analysis(held, &path, &attrs));
4991 true
4992 },
4993 );
4994 count
4995 }
4996
4997 pub(crate) fn apply_analysis(
5007 &mut self,
5008 observation: AnalysisObservation,
5009 ) -> AnalysisApplyOutcome {
5010 self.apply_analysis_record(observation)
5011 }
5012
5013 pub(crate) fn apply_restored_analysis(
5018 &mut self,
5019 candidate: RestoreCandidate,
5020 analysis: crate::stored_state::AdmittedRecord<'_>,
5021 ) -> AnalysisApplyOutcome {
5022 let Some(entry) = self.try_entry(candidate.entry_id) else {
5023 return AnalysisApplyOutcome::Stale;
5024 };
5025 if entry.kind != EntryKind::File
5026 || entry.revision != candidate.revision
5027 || entry.attrs.fingerprint() != candidate.attrs.fingerprint()
5028 {
5029 return AnalysisApplyOutcome::Stale;
5030 }
5031 let Some(content) = self.content.as_mut() else {
5032 return AnalysisApplyOutcome::Stale;
5033 };
5034 if content.commit_without_rollup(candidate.relative_path, analysis) {
5035 AnalysisApplyOutcome::Applied
5036 } else {
5037 AnalysisApplyOutcome::Stale
5038 }
5039 }
5040
5041 pub(crate) fn rebuild_content_rollups(&mut self) {
5042 if let Some(content) = self.content.as_mut() {
5043 content.rebuild_rollups();
5044 }
5045 }
5046
5047 fn apply_analysis_record(&mut self, observation: AnalysisObservation) -> AnalysisApplyOutcome {
5048 let candidate = &observation.candidate;
5049 let Some(entry) = self.try_entry(candidate.entry_id) else {
5050 return AnalysisApplyOutcome::Stale;
5051 };
5052 if entry.kind != EntryKind::File
5053 || entry.revision != candidate.revision
5054 || entry.attrs.fingerprint() != candidate.attrs.fingerprint()
5055 || self.classify(&candidate.relative_path) != candidate.classification
5056 {
5057 return AnalysisApplyOutcome::Stale;
5058 }
5059 let Some(content) = self.content.as_mut() else {
5060 return AnalysisApplyOutcome::Stale;
5061 };
5062 if content.commit(
5063 candidate.relative_path.clone(),
5064 observation.profile,
5065 &observation.provenance,
5066 observation.analysis,
5067 ) {
5068 AnalysisApplyOutcome::Applied
5069 } else {
5070 AnalysisApplyOutcome::Stale
5071 }
5072 }
5073
5074 pub fn clear_content(&mut self) {
5076 self.content = None;
5077 }
5078
5079 fn try_entry(&self, id: EntryId) -> Option<&Entry> {
5082 match self.arena.get(id.idx())? {
5083 Slot::Occupied { generation, entry } if *generation == id.generation => Some(entry),
5084 Slot::Occupied { .. } | Slot::Free { .. } => None,
5085 }
5086 }
5087
5088 fn expectation_matches(&self, op: &Op, expected: PathExpectation) -> bool {
5089 let current = self.path_state(op.path());
5090 if self.holds_target(op, current) {
5096 return true;
5097 }
5098 if current != expected.state {
5099 return false;
5100 }
5101
5102 let require_structure = match (op, expected.state) {
5103 (Op::Remove { .. }, _) => true,
5104 (Op::Upsert { kind, .. }, PathState::Present { kind: baseline, .. }) => {
5105 *kind != baseline
5106 }
5107 (
5108 Op::Upsert { .. }
5109 | Op::ControlUpsert { .. }
5110 | Op::ControlRemove { .. }
5111 | Op::InvalidateSubtree { .. },
5112 _,
5113 ) => false,
5114 };
5115 if !same_target(self.entry_identity(op.path()), expected.entry(), require_structure) {
5116 return false;
5117 }
5118
5119 match expected.absence_guard() {
5120 Some(expected) => self
5121 .absence_guard_identity(op.path())
5122 .is_some_and(|current| current.same_absence_guard(expected)),
5123 None => true,
5124 }
5125 }
5126
5127 fn holds_target(&self, op: &Op, current: PathState) -> bool {
5136 match op {
5137 Op::Upsert { kind, attrs, .. } => {
5138 current == PathState::Present { kind: *kind, attrs: *attrs }
5139 }
5140 Op::Remove { .. } => current == PathState::Absent,
5141 Op::ControlUpsert { path, source } => self.controls.source_is(path, source),
5142 Op::ControlRemove { path } => !self.controls.contains(path),
5143 Op::InvalidateSubtree { .. } => false,
5144 }
5145 }
5146
5147 fn absence_guard_identity(&self, path: &Path) -> Option<EntryIdentity> {
5148 let parts = normalize(path)?;
5149 let (_, ancestors) = parts.split_last()?;
5150 let mut current = EntryId::ROOT;
5151 for part in ancestors {
5152 let Some(child) = self.child(current, part) else {
5153 break;
5154 };
5155 current = child;
5156 }
5157 Some(self.identity(current))
5158 }
5159
5160 fn validate_known_ancestry(
5166 &self,
5167 ops: &[ObservationOp],
5168 accepted: &[bool],
5169 ) -> crate::Result<()> {
5170 if let Some((path, reconcile_from)) =
5171 self.unknown_ancestry(ops, accepted).into_iter().next()
5172 {
5173 return Err(crate::Error::UnknownAncestry { path, reconcile_from });
5174 }
5175 Ok(())
5176 }
5177
5178 fn accepted_operations(&self, ops: &[ObservationOp]) -> Vec<bool> {
5179 ops.iter()
5180 .map(|observed| match observed.expectation {
5181 Expectation::Any => true,
5182 Expectation::State(expected) => self.expectation_matches(&observed.op, expected),
5183 })
5184 .collect()
5185 }
5186
5187 fn prepare_scanner_batch(
5203 &self,
5204 batch: crate::scan::ScannerBatch,
5205 ) -> crate::Result<PreparedObservation> {
5206 let ops = batch.into_ops();
5207 let mut parents = Vec::with_capacity(ops.len());
5208 let mut last_parent: Option<(&Path, ResolvedParent)> = None;
5209 let mut has_batch_parents = false;
5210 let mut path_comparisons = 0_u64;
5211 let mut replaces_kind = false;
5212
5213 for (op_index, observed) in ops.iter().enumerate() {
5214 if !matches!(observed.expectation, Expectation::Any) {
5215 return Err(crate::Error::UnsupportedScanConfig(
5216 "scanner batches contain unconditional discoveries only",
5217 ));
5218 }
5219 let op = &observed.op;
5220 let path = op.path();
5221 if path.as_os_str().is_empty() {
5222 return Err(crate::Error::UnsupportedScanConfig(
5223 "scanner batches cannot mutate the index root",
5224 ));
5225 }
5226 for component in path.components() {
5227 match component {
5228 Component::Normal(_) => {}
5229 Component::CurDir => {
5230 return Err(crate::Error::UnsupportedScanConfig(
5231 "scanner batches require canonical relative paths",
5232 ));
5233 }
5234 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
5235 return Err(crate::Error::PathEscapesRoot(path.to_path_buf()));
5236 }
5237 }
5238 }
5239 match op {
5240 Op::Upsert { .. } => {}
5241 Op::ControlUpsert { .. } | Op::ControlRemove { .. }
5242 if crate::control::is_control_file(path) => {}
5243 Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {
5244 return Err(crate::Error::InvalidControlPath(path.to_path_buf()));
5245 }
5246 Op::Remove { .. } | Op::InvalidateSubtree { .. } => {
5247 return Err(crate::Error::UnsupportedScanConfig(
5248 "scanner batches contain discoveries only",
5249 ));
5250 }
5251 }
5252 if replaces_kind {
5253 continue;
5256 }
5257
5258 let parent_path = path.parent().expect("a non-root relative path has a parent");
5259 if last_parent.is_some() {
5260 path_comparisons = path_comparisons.saturating_add(1);
5261 }
5262 let same_parent = last_parent
5263 .filter(|(previous, _)| *previous == parent_path)
5264 .map(|(_, parent)| parent);
5265 let parent = if let Some(parent) = same_parent {
5266 parent
5267 } else {
5268 self.lookup(parent_path)
5269 .filter(|id| self.entry(*id).kind.is_dir())
5270 .map(ResolvedParent::Existing)
5271 .or_else(|| Self::earlier_scanner_parent(&ops, op_index, parent_path))
5272 .ok_or_else(|| crate::Error::UnknownAncestry {
5273 path: path.to_path_buf(),
5274 reconcile_from: PathBuf::new(),
5275 })?
5276 };
5277 if let (Op::Upsert { kind, .. }, ResolvedParent::Existing(parent)) = (op, parent) {
5278 if path.file_name().is_some_and(|name| {
5279 self.child(parent, name).is_some_and(|child| self.entry(child).kind != *kind)
5280 }) {
5281 replaces_kind = true;
5282 continue;
5283 }
5284 }
5285 has_batch_parents |= matches!(parent, ResolvedParent::Earlier(_));
5286 parents.push(parent);
5287 last_parent = Some((parent_path, parent));
5288 }
5289
5290 if replaces_kind {
5291 return prepare_observation(&Observation::from_ops(ops));
5292 }
5293
5294 crate::counters::bump(|counts| {
5295 counts.ancestry_path_comparisons =
5296 counts.ancestry_path_comparisons.saturating_add(path_comparisons);
5297 counts.ancestry_parent_proofs = counts
5298 .ancestry_parent_proofs
5299 .saturating_add(u64::try_from(ops.len()).unwrap_or(u64::MAX));
5300 });
5301 Ok(PreparedObservation {
5302 ops,
5303 ancestry: PreparedAncestry::Scanner { parents, has_batch_parents },
5304 #[cfg(test)]
5305 reject_before_apply: false,
5306 })
5307 }
5308
5309 fn earlier_scanner_parent(
5311 ops: &[ObservationOp],
5312 before: usize,
5313 parent_path: &Path,
5314 ) -> Option<ResolvedParent> {
5315 let (op_index, op) = ops[..before].iter().enumerate().rev().find(
5316 |(_, observed)| matches!(&observed.op, Op::Upsert { path, .. } if path == parent_path),
5317 )?;
5318 let Op::Upsert { kind, .. } = &op.op else {
5319 unreachable!("the search selected an upsert");
5320 };
5321 kind.is_dir().then_some(ResolvedParent::Earlier(op_index))
5322 }
5323
5324 fn projected_controls(
5330 &self,
5331 ops: &[ObservationOp],
5332 accepted: &[bool],
5333 ) -> crate::Result<Option<crate::control::ControlTable>> {
5334 self.projected_controls_from(
5335 ops.iter()
5336 .zip(accepted)
5337 .filter_map(|(observed, accepted)| accepted.then_some(&observed.op)),
5338 )
5339 }
5340
5341 fn projected_controls_from<'a>(
5343 &self,
5344 ops: impl Iterator<Item = &'a Op> + Clone,
5345 ) -> crate::Result<Option<crate::control::ControlTable>> {
5346 if self.controls_unchanged_by(ops.clone()) {
5347 return Ok(None);
5348 }
5349 let mut projected = self.controls.clone();
5350 #[cfg(test)]
5351 CONTROL_PROJECTION_CLONES.with(|clones| clones.set(clones.get() + 1));
5352 let mut structure = StructuralOverlay::default();
5353 for op in ops {
5354 match op {
5355 Op::Upsert { path, kind, .. } => {
5356 if crate::control::is_control_file(path) && *kind != EntryKind::File {
5357 projected.remove(path)?;
5358 }
5359 if structure.kind(self, path) == Some(EntryKind::Dir) && !kind.is_dir() {
5360 projected.remove_subtree(path);
5361 }
5362 structure.upsert(self, path, *kind);
5363 }
5364 Op::Remove { path } => {
5365 if crate::control::is_control_file(path) {
5366 projected.remove(path)?;
5367 }
5368 projected.remove_subtree(path);
5369 structure.remove(self, path);
5370 }
5371 Op::ControlUpsert { path, source } => {
5372 projected.upsert(path, source.clone())?;
5373 }
5374 Op::ControlRemove { path } => {
5375 projected.remove(path)?;
5376 }
5377 Op::InvalidateSubtree { .. } => {}
5378 }
5379 }
5380 Ok(Some(projected))
5381 }
5382
5383 fn controls_unchanged_by<'a>(&self, ops: impl Iterator<Item = &'a Op>) -> bool {
5397 if self.controls.is_vacant() {
5403 return ops.into_iter().all(|op| match op {
5404 Op::ControlUpsert { .. } => false,
5405 Op::ControlRemove { path } => self.controls.remove_is_inert(path),
5406 Op::Upsert { .. } | Op::Remove { .. } | Op::InvalidateSubtree { .. } => true,
5407 });
5408 }
5409
5410 ops.into_iter().all(|op| match op {
5411 Op::ControlUpsert { path, source } => self.controls.upsert_is_inert(path, source),
5412 Op::ControlRemove { path } => self.controls.remove_is_inert(path),
5413 Op::Upsert { path, kind, .. } => {
5414 let drops_control = *kind != EntryKind::File
5417 && crate::control::is_control_file(path)
5418 && self.controls.contains(path);
5419 let prunes_subtree = !kind.is_dir() && self.controls.has_record_at_or_below(path);
5420 !drops_control && !prunes_subtree
5421 }
5422 Op::Remove { path } => {
5423 let drops_control =
5424 crate::control::is_control_file(path) && self.controls.contains(path);
5425 !drops_control && !self.controls.has_record_at_or_below(path)
5426 }
5427 Op::InvalidateSubtree { .. } => true,
5428 })
5429 }
5430
5431 fn apply_control_transition<C: ConsequenceSink>(
5432 &mut self,
5433 projected: Option<crate::control::ControlTable>,
5434 stats: &mut ApplyStats,
5435 effects: &mut C,
5436 ) {
5437 let Some(projected) = projected else {
5438 return;
5439 };
5440 let changes = projected.changes_from(&self.controls);
5441 let refusals = projected.refusal_changes_from(&self.controls);
5442 if changes.is_empty() && refusals.is_empty() {
5443 return;
5444 }
5445 let affected: Vec<PathBuf> = changes
5446 .iter()
5447 .filter_map(|(path, _, _)| crate::control::ControlTable::affected_subtree(path).ok())
5448 .collect();
5449 self.controls = projected;
5450 stats.controls = u64::try_from(changes.len() + refusals.len()).unwrap_or(u64::MAX);
5451 for (path, previous, current) in changes {
5452 effects.change(|| EffectiveChange::ControlUpdated { path, previous, current });
5453 }
5454 for (path, previous, current) in refusals {
5457 effects.change(|| EffectiveChange::ControlRefusalUpdated { path, previous, current });
5458 }
5459 self.reclassify_controlled_subtrees(&affected, stats, effects);
5460 }
5461
5462 fn reclassify_controlled_subtrees<C: ConsequenceSink>(
5465 &mut self,
5466 affected: &[PathBuf],
5467 stats: &mut ApplyStats,
5468 effects: &mut C,
5469 ) {
5470 let mut roots: Vec<PathBuf> = affected.to_vec();
5471 roots.sort();
5472 roots.dedup();
5473 let mut collapsed = Vec::new();
5474 for root in roots {
5475 if collapsed.iter().any(|ancestor: &PathBuf| root.starts_with(ancestor)) {
5476 continue;
5477 }
5478 collapsed.push(root);
5479 }
5480
5481 let mut moved = false;
5482 for root in collapsed {
5483 let Some(root_id) = self.lookup(&root) else {
5484 continue;
5485 };
5486 let children: Vec<(PathBuf, EntryId)> = self
5487 .children_of(root_id)
5488 .expect("controlled subtree root is live")
5489 .map(|(name, id)| (root.join(name), id))
5490 .collect();
5491 let mut queue = VecDeque::from(children);
5492 while let Some((path, id)) = queue.pop_front() {
5493 #[cfg(test)]
5494 RECLASSIFY_VISITS.with(|visits| visits.set(visits.get() + 1));
5495 let entry = self.entry(id);
5496 let parent_ignored = entry.parent.is_some_and(|parent| self.entry(parent).ignored);
5497 let current = entry.ignored;
5498 let next = parent_ignored
5499 || self.controls.matcher_for(&path).is_ignored(entry.kind.is_dir());
5500 let descendants: Vec<(PathBuf, EntryId)> = self
5501 .children_of(id)
5502 .expect("controlled subtree entry is live")
5503 .map(|(name, child)| (path.join(name), child))
5504 .collect();
5505 if current != next {
5506 self.move_serving_file_partition(&path, id, current, next);
5507 self.entry_mut(id).ignored = next;
5508 stats.reclassified += 1;
5509 effects.change(|| EffectiveChange::Reclassified {
5510 path: path.clone(),
5511 previous_ignored: current,
5512 current_ignored: next,
5513 });
5514 moved = true;
5515 }
5516 queue.extend(descendants);
5517 }
5518 }
5519 if moved {
5520 self.rebuild_unignored_rollups();
5521 }
5522 }
5523
5524 fn rebuild_unignored_rollups(&mut self) {
5525 let mut order = Vec::with_capacity(usize::try_from(self.live).unwrap_or(0));
5526 let mut stack = vec![EntryId::ROOT];
5527 while let Some(id) = stack.pop() {
5528 order.push(id);
5529 if self.entry(id).kind.is_dir() {
5530 stack.extend(self.child_ids(id));
5531 }
5532 if self.entry(id).kind.is_dir() {
5533 self.entry_mut(id).rollup_mut().unignored = InternedRollUp::default();
5534 }
5535 }
5536 for id in order.into_iter().rev() {
5537 let Some(parent) = self.entry(id).parent else {
5538 continue;
5539 };
5540 let contribution = self.contribution(id).unignored;
5541 self.entry_mut(parent).rollup_mut().unignored.merge(&contribution);
5542 }
5543 }
5544
5545 fn unknown_ancestry(
5546 &self,
5547 ops: &[ObservationOp],
5548 accepted: &[bool],
5549 ) -> Vec<(PathBuf, PathBuf)> {
5550 let mut structure = StructuralOverlay::default();
5551 let mut unknown = Vec::new();
5552 let mut overlay_inserts = 0_u64;
5553 let mut path_comparisons = 0_u64;
5554 let mut parent_proofs = 0_u64;
5555 let count_preflight = crate::counters::enabled();
5556 let mut proven_dir: Option<PathBuf> = None;
5565 let mut ancestor = PathBuf::new();
5566 for (observed, accepted) in ops.iter().zip(accepted) {
5567 if !accepted {
5568 continue;
5569 }
5570 match &observed.op {
5571 Op::Upsert { path, .. } | Op::ControlUpsert { path, .. }
5572 if !path.as_os_str().is_empty() =>
5573 {
5574 if count_preflight && proven_dir.is_some() {
5575 path_comparisons = path_comparisons.saturating_add(1);
5576 }
5577 let same_proven_parent = matches!(
5578 (path.parent(), proven_dir.as_deref()),
5579 (Some(parent), Some(proven)) if parent == proven
5580 );
5581 if same_proven_parent {
5582 if count_preflight {
5583 parent_proofs = parent_proofs.saturating_add(1);
5584 }
5585 } else {
5586 let mut reconcile_from = PathBuf::new();
5587 let mut ancestry_known = true;
5588 let parts = normalize(path).expect("prepared paths are canonical");
5589 let (_, ancestors) = parts.split_last().expect("non-root path has a name");
5590 ancestor.clear();
5591 for part in ancestors {
5592 ancestor.push(part);
5593 if structure.kind(self, &ancestor) != Some(EntryKind::Dir) {
5594 unknown.push((path.clone(), reconcile_from));
5595 ancestry_known = false;
5596 break;
5597 }
5598 reconcile_from.clone_from(&ancestor);
5599 }
5600 if !ancestry_known {
5601 proven_dir = None;
5602 continue;
5603 }
5604 if count_preflight {
5605 parent_proofs = parent_proofs.saturating_add(1);
5606 }
5607 match &mut proven_dir {
5608 Some(proven) => {
5609 proven.clear();
5610 path.parent().unwrap_or(Path::new("")).clone_into(proven);
5611 }
5612 None => {
5613 proven_dir =
5614 Some(path.parent().unwrap_or(Path::new("")).to_path_buf());
5615 }
5616 }
5617 }
5618 if let Op::Upsert { kind, .. } = &observed.op {
5619 structure.upsert(self, path, *kind);
5620 if count_preflight {
5621 overlay_inserts = overlay_inserts.saturating_add(1);
5622 }
5623 if !kind.is_dir() {
5624 if proven_dir.as_deref().is_some_and(|proven| proven.starts_with(path))
5628 {
5629 proven_dir = None;
5630 }
5631 }
5632 }
5633 }
5634 Op::Remove { path } if !path.as_os_str().is_empty() => {
5635 structure.remove(self, path);
5636 if proven_dir.as_deref().is_some_and(|proven| proven.starts_with(path)) {
5637 proven_dir = None;
5638 }
5639 }
5640 Op::Upsert { .. }
5641 | Op::Remove { .. }
5642 | Op::ControlUpsert { .. }
5643 | Op::ControlRemove { .. }
5644 | Op::InvalidateSubtree { .. } => {}
5645 }
5646 }
5647 if count_preflight {
5648 crate::counters::bump(|counts| {
5649 counts.ancestry_overlay_inserts =
5650 counts.ancestry_overlay_inserts.saturating_add(overlay_inserts);
5651 counts.ancestry_path_comparisons =
5652 counts.ancestry_path_comparisons.saturating_add(path_comparisons);
5653 counts.ancestry_parent_proofs =
5654 counts.ancestry_parent_proofs.saturating_add(parent_proofs);
5655 });
5656 }
5657 unknown
5658 }
5659
5660 fn entry_identity(&self, path: &Path) -> Option<EntryIdentity> {
5661 Some(self.identity(self.lookup(path)?))
5662 }
5663
5664 fn identity(&self, id: EntryId) -> EntryIdentity {
5665 let entry = self.entry(id);
5666 EntryIdentity::new(
5667 id.slot,
5668 id.generation,
5669 entry.revision,
5670 entry.directory.as_deref().map_or(0, |directory| directory.children_revision),
5671 entry.kind.is_dir(),
5672 )
5673 }
5674
5675 fn bump_revision(entry: &mut Entry) {
5676 entry.revision = entry.revision.checked_add(1).expect("entry revision exhausted");
5677 }
5678
5679 fn bump_children_revision(entry: &mut Entry) {
5680 let directory = entry.directory_mut();
5681 directory.children_revision =
5682 directory.children_revision.checked_add(1).expect("entry children revision exhausted");
5683 }
5684
5685 fn child(&self, parent: EntryId, name: &OsStr) -> Option<EntryId> {
5686 let children = &self.entry(parent).directory.as_deref()?.children;
5687 match children {
5688 DirectoryChildren::Sorted(ids) => ids
5689 .binary_search_by(|id| self.entry(*id).name.as_os_str().cmp(name))
5690 .ok()
5691 .map(|position| ids[position]),
5692 DirectoryChildren::Mutable(children) => children.get(name).copied(),
5693 }
5694 }
5695
5696 fn child_ids(&self, parent: EntryId) -> ChildIds<'_> {
5697 self.entry(parent).directory().children.ids()
5698 }
5699
5700 fn promote_children(&mut self, parent: EntryId) {
5706 let ids = match &mut self.entry_mut(parent).directory_mut().children {
5707 DirectoryChildren::Sorted(ids) => std::mem::take(ids),
5708 DirectoryChildren::Mutable(_) => return,
5709 };
5710 let expected = ids.len();
5711 let children =
5712 ids.into_iter().map(|id| (self.entry(id).name.clone(), id)).collect::<BTreeMap<_, _>>();
5713 assert_eq!(
5714 children.len(),
5715 expected,
5716 "compact child names must remain unique before promotion"
5717 );
5718 self.entry_mut(parent).directory_mut().children = DirectoryChildren::Mutable(children);
5719 }
5720
5721 fn insert_child(&mut self, parent: EntryId, name: OsString, child: EntryId) {
5722 self.promote_children(parent);
5723 let entry = self.entry_mut(parent);
5724 let DirectoryChildren::Mutable(children) = &mut entry.directory_mut().children else {
5725 unreachable!("child promotion produces mutable storage")
5726 };
5727 children.insert(name, child);
5728 Self::bump_children_revision(entry);
5729 }
5730
5731 fn reserve_detached_children(&mut self, parent: EntryId, additional: usize) {
5732 let entry = self.entry_mut(parent);
5733 let DirectoryChildren::Sorted(children) = &mut entry.directory_mut().children else {
5734 unreachable!("detached directories retain sorted child storage")
5735 };
5736 children.reserve(additional);
5737 }
5738
5739 fn push_detached_child(&mut self, parent: EntryId, child: EntryId) {
5740 let entry = self.entry_mut(parent);
5741 let DirectoryChildren::Sorted(children) = &mut entry.directory_mut().children else {
5742 unreachable!("detached directories retain sorted child storage")
5743 };
5744 children.push(child);
5745 Self::bump_children_revision(entry);
5746 }
5747
5748 fn sort_detached_children(&mut self, parent: EntryId) {
5751 let DirectoryChildren::Sorted(ids) = &mut self.entry_mut(parent).directory_mut().children
5752 else {
5753 unreachable!("detached directories retain sorted child storage")
5754 };
5755 let mut ids = std::mem::take(ids);
5756 ids.sort_unstable_by(|left, right| self.entry(*left).name.cmp(&self.entry(*right).name));
5757 self.entry_mut(parent).directory_mut().children = DirectoryChildren::Sorted(ids);
5758 }
5759
5760 fn merge_detached_descendants(&mut self, parent: EntryId, child: EntryId) {
5762 debug_assert!(parent.idx() < child.idx(), "cold parents must precede descendants");
5763 let (parents, children) = self.arena.split_at_mut(child.idx());
5764 let child_rollup = match &children[0] {
5765 Slot::Occupied { generation, entry } if *generation == child.generation => {
5766 entry.rollup()
5767 }
5768 Slot::Occupied { .. } | Slot::Free { .. } => {
5769 panic!("detached child handle must be live: {child:?}")
5770 }
5771 };
5772 let parent_entry = match &mut parents[parent.idx()] {
5773 Slot::Occupied { generation, entry } if *generation == parent.generation => entry,
5774 Slot::Occupied { .. } | Slot::Free { .. } => {
5775 panic!("detached parent handle must be live: {parent:?}")
5776 }
5777 };
5778 parent_entry.rollup_mut().merge(child_rollup);
5779 }
5780
5781 fn remove_child(&mut self, parent: EntryId, name: &OsStr) {
5782 self.promote_children(parent);
5783 let entry = self.entry_mut(parent);
5784 let DirectoryChildren::Mutable(children) = &mut entry.directory_mut().children else {
5785 unreachable!("child promotion produces mutable storage")
5786 };
5787 if children.remove(name).is_some() {
5788 Self::bump_children_revision(entry);
5789 }
5790 }
5791
5792 fn entry(&self, id: EntryId) -> &Entry {
5793 self.try_entry(id).expect("internal entry handle must be live")
5794 }
5795
5796 fn entry_mut(&mut self, id: EntryId) -> &mut Entry {
5797 match self.arena.get_mut(id.idx()) {
5798 Some(Slot::Occupied { generation, entry }) if *generation == id.generation => entry,
5799 Some(Slot::Occupied { .. } | Slot::Free { .. }) | None => {
5800 panic!("internal entry handle must be live: {id:?}")
5801 }
5802 }
5803 }
5804
5805 fn alloc(&mut self, entry: Entry) -> EntryId {
5806 crate::counters::bump(|c| c.entries_allocated += 1);
5807 self.live += 1;
5808 if let Some(free_slot) = self.free_head {
5809 let free_idx = free_slot as usize;
5810 let (generation, next) = match &self.arena[free_idx] {
5811 Slot::Free { generation, next_free } => (*generation, *next_free),
5812 Slot::Occupied { .. } => unreachable!("free list pointed at a live slot"),
5813 };
5814 self.free_head = next;
5815 self.arena[free_idx] = Slot::Occupied { generation, entry };
5816 return EntryId { slot: free_slot, generation };
5817 }
5818 let slot = u32::try_from(self.arena.len()).expect("index arena exceeded u32 capacity");
5819 let id = EntryId { slot, generation: 0 };
5820 self.arena.push(Slot::Occupied { generation: 0, entry });
5821 id
5822 }
5823
5824 fn free(&mut self, id: EntryId) {
5825 let next_generation = match &self.arena[id.idx()] {
5826 Slot::Occupied { generation, .. } if *generation == id.generation => {
5827 generation.checked_add(1).expect("entry generation exhausted")
5828 }
5829 Slot::Occupied { .. } | Slot::Free { .. } => {
5830 panic!("internal entry handle must be live: {id:?}")
5831 }
5832 };
5833 self.arena[id.idx()] =
5834 Slot::Free { generation: next_generation, next_free: self.free_head };
5835 self.free_head = Some(id.slot);
5836 self.live -= 1;
5837 }
5838
5839 fn now_unix_nanos() -> i64 {
5843 std::time::SystemTime::now()
5844 .duration_since(std::time::UNIX_EPOCH)
5845 .ok()
5846 .and_then(|since| i64::try_from(since.as_nanos()).ok())
5847 .unwrap_or(0)
5848 }
5849
5850 pub fn provenance(&self, path: &Path) -> Option<Provenance> {
5870 let id = self.lookup(path)?;
5871 Some(self.provenance_of(id))
5872 }
5873
5874 fn provenance_of(&self, id: EntryId) -> Provenance {
5875 let entry = self.entry(id);
5876 let status = self.status_of(id);
5877 if entry.source >= Source::Revalidated {
5898 if let Some(path) = self.path_of(id) {
5899 if self.freshness_at(&path) == Freshness::Fresh {
5900 if let Some(verified_at) = self.verified_at(&path) {
5901 return Provenance {
5902 source: Source::Revalidated,
5903 observed_at_ns: verified_at,
5904 status,
5905 };
5906 }
5907 }
5908 }
5909 }
5910 Provenance { source: entry.source, observed_at_ns: self.observed_at(entry.source), status }
5911 }
5912
5913 fn status_of(&self, id: EntryId) -> Status {
5926 let Some(path) = self.path_of(id) else {
5927 return Status::Complete;
5928 };
5929 match self.freshness_at(&path) {
5930 Freshness::Fresh | Freshness::Reconciling | Freshness::Stale => Status::Complete,
5931 Freshness::Partial => Status::Partial,
5932 }
5933 }
5934
5935 const fn observed_at(&self, source: Source) -> i64 {
5937 match source {
5938 Source::Cached | Source::JournalScoped => self.captured_at_ns,
5939 Source::Scanned | Source::Revalidated => self.scanned_at_ns,
5940 }
5941 }
5942
5943 pub(crate) const fn writing_pass_started_at_ns(&self) -> i64 {
5946 self.writing_pass_started_at_ns
5947 }
5948
5949 pub(crate) fn set_writing_pass_started_at_ns(&mut self, writing_pass_started_at_ns: i64) {
5951 self.writing_pass_started_at_ns = writing_pass_started_at_ns;
5952 }
5953
5954 pub(crate) const fn persistence_owed(&self) -> bool {
5956 self.persistence_owed
5957 }
5958
5959 pub(crate) fn set_persistence_owed(&mut self, owed: bool) {
5962 self.persistence_owed = owed;
5963 }
5964
5965 pub(crate) fn set_applying_source(&mut self, source: Source, captured_at_ns: i64) -> Source {
5971 let previous = self.applying_source;
5972 self.applying_source = source;
5973 if source == Source::Cached {
5974 self.state.source = Source::Cached;
5975 }
5976 if captured_at_ns != 0 {
5977 self.captured_at_ns = captured_at_ns;
5978 }
5979 previous
5980 }
5981
5982 fn intern_ext(&mut self, name: &str) -> ExtId {
5988 if let Some(&id) = self.ext_ids.get(name) {
5989 let refcount =
5990 self.ext_refcounts.get_mut(id as usize).expect("a live id has a refcount");
5991 *refcount = refcount.checked_add(1).expect("extension refcount exhausted");
5992 return id;
5993 }
5994 let id = if let Some(id) = self.free_ext_ids.pop() {
5995 let slot = id as usize;
5996 self.ext_names[slot] = Some(name.to_string());
5997 self.ext_refcounts[slot] = 1;
5998 id
5999 } else {
6000 let id = ExtId::try_from(self.ext_names.len()).expect("extension interner exhausted");
6001 self.ext_names.push(Some(name.to_string()));
6002 self.ext_refcounts.push(1);
6003 id
6004 };
6005 self.ext_ids.insert(name.to_string(), id);
6006 id
6007 }
6008
6009 fn release_ext(&mut self, id: ExtId) {
6011 let slot = id as usize;
6012 let refcount = self.ext_refcounts.get_mut(slot).expect("a live id has a refcount");
6013 debug_assert!(*refcount > 0, "extension reference released twice");
6014 *refcount -= 1;
6015 if *refcount != 0 {
6016 return;
6017 }
6018 let name = self.ext_names[slot].take().expect("a live id has a name");
6019 let removed = self.ext_ids.remove(&name);
6020 debug_assert_eq!(removed, Some(id), "the interner's two maps disagreed");
6021 self.free_ext_ids.push(id);
6022 }
6023
6024 fn named_rollup(&self, rollup: &InternedRollUp) -> RollUp {
6026 let by_ext = rollup
6027 .by_ext
6028 .iter()
6029 .map(|(id, tally)| {
6030 let name = self
6031 .ext_names
6032 .get(*id as usize)
6033 .and_then(Option::as_ref)
6034 .expect("a live roll-up's extension id has a name");
6035 (name.clone(), *tally)
6036 })
6037 .collect();
6038 RollUp {
6039 files: rollup.files,
6040 dirs: rollup.dirs,
6041 bytes: rollup.bytes,
6042 allocated: rollup.allocated,
6043 newest_mtime_ns: rollup.newest_mtime_ns,
6044 by_ext,
6045 }
6046 }
6047
6048 fn named_partitions(&self, rollup: &InternedPartitionRollUp) -> PartitionRollUp {
6049 PartitionRollUp {
6050 all: self.named_rollup(&rollup.all),
6051 unignored: self.named_rollup(&rollup.unignored),
6052 }
6053 }
6054
6055 fn contribution(&self, id: EntryId) -> InternedPartitionRollUp {
6057 let entry = self.entry(id);
6058 match entry.kind {
6059 EntryKind::Dir => {
6060 let mut all = entry.rollup().all.clone();
6061 all.dirs += 1;
6062 let mut unignored = InternedRollUp::default();
6063 if !entry.ignored {
6064 unignored = entry.rollup().unignored.clone();
6065 unignored.dirs += 1;
6066 }
6067 InternedPartitionRollUp { all, unignored }
6068 }
6069 EntryKind::File => Self::file_contribution(&entry.attrs, entry.ext_id, entry.ignored),
6070 EntryKind::Symlink | EntryKind::Other => InternedPartitionRollUp::default(),
6071 }
6072 }
6073
6074 fn file_contribution(
6078 attrs: &Attrs,
6079 ext_id: Option<ExtId>,
6080 ignored: bool,
6081 ) -> InternedPartitionRollUp {
6082 let mut all = InternedRollUp {
6083 files: 1,
6084 dirs: 0,
6085 bytes: attrs.size,
6086 allocated: attrs.allocated,
6087 newest_mtime_ns: attrs.mtime_ns,
6088 by_ext: BTreeMap::new(),
6089 };
6090 if let Some(ext_id) = ext_id {
6091 all.by_ext.insert(
6092 ext_id,
6093 ExtTally { files: 1, bytes: attrs.size, allocated: attrs.allocated },
6094 );
6095 }
6096 let unignored = if ignored { InternedRollUp::default() } else { all.clone() };
6097 InternedPartitionRollUp { all, unignored }
6098 }
6099
6100 fn merge_upward(
6101 &mut self,
6102 from_parent: Option<EntryId>,
6103 contribution: &InternedPartitionRollUp,
6104 ) {
6105 let mut current = from_parent;
6106 while let Some(id) = current {
6107 crate::counters::bump(|c| c.rollup_merges += 1);
6110 let entry = self.entry_mut(id);
6111 entry.rollup_mut().merge(contribution);
6112 current = entry.parent;
6113 }
6114 }
6115
6116 fn unmerge_upward(
6117 &mut self,
6118 from_parent: Option<EntryId>,
6119 contribution: &InternedPartitionRollUp,
6120 ) {
6121 let mut current = from_parent;
6122 while let Some(id) = current {
6123 let entry = self.entry_mut(id);
6124 entry.rollup_mut().unmerge(contribution);
6125 current = entry.parent;
6126 }
6127 }
6128
6129 fn recompute_newest_upward(&mut self, from: Option<EntryId>) {
6137 let mut current = from;
6138 while let Some(id) = current {
6139 let mut newest: Option<i64> = None;
6140 for child in self.child_ids(id) {
6141 let child_entry = self.entry(child);
6142 let candidate = match child_entry.kind {
6143 EntryKind::Dir => (child_entry.rollup().files > 0)
6144 .then_some(child_entry.rollup().newest_mtime_ns),
6145 EntryKind::File => Some(child_entry.attrs.mtime_ns),
6146 EntryKind::Symlink | EntryKind::Other => None,
6147 };
6148 if let Some(candidate) = candidate {
6149 newest = Some(newest.map_or(candidate, |current| current.max(candidate)));
6150 }
6151 }
6152 let newest = newest.unwrap_or(0);
6153 self.entry_mut(id).rollup_mut().all.newest_mtime_ns = newest;
6154
6155 let mut newest_unignored: Option<i64> = None;
6156 for child in self.child_ids(id) {
6157 let child_entry = self.entry(child);
6158 let candidate = match child_entry.kind {
6159 EntryKind::Dir => (!child_entry.ignored
6160 && child_entry.rollup().unignored.files > 0)
6161 .then_some(child_entry.rollup().unignored.newest_mtime_ns),
6162 EntryKind::File => (!child_entry.ignored).then_some(child_entry.attrs.mtime_ns),
6163 EntryKind::Symlink | EntryKind::Other => None,
6164 };
6165 if let Some(candidate) = candidate {
6166 newest_unignored =
6167 Some(newest_unignored.map_or(candidate, |current| current.max(candidate)));
6168 }
6169 }
6170 let entry = self.entry_mut(id);
6171 entry.rollup_mut().unignored.newest_mtime_ns = newest_unignored.unwrap_or(0);
6172 current = entry.parent;
6173 }
6174 }
6175
6176 fn resolve_dir_chain(&self, parts: &[&OsStr]) -> EntryId {
6178 let mut current = EntryId::ROOT;
6179 for part in parts {
6180 current = self
6181 .child(current, part)
6182 .expect("validated ancestry remains present under the writer lock");
6183 debug_assert!(self.entry(current).kind.is_dir());
6184 }
6185 current
6186 }
6187
6188 fn apply_upsert<C: ConsequenceSink>(
6189 &mut self,
6190 path: &Path,
6191 kind: EntryKind,
6192 attrs: Attrs,
6193 stats: &mut ApplyStats,
6194 effects: &mut C,
6195 parent_memo: &mut ParentMemo,
6196 ) -> bool {
6197 crate::counters::bump(|c| c.upserts += 1);
6203 if let (Some(dir), Some(name)) = (path.parent(), path.file_name()) {
6204 if let Some(parent) = parent_memo.get(dir) {
6205 crate::counters::bump(|c| c.parent_memo_hits += 1);
6206 return self.upsert_beneath(parent, name, path, kind, attrs, stats, effects);
6207 }
6208 }
6209 crate::counters::bump(|c| c.parent_resolutions += 1);
6210
6211 let Some(parts) = normalize(path) else {
6212 return false;
6213 };
6214 let source = self.applying_source;
6215
6216 let Some((name, ancestors)) = parts.split_last() else {
6217 if self.entry(EntryId::ROOT).attrs == attrs {
6223 self.entry_mut(EntryId::ROOT).source = source;
6224 stats.unchanged += 1;
6225 return false;
6226 }
6227 let root = self.entry_mut(EntryId::ROOT);
6228 let previous = root.attrs;
6229 root.attrs = attrs;
6230 root.source = source;
6231 Self::bump_revision(root);
6232 stats.updated += 1;
6233 effects.change(|| EffectiveChange::Updated {
6234 path: PathBuf::new(),
6235 kind: EntryKind::Dir,
6236 previous,
6237 current: attrs,
6238 });
6239 return true;
6240 };
6241 let parent = self.resolve_dir_chain(ancestors);
6242 if let Some(dir) = path.parent() {
6243 parent_memo.set(dir, parent);
6244 }
6245 self.upsert_beneath(parent, name, path, kind, attrs, stats, effects)
6246 }
6247
6248 #[allow(clippy::too_many_arguments)]
6255 fn upsert_beneath<C: ConsequenceSink>(
6256 &mut self,
6257 parent: EntryId,
6258 name: &OsStr,
6259 path: &Path,
6260 kind: EntryKind,
6261 attrs: Attrs,
6262 stats: &mut ApplyStats,
6263 effects: &mut C,
6264 ) -> bool {
6265 let source = self.applying_source;
6266 let existing = self.child(parent, name);
6267
6268 if let Some(id) = existing {
6269 let entry = self.entry(id);
6270 if entry.kind == kind {
6271 if entry.attrs == attrs {
6272 self.entry_mut(id).source = source;
6278 stats.unchanged += 1;
6279 return false;
6280 }
6281 if kind.is_dir() {
6282 let entry = self.entry_mut(id);
6285 let previous = entry.attrs;
6286 entry.attrs = attrs;
6287 entry.source = source;
6288 Self::bump_revision(entry);
6289 stats.updated += 1;
6290 effects.change(|| EffectiveChange::Updated {
6291 path: path.to_path_buf(),
6292 kind,
6293 previous,
6294 current: attrs,
6295 });
6296 return true;
6297 }
6298 let previous_attrs = entry.attrs;
6299 self.invalidate_content(path);
6300 if kind == EntryKind::File {
6301 self.remove_serving_file_semantics(path, id, previous_attrs);
6302 }
6303 self.remove_serving_entry(path, kind, previous_attrs, id);
6304 let old = self.contribution(id);
6305 self.unmerge_upward(Some(parent), &old);
6306 let entry = self.entry_mut(id);
6307 let previous = entry.attrs;
6308 entry.attrs = attrs;
6309 entry.source = source;
6310 Self::bump_revision(entry);
6311 let new = self.contribution(id);
6312 self.merge_upward(Some(parent), &new);
6313 self.insert_serving_entry(path, kind, attrs, id);
6314 if new.newest_mtime_ns < old.newest_mtime_ns {
6315 self.recompute_newest_upward(Some(parent));
6316 }
6317 stats.updated += 1;
6318 effects.change(|| EffectiveChange::Updated {
6319 path: path.to_path_buf(),
6320 kind,
6321 previous,
6322 current: attrs,
6323 });
6324 return true;
6325 }
6326 self.remove_entry(id, stats, effects);
6334 }
6335
6336 let ext_id =
6337 (kind == EntryKind::File).then(|| self.intern_ext(&crate::classify::ext_bucket(name)));
6338 let ignored =
6339 self.entry(parent).ignored || self.controls.matcher_for(path).is_ignored(kind.is_dir());
6340 let id = self.alloc(Entry::new(
6341 NewEntry {
6342 parent: Some(parent),
6343 name: name.to_os_string(),
6344 ext_id,
6345 ignored,
6346 source,
6347 kind,
6348 attrs,
6349 },
6350 false,
6351 ));
6352 self.insert_child(parent, name.to_os_string(), id);
6353 let contribution = self.contribution(id);
6354 self.merge_upward(Some(parent), &contribution);
6355 stats.inserted += 1;
6356 effects.change(|| EffectiveChange::Inserted { path: path.to_path_buf(), kind, attrs });
6357 self.insert_serving_entry(path, kind, attrs, id);
6358 true
6359 }
6360
6361 pub(crate) fn insert_loaded_child(
6381 &mut self,
6382 parent: EntryId,
6383 name: OsString,
6384 kind: EntryKind,
6385 attrs: Attrs,
6386 ) -> Option<EntryId> {
6387 let parent_entry = self.try_entry(parent)?;
6388 if parent_entry.kind != EntryKind::Dir || self.child(parent, &name).is_some() {
6389 return None;
6390 }
6391 let source = self.applying_source;
6392 let ext_id =
6393 (kind == EntryKind::File).then(|| self.intern_ext(&crate::classify::ext_bucket(&name)));
6394 let id = self.alloc(Entry::new(
6395 NewEntry {
6396 parent: Some(parent),
6397 name: name.clone(),
6398 ext_id,
6399 ignored: false,
6400 source,
6401 kind,
6402 attrs,
6403 },
6404 true,
6405 ));
6406 self.insert_child(parent, name, id);
6407 let contribution = self.contribution(id);
6411 self.merge_upward(Some(parent), &contribution);
6412 if self.serving.is_some() {
6415 let path = self.path_of(id).expect("a newly loaded entry has a path");
6416 self.insert_serving_entry(&path, kind, attrs, id);
6417 }
6418 Some(id)
6419 }
6420
6421 fn apply_remove<C: ConsequenceSink>(
6422 &mut self,
6423 path: &Path,
6424 stats: &mut ApplyStats,
6425 effects: &mut C,
6426 ) -> bool {
6427 let Some(id) = self.lookup(path) else {
6428 stats.unchanged += 1;
6429 return false;
6430 };
6431 if id == EntryId::ROOT {
6432 stats.unchanged += 1;
6433 return false;
6434 }
6435 self.remove_entry(id, stats, effects);
6436 true
6437 }
6438
6439 fn remove_entry<C: ConsequenceSink>(
6440 &mut self,
6441 id: EntryId,
6442 stats: &mut ApplyStats,
6443 effects: &mut C,
6444 ) {
6445 let removed_root = self.path_of(id).expect("a live entry has a path");
6446 self.invalidate_content(&removed_root);
6447 self.remove_serving_subtree_semantics(id, &removed_root);
6448 let parent = self.entry(id).parent;
6449 let name = self.entry(id).name.clone();
6450 let contribution = self.contribution(id);
6451
6452 self.unmerge_upward(parent, &contribution);
6453 if let Some(parent) = parent {
6454 self.remove_child(parent, &name);
6455 }
6456
6457 let mut queue = VecDeque::from([(id, removed_root)]);
6460 while let Some((node, path)) = queue.pop_front() {
6461 let entry = self.entry(node);
6462 let kind = entry.kind;
6463 let attrs = entry.attrs;
6464 let children: Vec<(OsString, EntryId)> = self
6465 .children_of(node)
6466 .expect("removed subtree entry is live")
6467 .map(|(name, child)| (name.to_os_string(), child))
6468 .collect();
6469 let ext_id = entry.ext_id;
6470 for (name, child) in children {
6471 queue.push_back((child, path.join(name)));
6472 }
6473 self.remove_serving_entry(&path, kind, attrs, node);
6474 effects.change(|| EffectiveChange::Removed { path, kind, attrs });
6475 if let Some(ext_id) = ext_id {
6478 self.release_ext(ext_id);
6479 }
6480 self.free(node);
6481 stats.removed += 1;
6482 }
6483
6484 self.recompute_newest_upward(parent);
6486 }
6487
6488 fn invalidate_content(&mut self, path: &Path) {
6489 if let Some(content) = self.content.as_mut() {
6490 content.invalidate(path);
6491 }
6492 }
6493}
6494
6495pub(crate) fn collect_child_expectations(
6503 index: &Index,
6504 path: &Path,
6505) -> BTreeMap<OsString, PathExpectation> {
6506 index.children(path).map_or_else(BTreeMap::new, |children| {
6507 children
6508 .map(|(name, id)| {
6509 let entry = index.entry(id);
6510 let expectation = PathExpectation::new(
6511 PathState::Present { kind: entry.kind, attrs: entry.attrs },
6512 Some(index.identity(id)),
6513 None,
6514 );
6515 (name.to_os_string(), expectation)
6516 })
6517 .collect()
6518 })
6519}
6520
6521#[derive(Default)]
6549struct ParentMemo {
6550 entry: Option<(PathBuf, EntryId)>,
6551}
6552
6553impl ParentMemo {
6554 fn get(&self, dir: &Path) -> Option<EntryId> {
6556 self.entry.as_ref().filter(|(cached, _)| cached == dir).map(|&(_, id)| id)
6557 }
6558
6559 fn set(&mut self, dir: &Path, id: EntryId) {
6560 match &mut self.entry {
6561 Some((cached, cached_id)) => {
6564 cached.clear();
6565 cached.push(dir);
6566 *cached_id = id;
6567 }
6568 slot => *slot = Some((dir.to_path_buf(), id)),
6569 }
6570 }
6571
6572 fn clear(&mut self) {
6573 self.entry = None;
6574 }
6575}
6576
6577#[derive(Default)]
6579struct StructuralOverlay {
6580 entries: HashMap<PathBuf, EntryKind>,
6583 removed_roots: Vec<PathBuf>,
6584}
6585
6586impl StructuralOverlay {
6587 fn kind(&self, index: &Index, path: &Path) -> Option<EntryKind> {
6588 self.entries.get(path).copied().or_else(|| {
6589 (!self.removed_roots.iter().any(|removed| path.starts_with(removed)))
6590 .then(|| index.kind(path))
6591 .flatten()
6592 })
6593 }
6594
6595 fn upsert(&mut self, index: &Index, path: &Path, kind: EntryKind) {
6596 if self.kind(index, path).is_some_and(|current| current != kind) {
6597 self.remove(index, path);
6598 }
6599 self.entries.insert(path.to_path_buf(), kind);
6600 }
6601
6602 fn remove(&mut self, index: &Index, path: &Path) {
6603 if self.kind(index, path).is_none() {
6604 return;
6605 }
6606 self.entries.retain(|candidate, _| !candidate.starts_with(path));
6607 self.removed_roots.retain(|candidate| !candidate.starts_with(path));
6608 if !self.removed_roots.iter().any(|removed| path.starts_with(removed)) {
6609 self.removed_roots.push(path.to_path_buf());
6610 }
6611 }
6612}
6613
6614fn normalize(path: &Path) -> Option<Vec<&OsStr>> {
6615 let mut parts = Vec::new();
6616 for component in path.components() {
6617 match component {
6618 Component::Normal(part) => parts.push(part),
6619 Component::CurDir => {}
6620 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
6621 }
6622 }
6623 Some(parts)
6624}
6625
6626fn prepare_observation(observation: &Observation) -> crate::Result<PreparedObservation> {
6627 let mut ops = Vec::with_capacity(observation.len());
6628 for observed in &observation.ops {
6629 let path = canonical_relative_path(observed.op.path())?;
6630 let op = match &observed.op {
6631 Op::Upsert { kind, attrs, .. } => Op::Upsert { path, kind: *kind, attrs: *attrs },
6632 Op::Remove { .. } => Op::Remove { path },
6633 Op::ControlUpsert { source, .. } => {
6634 if !crate::control::is_control_file(&path) {
6635 return Err(crate::Error::InvalidControlPath(path));
6636 }
6637 Op::ControlUpsert { path, source: source.clone() }
6638 }
6639 Op::ControlRemove { .. } => {
6640 if !crate::control::is_control_file(&path) {
6641 return Err(crate::Error::InvalidControlPath(path));
6642 }
6643 Op::ControlRemove { path }
6644 }
6645 Op::InvalidateSubtree { reason, .. } => Op::InvalidateSubtree { path, reason: *reason },
6646 };
6647 ops.push(ObservationOp { op, expectation: observed.expectation });
6648 }
6649 Ok(PreparedObservation {
6650 ops,
6651 ancestry: PreparedAncestry::General,
6652 #[cfg(test)]
6653 reject_before_apply: false,
6654 })
6655}
6656
6657fn canonical_relative_path(path: &Path) -> crate::Result<PathBuf> {
6658 let mut canonical = PathBuf::with_capacity(path.as_os_str().as_encoded_bytes().len());
6659 for component in path.components() {
6660 match component {
6661 Component::Normal(part) => canonical.push(part),
6662 Component::CurDir => {}
6663 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
6664 return Err(crate::Error::PathEscapesRoot(path.to_path_buf()));
6665 }
6666 }
6667 }
6668 Ok(canonical)
6669}
6670
6671fn derive_impact(changes: &[EffectiveChange], state: &[StateTransition]) -> Impact {
6672 let mut domains = BTreeSet::new();
6673 let mut paths = BTreeSet::new();
6674 let mut all_dirty = false;
6675 let mut ancestor_visits = 0_u64;
6676 let count_impact = crate::counters::enabled();
6677
6678 for change in changes {
6679 match change {
6680 EffectiveChange::Inserted { .. } | EffectiveChange::Removed { .. } => {
6681 domains.extend([
6682 ImpactDomain::Topology,
6683 ImpactDomain::Metadata,
6684 ImpactDomain::Classification,
6685 ImpactDomain::Aggregates,
6686 ImpactDomain::Content,
6687 ]);
6688 }
6689 EffectiveChange::Updated { .. } => {
6690 domains.extend([
6691 ImpactDomain::Metadata,
6692 ImpactDomain::Aggregates,
6693 ImpactDomain::Content,
6694 ]);
6695 }
6696 EffectiveChange::ControlUpdated { .. } | EffectiveChange::Reclassified { .. } => {
6697 domains.extend([ImpactDomain::Classification, ImpactDomain::Aggregates]);
6698 }
6699 EffectiveChange::ControlRefusalUpdated { .. } => {
6700 domains.insert(ImpactDomain::Classification);
6701 }
6702 EffectiveChange::Invalidated { .. } => {
6703 domains.insert(ImpactDomain::State);
6704 }
6705 }
6706 insert_dirty_ancestors(
6707 change.path(),
6708 &mut paths,
6709 &mut all_dirty,
6710 count_impact,
6711 &mut ancestor_visits,
6712 );
6713 }
6714 for transition in state {
6715 domains.insert(ImpactDomain::State);
6716 insert_dirty_ancestors(
6717 transition.path(),
6718 &mut paths,
6719 &mut all_dirty,
6720 count_impact,
6721 &mut ancestor_visits,
6722 );
6723 }
6724
6725 if count_impact {
6726 let candidates =
6727 u64::try_from(changes.len().saturating_add(state.len())).unwrap_or(u64::MAX);
6728 let retained_dirty_paths = u64::try_from(paths.len()).unwrap_or(u64::MAX);
6729 crate::counters::bump(|counts| {
6730 counts.impact_candidates = counts.impact_candidates.saturating_add(candidates);
6731 counts.impact_ancestor_visits =
6732 counts.impact_ancestor_visits.saturating_add(ancestor_visits);
6733 counts.impact_retained_dirty_paths =
6734 counts.impact_retained_dirty_paths.saturating_add(retained_dirty_paths);
6735 counts.impact_all_dirty = counts.impact_all_dirty.saturating_add(u64::from(all_dirty));
6736 });
6737 }
6738
6739 Impact {
6740 domains: domains.into_iter().collect(),
6741 dirty_paths: if all_dirty { Vec::new() } else { paths.into_iter().collect() },
6742 all_dirty,
6743 }
6744}
6745
6746fn commit_work(observations: u64, stats: ApplyStats) -> Work {
6747 Work {
6748 observations,
6749 unchanged: stats.unchanged,
6750 stale: stats.stale,
6751 resource_refused: stats.resource_refused,
6752 ..Work::default()
6753 }
6754}
6755
6756fn insert_dirty_ancestors(
6757 path: &Path,
6758 paths: &mut BTreeSet<PathBuf>,
6759 all_dirty: &mut bool,
6760 count_impact: bool,
6761 ancestor_visits: &mut u64,
6762) {
6763 if *all_dirty {
6764 return;
6765 }
6766 for ancestor in path.ancestors() {
6767 if count_impact {
6768 *ancestor_visits = ancestor_visits.saturating_add(1);
6769 }
6770 paths.insert(ancestor.to_path_buf());
6771 if paths.len() > MAX_DIRTY_PATHS {
6772 paths.clear();
6773 *all_dirty = true;
6774 return;
6775 }
6776 }
6777}
6778
6779fn same_target(
6780 current: Option<EntryIdentity>,
6781 expected: Option<EntryIdentity>,
6782 require_structure: bool,
6783) -> bool {
6784 match (current, expected) {
6785 (Some(current), Some(expected)) => current.same_target(expected, require_structure),
6786 (None, None) => true,
6787 (Some(_), None) | (None, Some(_)) => false,
6788 }
6789}
6790
6791#[cfg(test)]
6792mod tests {
6793 use super::*;
6794 use crate::engine_contract::ObservationOp;
6795 use std::sync::{Arc, Barrier};
6796
6797 #[test]
6798 fn reusable_entry_keeps_directory_state_out_of_line() {
6799 let entry_bytes = std::mem::size_of::<Entry>();
6800 let slot_bytes = std::mem::size_of::<Slot>();
6801
6802 assert!(
6803 entry_bytes <= 136,
6804 "common entry storage must not inline directory-only maps and roll-ups: {entry_bytes} bytes"
6805 );
6806 assert!(
6807 slot_bytes <= entry_bytes + 16,
6808 "the arena slot must not add a second per-entry allocation: entry={entry_bytes}, slot={slot_bytes}"
6809 );
6810 }
6811
6812 #[test]
6813 fn detached_children_store_each_name_once_and_promote_on_mutation() {
6814 let mut builder = DetachedIndexBuilder::new(
6815 "/root",
6816 ScanScope::default(),
6817 crate::classify::TypeRegistry::compiled_shared(),
6818 );
6819 builder
6820 .push_directory(&mut crate::scan::DetachedDirectory {
6821 path: PathBuf::new(),
6822 children: vec![
6823 crate::scan::DetachedChild {
6824 name: OsString::from("dir"),
6825 kind: EntryKind::Dir,
6826 attrs: Attrs::default(),
6827 position: 0,
6828 },
6829 crate::scan::DetachedChild {
6830 name: OsString::from("z.txt"),
6831 kind: EntryKind::File,
6832 attrs: file_attrs(1, 1),
6833 position: 1,
6834 },
6835 ],
6836 control: None,
6837 })
6838 .expect("detached root listing");
6839 builder
6840 .push_directory(&mut crate::scan::DetachedDirectory {
6841 path: PathBuf::from("dir"),
6842 children: vec![
6843 crate::scan::DetachedChild {
6844 name: OsString::from("z.txt"),
6845 kind: EntryKind::File,
6846 attrs: file_attrs(2, 2),
6847 position: 0,
6848 },
6849 crate::scan::DetachedChild {
6850 name: OsString::from("a.txt"),
6851 kind: EntryKind::File,
6852 attrs: file_attrs(3, 3),
6853 position: 1,
6854 },
6855 ],
6856 control: None,
6857 })
6858 .expect("detached child listing");
6859 let mut index = builder.finish();
6860 let directory = index.lookup(Path::new("dir")).expect("detached directory");
6861
6862 assert!(index.entry(EntryId::ROOT).directory().children.is_sorted());
6863 assert!(index.entry(directory).directory().children.is_sorted());
6864 assert_eq!(
6865 index
6866 .children(Path::new("dir"))
6867 .expect("directory children")
6868 .map(|(name, _)| name.to_os_string())
6869 .collect::<Vec<_>>(),
6870 [OsString::from("a.txt"), OsString::from("z.txt")]
6871 );
6872
6873 index.apply_ok(&Observation::new(vec![upsert(
6874 "dir/m.txt",
6875 EntryKind::File,
6876 file_attrs(4, 4),
6877 )]));
6878
6879 assert!(index.entry(EntryId::ROOT).directory().children.is_sorted());
6880 assert!(index.entry(directory).directory().children.is_mutable());
6881 assert_eq!(
6882 index
6883 .children(Path::new("dir"))
6884 .expect("directory children")
6885 .map(|(name, _)| name.to_os_string())
6886 .collect::<Vec<_>>(),
6887 [OsString::from("a.txt"), OsString::from("m.txt"), OsString::from("z.txt")]
6888 );
6889 }
6890
6891 #[test]
6896 fn a_folded_builder_keeps_the_largest_files_and_leaves_folded_names_to_the_walker() {
6897 let child = |name: &str, kind, size, position| crate::scan::DetachedChild {
6898 name: OsString::from(name),
6899 kind,
6900 attrs: file_attrs(size, 1),
6901 position,
6902 };
6903 let mut builder = DetachedIndexBuilder::new(
6904 "/root",
6905 ScanScope::default(),
6906 crate::classify::TypeRegistry::compiled_shared(),
6907 )
6908 .folding(crate::execution::TreeRetention {
6909 largest_files: 2,
6910 size: crate::query::SizeMetric::Apparent,
6911 });
6912 let mut root = crate::scan::DetachedDirectory {
6913 path: PathBuf::new(),
6914 children: vec![
6915 child("small.log", EntryKind::File, 1, 0),
6916 child("dir", EntryKind::Dir, 0, 1),
6917 child("big.bin", EntryKind::File, 50, 2),
6918 child("twice.txt", EntryKind::File, 3, 3),
6919 child("twice.txt", EntryKind::File, 40, 4),
6920 child("mid.rs", EntryKind::File, 5, 5),
6921 ],
6922 control: Some(Op::ControlUpsert {
6923 path: PathBuf::from(".gitignore"),
6924 source: b"*.log\n".to_vec(),
6925 }),
6926 };
6927 builder.push_directory(&mut root).expect("root listing");
6928 let left: Vec<_> =
6932 root.children.iter().filter(|child| !child.name.is_empty()).map(|c| &c.name).collect();
6933 assert_eq!(left, [&OsString::from("small.log")]);
6934
6935 let index = builder.finish();
6936 assert!(index.is_folded());
6937 assert_eq!(index.len(), 4, "the root, the directory, and the two largest files");
6938 assert_eq!(
6939 index
6940 .children(Path::new(""))
6941 .expect("root children")
6942 .map(|(name, _)| name.to_os_string())
6943 .collect::<Vec<_>>(),
6944 [OsString::from("big.bin"), OsString::from("dir"), OsString::from("twice.txt")],
6945 "kept files join their directory in name order"
6946 );
6947 assert_eq!(index.attrs(Path::new("twice.txt")), Some(&file_attrs(40, 1)));
6948 let total = index.total();
6949 assert_eq!((total.files, total.dirs, total.bytes), (4, 1, 96));
6950 assert!(total.by_ext.is_empty(), "a folded index keeps no extension tallies");
6951 assert_eq!(
6952 index.partition_total().expect("observed").unignored.bytes,
6953 95,
6954 "the folded ignored file left the unignored partition"
6955 );
6956 assert_eq!(
6957 index.folded_children(EntryId::ROOT),
6958 Some(FoldedFiles {
6959 files: 2,
6960 bytes: 6,
6961 allocated: 1_024,
6962 ignored: crate::query::IgnoredSize { bytes: 1, allocated: 512 },
6963 })
6964 );
6965 assert!(index.children_classification_known(Path::new("")));
6966 let dir = index.lookup(Path::new("dir")).expect("the directory");
6967 assert_eq!(index.folded_children(dir), None, "nothing was folded there");
6968 }
6969
6970 #[test]
6973 fn a_displaced_file_folds_into_its_own_directory() {
6974 let child = |name: &str, kind, size, position| crate::scan::DetachedChild {
6975 name: OsString::from(name),
6976 kind,
6977 attrs: file_attrs(size, 1),
6978 position,
6979 };
6980 let listing = |path: &str, children| crate::scan::DetachedDirectory {
6981 path: PathBuf::from(path),
6982 children,
6983 control: None,
6984 };
6985 let mut builder = DetachedIndexBuilder::new(
6986 "/root",
6987 ScanScope::default(),
6988 crate::classify::TypeRegistry::compiled_shared(),
6989 )
6990 .folding(crate::execution::TreeRetention {
6991 largest_files: 1,
6992 size: crate::query::SizeMetric::Apparent,
6993 });
6994 builder
6995 .push_directory(&mut listing(
6996 "",
6997 vec![child("first", EntryKind::File, 10, 0), child("dir", EntryKind::Dir, 0, 1)],
6998 ))
6999 .expect("root listing");
7000 builder
7001 .push_directory(&mut listing("dir", vec![child("larger", EntryKind::File, 20, 0)]))
7002 .expect("nested listing");
7003 let index = builder.finish();
7004 assert_eq!(
7005 index.folded_children(EntryId::ROOT),
7006 Some(FoldedFiles { files: 1, bytes: 10, allocated: 512, ..FoldedFiles::default() })
7007 );
7008 let dir = index.lookup(Path::new("dir")).expect("the directory");
7009 assert_eq!(index.folded_children(dir), None);
7010 assert_eq!(index.kind(Path::new("dir/larger")), Some(EntryKind::File));
7011 assert_eq!(index.kind(Path::new("first")), None);
7012 assert_eq!(index.total().bytes, 30, "both files count in the totals");
7013 }
7014
7015 #[test]
7016 fn detached_builder_tolerates_a_duplicate_readdir_name() {
7017 let mut builder = DetachedIndexBuilder::new(
7018 "/root",
7019 ScanScope::default(),
7020 crate::classify::TypeRegistry::compiled_shared(),
7021 );
7022 let twice = |position, mtime_ns| crate::scan::DetachedChild {
7023 name: OsString::from("twice.txt"),
7024 kind: EntryKind::File,
7025 attrs: file_attrs(1, mtime_ns),
7026 position,
7027 };
7028 let result = builder.push_directory(&mut crate::scan::DetachedDirectory {
7029 path: PathBuf::new(),
7030 children: vec![twice(0, 1), twice(1, 2)],
7031 control: None,
7032 });
7033 assert!(result.is_ok(), "a duplicate listing name must not fail the scan: {result:?}");
7034 let detached = builder.finish();
7035 assert_eq!(detached.total().files, 1);
7036 assert_eq!(detached.attrs(Path::new("twice.txt")), Some(&file_attrs(1, 2)));
7037
7038 let mut streaming = Index::new("/root");
7040 streaming
7041 .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
7042 upsert("twice.txt", EntryKind::File, file_attrs(1, 1)),
7043 upsert("twice.txt", EntryKind::File, file_attrs(1, 2)),
7044 ]))
7045 .expect("streaming tolerates a re-upsert");
7046 assert_eq!(streaming.total(), detached.total());
7047 assert_eq!(streaming.attrs(Path::new("twice.txt")), detached.attrs(Path::new("twice.txt")));
7048 }
7049
7050 #[test]
7051 fn detached_builder_accepts_the_repeated_walk_of_a_duplicated_directory() {
7052 let child = |name: &str, kind, attrs, position| crate::scan::DetachedChild {
7053 name: OsString::from(name),
7054 kind,
7055 attrs,
7056 position,
7057 };
7058 let listing = |path: &str, children| crate::scan::DetachedDirectory {
7059 path: PathBuf::from(path),
7060 children,
7061 control: None,
7062 };
7063 let mut builder = DetachedIndexBuilder::new(
7064 "/root",
7065 ScanScope::default(),
7066 crate::classify::TypeRegistry::compiled_shared(),
7067 );
7068 builder
7071 .push_directory(&mut listing(
7072 "",
7073 vec![
7074 child("dir", EntryKind::Dir, file_attrs(0, 1), 0),
7075 child("swapped", EntryKind::Dir, file_attrs(0, 1), 1),
7076 child("dir", EntryKind::Dir, file_attrs(0, 2), 2),
7077 child("swapped", EntryKind::File, file_attrs(5, 2), 3),
7078 ],
7079 ))
7080 .expect("root listing with repeated names");
7081 for _ in 0..2 {
7083 builder
7084 .push_directory(&mut listing(
7085 "dir",
7086 vec![child("nested", EntryKind::Dir, file_attrs(0, 3), 0)],
7087 ))
7088 .expect("each walk of the repeated directory");
7089 builder
7090 .push_directory(&mut listing(
7091 "dir/nested",
7092 vec![child("file.txt", EntryKind::File, file_attrs(4, 4), 0)],
7093 ))
7094 .expect("each walk below the repeated directory");
7095 }
7096 builder
7098 .push_directory(&mut listing(
7099 "swapped",
7100 vec![child("stale.txt", EntryKind::File, file_attrs(6, 5), 0)],
7101 ))
7102 .expect("the superseded directory's walk");
7103 let error = builder
7105 .push_directory(&mut listing("elsewhere", Vec::new()))
7106 .expect_err("a listing whose parent was never listed");
7107 assert!(matches!(
7108 error,
7109 crate::Error::UnknownAncestry { path, .. } if path == Path::new("elsewhere")
7110 ));
7111
7112 let index = builder.finish();
7113 assert_eq!(index.attrs(Path::new("dir")), Some(&file_attrs(0, 2)));
7114 assert_eq!(index.kind(Path::new("swapped")), Some(EntryKind::File));
7115 assert!(index.lookup(Path::new("swapped/stale.txt")).is_none());
7116 let total = index.total();
7117 assert_eq!((total.files, total.dirs, total.bytes), (2, 2, 9));
7118 }
7119
7120 #[test]
7121 fn detached_builder_drains_an_applied_listing_for_its_worker() {
7122 let child = |name: &str, kind, position| crate::scan::DetachedChild {
7125 name: OsString::from(name),
7126 kind,
7127 attrs: file_attrs(1, 1),
7128 position,
7129 };
7130 let mut builder = DetachedIndexBuilder::new(
7131 "/root",
7132 ScanScope::default(),
7133 crate::classify::TypeRegistry::compiled_shared(),
7134 );
7135 let mut root = crate::scan::DetachedDirectory {
7136 path: PathBuf::new(),
7137 children: vec![child("z.txt", EntryKind::File, 0), child("dir", EntryKind::Dir, 1)],
7138 control: Some(Op::ControlUpsert {
7139 path: PathBuf::from(".gitignore"),
7140 source: b"*.log\n".to_vec(),
7141 }),
7142 };
7143 let (buffer, capacity) = (root.children.as_ptr(), root.children.capacity());
7144 builder.push_directory(&mut root).expect("root listing");
7145 assert_eq!(root.path, PathBuf::new());
7146 assert!(root.children.is_empty());
7147 assert_eq!((root.children.as_ptr(), root.children.capacity()), (buffer, capacity));
7148 assert!(root.control.is_none(), "the control is applied, not left to be applied twice");
7149
7150 let mut orphan = crate::scan::DetachedDirectory {
7152 path: PathBuf::from("elsewhere"),
7153 children: vec![child("kept.txt", EntryKind::File, 0)],
7154 control: None,
7155 };
7156 builder.push_directory(&mut orphan).expect_err("a listing whose parent was never listed");
7157 assert_eq!(orphan.children.len(), 1);
7158
7159 let index = builder.finish();
7160 assert_eq!(index.kind(Path::new("dir")), Some(EntryKind::Dir));
7161 assert_eq!(index.kind(Path::new("z.txt")), Some(EntryKind::File));
7162 assert_eq!(index.total().files, 1);
7163 }
7164
7165 fn file_attrs(size: u64, mtime_ns: i64) -> Attrs {
7166 Attrs {
7167 size,
7168 allocated: size.div_ceil(512) * 512,
7169 mtime_ns,
7170 ctime_ns: mtime_ns,
7171 inode: size.wrapping_mul(31).wrapping_add(mtime_ns.unsigned_abs()),
7172 dev: 1,
7173 }
7174 }
7175
7176 fn upsert(path: &str, kind: EntryKind, attrs: Attrs) -> Op {
7177 Op::Upsert { path: PathBuf::from(path), kind, attrs }
7178 }
7179
7180 fn assert_serving_indexes(index: &Index) {
7181 let serving = index.serving.as_ref().expect("test index has serving state");
7182 let mut entries = BTreeMap::new();
7183 let mut children = BTreeMap::<PathBuf, PortableChildren>::new();
7184 let mut recent_files = BTreeSet::new();
7185 let mut semantic_by_directory =
7186 BTreeMap::<EntryId, (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>)>::new();
7187 let declared_exact_names: BTreeSet<_> =
7188 index.types.exact_filenames().map(str::to_ascii_lowercase).collect();
7189 let mut exact_name_by_directory =
7190 BTreeMap::<EntryId, (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>)>::new();
7191 let mut semantic_refcounts = BTreeMap::<String, u64>::new();
7192 let mut pending = vec![(EntryId::ROOT, PathBuf::new(), vec![EntryId::ROOT])];
7193 while let Some((parent_id, parent_path, ancestors)) = pending.pop() {
7194 let facts: Vec<_> = index
7195 .children_of(parent_id)
7196 .expect("live directory")
7197 .map(|(name, id)| (name.to_os_string(), id))
7198 .collect();
7199 for (name, id) in facts {
7200 let path = parent_path.join(&name);
7201 let kind = index.kind_of(id).expect("live child");
7202 let portable = crate::opened::read::portable_path(&path);
7203 entries.insert(portable.clone(), id);
7204 if kind == EntryKind::File {
7205 recent_files.insert(RecentKey {
7206 mtime_ns: index.attrs(&path).expect("live child has attributes").mtime_ns,
7207 portable_path: portable,
7208 id,
7209 });
7210 }
7211 if kind == EntryKind::File {
7212 let semantic = index.classify(&path).file_type.as_str().to_string();
7213 *semantic_refcounts.entry(semantic.clone()).or_default() += 1;
7214 let attrs = *index.attrs(&path).expect("live child has attributes");
7215 let ignored = index.entry(id).ignored;
7216 for ancestor in &ancestors {
7217 let partition = semantic_by_directory.entry(*ancestor).or_default();
7218 let all = partition.0.entry(semantic.clone()).or_default();
7219 all.files += 1;
7220 all.bytes += attrs.size;
7221 all.allocated += attrs.allocated;
7222 if !ignored {
7223 let unignored = partition.1.entry(semantic.clone()).or_default();
7224 unignored.files += 1;
7225 unignored.bytes += attrs.size;
7226 unignored.allocated += attrs.allocated;
7227 }
7228 }
7229 if let Some(exact_name) = name
7230 .to_str()
7231 .map(str::to_ascii_lowercase)
7232 .filter(|name| declared_exact_names.contains(name))
7233 {
7234 for ancestor in &ancestors {
7235 let partition = exact_name_by_directory.entry(*ancestor).or_default();
7236 let all = partition.0.entry(exact_name.clone()).or_default();
7237 all.files += 1;
7238 all.bytes += attrs.size;
7239 all.allocated += attrs.allocated;
7240 if !ignored {
7241 let unignored = partition.1.entry(exact_name.clone()).or_default();
7242 unignored.files += 1;
7243 unignored.bytes += attrs.size;
7244 unignored.allocated += attrs.allocated;
7245 }
7246 }
7247 }
7248 }
7249 let partition = children.entry(parent_path.clone()).or_default();
7250 let portable_name = crate::opened::read::portable_component(&name);
7251 if kind.is_dir() {
7252 partition.directories.insert(portable_name, id);
7253 } else {
7254 partition.nondirectories.insert(portable_name, id);
7255 }
7256 if kind.is_dir() {
7257 let mut child_ancestors = ancestors.clone();
7258 child_ancestors.insert(0, id);
7259 pending.push((id, path, child_ancestors));
7260 }
7261 }
7262 }
7263
7264 assert_eq!(serving.portable_entries, entries);
7265 assert_eq!(serving.recent_files, recent_files);
7266 let actual_semantics: BTreeMap<_, _> = serving
7267 .semantic_by_directory
7268 .iter()
7269 .map(|(directory, partitions)| {
7270 let named = |source: &BTreeMap<u32, ExtTally>| {
7271 source
7272 .iter()
7273 .map(|(semantic, tally)| {
7274 let name = serving.semantic_names[*semantic as usize]
7275 .as_ref()
7276 .expect("live semantic has a name")
7277 .clone();
7278 (name, *tally)
7279 })
7280 .collect()
7281 };
7282 (*directory, (named(&partitions.all), named(&partitions.unignored)))
7283 })
7284 .collect();
7285 assert_eq!(actual_semantics, semantic_by_directory);
7286 let actual_exact_names: BTreeMap<_, _> = serving
7287 .exact_name_by_directory
7288 .iter()
7289 .map(|(directory, partitions)| {
7290 let named = |source: &BTreeMap<u32, ExtTally>| {
7291 source
7292 .iter()
7293 .map(|(exact_name, tally)| {
7294 (serving.exact_names[*exact_name as usize].clone(), *tally)
7295 })
7296 .collect()
7297 };
7298 (*directory, (named(&partitions.all), named(&partitions.unignored)))
7299 })
7300 .collect();
7301 assert_eq!(actual_exact_names, exact_name_by_directory);
7302 assert_eq!(
7303 serving.exact_names.iter().cloned().collect::<BTreeSet<_>>(),
7304 declared_exact_names
7305 );
7306 assert_eq!(
7307 serving.exact_name_ids,
7308 serving
7309 .exact_names
7310 .iter()
7311 .enumerate()
7312 .map(|(position, name)| {
7313 (
7314 name.clone(),
7315 u32::try_from(position).expect("the exact-name vocabulary fits u32"),
7316 )
7317 })
7318 .collect()
7319 );
7320 assert!(serving.exact_name_by_directory.len() <= index.arena.len());
7321 assert!(serving.exact_name_by_directory.values().all(|partitions| {
7322 partitions.all.len() <= serving.exact_names.len()
7323 && partitions.unignored.len() <= serving.exact_names.len()
7324 && partitions
7325 .all
7326 .keys()
7327 .chain(partitions.unignored.keys())
7328 .all(|name| (*name as usize) < serving.exact_names.len())
7329 }));
7330 let actual_refcounts: BTreeMap<_, _> = serving
7331 .semantic_ids
7332 .iter()
7333 .map(|(name, semantic)| (name.clone(), serving.semantic_refcounts[*semantic as usize]))
7334 .collect();
7335 assert_eq!(actual_refcounts, semantic_refcounts);
7336 assert_eq!(serving.portable_children, children);
7337
7338 assert_eq!(
7342 u64::try_from(serving.portable_entries.len()).expect("entry count fits u64"),
7343 index.len().saturating_sub(1),
7344 "every retained non-root entry has exactly one portable name"
7345 );
7346 }
7347
7348 #[test]
7349 fn portable_indexes_conserve_insert_kind_change_and_subtree_removal() {
7350 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
7351 "/root",
7352 ScanScope::default(),
7353 crate::classify::TypeRegistry::compiled_shared(),
7354 DEFAULT_JOURNAL_CAPACITY_BYTES,
7355 );
7356 index.apply_ok(&Observation::new(vec![
7357 upsert("dir", EntryKind::Dir, Attrs::default()),
7358 upsert("dir/a", EntryKind::File, file_attrs(1, 1)),
7359 upsert("replace", EntryKind::File, file_attrs(2, 2)),
7360 ]));
7361 assert_serving_indexes(&index);
7362
7363 index.apply_ok(&Observation::new(vec![
7364 upsert("replace", EntryKind::Dir, Attrs::default()),
7365 upsert("replace/child", EntryKind::File, file_attrs(3, 3)),
7366 ]));
7367 assert_serving_indexes(&index);
7368
7369 index.apply_ok(&Observation::new(vec![upsert("dir/a", EntryKind::File, file_attrs(4, 9))]));
7370 assert_serving_indexes(&index);
7371 assert_eq!(
7372 index
7373 .serving
7374 .as_ref()
7375 .expect("opened test index")
7376 .recent_files
7377 .iter()
7378 .map(|entry| entry.portable_path.as_str())
7379 .collect::<Vec<_>>(),
7380 vec!["dir/a", "replace/child"]
7381 );
7382
7383 index.apply_ok(&Observation::new(vec![
7389 upsert("dir/current", EntryKind::Symlink, file_attrs(5, 5)),
7390 upsert("dir/pipe", EntryKind::Other, file_attrs(6, 6)),
7391 ]));
7392 assert_serving_indexes(&index);
7393 index.apply_ok(&Observation::new(vec![
7394 upsert("dir/current", EntryKind::Symlink, file_attrs(7, 7)),
7395 upsert("dir/pipe", EntryKind::Other, file_attrs(8, 8)),
7396 ]));
7397 assert_serving_indexes(&index);
7398
7399 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("replace") }]));
7400 assert_serving_indexes(&index);
7401 assert_eq!(
7402 index.portable_entries().keys().map(crate::PortablePath::as_str).collect::<Vec<_>>(),
7403 vec!["dir", "dir/a", "dir/current", "dir/pipe"]
7404 );
7405 }
7406
7407 #[test]
7416 fn non_file_attrs_updates_leave_file_semantics_untouched() {
7417 for kind in [EntryKind::Symlink, EntryKind::Other] {
7418 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
7419 "/root",
7420 ScanScope::default(),
7421 crate::classify::TypeRegistry::compiled_shared(),
7422 DEFAULT_JOURNAL_CAPACITY_BYTES,
7423 );
7424 index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(1, 1))]));
7425 assert_serving_indexes(&index);
7426 index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(1, 2))]));
7427 assert_serving_indexes(&index);
7428
7429 index.apply_ok(&Observation::new(vec![upsert(
7430 "notes",
7431 EntryKind::File,
7432 file_attrs(5, 3),
7433 )]));
7434 assert_serving_indexes(&index);
7435 index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(4, 4))]));
7436 assert_serving_indexes(&index);
7437
7438 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("notes") }]));
7439 assert_serving_indexes(&index);
7440 }
7441 }
7442
7443 #[test]
7456 fn escaping_touches_only_invalid_bytes_and_percent() {
7457 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
7458 "/root",
7459 ScanScope::default(),
7460 crate::classify::TypeRegistry::compiled_shared(),
7461 DEFAULT_JOURNAL_CAPACITY_BYTES,
7462 );
7463 index.apply_ok(&Observation::new(vec![
7464 upsert("dir", EntryKind::Dir, Attrs::default()),
7465 upsert("dir/plain.txt", EntryKind::File, file_attrs(1, 1)),
7466 upsert("café", EntryKind::Dir, Attrs::default()),
7467 upsert("café/naïve.txt", EntryKind::File, file_attrs(2, 2)),
7468 upsert("日本語.md", EntryKind::File, file_attrs(3, 3)),
7469 upsert("a b", EntryKind::Dir, Attrs::default()),
7470 upsert("a b/c d.txt", EntryKind::File, file_attrs(5, 5)),
7471 upsert("100%.txt", EntryKind::File, file_attrs(4, 4)),
7472 ]));
7473
7474 let names: Vec<_> =
7475 index.portable_entries().keys().map(crate::PortablePath::as_str).collect();
7476 assert_eq!(
7477 names,
7478 vec![
7479 "100%25.txt",
7480 "a b",
7481 "a b/c d.txt",
7482 "café",
7483 "café/naïve.txt",
7484 "dir",
7485 "dir/plain.txt",
7486 "日本語.md",
7487 ],
7488 "only the literal percent is rewritten; separators, spaces and non-ASCII are not"
7489 );
7490 }
7491
7492 #[test]
7493 fn declared_exact_names_roll_up_by_ancestor_and_partition() {
7494 let types = Arc::new(
7495 crate::classify::TypeRegistry::from_manifest(
7496 "[[kind]]\nid = \"make\"\nfamily = \"code\"\nfilenames = [\"Makefile\"]\n",
7497 )
7498 .expect("custom registry"),
7499 );
7500 let scope =
7501 ScanScope { type_rules_fingerprint: types.fingerprint(), ..ScanScope::default() };
7502 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
7503 "/root",
7504 scope,
7505 types,
7506 DEFAULT_JOURNAL_CAPACITY_BYTES,
7507 );
7508 index.apply_ok(&Observation::new(vec![
7509 upsert("Makefile", EntryKind::File, file_attrs(2, 1)),
7510 upsert("dir", EntryKind::Dir, Attrs::default()),
7511 upsert("dir/makefile", EntryKind::File, file_attrs(3, 2)),
7512 upsert("dir/notes", EntryKind::File, file_attrs(5, 3)),
7513 ]));
7514
7515 let serving = index.serving.as_ref().expect("opened test index");
7516 let exact_name = serving.exact_name_ids["makefile"];
7517 let root = &serving.exact_name_by_directory[&EntryId::ROOT];
7518 assert_eq!(root.all[&exact_name], ExtTally { files: 2, bytes: 5, allocated: 1_024 });
7519 assert_eq!(root.unignored, root.all);
7520
7521 let directory = index.lookup(Path::new("dir")).expect("directory");
7522 let nested = &serving.exact_name_by_directory[&directory];
7523 assert_eq!(nested.all[&exact_name], ExtTally { files: 1, bytes: 3, allocated: 512 });
7524 assert_eq!(nested.unignored, nested.all);
7525 }
7526
7527 #[test]
7528 #[ignore = "manual opened-root commit-cost evidence"]
7529 fn measure_opened_serving_commit_cost() {
7530 const DIRECTORY_COUNT: usize = 100;
7531 const FILES_PER_DIRECTORY: usize = 100;
7532 const SAMPLE_COUNT: usize = 7;
7533
7534 let mut operations = Vec::with_capacity(
7535 DIRECTORY_COUNT.saturating_mul(FILES_PER_DIRECTORY.saturating_add(1)),
7536 );
7537 for directory in 0..DIRECTORY_COUNT {
7538 let parent = format!("d{directory:03}");
7539 operations.push(upsert(&parent, EntryKind::Dir, Attrs::default()));
7540 for file in 0..FILES_PER_DIRECTORY {
7541 let size = u64::try_from(file).expect("the probe file count fits u64") + 1;
7542 let mtime = i64::try_from(file).expect("the probe file count fits i64");
7543 let name = if file == 0 {
7544 format!("{parent}/Makefile")
7545 } else {
7546 format!("{parent}/f{file:03}.rs")
7547 };
7548 operations.push(upsert(&name, EntryKind::File, file_attrs(size, mtime)));
7549 }
7550 }
7551 let observation = Observation::new(operations);
7552 let types = crate::classify::TypeRegistry::compiled_shared();
7553 let scope =
7554 ScanScope { type_rules_fingerprint: types.fingerprint(), ..ScanScope::default() };
7555 let measure = |opened: bool| {
7556 let mut index = if opened {
7557 Index::new_opened_with_scope_types_and_journal_capacity_bytes(
7558 "/root",
7559 scope,
7560 Arc::clone(&types),
7561 DEFAULT_JOURNAL_CAPACITY_BYTES,
7562 )
7563 } else {
7564 Index::new_with_scope_types_and_journal_capacity_bytes(
7565 "/root",
7566 scope,
7567 Arc::clone(&types),
7568 DEFAULT_JOURNAL_CAPACITY_BYTES,
7569 )
7570 };
7571 let started = std::time::Instant::now();
7572 index.apply_ok(&observation);
7573 let elapsed = started.elapsed();
7574 std::hint::black_box(index.len());
7575 (elapsed, index)
7576 };
7577
7578 let _ = measure(false);
7579 let _ = measure(true);
7580 let mut detached = Vec::with_capacity(SAMPLE_COUNT);
7581 let mut opened = Vec::with_capacity(SAMPLE_COUNT);
7582 let mut last_opened = None;
7583 for sample in 0..SAMPLE_COUNT {
7584 if sample % 2 == 0 {
7585 detached.push(measure(false).0);
7586 let (duration, index) = measure(true);
7587 opened.push(duration);
7588 last_opened = Some(index);
7589 } else {
7590 let (duration, index) = measure(true);
7591 opened.push(duration);
7592 last_opened = Some(index);
7593 detached.push(measure(false).0);
7594 }
7595 }
7596 detached.sort_unstable();
7597 opened.sort_unstable();
7598 let detached_median = detached[SAMPLE_COUNT / 2];
7599 let opened_median = opened[SAMPLE_COUNT / 2];
7600 let ratio = opened_median.as_secs_f64() / detached_median.as_secs_f64();
7601
7602 let index = last_opened.expect("an opened sample ran");
7603 let serving = index.serving.as_ref().expect("opened sample has serving indexes");
7604 let semantic_rows: usize = serving
7605 .semantic_by_directory
7606 .values()
7607 .map(|partitions| partitions.all.len() + partitions.unignored.len())
7608 .sum();
7609 let exact_name_rows: usize = serving
7610 .exact_name_by_directory
7611 .values()
7612 .map(|partitions| partitions.all.len() + partitions.unignored.len())
7613 .sum();
7614 eprintln!(
7615 "entries={} detached_median_us={} opened_median_us={} ratio={ratio:.3} \
7616 portable_rows={} child_rows={} recent_rows={} semantic_rows={} exact_name_rows={} \
7617 exact_name_vocabulary={}",
7618 index.len(),
7619 detached_median.as_micros(),
7620 opened_median.as_micros(),
7621 serving.portable_entries.len(),
7622 serving
7623 .portable_children
7624 .values()
7625 .map(|children| children.directories.len() + children.nondirectories.len())
7626 .sum::<usize>(),
7627 serving.recent_files.len(),
7628 semantic_rows,
7629 exact_name_rows,
7630 serving.exact_names.len(),
7631 );
7632 }
7633
7634 #[test]
7635 fn detached_indexes_never_allocate_or_populate_serving_state() {
7636 let mut index = Index::new("/root");
7637 index.apply_ok(&Observation::new(vec![
7638 upsert("dir", EntryKind::Dir, Attrs::default()),
7639 upsert("dir/a", EntryKind::File, file_attrs(1, 1)),
7640 ]));
7641
7642 assert!(index.serving.is_none());
7643 assert!(index.portable_children(Path::new("dir")).is_none());
7644 }
7645
7646 #[test]
7647 fn insert_loaded_child_skips_serving_path_when_serving_is_off() {
7648 let mut index = Index::new("/root");
7649 let id = index
7650 .insert_loaded_child(
7651 EntryId::ROOT,
7652 OsString::from("a.rs"),
7653 EntryKind::File,
7654 file_attrs(4, 1),
7655 )
7656 .expect("parent is a live directory");
7657 assert!(!index.serving_indexes_enabled());
7658 assert_eq!(index.path_of(id), Some(PathBuf::from("a.rs")));
7659 assert_eq!(index.lookup(Path::new("a.rs")), Some(id));
7660 assert_eq!(index.total().files, 1);
7661 }
7662
7663 #[test]
7664 fn insert_loaded_child_fills_serving_when_enabled() {
7665 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
7666 "/root",
7667 ScanScope::default(),
7668 crate::classify::TypeRegistry::compiled_shared(),
7669 DEFAULT_JOURNAL_CAPACITY_BYTES,
7670 );
7671 let id = index
7672 .insert_loaded_child(
7673 EntryId::ROOT,
7674 OsString::from("a.rs"),
7675 EntryKind::File,
7676 file_attrs(4, 1),
7677 )
7678 .expect("parent is a live directory");
7679 assert!(index.serving_indexes_enabled());
7680 assert_eq!(index.path_of(id), Some(PathBuf::from("a.rs")));
7681 let serving = index.serving.as_ref().expect("opened test index");
7682 assert!(serving.portable_entries.keys().any(|path| path.as_str() == "a.rs"));
7683 }
7684
7685 #[test]
7686 fn opened_entry_values_project_name_identity_without_retaining_it_on_detached_entries() {
7687 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
7688 "/root",
7689 ScanScope::default(),
7690 crate::classify::TypeRegistry::compiled_shared(),
7691 DEFAULT_JOURNAL_CAPACITY_BYTES,
7692 );
7693 index.apply_ok(&Observation::new(vec![upsert(
7694 "bundle.umd.min.js",
7695 EntryKind::File,
7696 file_attrs(7, 1),
7697 )]));
7698
7699 let row = index.entry_value(Path::new("bundle.umd.min.js")).expect("entry value");
7700 let identity = row.classification.expect("regular files carry name identity");
7701 assert_eq!(identity.logical_extension(), Some(".min.js"));
7702 assert_eq!(identity.canonical_extension(), Some(".js"));
7703 assert_eq!(identity.kind_id(), Some("javascript"));
7704 assert_eq!(identity.content_family(), crate::classify::ContentFamily::Code);
7705 }
7706
7707 #[test]
7708 fn a_shared_snapshot_drops_opened_root_serving_state() {
7709 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
7710 "/root",
7711 ScanScope::default(),
7712 crate::classify::TypeRegistry::compiled_shared(),
7713 DEFAULT_JOURNAL_CAPACITY_BYTES,
7714 );
7715 index.apply_ok(&Observation::new(vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))]));
7716 assert!(index.serving_indexes_enabled());
7717
7718 let snapshot = IndexHandle::new(index).snapshot().expect("detached snapshot");
7719
7720 assert!(!snapshot.serving_indexes_enabled());
7721 assert_eq!(snapshot.total().files, 1);
7722 }
7723
7724 #[test]
7725 #[should_panic(expected = "an index's registry must match its semantic scope")]
7726 fn an_index_cannot_claim_a_registry_different_from_its_scope() {
7727 let types = Arc::new(
7728 crate::classify::TypeRegistry::from_manifest(
7729 "[[kind]]\nid = \"notes\"\nfamily = \"prose\"\nextensions = [\"rs\"]\n",
7730 )
7731 .expect("custom registry"),
7732 );
7733
7734 let _ = Index::new_with_scope_and_types("/root", ScanScope::default(), types);
7735 }
7736
7737 #[test]
7744 fn parent_memo_does_not_survive_removing_the_directory_it_remembers() {
7745 let mut index = Index::new(PathBuf::from("/root"));
7746 index.apply_ok(&Observation::new(vec![
7747 upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
7748 upsert("dir/a.txt", EntryKind::File, file_attrs(10, 1)),
7749 ]));
7750
7751 index.apply_ok(&Observation::new(vec![
7755 upsert("dir/b.txt", EntryKind::File, file_attrs(20, 1)),
7756 Op::Remove { path: PathBuf::from("dir") },
7757 upsert("dir", EntryKind::Dir, file_attrs(0, 2)),
7758 upsert("dir/c.txt", EntryKind::File, file_attrs(30, 2)),
7759 ]));
7760
7761 let children = index.children(Path::new("dir")).expect("dir survives");
7762 let names: Vec<_> = children.map(|(name, _)| name.to_os_string()).collect();
7763 assert_eq!(names, vec![OsString::from("c.txt")], "only the re-added child remains");
7764 assert_eq!(index.total().bytes, 30, "totals match the surviving child");
7765 }
7766
7767 #[test]
7768 fn a_kind_change_mid_batch_leaves_the_memo_usable_for_the_next_sibling() {
7769 let mut index = Index::new(PathBuf::from("/root"));
7770 index.apply_ok(&Observation::new(vec![
7771 upsert("swap", EntryKind::Dir, file_attrs(0, 1)),
7772 upsert("swap/inner", EntryKind::Dir, file_attrs(0, 1)),
7773 upsert("swap/inner/deep.txt", EntryKind::File, file_attrs(40, 1)),
7774 ]));
7775
7776 index.apply_ok(&Observation::new(vec![
7782 upsert("swap/inner/other.txt", EntryKind::File, file_attrs(50, 1)),
7783 upsert("swap/inner", EntryKind::File, file_attrs(60, 2)),
7784 upsert("swap/sibling.txt", EntryKind::File, file_attrs(70, 2)),
7785 ]));
7786
7787 let children = index.children(Path::new("swap")).expect("swap survives");
7788 let names: Vec<_> = children.map(|(name, _)| name.to_os_string()).collect();
7789 assert_eq!(names, vec![OsString::from("inner"), OsString::from("sibling.txt")]);
7790 assert_eq!(index.total().files, 2, "inner counts once, as a file");
7791 assert_eq!(index.total().bytes, 130, "the dropped subtree's bytes are gone");
7792 }
7793
7794 #[test]
7795 fn parent_memo_distinguishes_directories_that_share_a_name_prefix() {
7796 let mut index = Index::new(PathBuf::from("/root"));
7797 index.apply_ok(&Observation::new(vec![
7800 upsert("src", EntryKind::Dir, file_attrs(0, 1)),
7801 upsert("src2", EntryKind::Dir, file_attrs(0, 1)),
7802 upsert("src/one.txt", EntryKind::File, file_attrs(11, 1)),
7803 upsert("src2/two.txt", EntryKind::File, file_attrs(22, 1)),
7804 upsert("src/three.txt", EntryKind::File, file_attrs(33, 1)),
7805 ]));
7806
7807 let in_src: Vec<_> = index
7808 .children(Path::new("src"))
7809 .expect("src")
7810 .map(|(name, _)| name.to_os_string())
7811 .collect();
7812 let in_src2: Vec<_> = index
7813 .children(Path::new("src2"))
7814 .expect("src2")
7815 .map(|(name, _)| name.to_os_string())
7816 .collect();
7817 assert_eq!(in_src, vec![OsString::from("one.txt"), OsString::from("three.txt")]);
7818 assert_eq!(in_src2, vec![OsString::from("two.txt")]);
7819 }
7820
7821 #[test]
7822 fn parent_memo_leaves_root_level_entries_alone() {
7823 let mut index = Index::new(PathBuf::from("/root"));
7826 index.apply_ok(&Observation::new(vec![
7827 upsert("a.txt", EntryKind::File, file_attrs(5, 1)),
7828 upsert("b.txt", EntryKind::File, file_attrs(6, 1)),
7829 upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
7830 upsert("dir/c.txt", EntryKind::File, file_attrs(7, 1)),
7831 upsert("d.txt", EntryKind::File, file_attrs(8, 1)),
7832 ]));
7833
7834 let top: Vec<_> = index
7835 .children(Path::new(""))
7836 .expect("root children")
7837 .map(|(name, _)| name.to_os_string())
7838 .collect();
7839 assert_eq!(
7840 top,
7841 vec![
7842 OsString::from("a.txt"),
7843 OsString::from("b.txt"),
7844 OsString::from("d.txt"),
7845 OsString::from("dir"),
7846 ]
7847 );
7848 assert_eq!(index.total().files, 4);
7849 assert_eq!(index.total().bytes, 26);
7850 }
7851
7852 #[test]
7853 fn shared_queries_return_owned_values_and_release_the_lock() {
7854 let handle = IndexHandle::new(index_with_sample_tree());
7855 let retained_total = handle.total().expect("total");
7856 let retained_history = handle.since(Clock::ZERO).expect("history");
7857 let retained_children = handle.children(Path::new("src")).expect("children");
7858 let retained_snapshot = handle.snapshot().expect("snapshot");
7859
7860 let writer = handle.clone();
7861 let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
7862 let thread = std::thread::spawn(move || {
7863 let result = writer.apply(&Observation::new(vec![upsert(
7864 "concurrent.txt",
7865 EntryKind::File,
7866 file_attrs(7, 30),
7867 )]));
7868 done_tx.send(result).expect("report writer result");
7869 });
7870
7871 let outcome = done_rx
7872 .recv_timeout(std::time::Duration::from_secs(5))
7873 .expect("owned query results must not retain the read lock")
7874 .expect("writer apply");
7875 thread.join().expect("writer thread");
7876
7877 assert_eq!(outcome.inserted, 1);
7878 assert_eq!(retained_total.files, 3);
7879 assert!(!retained_history.commits.is_empty());
7880 assert_eq!(retained_children.expect("src directory").len(), 2);
7881 assert!(retained_snapshot.lookup(Path::new("concurrent.txt")).is_none());
7882 assert!(handle.kind(Path::new("concurrent.txt")).expect("query").is_some());
7883 }
7884
7885 #[test]
7886 fn cloned_indexes_are_independent_detached_images() {
7887 let original = index_with_sample_tree();
7888 let original_clock = original.clock();
7889 let mut detached = original.clone();
7890
7891 detached.apply_ok(&Observation::new(vec![upsert(
7892 "detached-only.txt",
7893 EntryKind::File,
7894 file_attrs(7, 30),
7895 )]));
7896
7897 assert_eq!(original.clock(), original_clock);
7898 assert!(original.lookup(Path::new("detached-only.txt")).is_none());
7899 assert!(detached.lookup(Path::new("detached-only.txt")).is_some());
7900 assert_eq!(detached.total().files, original.total().files + 1);
7901 }
7902
7903 #[test]
7904 fn captured_child_expectations_match_individual_path_lookups() {
7905 let index = index_with_sample_tree();
7906 let captured = collect_child_expectations(&index, Path::new("src"));
7907
7908 assert!(!captured.is_empty());
7909 for (name, expectation) in captured {
7910 assert_eq!(expectation, index.expectation(&Path::new("src").join(name)));
7911 }
7912 }
7913
7914 #[test]
7915 fn index_and_shared_handle_are_send_and_sync() {
7916 fn assert_send_sync<T: Send + Sync>() {}
7917
7918 assert_send_sync::<Index>();
7919 assert_send_sync::<IndexHandle>();
7920 }
7921
7922 #[test]
7923 fn simultaneous_writers_commit_unique_contiguous_clocks_in_journal_order() {
7924 let writer_count: usize = 8;
7925 let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
7926 let barrier: Arc<Barrier> = Arc::new(Barrier::new(writer_count));
7927 let (result_tx, result_rx) = std::sync::mpsc::sync_channel(writer_count);
7928
7929 std::thread::scope(|scope| {
7930 for worker_id in 0..writer_count {
7931 let worker: IndexHandle = handle.clone();
7932 let start: Arc<Barrier> = Arc::clone(&barrier);
7933 let results = result_tx.clone();
7934 scope.spawn(move || {
7935 let ordinal: u64 = u64::try_from(worker_id + 1).expect("small worker count");
7936 let path: String = format!("writer-{ordinal}.txt");
7937 start.wait();
7938 let outcome: crate::Result<ApplyOutcome> =
7939 worker.apply(&Observation::new(vec![upsert(
7940 &path,
7941 EntryKind::File,
7942 file_attrs(ordinal, i64::try_from(ordinal).expect("small ordinal")),
7943 )]));
7944 results.send((path, outcome)).expect("report writer result");
7945 });
7946 }
7947 });
7948 drop(result_tx);
7949
7950 let mut committed_clocks: Vec<u64> = Vec::with_capacity(writer_count);
7951 for (path, outcome) in result_rx {
7952 let commit = outcome.expect("writer apply").commit.expect("unique upsert must commit");
7953 committed_clocks.push(commit.clock.0);
7954 assert!(handle.kind(Path::new(&path)).expect("query committed path").is_some());
7955 }
7956 committed_clocks.sort_unstable();
7957
7958 let last_clock: u64 = u64::try_from(writer_count).expect("small writer count");
7959 let expected_clocks: Vec<u64> = (1..=last_clock).collect();
7960 assert_eq!(committed_clocks, expected_clocks);
7961 assert_eq!(handle.clock().expect("clock"), Clock(last_clock));
7962
7963 let journal_clocks: Vec<u64> = handle
7964 .since(Clock::ZERO)
7965 .expect("journal")
7966 .commits
7967 .iter()
7968 .map(|commit| commit.clock.0)
7969 .collect();
7970 assert_eq!(journal_clocks, expected_clocks);
7971 }
7972
7973 #[test]
7974 fn readers_observe_only_complete_states_around_a_large_batch() {
7975 let file_count: u64 = 2_048;
7976 let expected_bytes: u64 = file_count * (file_count + 1) / 2;
7977 let operations: Vec<Op> =
7978 std::iter::once(upsert("batch", EntryKind::Dir, file_attrs(0, 1)))
7979 .chain((1..=file_count).map(|ordinal| {
7980 upsert(
7981 &format!("batch/file-{ordinal}.bin"),
7982 EntryKind::File,
7983 file_attrs(ordinal, i64::try_from(ordinal).expect("small ordinal")),
7984 )
7985 }))
7986 .collect();
7987 let observation: Observation = Observation::new(operations);
7988 let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
7989 let before: Index = handle.snapshot().expect("before snapshot");
7990 assert_eq!(before.total().files, 0);
7991
7992 let barrier: Arc<Barrier> = Arc::new(Barrier::new(2));
7993 let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
7994 std::thread::scope(|scope| {
7995 let writer: IndexHandle = handle.clone();
7996 let writer_start: Arc<Barrier> = Arc::clone(&barrier);
7997 scope.spawn(move || {
7998 writer_start.wait();
7999 let result: crate::Result<ApplyOutcome> = writer.apply(&observation);
8000 done_tx.send(result).expect("report batch result");
8001 });
8002
8003 let reader: IndexHandle = handle.clone();
8004 let reader_start: Arc<Barrier> = Arc::clone(&barrier);
8005 scope.spawn(move || {
8006 reader_start.wait();
8007 let deadline: std::time::Instant =
8008 std::time::Instant::now() + std::time::Duration::from_secs(10);
8009 loop {
8010 assert!(
8011 std::time::Instant::now() < deadline,
8012 "reader did not observe batch completion before the deadline"
8013 );
8014 let image: Index = reader.snapshot().expect("coherent reader snapshot");
8015 let total = image.total();
8016 match total.files {
8017 0 => {
8018 assert_eq!(total.bytes, 0);
8019 assert_eq!(image.len(), 1);
8020 }
8021 count if count == file_count => {
8022 assert_eq!(total.bytes, expected_bytes);
8023 assert_eq!(total.dirs, 1);
8024 assert_eq!(image.len(), file_count + 2);
8025 }
8026 partial => panic!("reader observed partial batch with {partial} files"),
8027 }
8028
8029 match done_rx.try_recv() {
8030 Ok(result) => {
8031 assert_eq!(result.expect("batch apply").inserted, file_count + 1);
8032 break;
8033 }
8034 Err(std::sync::mpsc::TryRecvError::Empty) => {}
8035 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
8036 panic!("batch writer disconnected")
8037 }
8038 }
8039 }
8040 });
8041 });
8042
8043 let after: Index = handle.snapshot().expect("after snapshot");
8044 assert_eq!(after.total().files, file_count);
8045 assert_eq!(after.total().bytes, expected_bytes);
8046 assert_eq!(after.len(), file_count + 2);
8047 }
8048
8049 #[test]
8050 fn poisoned_shared_lock_returns_typed_errors() {
8051 let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
8052 let poisoner: IndexHandle = handle.clone();
8053 let panic_result: std::thread::Result<()> = std::thread::spawn(move || {
8054 let _poison_guard = poisoner.write_index().expect("initial write lock");
8055 panic!("intentional lock poison");
8056 })
8057 .join();
8058 assert!(panic_result.is_err());
8059
8060 assert!(matches!(handle.total(), Err(crate::Error::IndexLockPoisoned)));
8061 assert!(matches!(
8062 handle.apply(&Observation::new(vec![upsert(
8063 "never-applied.txt",
8064 EntryKind::File,
8065 file_attrs(1, 1),
8066 )])),
8067 Err(crate::Error::IndexLockPoisoned)
8068 ));
8069 }
8070
8071 #[test]
8072 fn clock_exhaustion_rejects_before_any_mutation() {
8073 let mut index = index_with_sample_tree();
8074 index.clock = Clock(u64::MAX);
8075 let before_total = index.total();
8076 let before_len = index.len();
8077
8078 let error = index
8079 .apply(&Observation::new(vec![upsert(
8080 "too-late.txt",
8081 EntryKind::File,
8082 file_attrs(1, 1),
8083 )]))
8084 .expect_err("clock exhaustion must be typed");
8085
8086 assert!(matches!(error, crate::Error::ClockExhausted));
8087 assert_eq!(index.clock(), Clock(u64::MAX));
8088 assert_eq!(index.len(), before_len);
8089 assert_eq!(index.total(), before_total);
8090 assert!(index.lookup(Path::new("too-late.txt")).is_none());
8091 }
8092
8093 #[test]
8094 fn terminal_clock_still_accepts_no_op_and_stale_observations() {
8095 let mut index = index_with_sample_tree();
8096 let current = *index.attrs(Path::new("src/main.rs")).expect("sample attributes");
8097 let stale_baseline = index.expectation(Path::new("src/main.rs"));
8098 index.apply_ok(&Observation::new(vec![upsert(
8099 "src/main.rs",
8100 EntryKind::File,
8101 file_attrs(99, 99),
8102 )]));
8103 index.clock = Clock(u64::MAX);
8104 let before_total = index.total();
8105 let before_len = index.len();
8106 let before_journal = index.journal.clone();
8107
8108 let no_op = index
8109 .apply(&Observation::new(vec![upsert(
8110 "src/main.rs",
8111 EntryKind::File,
8112 file_attrs(99, 99),
8113 )]))
8114 .expect("a no-op needs no new clock");
8115 let stale = index
8116 .apply(&Observation::from_ops(vec![ObservationOp::if_state(
8117 upsert("src/main.rs", EntryKind::File, current),
8118 stale_baseline,
8119 )]))
8120 .expect("a rejected stale observation needs no new clock");
8121
8122 assert_eq!(no_op.unchanged, 1);
8123 assert!(no_op.commit.is_none());
8124 assert_eq!(stale.stale, 1);
8125 assert!(stale.commit.is_none());
8126 assert_eq!(index.clock(), Clock(u64::MAX));
8127 assert_eq!(index.len(), before_len);
8128 assert_eq!(index.total(), before_total);
8129 assert_eq!(index.journal, before_journal);
8130 }
8131
8132 #[test]
8133 fn delayed_conditional_observation_cannot_overwrite_newer_state() {
8134 let mut index = Index::new("/root");
8135 index.apply_ok(&Observation::new(vec![upsert(
8136 "file.txt",
8137 EntryKind::File,
8138 file_attrs(10, 1),
8139 )]));
8140
8141 let baseline = index.expectation(Path::new("file.txt"));
8142 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
8143 upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
8144 baseline,
8145 )]);
8146
8147 index.apply_ok(&Observation::new(vec![upsert(
8148 "file.txt",
8149 EntryKind::File,
8150 file_attrs(30, 3),
8151 )]));
8152 let outcome = index.apply_ok(&delayed);
8153
8154 assert_eq!(outcome.stats.stale, 1);
8155 assert!(outcome.commit.is_none());
8156 assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 30);
8157 }
8158
8159 #[test]
8160 fn delayed_absent_child_cannot_replace_a_newer_parent_file() {
8161 let mut index = Index::new("/root");
8162 index.apply_ok(&Observation::new(vec![upsert("parent", EntryKind::Dir, file_attrs(0, 1))]));
8163 let child_baseline = index.expectation(Path::new("parent/child.txt"));
8164 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
8165 upsert("parent/child.txt", EntryKind::File, file_attrs(10, 2)),
8166 child_baseline,
8167 )]);
8168
8169 index.apply_ok(&Observation::new(vec![upsert(
8170 "parent",
8171 EntryKind::File,
8172 file_attrs(20, 3),
8173 )]));
8174 let outcome = index.apply_ok(&delayed);
8175
8176 assert_eq!(outcome.stats.stale, 1);
8177 assert!(outcome.commit.is_none());
8178 assert_eq!(index.kind(Path::new("parent")), Some(EntryKind::File));
8179 assert!(index.lookup(Path::new("parent/child.txt")).is_none());
8180 }
8181
8182 #[test]
8183 fn conditional_observation_rejects_present_state_aba() {
8184 let mut index = Index::new("/root");
8185 index.apply_ok(&Observation::new(vec![upsert(
8186 "file.txt",
8187 EntryKind::File,
8188 file_attrs(10, 1),
8189 )]));
8190 let baseline = index.expectation(Path::new("file.txt"));
8191 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
8192 upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
8193 baseline,
8194 )]);
8195
8196 index.apply_ok(&Observation::new(vec![upsert(
8197 "file.txt",
8198 EntryKind::File,
8199 file_attrs(30, 3),
8200 )]));
8201 index.apply_ok(&Observation::new(vec![upsert(
8202 "file.txt",
8203 EntryKind::File,
8204 file_attrs(10, 1),
8205 )]));
8206 let outcome = index.apply_ok(&delayed);
8207
8208 assert_eq!(outcome.stats.stale, 1);
8209 assert!(outcome.commit.is_none());
8210 assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 10);
8211 }
8212
8213 #[test]
8214 fn conditional_observation_rejects_absent_state_aba() {
8215 let mut index = Index::new("/root");
8216 let baseline = index.expectation(Path::new("file.txt"));
8217 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
8218 upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
8219 baseline,
8220 )]);
8221
8222 index.apply_ok(&Observation::new(vec![upsert(
8223 "file.txt",
8224 EntryKind::File,
8225 file_attrs(30, 3),
8226 )]));
8227 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("file.txt") }]));
8228 let outcome = index.apply_ok(&delayed);
8229
8230 assert_eq!(outcome.stats.stale, 1);
8231 assert!(outcome.commit.is_none());
8232 assert!(index.lookup(Path::new("file.txt")).is_none());
8233 }
8234
8235 #[test]
8240 fn convergent_conditional_upsert_applies_as_unchanged_not_stale() {
8241 let mut index = Index::new("/root");
8242 index.apply_ok(&Observation::new(vec![upsert(
8243 "file.txt",
8244 EntryKind::File,
8245 file_attrs(10, 1),
8246 )]));
8247 let baseline = index.expectation(Path::new("file.txt"));
8248 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
8249 upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
8250 baseline,
8251 )]);
8252
8253 index.apply_ok(&Observation::new(vec![upsert(
8254 "file.txt",
8255 EntryKind::File,
8256 file_attrs(20, 2),
8257 )]));
8258 let outcome = index.apply_ok(&delayed);
8259
8260 assert_eq!(outcome.stats.stale, 0);
8261 assert_eq!(outcome.stats.unchanged, 1);
8262 assert!(outcome.commit.is_none());
8263 assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 20);
8264 }
8265
8266 #[test]
8267 fn convergent_conditional_remove_applies_as_unchanged_not_stale() {
8268 let mut index = Index::new("/root");
8269 index.apply_ok(&Observation::new(vec![
8270 upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
8271 upsert("dir/file.txt", EntryKind::File, file_attrs(10, 1)),
8272 ]));
8273 let baseline = index.expectation(Path::new("dir/file.txt"));
8274 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
8275 Op::Remove { path: PathBuf::from("dir/file.txt") },
8276 baseline,
8277 )]);
8278
8279 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("dir/file.txt") }]));
8280 let outcome = index.apply_ok(&delayed);
8281
8282 assert_eq!(outcome.stats.stale, 0);
8283 assert_eq!(outcome.stats.unchanged, 1);
8284 assert!(outcome.commit.is_none());
8285 assert!(index.lookup(Path::new("dir/file.txt")).is_none());
8286 }
8287
8288 #[test]
8293 fn convergent_conditional_control_ops_apply_as_unchanged_not_stale() {
8294 let path = PathBuf::from(".gitignore");
8295 let rules =
8296 |source: &[u8]| Op::ControlUpsert { path: path.clone(), source: source.to_vec() };
8297 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
8298 index.apply_ok(&Observation::new(vec![
8299 upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
8300 rules(b"before"),
8301 ]));
8302 let baseline = index.expectation(&path);
8303 index.apply_ok(&Observation::new(vec![
8304 upsert(".gitignore", EntryKind::File, file_attrs(7, 2)),
8305 rules(b"changed"),
8306 ]));
8307
8308 let outcome = index.apply_ok(&Observation::from_ops(vec![
8309 ObservationOp::if_state(
8310 upsert(".gitignore", EntryKind::File, file_attrs(7, 2)),
8311 baseline,
8312 ),
8313 ObservationOp::if_state(rules(b"changed"), baseline),
8314 ]));
8315 assert_eq!(outcome.stats.stale, 0);
8316 assert!(outcome.commit.is_none());
8317 let diverged = index.apply_ok(&Observation::from_ops(vec![ObservationOp::if_state(
8318 rules(b"other"),
8319 baseline,
8320 )]));
8321 assert_eq!(diverged.stats.stale, 1);
8322 assert!(index.controls().expect("control state observed").source_is(&path, b"changed"));
8323
8324 let baseline = index.expectation(&path);
8325 index.apply_ok(&Observation::new(vec![Op::Remove { path: path.clone() }]));
8326 let outcome = index.apply_ok(&Observation::from_ops(vec![
8327 ObservationOp::if_state(Op::Remove { path: path.clone() }, baseline),
8328 ObservationOp::if_state(Op::ControlRemove { path: path.clone() }, baseline),
8329 ]));
8330 assert_eq!(outcome.stats.stale, 0);
8331 assert!(outcome.commit.is_none());
8332 assert!(!index.controls().expect("control state observed").contains(&path));
8333 }
8334
8335 #[test]
8336 fn unrelated_mutation_does_not_stale_an_absent_path() {
8337 let mut index = Index::new("/root");
8338 index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
8339 let baseline = index.expectation(Path::new("dir/new.txt"));
8340 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
8341 upsert("dir/new.txt", EntryKind::File, file_attrs(20, 2)),
8342 baseline,
8343 )]);
8344
8345 index.apply_ok(&Observation::new(vec![upsert(
8346 "other.txt",
8347 EntryKind::File,
8348 file_attrs(30, 3),
8349 )]));
8350 let outcome = index.apply_ok(&delayed);
8351
8352 assert_eq!(outcome.stats.stale, 0);
8353 assert_eq!(outcome.stats.inserted, 1);
8354 assert_eq!(index.attrs(Path::new("dir/new.txt")).expect("file").size, 20);
8355 }
8356
8357 #[test]
8358 fn directory_metadata_change_does_not_stale_an_absent_child() {
8359 let mut index = Index::new("/root");
8360 index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
8361 let baseline = index.expectation(Path::new("dir/new.txt"));
8362 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
8363 upsert("dir/new.txt", EntryKind::File, file_attrs(20, 2)),
8364 baseline,
8365 )]);
8366
8367 index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 3))]));
8368 let outcome = index.apply_ok(&delayed);
8369
8370 assert_eq!(outcome.stats.stale, 0);
8371 assert_eq!(outcome.stats.inserted, 1);
8372 }
8373
8374 #[test]
8375 fn file_parent_metadata_change_stales_an_absent_child() {
8376 let mut index = Index::new("/root");
8377 index.apply_ok(&Observation::new(vec![upsert(
8378 "parent",
8379 EntryKind::File,
8380 file_attrs(10, 1),
8381 )]));
8382 let baseline = index.expectation(Path::new("parent/child.txt"));
8383 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
8384 upsert("parent/child.txt", EntryKind::File, file_attrs(20, 2)),
8385 baseline,
8386 )]);
8387
8388 index.apply_ok(&Observation::new(vec![upsert(
8389 "parent",
8390 EntryKind::File,
8391 file_attrs(30, 3),
8392 )]));
8393 let outcome = index.apply_ok(&delayed);
8394
8395 assert_eq!(outcome.stats.stale, 1);
8396 assert_eq!(index.kind(Path::new("parent")), Some(EntryKind::File));
8397 assert!(index.lookup(Path::new("parent/child.txt")).is_none());
8398 }
8399
8400 #[test]
8401 fn delayed_directory_remove_cannot_delete_a_newer_child() {
8402 let mut index = Index::new("/root");
8403 index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
8404 let baseline = index.expectation(Path::new("dir"));
8405 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
8406 Op::Remove { path: PathBuf::from("dir") },
8407 baseline,
8408 )]);
8409
8410 index.apply_ok(&Observation::new(vec![upsert(
8411 "dir/new.txt",
8412 EntryKind::File,
8413 file_attrs(20, 2),
8414 )]));
8415 let outcome = index.apply_ok(&delayed);
8416
8417 assert_eq!(outcome.stats.stale, 1);
8418 assert!(index.lookup(Path::new("dir/new.txt")).is_some());
8419 }
8420
8421 #[test]
8422 fn conditional_batch_is_validated_at_one_boundary() {
8423 let mut index = Index::new("/root");
8424 let first = index.expectation(Path::new("first.txt"));
8425 let second = index.expectation(Path::new("second.txt"));
8426
8427 let outcome = index.apply_ok(&Observation::from_ops(vec![
8428 ObservationOp::if_state(upsert("first.txt", EntryKind::File, file_attrs(10, 1)), first),
8429 ObservationOp::if_state(
8430 upsert("second.txt", EntryKind::File, file_attrs(20, 2)),
8431 second,
8432 ),
8433 ]));
8434
8435 assert_eq!(outcome.stats.inserted, 2);
8436 assert_eq!(outcome.stats.stale, 0);
8437 }
8438
8439 #[test]
8440 fn public_observation_outputs_use_canonical_encoded_paths() {
8441 let separator = std::path::MAIN_SEPARATOR;
8442 let dotted = PathBuf::from(format!("dotted{separator}.{separator}file.txt"));
8443 let dotted_canonical = PathBuf::from(format!("dotted{separator}file.txt"));
8444 let repeated = PathBuf::from(format!("repeated{separator}{separator}file.txt"));
8445 let repeated_canonical = PathBuf::from(format!("repeated{separator}file.txt"));
8446 let mut index = Index::new("/root");
8447
8448 let outcome = index.apply_ok(&Observation::new(vec![
8449 upsert("dotted", EntryKind::Dir, file_attrs(0, 1)),
8450 Op::Upsert { path: dotted, kind: EntryKind::File, attrs: file_attrs(10, 2) },
8451 upsert("repeated", EntryKind::Dir, file_attrs(0, 3)),
8452 Op::Upsert { path: repeated, kind: EntryKind::File, attrs: file_attrs(20, 4) },
8453 ]));
8454 let commit = outcome.commit.as_ref().expect("one exact commit");
8455
8456 for (actual, canonical) in [
8457 (commit.changes[1].path(), dotted_canonical.as_path()),
8458 (commit.changes[3].path(), repeated_canonical.as_path()),
8459 ] {
8460 assert_eq!(
8461 actual.as_os_str().as_encoded_bytes(),
8462 canonical.as_os_str().as_encoded_bytes()
8463 );
8464 }
8465
8466 for canonical in [&dotted_canonical, &repeated_canonical] {
8467 let dirty = commit
8468 .impact
8469 .dirty_paths
8470 .iter()
8471 .find(|candidate| candidate.as_path() == canonical)
8472 .expect("changed path is dirty");
8473 assert_eq!(
8474 dirty.as_os_str().as_encoded_bytes(),
8475 canonical.as_os_str().as_encoded_bytes()
8476 );
8477 }
8478 }
8479
8480 #[test]
8487 fn trailing_path_spellings_leave_errors_and_changes_canonical() {
8488 let separator = std::path::MAIN_SEPARATOR;
8489 let canonical = format!("a{separator}b");
8490 for spelling in [format!("a{separator}b{separator}"), format!("a{separator}b{separator}.")]
8491 {
8492 let file = |mtime_ns| Op::Upsert {
8493 path: PathBuf::from(&spelling),
8494 kind: EntryKind::File,
8495 attrs: file_attrs(1, mtime_ns),
8496 };
8497 let mut index = Index::new("/root");
8498
8499 let error = index
8500 .apply(&Observation::new(vec![file(1)]))
8501 .expect_err("a child of an unknown directory is refused");
8502 let crate::Error::UnknownAncestry { path, .. } = error else {
8503 panic!("expected unknown ancestry for {spelling:?}, got {error}");
8504 };
8505 assert_eq!(path.as_os_str().as_encoded_bytes(), canonical.as_bytes(), "{spelling:?}");
8506
8507 let outcome = index.apply_ok(&Observation::new(vec![
8508 upsert("a", EntryKind::Dir, file_attrs(0, 1)),
8509 file(2),
8510 ]));
8511 let commit = outcome.commit.as_ref().expect("one exact commit");
8512 assert_eq!(
8513 commit.changes[1].path().as_os_str().as_encoded_bytes(),
8514 canonical.as_bytes(),
8515 "{spelling:?}"
8516 );
8517 }
8518 }
8519
8520 #[test]
8521 fn malformed_batch_is_rejected_before_any_index_mutation() {
8522 let invalid_paths = [
8523 PathBuf::from("../escape"),
8524 PathBuf::from(format!("{}absolute", std::path::MAIN_SEPARATOR)),
8525 ];
8526
8527 for invalid_path in invalid_paths {
8528 for invalid_first in [false, true] {
8529 let mut index = index_with_sample_tree();
8530 let before_clock = index.clock;
8531 let before_live = index.live;
8532 let before_total = index.total();
8533 let before_journal = index.journal.clone();
8534 let before_journal_cost = index.journal_cost;
8535 let before_journal_floor = index.journal_floor;
8536 let before_invalidations = index.pending_invalidations.clone();
8537 let before_freshness_epoch = index.freshness_epoch;
8538 let before_freshness = index.freshness();
8539 let valid = upsert("new.txt", EntryKind::File, file_attrs(99, 99));
8540 let invalid = Op::InvalidateSubtree {
8541 path: invalid_path.clone(),
8542 reason: InvalidateReason::Requested,
8543 };
8544 let ops = if invalid_first { vec![invalid, valid] } else { vec![valid, invalid] };
8545
8546 let error = index.apply(&Observation::new(ops)).expect_err("malformed batch");
8547
8548 assert!(
8549 matches!(error, crate::Error::PathEscapesRoot(path) if path == invalid_path)
8550 );
8551 assert_eq!(index.clock, before_clock);
8552 assert_eq!(index.live, before_live);
8553 assert_eq!(index.total(), before_total);
8554 assert_eq!(index.journal, before_journal);
8555 assert_eq!(index.journal_cost, before_journal_cost);
8556 assert_eq!(index.journal_floor, before_journal_floor);
8557 assert_eq!(index.pending_invalidations, before_invalidations);
8558 assert_eq!(index.freshness_epoch, before_freshness_epoch);
8559 assert_eq!(index.freshness(), before_freshness);
8560 assert!(index.lookup(Path::new("new.txt")).is_none());
8561 }
8562 }
8563 }
8564
8565 #[cfg(windows)]
8566 #[test]
8567 fn windows_prefix_is_rejected_before_mutation() {
8568 let mut index = index_with_sample_tree();
8569 let before_clock = index.clock();
8570
8571 let error = index
8572 .apply(&Observation::new(vec![Op::Remove { path: PathBuf::from(r"C:\escape") }]))
8573 .expect_err("prefixed path");
8574
8575 assert!(
8576 matches!(error, crate::Error::PathEscapesRoot(path) if path == Path::new(r"C:\escape"))
8577 );
8578 assert_eq!(index.clock(), before_clock);
8579 }
8580
8581 fn index_with_sample_tree() -> Index {
8582 let mut index = Index::new("/root");
8583 index.apply_ok(&Observation::new(vec![
8584 upsert("src", EntryKind::Dir, Attrs::default()),
8585 upsert("src/main.rs", EntryKind::File, file_attrs(100, 10)),
8586 upsert("src/lib.rs", EntryKind::File, file_attrs(200, 20)),
8587 upsert("docs", EntryKind::Dir, Attrs::default()),
8588 upsert("docs/guide.md", EntryKind::File, file_attrs(300, 30)),
8589 ]));
8590 index
8591 }
8592
8593 #[test]
8594 fn the_extension_interner_reclaims_ids_after_churn() {
8595 let mut index = Index::new("/root");
8599 for sequence in 0..128 {
8600 let path = PathBuf::from(format!("build.out-{sequence}"));
8601 index.apply_ok(&Observation::new(vec![Op::Upsert {
8602 path: path.clone(),
8603 kind: EntryKind::File,
8604 attrs: file_attrs(1, sequence),
8605 }]));
8606 index.apply_ok(&Observation::new(vec![Op::Remove { path }]));
8607 }
8608
8609 assert!(index.ext_ids.is_empty(), "no extension survives the file that named it");
8610 assert_eq!(index.ext_names.len(), 1, "128 dead extensions reuse one interner slot");
8611 assert!(index.total().by_ext.is_empty());
8612 }
8613
8614 #[test]
8615 fn a_reclaimed_extension_id_does_not_alias_a_live_tally() {
8616 let mut index = Index::new("/root");
8619 index.apply_ok(&Observation::new(vec![
8620 upsert("keep.rs", EntryKind::File, file_attrs(10, 1)),
8621 upsert("drop.tmp", EntryKind::File, file_attrs(20, 2)),
8622 ]));
8623 let retained = index.total();
8624 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("drop.tmp") }]));
8625 index.apply_ok(&Observation::new(vec![upsert(
8626 "next.bak",
8627 EntryKind::File,
8628 file_attrs(30, 3),
8629 )]));
8630
8631 let tallies = index.total().by_ext;
8632 assert_eq!(tallies[".rs"], ExtTally { files: 1, bytes: 10, allocated: 512 });
8633 assert_eq!(tallies[".bak"], ExtTally { files: 1, bytes: 30, allocated: 512 });
8634 assert!(!tallies.contains_key(".tmp"), "the removed extension is gone");
8635 assert_eq!(
8636 retained.by_ext[".tmp"],
8637 ExtTally { files: 1, bytes: 20, allocated: 512 },
8638 "an owned roll-up stays self-describing after its interner slot is reused"
8639 );
8640 assert!(!retained.by_ext.contains_key(".bak"));
8641 }
8642
8643 #[test]
8644 fn rollups_aggregate_up_the_tree() {
8645 let index = index_with_sample_tree();
8646
8647 let total = index.total();
8648 assert_eq!(total.files, 3);
8649 assert_eq!(total.dirs, 2);
8650 assert_eq!(total.bytes, 600);
8651 assert_eq!(total.newest_mtime_ns, 30);
8652
8653 let src = index.rollup(Path::new("src")).expect("src is a directory");
8654 assert_eq!(src.files, 2);
8655 assert_eq!(src.dirs, 0);
8656 assert_eq!(src.bytes, 300);
8657 assert_eq!(src.newest_mtime_ns, 20);
8658 }
8659
8660 #[test]
8661 fn rollups_conserve_hand_counted_populations_through_updates_and_subtree_replacement() {
8662 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
8663 let attrs =
8664 |size, allocated, inode| Attrs { size, allocated, inode, dev: 1, ..Attrs::default() };
8665 index.apply_ok(&Observation::new(vec![
8666 upsert(".gitignore", EntryKind::File, attrs(6, 512, 1)),
8667 upsert("a", EntryKind::Dir, Attrs::default()),
8668 upsert("a/keep.rs", EntryKind::File, attrs(7, 512, 99)),
8669 upsert("a/drop.log", EntryKind::File, attrs(11, 1_024, 3)),
8670 upsert("z.rs", EntryKind::File, attrs(5, 4_096, 99)),
8671 Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
8672 ]));
8673
8674 let total = index.partition_total().expect("control state observed");
8675 assert_eq!(
8676 (total.all.files, total.all.dirs, total.all.bytes, total.all.allocated),
8677 (4, 1, 29, 6_144)
8678 );
8679 assert_eq!(
8680 (total.unignored.files, total.unignored.bytes, total.unignored.allocated),
8681 (3, 18, 5_120)
8682 );
8683 assert_eq!(index.rollup(Path::new("a")).expect("directory").bytes, 18);
8684 assert_eq!(
8685 total.all.by_ext[".rs"].files, 2,
8686 "two paths with one inode each contribute a file"
8687 );
8688
8689 index.apply_ok(&Observation::new(vec![upsert(
8690 "a/drop.log",
8691 EntryKind::File,
8692 attrs(13, 1_536, 3),
8693 )]));
8694 let total = index.partition_total().expect("control state observed");
8695 assert_eq!((total.all.files, total.all.bytes, total.all.allocated), (4, 31, 6_656));
8696 assert_eq!(
8697 (total.unignored.files, total.unignored.bytes, total.unignored.allocated),
8698 (3, 18, 5_120)
8699 );
8700
8701 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("a") }]));
8702 let total = index.partition_total().expect("control state observed");
8703 assert_eq!(
8704 (total.all.files, total.all.dirs, total.all.bytes, total.all.allocated),
8705 (2, 0, 11, 4_608)
8706 );
8707 assert_eq!(total.all, total.unignored);
8708
8709 index.apply_ok(&Observation::new(vec![
8710 upsert("a", EntryKind::Dir, Attrs::default()),
8711 upsert("a/final.log", EntryKind::File, attrs(17, 4_096, 4)),
8712 ]));
8713 let total = index.partition_total().expect("control state observed");
8714 assert_eq!(
8715 (total.all.files, total.all.dirs, total.all.bytes, total.all.allocated),
8716 (3, 1, 28, 8_704)
8717 );
8718 assert_eq!(
8719 (
8720 total.unignored.files,
8721 total.unignored.dirs,
8722 total.unignored.bytes,
8723 total.unignored.allocated
8724 ),
8725 (2, 1, 11, 4_608)
8726 );
8727 assert_eq!(index.rollup(Path::new("a")).expect("directory").bytes, 17);
8728 }
8729
8730 #[test]
8731 fn symlinks_and_special_nodes_do_not_contribute_regular_file_tallies() {
8732 let mut index = Index::new("/root");
8733 index.apply_ok(&Observation::new(vec![
8734 upsert("regular.txt", EntryKind::File, file_attrs(10, 10)),
8735 upsert("link.rs", EntryKind::Symlink, file_attrs(99, 99)),
8736 upsert("socket.md", EntryKind::Other, file_attrs(88, 88)),
8737 ]));
8738
8739 let total = index.total();
8740 assert_eq!(total.files, 1);
8741 assert_eq!(total.bytes, 10);
8742 assert_eq!(total.allocated, 512);
8743 assert_eq!(total.newest_mtime_ns, 10);
8744 assert_eq!(total.by_ext[".txt"], ExtTally { files: 1, bytes: 10, allocated: 512 });
8745 assert!(!total.by_ext.contains_key(".rs"));
8746 assert!(!total.by_ext.contains_key(".md"));
8747
8748 index.apply_ok(&Observation::new(vec![upsert(
8749 "link.rs",
8750 EntryKind::File,
8751 file_attrs(99, 99),
8752 )]));
8753 assert_eq!(index.total().files, 2);
8754 assert_eq!(index.total().bytes, 109);
8755
8756 index.apply_ok(&Observation::new(vec![upsert(
8757 "link.rs",
8758 EntryKind::Symlink,
8759 file_attrs(99, 99),
8760 )]));
8761 assert_eq!(index.total().files, 1);
8762 assert_eq!(index.total().bytes, 10);
8763 }
8764
8765 #[test]
8766 fn per_extension_tallies_roll_up_hierarchically() {
8767 let index = index_with_sample_tree();
8768
8769 let total = index.total();
8770 assert_eq!(total.by_ext[".rs"], ExtTally { files: 2, bytes: 300, allocated: 1024 });
8771 assert_eq!(total.by_ext[".md"], ExtTally { files: 1, bytes: 300, allocated: 512 });
8772
8773 let src = index.rollup(Path::new("src")).expect("src is a directory");
8775 assert_eq!(src.by_ext[".rs"], ExtTally { files: 2, bytes: 300, allocated: 1024 });
8776 assert!(!src.by_ext.contains_key(".md"));
8777 }
8778
8779 #[test]
8780 fn per_extension_allocated_tracks_apparent_bytes_separately() {
8781 let mut index = Index::new("/root");
8784 index.apply_ok(&Observation::new(vec![
8785 upsert("a.rs", EntryKind::File, file_attrs(1, 10)),
8787 upsert("b.rs", EntryKind::File, file_attrs(2, 20)),
8788 ]));
8789
8790 let rs = index.total().by_ext[".rs"];
8791 assert_eq!((rs.files, rs.bytes), (2, 3));
8792 assert_eq!(rs.allocated, 1024, "each file occupies one 512-byte block");
8793
8794 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("b.rs") }]));
8796 let rs = index.total().by_ext[".rs"];
8797 assert_eq!((rs.files, rs.bytes, rs.allocated), (1, 1, 512));
8798 }
8799
8800 #[test]
8801 fn upsert_with_matching_fingerprint_is_a_no_op() {
8802 let mut index = index_with_sample_tree();
8803 let before = index.total();
8804 let mark = index.clock();
8805
8806 let stats = index.apply_ok(&Observation::new(vec![upsert(
8807 "src/main.rs",
8808 EntryKind::File,
8809 file_attrs(100, 10),
8810 )]));
8811
8812 assert_eq!(stats.unchanged, 1);
8813 assert_eq!(stats.updated, 0);
8814 assert_eq!(index.total(), before);
8815 assert_eq!(index.clock(), mark);
8816 assert!(index.since(mark).commits.is_empty());
8817 }
8818
8819 #[test]
8820 fn commit_contains_only_effective_mutations() {
8821 let mut index = index_with_sample_tree();
8822 let outcome = index.apply_ok(&Observation::new(vec![
8823 upsert("src/main.rs", EntryKind::File, file_attrs(100, 10)),
8824 upsert("new.txt", EntryKind::File, file_attrs(4, 4)),
8825 Op::Remove { path: PathBuf::from("missing.txt") },
8826 ]));
8827
8828 assert_eq!(outcome.stats.unchanged, 2);
8829 let commit = outcome.commit.expect("one effective insert");
8830 assert_eq!(commit.changes.len(), 1);
8831 assert_eq!(commit.changes[0].path(), Path::new("new.txt"));
8832 }
8833
8834 #[test]
8835 fn mutation_counters_match_exact_batch_and_consequence_totals() {
8836 struct DisableCounters;
8837
8838 impl Drop for DisableCounters {
8839 fn drop(&mut self) {
8840 crate::counters::enable(false);
8841 crate::counters::test_thread_reset();
8842 }
8843 }
8844
8845 let _serial = crate::counters::test_serial();
8846 crate::counters::enable(true);
8847 let _disable = DisableCounters;
8848 let observation = Observation::new(vec![
8849 upsert("a", EntryKind::Dir, Attrs::default()),
8850 upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
8851 upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
8852 ]);
8853
8854 crate::counters::test_thread_reset();
8855 let mut baseline = Index::new("/root");
8856 baseline.apply_baseline_ok(&observation);
8857 let counts = crate::counters::test_thread_snapshot();
8858 assert_eq!(counts.baseline_batches, 1);
8859 assert_eq!(counts.baseline_accepted_ops, 3);
8860 assert_eq!(counts.opened_batches, 0);
8861 assert_eq!(counts.public_batches, 0);
8862 assert_eq!(counts.ancestry_overlay_inserts, 3);
8863 assert_eq!(counts.ancestry_path_comparisons, 2);
8864 assert_eq!(counts.ancestry_parent_proofs, 3);
8865 assert_eq!(counts.effect_paths, 0);
8866 assert_eq!(counts.effect_path_bytes, 0);
8867 assert_eq!(counts.impact_candidates, 0);
8868 assert_eq!(counts.impact_ancestor_visits, 0);
8869 assert_eq!(counts.impact_retained_dirty_paths, 0);
8870 assert_eq!(counts.impact_all_dirty, 0);
8871 assert_eq!(counts.journal_cloned_commits, 0);
8872 assert_eq!(counts.journal_retained_commits, 0);
8873
8874 crate::counters::test_thread_reset();
8875 let mut public = Index::new("/root");
8876 public.apply_ok(&observation);
8877 let counts = crate::counters::test_thread_snapshot();
8878 assert_eq!(counts.baseline_batches, 0);
8879 assert_eq!(counts.opened_batches, 0);
8880 assert_eq!(counts.public_batches, 1);
8881 assert_eq!(counts.public_accepted_ops, 3);
8882 assert_eq!(counts.effect_paths, 3);
8883 assert_eq!(counts.journal_cloned_commits, 1);
8884 assert_eq!(counts.journal_retained_commits, 1);
8885 assert_eq!(counts.journal_oversized_commits, 0);
8886 assert_eq!(counts.journal_dropped_commits, 0);
8887
8888 crate::counters::test_thread_reset();
8889 let opened = IndexHandle::new(Index::new("/root"));
8890 opened
8891 .apply_discovery(&observation, DiscoveryCommit::default())
8892 .expect("opened discovery batch");
8893 let counts = crate::counters::test_thread_snapshot();
8894 assert_eq!(counts.baseline_batches, 0);
8895 assert_eq!(counts.opened_batches, 1);
8896 assert_eq!(counts.opened_accepted_ops, 3);
8897 assert_eq!(counts.public_batches, 0);
8898
8899 crate::counters::test_thread_reset();
8900 let mut scanner = Index::new("/root");
8901 scanner
8902 .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
8903 upsert("a", EntryKind::Dir, Attrs::default()),
8904 upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
8905 upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
8906 ]))
8907 .expect("private scanner batch");
8908 let counts = crate::counters::test_thread_snapshot();
8909 assert_eq!(counts.baseline_batches, 1);
8910 assert_eq!(counts.baseline_accepted_ops, 3);
8911 assert_eq!(counts.ancestry_overlay_inserts, 0);
8912 assert_eq!(counts.ancestry_path_comparisons, 2);
8913 assert_eq!(counts.ancestry_parent_proofs, 3);
8914 assert_eq!(counts.parent_resolutions, 0);
8915 assert_eq!(counts.parent_memo_hits, 0);
8916 assert_eq!(scanner.total().dirs, 1);
8917 assert_eq!(scanner.total().files, 2);
8918
8919 crate::counters::test_thread_reset();
8920 let opened_scanner = IndexHandle::new(Index::new("/root"));
8921 opened_scanner
8922 .apply_scanner_discovery_bounded(
8923 crate::scan::ScannerBatch::from_ops(vec![
8924 upsert("a", EntryKind::Dir, Attrs::default()),
8925 upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
8926 upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
8927 ]),
8928 DiscoveryCommit::default(),
8929 None,
8930 )
8931 .expect("opened scanner batch");
8932 let counts = crate::counters::test_thread_snapshot();
8933 assert_eq!(counts.opened_batches, 1);
8934 assert_eq!(counts.opened_accepted_ops, 3);
8935 assert_eq!(counts.ancestry_overlay_inserts, 0);
8936 assert_eq!(counts.effect_paths, 3);
8937 assert_eq!(counts.journal_retained_commits, 1);
8938
8939 let two_commits = 2 * commit_cost(vec![upsert("one", EntryKind::File, file_attrs(1, 1))]);
8942 crate::counters::test_thread_reset();
8943 let mut bounded = Index::new("/root");
8944 bounded.journal_capacity_bytes = two_commits;
8945 bounded.apply_ok(&Observation::new(vec![upsert("one", EntryKind::File, file_attrs(1, 1))]));
8946 bounded.apply_ok(&Observation::new(vec![upsert("two", EntryKind::File, file_attrs(2, 2))]));
8947 bounded.journal_capacity_bytes = 1;
8948 bounded.apply_ok(&Observation::new(vec![upsert(
8949 "three",
8950 EntryKind::File,
8951 file_attrs(3, 3),
8952 )]));
8953 let counts = crate::counters::test_thread_snapshot();
8954 assert_eq!(counts.journal_cloned_commits, 2);
8955 assert_eq!(counts.journal_retained_commits, 2);
8956 assert_eq!(counts.journal_oversized_commits, 1);
8957 assert_eq!(counts.journal_dropped_commits, 2);
8958 }
8959
8960 #[test]
8961 fn detached_and_exact_reducers_produce_the_same_facts_and_stats() {
8962 fn fact_image(index: &Index) -> Vec<(PathBuf, EntryKind, Attrs, bool, Source, bool)> {
8963 let mut image = Vec::new();
8964 let mut frontier = VecDeque::from([EntryId::ROOT]);
8965 while let Some(id) = frontier.pop_front() {
8966 let entry = index.entry(id);
8967 if entry.kind.is_dir() {
8968 frontier.extend(index.child_ids(id));
8969 }
8970 image.push((
8971 index.path_of(id).expect("live entry path"),
8972 entry.kind,
8973 entry.attrs,
8974 entry.ignored,
8975 entry.source,
8976 entry.directory.as_deref().is_none_or(|directory| directory.children_complete),
8977 ));
8978 }
8979 image.sort_by(|left, right| left.0.cmp(&right.0));
8980 image
8981 }
8982
8983 fn assert_same_facts(detached: &Index, exact: &Index) {
8984 assert_eq!(fact_image(detached), fact_image(exact));
8985 assert_eq!(detached.total(), exact.total());
8986 let partitions =
8987 |index: &Index| index.named_partitions(index.entry(EntryId::ROOT).rollup());
8988 assert_eq!(partitions(detached), partitions(exact));
8989 let controls = |index: &Index| {
8990 index
8991 .controls
8992 .sources()
8993 .map(|(path, source)| (path, source.to_vec()))
8994 .collect::<Vec<_>>()
8995 };
8996 assert_eq!(controls(detached), controls(exact));
8997 assert_eq!(detached.state, exact.state);
8998 assert_eq!(detached.issues, exact.issues);
8999 let freshness = |index: &Index| {
9000 index
9001 .freshness_marks
9002 .iter()
9003 .map(|(path, mark)| (path.clone(), mark.state, mark.epoch))
9004 .collect::<Vec<_>>()
9005 };
9006 assert_eq!(freshness(detached), freshness(exact));
9007 assert_eq!(detached.verified, exact.verified);
9008 assert_eq!(detached.pending_invalidations, exact.pending_invalidations);
9009 }
9010
9011 let mut detached = Index::new("/root");
9012 let mut exact = detached.clone();
9013 let batches = [
9014 Observation::new(vec![
9015 upsert("a", EntryKind::Dir, file_attrs(0, 1)),
9016 upsert("a/one.rs", EntryKind::File, file_attrs(10, 2)),
9017 upsert("a/two.txt", EntryKind::File, file_attrs(20, 3)),
9018 ]),
9019 Observation::new(vec![
9020 upsert("a/one.rs", EntryKind::File, file_attrs(30, 4)),
9021 Op::Remove { path: PathBuf::from("a/two.txt") },
9022 Op::InvalidateSubtree {
9023 path: PathBuf::from("a"),
9024 reason: InvalidateReason::VerificationFailed,
9025 },
9026 ]),
9027 ];
9028
9029 for batch in batches {
9030 let detached_stats = detached.apply_baseline(&batch).expect("detached batch");
9031 let exact_outcome = exact.apply(&batch).expect("exact batch");
9032 assert_eq!(detached_stats, exact_outcome.stats);
9033 exact.establish_baseline();
9034 assert_same_facts(&detached, &exact);
9035 }
9036 }
9037
9038 #[test]
9039 fn exact_commit_records_verified_ancestry_and_kind_replacement() {
9040 let mut index = Index::new("/root");
9041 let inserted = index.apply_ok(&Observation::new(vec![
9042 upsert("unknown", EntryKind::Dir, file_attrs(0, 1)),
9043 upsert("unknown/deep", EntryKind::Dir, file_attrs(0, 2)),
9044 upsert("unknown/deep/file.txt", EntryKind::File, file_attrs(10, 3)),
9045 ]));
9046 let inserted = inserted.commit.expect("ancestry commit");
9047 assert_eq!(
9048 inserted.changes.iter().map(EffectiveChange::path).collect::<Vec<_>>(),
9049 [Path::new("unknown"), Path::new("unknown/deep"), Path::new("unknown/deep/file.txt")]
9050 );
9051 assert!(
9052 inserted
9053 .changes
9054 .iter()
9055 .all(|change| matches!(change, EffectiveChange::Inserted { .. }))
9056 );
9057
9058 let replaced = index.apply_ok(&Observation::new(vec![upsert(
9059 "unknown",
9060 EntryKind::File,
9061 file_attrs(20, 2),
9062 )]));
9063 let replaced = replaced.commit.expect("replacement commit");
9064 assert_eq!(
9065 replaced.changes,
9066 vec![
9067 EffectiveChange::Removed {
9068 path: "unknown".into(),
9069 kind: EntryKind::Dir,
9070 attrs: file_attrs(0, 1),
9071 },
9072 EffectiveChange::Removed {
9073 path: "unknown/deep".into(),
9074 kind: EntryKind::Dir,
9075 attrs: file_attrs(0, 2),
9076 },
9077 EffectiveChange::Removed {
9078 path: "unknown/deep/file.txt".into(),
9079 kind: EntryKind::File,
9080 attrs: file_attrs(10, 3),
9081 },
9082 EffectiveChange::Inserted {
9083 path: "unknown".into(),
9084 kind: EntryKind::File,
9085 attrs: file_attrs(20, 2),
9086 },
9087 ]
9088 );
9089 assert_eq!(
9090 replaced.impact.domains,
9091 vec![
9092 ImpactDomain::Topology,
9093 ImpactDomain::Metadata,
9094 ImpactDomain::Classification,
9095 ImpactDomain::Aggregates,
9096 ImpactDomain::Content,
9097 ]
9098 );
9099 assert_eq!(
9100 replaced.impact.dirty_paths,
9101 vec![
9102 PathBuf::new(),
9103 "unknown".into(),
9104 "unknown/deep".into(),
9105 "unknown/deep/file.txt".into(),
9106 ]
9107 );
9108 }
9109
9110 #[test]
9111 fn rejected_prepared_commit_is_fault_atomic() {
9112 let mut index = index_with_sample_tree();
9113 let before_clock = index.clock();
9114 let before_total = index.total();
9115 let before_len = index.len();
9116 let before_history = index.since(Clock::ZERO);
9117 let mut prepared = prepare_observation(&Observation::new(vec![upsert(
9118 "new/deep.txt",
9119 EntryKind::File,
9120 file_attrs(99, 99),
9121 )]))
9122 .expect("valid preparation");
9123 prepared.reject_before_apply = true;
9124
9125 let error = index.commit_prepared(prepared, true).expect_err("injected preflight");
9126
9127 assert!(matches!(error, crate::Error::CommitRejected("injected reducer preflight")));
9128 assert_eq!(index.clock(), before_clock);
9129 assert_eq!(index.total(), before_total);
9130 assert_eq!(index.len(), before_len);
9131 assert_eq!(index.since(Clock::ZERO), before_history);
9132 assert!(index.lookup(Path::new("new")).is_none());
9133 }
9134
9135 #[test]
9136 fn reconciliation_state_moves_through_exact_commits() {
9137 let mut index = Index::new("/root");
9138 let (started, start) = index.begin_reconcile(Path::new("src")).expect("begin");
9139 let start = start.expect("start commit");
9140 assert!(start.changes.is_empty());
9141 assert_eq!(
9142 start.state,
9143 vec![
9144 StateTransition::Freshness {
9145 path: "src".into(),
9146 previous: Freshness::Fresh,
9147 current: Freshness::Reconciling,
9148 },
9149 StateTransition::IndexState {
9150 previous: IndexState::default(),
9151 current: IndexState {
9152 freshness: Freshness::Reconciling,
9153 ..IndexState::default()
9154 },
9155 },
9156 ]
9157 );
9158
9159 let finish = index
9160 .finish_reconcile(
9161 Path::new("src"),
9162 started,
9163 true,
9164 &[],
9165 &[],
9166 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9167 )
9168 .expect("finish")
9169 .commit
9170 .expect("finish commit");
9171 assert!(finish.changes.is_empty());
9172 assert_eq!(
9173 finish.state,
9174 vec![
9175 StateTransition::Verified { path: "src".into() },
9176 StateTransition::Freshness {
9177 path: "src".into(),
9178 previous: Freshness::Reconciling,
9179 current: Freshness::Fresh,
9180 },
9181 StateTransition::IndexState {
9182 previous: IndexState {
9183 freshness: Freshness::Reconciling,
9184 ..IndexState::default()
9185 },
9186 current: IndexState::default(),
9187 },
9188 ]
9189 );
9190 }
9191
9192 #[cfg(feature = "watch")]
9193 #[test]
9194 fn observation_failure_cannot_overwrite_a_terminal_resource_stop() {
9195 let handle = IndexHandle::new(Index::new("/root"));
9196 handle
9197 .transition_discovery(DiscoveryTransition::BudgetRefused(Issue::resource_budget(0)))
9198 .expect("stop for budget");
9199 let stopped = handle.state().expect("stopped state");
9200 let clock = handle.clock().expect("stopped clock");
9201
9202 let outcome = handle
9203 .transition_observation(ObservationTransition::Failed(Issue::from_error(
9204 &crate::Error::WatchStopped,
9205 )))
9206 .expect("late observer failure is ignored");
9207
9208 assert_eq!(outcome.commit, None);
9209 assert_eq!(handle.state().expect("terminal state"), stopped);
9210 assert_eq!(handle.clock().expect("terminal clock"), clock);
9211 }
9212
9213 #[test]
9215 fn a_terminal_root_refuses_every_discovery_commit() {
9216 let terminals = [
9217 DiscoveryTransition::BudgetRefused(Issue::resource_budget(1)),
9218 DiscoveryTransition::Failed(Issue::from_error(&crate::Error::OpenedIndexClosed)),
9219 ];
9220 for terminal in terminals {
9221 let handle = IndexHandle::new(Index::new("/root"));
9222 handle.transition_discovery(DiscoveryTransition::Begin).expect("begin");
9223 handle
9224 .apply_discovery(
9225 &Observation::new(vec![upsert("dir", EntryKind::Dir, Attrs::default())]),
9226 DiscoveryCommit::default(),
9227 )
9228 .expect("listing before the stop");
9229 handle.transition_discovery(terminal.clone()).expect("terminal transition");
9230 let state = handle.state().expect("terminal state");
9231 let clock = handle.clock().expect("terminal clock");
9232
9233 let late = [
9234 (
9235 vec![upsert("dir/late.txt", EntryKind::File, file_attrs(1, 1))],
9236 DiscoveryCommit {
9237 directory_complete: Some(PathBuf::from("dir")),
9238 transition: None,
9239 },
9240 ),
9241 (
9242 Vec::new(),
9243 DiscoveryCommit {
9244 directory_complete: None,
9245 transition: Some(DiscoveryTransition::Finish),
9246 },
9247 ),
9248 (
9249 Vec::new(),
9250 DiscoveryCommit {
9251 directory_complete: None,
9252 transition: Some(DiscoveryTransition::Inaccessible {
9253 issues: vec![Issue::resource_budget(2)],
9254 omitted: 0,
9255 }),
9256 },
9257 ),
9258 ];
9259 for (ops, discovery) in late {
9260 assert!(
9261 matches!(
9262 handle.apply_discovery(&Observation::new(ops), discovery.clone()),
9263 Err(crate::Error::OpenedIndexStopped)
9264 ),
9265 "after {terminal:?}, {discovery:?} was accepted"
9266 );
9267 }
9268 assert_eq!(handle.state().expect("state"), state, "after {terminal:?}");
9269 assert_eq!(handle.clock().expect("clock"), clock, "after {terminal:?}");
9270 assert_eq!(handle.kind(Path::new("dir/late.txt")).expect("lookup"), None);
9271 }
9272 }
9273
9274 #[test]
9275 fn replaying_the_same_delta_twice_changes_nothing() {
9276 let mut index = Index::new("/root");
9277 let delta = Observation::new(vec![
9278 upsert("a", EntryKind::Dir, Attrs::default()),
9279 upsert("a/f.txt", EntryKind::File, file_attrs(10, 1)),
9280 ]);
9281 index.apply_ok(&delta);
9282 let after_first = index.total();
9283 let stats = index.apply_ok(&delta);
9284
9285 assert_eq!(stats.unchanged, 2);
9286 assert_eq!(index.total(), after_first);
9287 assert_eq!(index.len(), 3);
9288 }
9289
9290 #[test]
9291 fn changed_size_updates_every_ancestor() {
9292 let mut index = index_with_sample_tree();
9293 index.apply_ok(&Observation::new(vec![upsert(
9294 "src/main.rs",
9295 EntryKind::File,
9296 file_attrs(150, 11),
9297 )]));
9298
9299 assert_eq!(index.rollup(Path::new("src")).expect("dir").bytes, 350);
9300 assert_eq!(index.total().bytes, 650);
9301 assert_eq!(index.total().by_ext[".rs"], ExtTally { files: 2, bytes: 350, allocated: 1024 });
9302 }
9303
9304 #[test]
9305 fn allocated_size_change_updates_rollups_even_when_fingerprint_matches() {
9306 let mut index = Index::new("/root");
9307 let original = Attrs { allocated: 512, ..file_attrs(100, 10) };
9308 index.apply_ok(&Observation::new(vec![upsert("file.bin", EntryKind::File, original)]));
9309
9310 let repacked = Attrs { allocated: 4096, ..original };
9311 let outcome =
9312 index.apply_ok(&Observation::new(vec![upsert("file.bin", EntryKind::File, repacked)]));
9313
9314 assert_eq!(outcome.stats.updated, 1);
9315 assert_eq!(outcome.stats.unchanged, 0);
9316 assert_eq!(index.total().allocated, 4096);
9317 assert_eq!(index.attrs(Path::new("file.bin")), Some(&repacked));
9318 }
9319
9320 #[test]
9321 fn newest_mtime_preserves_pre_epoch_values_through_updates_and_removals() {
9322 let mut index = Index::new("/root");
9323 index.apply_ok(&Observation::new(vec![
9324 upsert("newer.txt", EntryKind::File, file_attrs(10, -10)),
9325 upsert("older.txt", EntryKind::File, file_attrs(20, -20)),
9326 ]));
9327
9328 assert_eq!(index.total().newest_mtime_ns, -10);
9329
9330 index.apply_ok(&Observation::new(vec![upsert(
9331 "newer.txt",
9332 EntryKind::File,
9333 file_attrs(10, -30),
9334 )]));
9335 assert_eq!(index.total().newest_mtime_ns, -20);
9336
9337 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("older.txt") }]));
9338 assert_eq!(index.total().newest_mtime_ns, -30);
9339 }
9340
9341 #[test]
9342 fn removing_a_file_corrects_sums_and_rebuilds_the_max() {
9343 let mut index = index_with_sample_tree();
9344 let stats = index
9346 .apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("docs/guide.md") }]));
9347
9348 assert_eq!(stats.removed, 1);
9349 let total = index.total();
9350 assert_eq!(total.files, 2);
9351 assert_eq!(total.bytes, 300);
9352 assert_eq!(total.newest_mtime_ns, 20, "max must fall back to src/lib.rs");
9353 assert!(!total.by_ext.contains_key(".md"), "emptied tallies are dropped");
9354 }
9355
9356 #[test]
9357 fn removing_a_directory_cascades_to_descendants() {
9358 let mut index = index_with_sample_tree();
9359 let stats = index
9360 .apply(&Observation::new(vec![Op::Remove { path: PathBuf::from("src") }]))
9361 .expect("valid observation");
9362
9363 assert_eq!(stats.removed, 3, "the directory and both files");
9364 let total = index.total();
9365 assert_eq!(total.files, 1);
9366 assert_eq!(total.dirs, 1);
9367 assert_eq!(total.bytes, 300);
9368 assert!(index.lookup(Path::new("src/main.rs")).is_none());
9369 assert!(!total.by_ext.contains_key(".rs"));
9370 }
9371
9372 #[test]
9373 fn freed_slots_are_reused() {
9374 let mut index = index_with_sample_tree();
9375 let before = index.len();
9376 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("src") }]));
9377 index.apply_ok(&Observation::new(vec![
9378 upsert("other", EntryKind::Dir, Attrs::default()),
9379 upsert("other/x.rs", EntryKind::File, file_attrs(1, 1)),
9380 upsert("other/y.rs", EntryKind::File, file_attrs(1, 1)),
9381 ]));
9382 assert_eq!(index.len(), before, "three freed slots, three new entries");
9383 }
9384
9385 #[test]
9386 fn stale_entry_handle_does_not_alias_a_reused_slot() {
9387 let mut index = Index::new("/root");
9388 index.apply_ok(&Observation::new(vec![upsert(
9389 "first.txt",
9390 EntryKind::File,
9391 file_attrs(1, 1),
9392 )]));
9393 let stale = index.lookup(Path::new("first.txt")).expect("first id");
9394 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("first.txt") }]));
9395 index.apply_ok(&Observation::new(vec![upsert(
9396 "second.txt",
9397 EntryKind::File,
9398 file_attrs(2, 2),
9399 )]));
9400 let current = index.lookup(Path::new("second.txt")).expect("second id");
9401
9402 assert_ne!(stale, current, "generation participates in handle identity");
9403 assert!(index.attrs_of(stale).is_none());
9404 assert!(index.kind_of(stale).is_none());
9405 assert!(index.name_of(stale).is_none());
9406 assert!(index.path_of(stale).is_none());
9407 assert!(index.children_of(stale).is_none());
9408 assert!(index.rollup_of(stale).is_none());
9409 assert_eq!(index.attrs_of(current).map(|attrs| attrs.size), Some(2));
9410 }
9411
9412 #[test]
9413 fn parent_first_batch_establishes_exact_ancestry() {
9414 let mut index = Index::new("/root");
9415 let deep = file_attrs(0, 1);
9416 let nested = file_attrs(0, 2);
9417 let tree = file_attrs(0, 3);
9418 index.apply_ok(&Observation::new(vec![
9419 upsert("deep", EntryKind::Dir, deep),
9420 upsert("deep/nested", EntryKind::Dir, nested),
9421 upsert("deep/nested/tree", EntryKind::Dir, tree),
9422 upsert("deep/nested/tree/file.txt", EntryKind::File, file_attrs(42, 7)),
9423 ]));
9424
9425 assert_eq!(index.total().files, 1);
9426 assert_eq!(index.total().dirs, 3);
9427 assert_eq!(index.total().bytes, 42);
9428 assert_eq!(index.rollup(Path::new("deep/nested")).expect("created").files, 1);
9429 assert_eq!(index.attrs(Path::new("deep")), Some(&deep));
9430 assert_eq!(index.attrs(Path::new("deep/nested")), Some(&nested));
9431 assert_eq!(index.attrs(Path::new("deep/nested/tree")), Some(&tree));
9432 }
9433
9434 #[test]
9435 fn scanner_parent_proof_matches_public_parent_first_application() {
9436 let ops = vec![
9437 upsert("deep", EntryKind::Dir, file_attrs(0, 1)),
9438 upsert("deep/nested", EntryKind::Dir, file_attrs(0, 2)),
9439 upsert("deep/nested/file.txt", EntryKind::File, file_attrs(42, 3)),
9440 ];
9441 let mut scanner = Index::new("/root");
9442 let scanner_stats = scanner
9443 .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(ops.clone()))
9444 .expect("scanner proof");
9445 let mut public = Index::new("/root");
9446 let public_stats = public.apply(&Observation::new(ops)).expect("public proof").stats;
9447
9448 assert_eq!(scanner_stats, public_stats);
9449 assert_eq!(scanner.total(), public.total());
9450 assert_eq!(scanner.len(), public.len());
9451 for path in ["deep", "deep/nested", "deep/nested/file.txt"] {
9452 assert_eq!(scanner.kind(Path::new(path)), public.kind(Path::new(path)));
9453 assert_eq!(scanner.attrs(Path::new(path)), public.attrs(Path::new(path)));
9454 }
9455 }
9456
9457 #[test]
9458 fn scanner_parent_proof_rejects_unknown_ancestry_before_mutation() {
9459 let mut index = Index::new("/root");
9460 let before = index.total();
9461 let error = index
9462 .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![upsert(
9463 "missing/child.txt",
9464 EntryKind::File,
9465 file_attrs(1, 1),
9466 )]))
9467 .expect_err("scanner proof must reject an unknown parent");
9468
9469 assert!(matches!(
9470 error,
9471 crate::Error::UnknownAncestry { path, .. }
9472 if path == Path::new("missing/child.txt")
9473 ));
9474 assert_eq!(index.total(), before);
9475 assert_eq!(index.len(), 1);
9476 assert_eq!(index.clock(), Clock::ZERO);
9477 }
9478
9479 #[test]
9480 fn scanner_kind_replacement_is_proved_by_the_general_lane() {
9481 let mut index = Index::new("/root");
9482 index.apply_ok(&Observation::new(vec![
9483 upsert("a", EntryKind::Dir, file_attrs(0, 1)),
9484 upsert("a/old.txt", EntryKind::File, file_attrs(7, 1)),
9485 ]));
9486 let before = index.total();
9487 let before_clock = index.clock();
9488
9489 let error = index
9492 .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
9493 upsert("a", EntryKind::File, file_attrs(2, 2)),
9494 upsert("a/new.txt", EntryKind::File, file_attrs(3, 2)),
9495 ]))
9496 .expect_err("a child cannot attach after its parent became a file");
9497
9498 assert!(matches!(
9499 error,
9500 crate::Error::UnknownAncestry { path, .. } if path == Path::new("a/new.txt")
9501 ));
9502 assert_eq!(index.total(), before);
9503 assert_eq!(index.clock(), before_clock);
9504 assert_eq!(index.kind(Path::new("a")), Some(EntryKind::Dir));
9505 assert!(index.lookup(Path::new("a/old.txt")).is_some());
9506 assert!(index.lookup(Path::new("a/new.txt")).is_none());
9507
9508 index
9510 .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![upsert(
9511 "a",
9512 EntryKind::File,
9513 file_attrs(2, 2),
9514 )]))
9515 .expect("a scanner batch can replace an entry's kind");
9516 assert_eq!(index.kind(Path::new("a")), Some(EntryKind::File));
9517 assert!(index.lookup(Path::new("a/old.txt")).is_none());
9518 let total = index.total();
9519 assert_eq!((total.files, total.dirs, total.bytes), (1, 0, 2));
9520 }
9521
9522 #[test]
9523 fn scanner_discovery_survives_a_kind_changed_by_a_concurrent_refresh() {
9524 let handle = IndexHandle::new(Index::new("/root"));
9525 handle
9526 .apply_scanner_discovery_bounded(
9527 crate::scan::ScannerBatch::from_ops(vec![upsert(
9528 "p",
9529 EntryKind::Dir,
9530 Attrs::default(),
9531 )]),
9532 DiscoveryCommit::default(),
9533 None,
9534 )
9535 .expect("root listing");
9536 handle
9538 .apply(&Observation::new(vec![upsert("p/d", EntryKind::File, file_attrs(1, 1))]))
9539 .expect("refresh insert");
9540 let outcome = handle.apply_scanner_discovery_bounded(
9542 crate::scan::ScannerBatch::from_ops(vec![upsert(
9543 "p/d",
9544 EntryKind::Dir,
9545 Attrs::default(),
9546 )]),
9547 DiscoveryCommit { directory_complete: Some(PathBuf::from("p")), transition: None },
9548 None,
9549 );
9550 assert!(outcome.is_ok(), "discovery must not die on a kind race: {outcome:?}");
9551 assert_eq!(handle.kind(Path::new("p/d")).expect("kind read"), Some(EntryKind::Dir));
9554 assert_eq!(handle.directory_complete(Path::new("p")).expect("read"), Some(true));
9555 }
9556
9557 #[test]
9558 fn live_upsert_refuses_unknown_ancestry_without_mutation() {
9559 let mut index = Index::new("/root");
9560 let before = index.clock();
9561
9562 let error = index
9563 .apply(&Observation::new(vec![upsert(
9564 "unknown/deep/file.txt",
9565 EntryKind::File,
9566 file_attrs(10, 1),
9567 )]))
9568 .expect_err("live input must not invent parent metadata");
9569
9570 assert!(matches!(
9571 error,
9572 crate::Error::UnknownAncestry { path, reconcile_from }
9573 if path == Path::new("unknown/deep/file.txt")
9574 && reconcile_from.as_os_str().is_empty()
9575 ));
9576 assert_eq!(index.clock(), before);
9577 assert_eq!(index.len(), 1);
9578 assert!(index.since(before).commits.is_empty());
9579 }
9580
9581 #[test]
9582 fn explicit_kind_replacement_precedes_attaching_a_child() {
9583 let mut index = Index::new("/root");
9584 index.apply_ok(&Observation::new(vec![upsert(
9585 "conflict",
9586 EntryKind::File,
9587 file_attrs(9, 1),
9588 )]));
9589
9590 let outcome = index.apply_ok(&Observation::new(vec![
9591 upsert("conflict", EntryKind::Dir, file_attrs(0, 2)),
9592 upsert("conflict/child.txt", EntryKind::File, file_attrs(4, 2)),
9593 ]));
9594
9595 assert_eq!(index.kind(Path::new("conflict")), Some(EntryKind::Dir));
9596 assert!(index.lookup(Path::new("conflict/child.txt")).is_some());
9597 assert_eq!(index.total().files, 1);
9598 assert_eq!(index.total().dirs, 1);
9599 assert_eq!(index.total().bytes, 4);
9600 assert_eq!(outcome.removed, 1);
9601 }
9602
9603 #[test]
9604 fn kind_change_replaces_the_entry() {
9605 let mut index = Index::new("/root");
9606 index.apply_ok(&Observation::new(vec![upsert(
9607 "thing",
9608 EntryKind::File,
9609 file_attrs(50, 5),
9610 )]));
9611 assert_eq!(index.total().files, 1);
9612
9613 index.apply_ok(&Observation::new(vec![upsert("thing", EntryKind::Dir, Attrs::default())]));
9614 let total = index.total();
9615 assert_eq!(total.files, 0);
9616 assert_eq!(total.dirs, 1);
9617 assert_eq!(total.bytes, 0);
9618 }
9619
9620 #[test]
9621 fn paths_are_reconstructed_from_parent_pointers() {
9622 let index = index_with_sample_tree();
9623 let id = index.lookup(Path::new("src/main.rs")).expect("present");
9624 assert_eq!(index.path_of(id), Some(PathBuf::from("src/main.rs")));
9625 assert_eq!(index.path_of(EntryId::ROOT), Some(PathBuf::new()));
9626 }
9627
9628 #[test]
9629 fn since_returns_commits_after_a_clock() {
9630 let mut index = Index::new("/root");
9631 index.apply_ok(&Observation::new(vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))]));
9632 let mark = index.clock();
9633 index.apply_ok(&Observation::new(vec![upsert("b.txt", EntryKind::File, file_attrs(2, 2))]));
9634
9635 let since = index.since(mark);
9636 assert!(!since.truncated);
9637 assert_eq!(since.commits.len(), 1);
9638 assert_eq!(since.commits[0].changes[0].path(), Path::new("b.txt"));
9639
9640 assert_eq!(index.since(index.clock()).commits.len(), 0);
9641 }
9642
9643 fn commit_cost(ops: Vec<Op>) -> usize {
9645 let mut probe = Index::new("/root");
9646 probe.apply_ok(&Observation::new(ops)).commit.expect("effective commit").retained_cost()
9647 }
9648
9649 #[test]
9650 fn oversized_single_batch_is_not_retained() {
9651 let batch = || {
9652 vec![
9653 upsert("a.txt", EntryKind::File, file_attrs(1, 1)),
9654 upsert("b.txt", EntryKind::File, file_attrs(2, 2)),
9655 upsert("c.txt", EntryKind::File, file_attrs(3, 3)),
9656 ]
9657 };
9658 let mut index = Index::with_journal_capacity_bytes("/root", commit_cost(batch()) - 1);
9659 let outcome = index.apply_ok(&Observation::new(batch()));
9660
9661 assert_eq!(outcome.commit.as_ref().expect("committed").changes.len(), 3);
9662 let since = index.since(Clock::ZERO);
9663 assert!(since.truncated);
9664 assert!(since.commits.is_empty());
9665 }
9666
9667 #[test]
9668 fn journal_eviction_charges_the_complete_retained_payload() {
9669 let first = || {
9670 vec![
9671 upsert("a.txt", EntryKind::File, file_attrs(1, 1)),
9672 upsert("b.txt", EntryKind::File, file_attrs(2, 2)),
9673 ]
9674 };
9675 let second = || {
9676 vec![
9677 upsert("c.txt", EntryKind::File, file_attrs(3, 3)),
9678 upsert("d.txt", EntryKind::File, file_attrs(4, 4)),
9679 ]
9680 };
9681 let capacity = commit_cost(first()) + commit_cost(second()) - 1;
9683 let mut index = Index::with_journal_capacity_bytes("/root", capacity);
9684 index.apply_ok(&Observation::new(first()));
9685 index.apply_ok(&Observation::new(second()));
9686
9687 let since = index.since(Clock::ZERO);
9688 assert!(since.truncated);
9689 assert_eq!(since.commits.len(), 1);
9690 assert_eq!(since.commits[0].changes.len(), 2);
9691 assert_eq!(since.commits[0].changes[0].path(), Path::new("c.txt"));
9692 }
9693
9694 #[test]
9699 fn journal_eviction_is_charged_in_path_bytes() {
9700 let long_name = format!("{}.txt", "n".repeat(4096));
9701 let short = || vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))];
9702 let long = || vec![upsert(&long_name, EntryKind::File, file_attrs(2, 2))];
9703 let short_cost = commit_cost(short());
9704 let long_cost = commit_cost(long());
9705 assert!(
9706 long_cost > short_cost + 4096,
9707 "path bytes must be charged: short {short_cost}, long {long_cost}"
9708 );
9709
9710 let mut index = Index::with_journal_capacity_bytes("/root", short_cost + long_cost - 1);
9711 index.apply_ok(&Observation::new(short()));
9712 index.apply_ok(&Observation::new(long()));
9713
9714 let since = index.since(Clock::ZERO);
9715 assert!(since.truncated, "an item budget kept both commits; a byte budget cannot");
9716 assert_eq!(since.commits.len(), 1);
9717 assert_eq!(since.commits[0].changes[0].path(), Path::new(&long_name));
9718 }
9719
9720 #[test]
9721 fn impact_drops_an_overflowing_path_set_instead_of_truncating_it() {
9722 let mut index = Index::new("/root");
9723 let ops = (0..=MAX_DIRTY_PATHS)
9724 .map(|which| {
9725 upsert(
9726 &format!("file-{which}.txt"),
9727 EntryKind::File,
9728 file_attrs(u64::try_from(which).expect("bounded"), 1),
9729 )
9730 })
9731 .collect();
9732
9733 let commit =
9734 index.apply_ok(&Observation::new(ops)).commit.expect("overflowing impact commit");
9735
9736 assert!(commit.impact.all_dirty);
9737 assert!(commit.impact.dirty_paths.is_empty(), "a partial path list must not escape");
9738 assert_eq!(commit.changes.len(), MAX_DIRTY_PATHS + 1);
9739 }
9740
9741 #[test]
9742 fn invalidations_are_queued_for_the_scan_layer() {
9743 let mut index = Index::new("/root");
9744 let stats = index.apply_ok(&Observation::new(vec![Op::InvalidateSubtree {
9745 path: PathBuf::from("src"),
9746 reason: InvalidateReason::WatchOverflow,
9747 }]));
9748
9749 assert_eq!(stats.invalidated, 1);
9750 let pending = index.take_pending_invalidations();
9751 assert_eq!(pending.len(), 1);
9752 assert_eq!(pending[0].0, PathBuf::from("src"));
9753 assert_eq!(pending[0].1, InvalidateReason::WatchOverflow);
9754 assert!(index.take_pending_invalidations().is_empty(), "drained once");
9755 }
9756
9757 #[test]
9758 fn paths_escaping_the_root_are_rejected() {
9759 assert!(normalize(Path::new("../escape")).is_none());
9760 assert!(normalize(Path::new("/absolute")).is_none());
9761 assert_eq!(
9762 normalize(Path::new("./a/b")).expect("relative"),
9763 vec![OsString::from("a"), OsString::from("b")]
9764 );
9765
9766 let mut index = Index::new("/root");
9767 let upsert_error = index
9768 .apply(&Observation::new(vec![upsert("../escape", EntryKind::File, file_attrs(1, 1))]))
9769 .expect_err("escaping upsert");
9770 assert!(matches!(upsert_error, crate::Error::PathEscapesRoot(_)));
9771 assert_eq!(index.total().files, 0);
9772
9773 let invalidation_error = index
9774 .apply(&Observation::new(vec![Op::InvalidateSubtree {
9775 path: PathBuf::from("../outside"),
9776 reason: InvalidateReason::Requested,
9777 }]))
9778 .expect_err("escaping invalidation");
9779 assert!(matches!(invalidation_error, crate::Error::PathEscapesRoot(_)));
9780 assert!(index.take_pending_invalidations().is_empty());
9781 assert_eq!(index.freshness(), Freshness::Fresh);
9782 }
9783
9784 #[cfg(unix)]
9785 #[test]
9786 fn distinct_non_utf8_names_have_distinct_identity() {
9787 use std::ffi::OsString;
9788 use std::os::unix::ffi::OsStringExt;
9789
9790 let first = PathBuf::from(OsString::from_vec(vec![b'n', 0x80]));
9791 let second = PathBuf::from(OsString::from_vec(vec![b'n', 0x81]));
9792 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
9793 "/root",
9794 ScanScope::default(),
9795 crate::classify::TypeRegistry::compiled_shared(),
9796 DEFAULT_JOURNAL_CAPACITY_BYTES,
9797 );
9798 index.apply_ok(&Observation::new(vec![
9799 Op::Upsert { path: first.clone(), kind: EntryKind::File, attrs: file_attrs(10, 1) },
9800 Op::Upsert { path: second.clone(), kind: EntryKind::File, attrs: file_attrs(20, 2) },
9801 ]));
9802
9803 assert_eq!(index.total().files, 2);
9804 assert_eq!(index.total().bytes, 30);
9805 assert!(index.lookup(&first).is_some());
9806 assert!(index.lookup(&second).is_some());
9807 assert_serving_indexes(&index);
9808 }
9809
9810 #[cfg(unix)]
9811 #[test]
9812 fn a_non_utf8_parent_still_lists_its_children() {
9813 use std::ffi::OsString;
9814 use std::os::unix::ffi::OsStringExt;
9815
9816 let directory = PathBuf::from(OsString::from_vec(vec![b'd', 0x80]));
9817 let child = directory.join("child");
9818 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
9819 "/root",
9820 ScanScope::default(),
9821 crate::classify::TypeRegistry::compiled_shared(),
9822 DEFAULT_JOURNAL_CAPACITY_BYTES,
9823 );
9824 index.apply_ok(&Observation::new(vec![
9825 Op::Upsert { path: directory.clone(), kind: EntryKind::Dir, attrs: Attrs::default() },
9826 Op::Upsert { path: child, kind: EntryKind::File, attrs: file_attrs(1, 1) },
9827 ]));
9828
9829 assert_serving_indexes(&index);
9830 assert_eq!(
9834 index.portable_children(&directory).map(|children| children.nondirectories.len()),
9835 Some(1)
9836 );
9837 assert!(
9838 index.portable_entries().keys().any(|portable| portable.as_str() == "d%80/child"),
9839 "a child under a non-utf8 directory is listed at its escaped path"
9840 );
9841 }
9842
9843 #[cfg(unix)]
9844 #[test]
9845 fn non_utf8_stem_keeps_ascii_extension_tally() {
9846 use std::ffi::OsString;
9847 use std::os::unix::ffi::OsStringExt;
9848
9849 let path = PathBuf::from(OsString::from_vec(vec![b'n', 0x80, b'.', b'R', b'S']));
9850 let mut index = Index::new("/root");
9851 index.apply_ok(&Observation::new(vec![Op::Upsert {
9852 path,
9853 kind: EntryKind::File,
9854 attrs: file_attrs(10, 1),
9855 }]));
9856
9857 let tallies = index.total().by_ext;
9858 assert_eq!(
9859 tallies.get(".rs"),
9860 Some(&ExtTally { files: 1, bytes: 10, allocated: file_attrs(10, 1).allocated })
9861 );
9862 }
9863
9864 #[test]
9865 fn restore_candidates_count_visited_files_not_pathbuf_aliases() {
9866 use crate::content::AnalysisSet;
9867
9868 let mut index = Index::new("/root");
9869 let attrs = file_attrs(10, 1);
9870 assert!(
9871 index
9872 .insert_loaded_child(EntryId::ROOT, OsString::from("a"), EntryKind::File, attrs)
9873 .is_some()
9874 );
9875 assert!(
9876 index
9877 .insert_loaded_child(EntryId::ROOT, OsString::from("a/"), EntryKind::File, attrs)
9878 .is_some()
9879 );
9880 let (candidates, visited) =
9881 index.restore_analysis_candidates(AnalysisSet::NONE.with_lines());
9882 assert_eq!(visited, 2, "each visited regular file is a completeness slot");
9883 assert_eq!(candidates.len(), 1, "PathBuf keys merge trailing-separator aliases");
9884 }
9885
9886 #[test]
9887 fn restore_candidates_match_analysis_file_identities() {
9888 use crate::content::AnalysisSet;
9889
9890 let mut index = Index::new("/root");
9891 index.apply_ok(&Observation::new(vec![
9892 upsert("src", EntryKind::Dir, file_attrs(0, 1)),
9893 upsert("src/nested", EntryKind::Dir, file_attrs(0, 2)),
9894 upsert("src/nested/lib.rs", EntryKind::File, file_attrs(10, 1)),
9895 upsert("README.md", EntryKind::File, file_attrs(20, 2)),
9896 ]));
9897 let profile = AnalysisSet::NONE.with_lines();
9898 let live = index.analysis_candidates(profile);
9899 let (restore, visited) = index.restore_analysis_candidates(profile);
9900 assert_eq!(restore.len(), live.len());
9901 assert_eq!(visited, u64::try_from(live.len()).expect("candidate count fits u64"));
9902 assert_eq!(restore.len(), 2);
9903 for candidate in &live {
9904 assert_eq!(
9905 index.path_of(candidate.entry_id).as_deref(),
9906 Some(candidate.relative_path.as_path())
9907 );
9908 let restored = restore.get(&candidate.relative_path).expect("same relative path");
9909 assert_eq!(restored.entry_id, candidate.entry_id);
9910 assert_eq!(restored.revision, candidate.revision);
9911 assert_eq!(restored.attrs.fingerprint(), candidate.attrs.fingerprint());
9912 }
9913 }
9914
9915 #[test]
9916 fn content_results_commit_conditionally_and_metadata_changes_invalidate_them() {
9917 use crate::content::{
9918 AnalysisApplyOutcome, AnalysisRequest, AnalysisSet, AnalyzerOutcome, BasicMetrics,
9919 ContentProvenance, FileAnalysis,
9920 };
9921
9922 let mut index = Index::new("/root");
9923 index.apply_ok(&Observation::new(vec![
9924 upsert("src", EntryKind::Dir, file_attrs(0, 1)),
9925 upsert("src/lib.rs", EntryKind::File, file_attrs(10, 1)),
9926 ]));
9927 let profile = AnalysisSet::NONE.with_lines();
9928 let candidate =
9929 index.analysis_candidates(profile).into_iter().next().expect("file candidate");
9930 let analysis = FileAnalysis {
9931 fingerprint: candidate.attrs.fingerprint(),
9932 bytes: candidate.attrs.size,
9933 detection: candidate.classification.clone().into(),
9934 lines: AnalyzerOutcome::analyzed(BasicMetrics {
9935 physical_lines: 2,
9936 nonblank_lines: 2,
9937 ..BasicMetrics::default()
9938 }),
9939 code: None,
9940 words: None,
9941 error: None,
9942 };
9943 let provenance = ContentProvenance::for_request(
9944 AnalysisRequest { profile, ..AnalysisRequest::default() },
9945 crate::classify::type_rule_fingerprint(),
9946 );
9947 let observation = AnalysisObservation {
9948 candidate: candidate.clone(),
9949 profile,
9950 provenance: provenance.clone(),
9951 analysis: analysis.clone(),
9952 };
9953 assert_eq!(
9954 index.apply_analysis(observation.clone()),
9955 AnalysisApplyOutcome::Stale,
9956 "an index prepared for no content identity holds no record"
9957 );
9958 assert!(index.content().is_none());
9959
9960 index.prepare_content_analysis(AnalysisRequest { profile, ..AnalysisRequest::default() });
9961 assert_eq!(index.content_set(), profile);
9962 assert_eq!(index.apply_analysis(observation), AnalysisApplyOutcome::Applied);
9963 assert_eq!(
9964 index
9965 .content_rollup(Path::new(""))
9966 .expect("content root")
9967 .total
9968 .lines
9969 .metrics
9970 .physical_lines,
9971 2
9972 );
9973
9974 index.apply_ok(&Observation::new(vec![upsert(
9975 "src/lib.rs",
9976 EntryKind::File,
9977 file_attrs(20, 2),
9978 )]));
9979 assert!(index.content_rollup(Path::new("")).is_none());
9980 assert_eq!(
9981 index.apply_analysis(AnalysisObservation { candidate, profile, provenance, analysis }),
9982 AnalysisApplyOutcome::Stale
9983 );
9984 }
9985
9986 #[test]
9987 fn operational_content_failures_are_retained_but_retried_until_recovery() {
9988 use crate::content::{
9989 AnalysisApplyOutcome, AnalysisRequest, AnalysisSet, AnalyzerOutcome, BasicMetrics,
9990 ContentProvenance, CoverageReason, FileAnalysis,
9991 };
9992
9993 let mut index = Index::new("/root");
9994 index.apply_ok(&Observation::new(vec![
9995 upsert("src", EntryKind::Dir, file_attrs(0, 1)),
9996 upsert("src/lib.rs", EntryKind::File, file_attrs(10, 1)),
9997 ]));
9998 let profile = AnalysisSet::LINES_ONLY;
9999 let request = AnalysisRequest { profile, ..AnalysisRequest::default() };
10000 index.prepare_content_analysis(request);
10001 let candidate = index.pending_analysis_candidates(request).pop().expect("candidate");
10002 let provenance =
10003 ContentProvenance::for_request(request, crate::classify::type_rule_fingerprint());
10004 let record = |reason, error: &str| FileAnalysis {
10005 fingerprint: candidate.attrs.fingerprint(),
10006 bytes: candidate.attrs.size,
10007 detection: candidate.classification.clone().into(),
10008 lines: AnalyzerOutcome::unavailable(reason),
10009 code: None,
10010 words: None,
10011 error: Some(error.to_owned()),
10012 };
10013
10014 for (reason, error) in [
10015 (CoverageReason::IoError, "read failed"),
10016 (CoverageReason::ChangedDuringRead, "changed during read"),
10017 ] {
10018 assert_eq!(
10019 index.apply_analysis(AnalysisObservation {
10020 candidate: candidate.clone(),
10021 profile,
10022 provenance: provenance.clone(),
10023 analysis: record(reason, error),
10024 }),
10025 AnalysisApplyOutcome::Applied
10026 );
10027 let retained = index.content().expect("content").file(Path::new("src/lib.rs"));
10028 assert_eq!(retained.and_then(FileAnalysis::operational_failure), Some(reason));
10029 assert_eq!(
10030 index.pending_analysis_candidates(request).len(),
10031 1,
10032 "an operational failure must remain pending"
10033 );
10034 }
10035
10036 let recovered = FileAnalysis {
10037 fingerprint: candidate.attrs.fingerprint(),
10038 bytes: candidate.attrs.size,
10039 detection: candidate.classification.clone().into(),
10040 lines: AnalyzerOutcome::analyzed(BasicMetrics {
10041 physical_lines: 1,
10042 nonblank_lines: 1,
10043 raw_words: 1,
10044 ..BasicMetrics::default()
10045 }),
10046 code: None,
10047 words: None,
10048 error: None,
10049 };
10050 assert_eq!(
10051 index.apply_analysis(AnalysisObservation {
10052 candidate,
10053 profile,
10054 provenance,
10055 analysis: recovered,
10056 }),
10057 AnalysisApplyOutcome::Applied
10058 );
10059 assert!(index.pending_analysis_candidates(request).is_empty());
10060 assert_eq!(
10061 index
10062 .content()
10063 .expect("content")
10064 .file(Path::new("src/lib.rs"))
10065 .and_then(FileAnalysis::operational_failure),
10066 None
10067 );
10068 }
10069
10070 #[test]
10073 fn an_index_that_did_not_observe_controls_refuses_ignore_questions() {
10074 let mut index =
10075 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
10076 assert!(!index.observes_controls());
10077 index.apply_ok(&Observation::new(vec![upsert(
10078 "debug.log",
10079 EntryKind::File,
10080 file_attrs(10, 1),
10081 )]));
10082 for path in ["debug.log", "absent.log"] {
10083 assert!(
10084 matches!(
10085 index.is_ignored(Path::new(path)),
10086 Err(crate::Error::ControlStateNotObserved)
10087 ),
10088 "{path}"
10089 );
10090 }
10091 assert!(matches!(index.controls(), Err(crate::Error::ControlStateNotObserved)));
10092
10093 let mut observed =
10094 Index::new_with_scope("/root", crate::test_support::observing_controls());
10095 assert!(observed.observes_controls());
10096 observed.apply_ok(&Observation::new(vec![upsert(
10097 "debug.log",
10098 EntryKind::File,
10099 file_attrs(10, 1),
10100 )]));
10101 assert_eq!(observed.is_ignored(Path::new("debug.log")).ok(), Some(Some(false)));
10102 assert_eq!(observed.is_ignored(Path::new("absent.log")).ok(), Some(None));
10103 assert!(observed.controls().is_ok_and(crate::control::ControlTable::is_empty));
10104 }
10105
10106 #[test]
10113 fn an_index_that_does_not_observe_controls_refuses_control_input() {
10114 let mut index =
10115 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
10116 index.apply_ok(&Observation::new(vec![
10117 upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
10118 upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
10119 ]));
10120 let stale_baseline = index.expectation(Path::new(".gitignore"));
10121 index.apply_ok(&Observation::new(vec![upsert(
10122 ".gitignore",
10123 EntryKind::File,
10124 file_attrs(7, 3),
10125 )]));
10126 let clock = index.clock();
10127 let total = index.total();
10128 let controls = || {
10129 [
10130 Op::ControlUpsert {
10131 path: PathBuf::from(".gitignore"),
10132 source: b"*.log\n".to_vec(),
10133 },
10134 Op::ControlRemove { path: PathBuf::from(".gitignore") },
10135 ]
10136 };
10137
10138 for control in controls() {
10139 let batch = Observation::new(vec![
10140 upsert("new.txt", EntryKind::File, file_attrs(1, 4)),
10141 control.clone(),
10142 ]);
10143 assert!(
10144 matches!(index.apply(&batch), Err(crate::Error::ControlStateNotObserved)),
10145 "{control:?}"
10146 );
10147 assert!(
10148 matches!(index.apply_baseline(&batch), Err(crate::Error::ControlStateNotObserved)),
10149 "{control:?}"
10150 );
10151 let stale = Observation::from_ops(vec![ObservationOp::if_state(
10152 control.clone(),
10153 stale_baseline,
10154 )]);
10155 assert!(
10156 matches!(index.apply(&stale), Err(crate::Error::ControlStateNotObserved)),
10157 "stale {control:?}"
10158 );
10159 }
10160 assert_eq!(index.clock(), clock, "a refused batch commits nothing");
10161 assert_eq!(index.total(), total);
10162 assert!(index.lookup(Path::new("new.txt")).is_none());
10163 assert!(index.control_table().is_empty());
10164
10165 let mut table = crate::control::ControlTable::default();
10166 table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control source");
10167 assert!(matches!(
10168 index.install_controls(table),
10169 Err(crate::Error::ControlStateNotObserved)
10170 ));
10171 assert!(index.control_table().is_empty());
10172
10173 let mut observed =
10174 Index::new_with_scope("/root", crate::test_support::observing_controls());
10175 for control in controls() {
10176 observed
10177 .apply(&Observation::new(vec![
10178 upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
10179 control,
10180 ]))
10181 .expect("an observing index accepts control input");
10182 }
10183 }
10184
10185 #[test]
10190 fn an_index_that_did_not_observe_controls_states_no_partition_or_child_ignore_fact() {
10191 let tree = Observation::new(vec![
10192 upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
10193 upsert("dir/debug.log", EntryKind::File, file_attrs(10, 2)),
10194 ]);
10195 let mut unobserved =
10196 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
10197 unobserved.apply_ok(&tree);
10198 assert!(matches!(unobserved.partition_total(), Err(crate::Error::ControlStateNotObserved)));
10199 for path in ["", "dir", "dir/debug.log", "absent"] {
10200 assert!(
10201 matches!(
10202 unobserved.partition_rollup(Path::new(path)),
10203 Err(crate::Error::ControlStateNotObserved)
10204 ),
10205 "{path}"
10206 );
10207 assert!(
10208 matches!(
10209 unobserved.partition_rollup_summary(Path::new(path)),
10210 Err(crate::Error::ControlStateNotObserved)
10211 ),
10212 "{path}"
10213 );
10214 }
10215 assert_eq!(unobserved.total().files, 1, "the all partition still answers");
10216 let children = IndexHandle::new(unobserved)
10217 .children(Path::new(""))
10218 .expect("children read")
10219 .expect("root directory");
10220 assert_eq!(children.len(), 1);
10221 assert_eq!(children[0].ignored, None);
10222 assert_eq!(children[0].partitions, None);
10223 assert_eq!(children[0].rollup.as_ref().map(|rollup| rollup.files), Some(1));
10224
10225 let mut observed =
10226 Index::new_with_scope("/root", crate::test_support::observing_controls());
10227 observed.apply_ok(&tree);
10228 assert_eq!(observed.partition_total().expect("control state observed").unignored.files, 1);
10229 assert!(
10230 observed.partition_rollup(Path::new("dir")).expect("control state observed").is_some()
10231 );
10232 assert_eq!(
10233 observed
10234 .partition_rollup_summary(Path::new("dir/debug.log"))
10235 .expect("control state observed"),
10236 None,
10237 "a file has no partitions"
10238 );
10239 let children = IndexHandle::new(observed)
10240 .children(Path::new(""))
10241 .expect("children read")
10242 .expect("root directory");
10243 assert_eq!(children[0].ignored, Some(false));
10244 assert_eq!(
10245 children[0].partitions.as_ref().map(|partitions| partitions.unignored.files),
10246 Some(1)
10247 );
10248 }
10249
10250 #[test]
10251 fn control_changes_atomically_move_fixed_partitions_without_changing_all() {
10252 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
10253 index.apply_ok(&Observation::new(vec![
10254 upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
10255 upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
10256 upsert("keep.rs", EntryKind::File, file_attrs(20, 3)),
10257 upsert("docs", EntryKind::Dir, file_attrs(0, 4)),
10258 upsert("docs/other.log", EntryKind::File, file_attrs(30, 5)),
10259 upsert("docs/keep.log", EntryKind::File, file_attrs(40, 6)),
10260 ]));
10261 let before = index.partition_total().expect("control state observed");
10262
10263 let outcome = index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
10264 path: PathBuf::from(".gitignore"),
10265 source: b"*.log\n".to_vec(),
10266 }]));
10267 let partitions = index.partition_total().expect("control state observed");
10268
10269 assert_eq!(partitions.all, before.all, "classification never changes all facts");
10270 assert_eq!(partitions.all.files, 5);
10271 assert_eq!(partitions.unignored.files, 2);
10272 assert_eq!(partitions.unignored.bytes, 26);
10273 assert_eq!(outcome.controls, 1);
10274 assert_eq!(outcome.reclassified, 3);
10275 assert_eq!(
10276 index.is_ignored(Path::new("debug.log")).expect("control state observed"),
10277 Some(true)
10278 );
10279 assert_eq!(
10280 index.is_ignored(Path::new("keep.rs")).expect("control state observed"),
10281 Some(false)
10282 );
10283
10284 let commit = outcome.commit.expect("control and classification commit together");
10285 assert!(matches!(
10286 commit.changes.first(),
10287 Some(EffectiveChange::ControlUpdated { path, previous: None, current: Some(_) })
10288 if path == Path::new(".gitignore")
10289 ));
10290 assert_eq!(
10291 commit
10292 .changes
10293 .iter()
10294 .filter(|change| matches!(change, EffectiveChange::Reclassified { .. }))
10295 .count(),
10296 3
10297 );
10298 }
10299
10300 #[test]
10301 fn serving_semantics_follow_ignore_reclassification_exactly() {
10302 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
10303 "/root",
10304 crate::test_support::observing_controls(),
10305 crate::classify::TypeRegistry::compiled_shared(),
10306 DEFAULT_JOURNAL_CAPACITY_BYTES,
10307 );
10308 index.apply_ok(&Observation::new(vec![
10309 upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
10310 upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
10311 upsert("keep.rs", EntryKind::File, file_attrs(20, 3)),
10312 upsert("Makefile", EntryKind::File, file_attrs(30, 4)),
10313 ]));
10314 assert_serving_indexes(&index);
10315
10316 index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
10317 path: PathBuf::from(".gitignore"),
10318 source: b"*.log\nMakefile\n".to_vec(),
10319 }]));
10320 assert_serving_indexes(&index);
10321
10322 index.apply_ok(&Observation::new(vec![Op::ControlRemove {
10323 path: PathBuf::from(".gitignore"),
10324 }]));
10325 assert_serving_indexes(&index);
10326 }
10327
10328 #[test]
10329 fn nested_negation_edit_and_last_control_deletion_reclassify_exactly() {
10330 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
10331 index.apply_ok(&Observation::new(vec![
10332 upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
10333 upsert("docs", EntryKind::Dir, file_attrs(0, 2)),
10334 upsert("docs/.gitignore", EntryKind::File, file_attrs(10, 3)),
10335 upsert("docs/keep.log", EntryKind::File, file_attrs(40, 4)),
10336 Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
10337 Op::ControlUpsert {
10338 path: PathBuf::from("docs/.gitignore"),
10339 source: b"!keep.log\n".to_vec(),
10340 },
10341 ]));
10342 assert_eq!(
10343 index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
10344 Some(false)
10345 );
10346
10347 let edited = index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
10348 path: PathBuf::from("docs/.gitignore"),
10349 source: b"# no exception\n".to_vec(),
10350 }]));
10351 assert_eq!(edited.reclassified, 1);
10352 assert_eq!(
10353 index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
10354 Some(true)
10355 );
10356
10357 let removed = index
10358 .apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from(".gitignore") }]));
10359 assert_eq!(removed.controls, 1, "removing the retained row removes its control state");
10360 assert_eq!(removed.reclassified, 1);
10361 assert_eq!(
10362 index.controls().expect("control state observed").len(),
10363 1,
10364 "the nested control remains"
10365 );
10366 assert_eq!(
10367 index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
10368 Some(false)
10369 );
10370
10371 index.apply_ok(&Observation::new(vec![Op::Remove {
10372 path: PathBuf::from("docs/.gitignore"),
10373 }]));
10374 assert!(index.controls().expect("control state observed").is_empty());
10375 assert_eq!(
10376 index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
10377 Some(false)
10378 );
10379 }
10380
10381 #[test]
10382 fn replacing_batch_ancestors_prunes_retained_and_transient_controls() {
10383 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
10384 index
10385 .apply(&Observation::new(vec![
10386 upsert("docs", EntryKind::Dir, file_attrs(0, 1)),
10387 upsert("docs/.gitignore", EntryKind::File, file_attrs(6, 2)),
10388 Op::ControlUpsert { path: "docs/.gitignore".into(), source: b"*.log\n".to_vec() },
10389 ]))
10390 .expect("retained control");
10391
10392 let outcome = index
10393 .apply(&Observation::new(vec![
10394 upsert("scratch", EntryKind::Dir, file_attrs(0, 3)),
10395 upsert("scratch/.gitignore", EntryKind::File, file_attrs(6, 4)),
10396 Op::ControlUpsert {
10397 path: "scratch/.gitignore".into(),
10398 source: b"*.log\n".to_vec(),
10399 },
10400 upsert("scratch", EntryKind::File, file_attrs(1, 5)),
10401 upsert("scratch", EntryKind::Dir, file_attrs(0, 6)),
10402 upsert("scratch/new.log", EntryKind::File, file_attrs(7, 7)),
10403 Op::Remove { path: "docs".into() },
10404 upsert("docs", EntryKind::Dir, file_attrs(0, 8)),
10405 upsert("docs/new.log", EntryKind::File, file_attrs(9, 9)),
10406 ]))
10407 .expect("mixed control and structural batch");
10408
10409 assert!(index.controls().expect("control state observed").is_empty());
10410 assert_eq!(
10411 index.is_ignored(Path::new("scratch/new.log")).expect("control state observed"),
10412 Some(false)
10413 );
10414 assert_eq!(
10415 index.is_ignored(Path::new("docs/new.log")).expect("control state observed"),
10416 Some(false)
10417 );
10418 assert_eq!(outcome.stats.controls, 1, "only the retained control has a net change");
10419 let controls = outcome
10420 .commit
10421 .as_ref()
10422 .expect("one exact commit")
10423 .changes
10424 .iter()
10425 .filter(|change| matches!(change, EffectiveChange::ControlUpdated { .. }))
10426 .collect::<Vec<_>>();
10427 assert!(matches!(
10428 controls.as_slice(),
10429 [EffectiveChange::ControlUpdated { path, previous: Some(_), current: None }]
10430 if path == Path::new("docs/.gitignore")
10431 ));
10432 }
10433
10434 #[test]
10438 fn an_over_budget_control_is_refused_while_its_batch_commits() {
10439 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
10440 "/root",
10441 crate::test_support::observing_controls(),
10442 crate::classify::TypeRegistry::compiled_shared(),
10443 DEFAULT_JOURNAL_CAPACITY_BYTES,
10444 );
10445 index.apply_ok(&Observation::new(vec![
10446 Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
10447 upsert("debug.log", EntryKind::File, file_attrs(10, 1)),
10448 ]));
10449 assert_eq!(index.is_ignored(Path::new("debug.log")).expect("observed"), Some(true));
10450 let mut oversized = crate::control::source_at_test_limit();
10451 oversized.push(b'a');
10452
10453 let outcome = index.apply_ok(&Observation::new(vec![
10454 upsert("ordinary.txt", EntryKind::File, file_attrs(1, 2)),
10455 Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: oversized },
10456 ]));
10457
10458 assert!(index.lookup(Path::new("ordinary.txt")).is_some(), "ordinary work commits");
10459 assert_eq!(index.total().files, 2);
10460 assert_eq!(
10461 index.is_ignored(Path::new("debug.log")).expect("observed"),
10462 None,
10463 "the refused replacement leaves classification unknown"
10464 );
10465 let changes = &outcome.commit.as_ref().expect("one commit").changes;
10466 let refused = Some(crate::control::ControlRefusalReason::Budget);
10467 assert!(changes.iter().any(|change| matches!(
10468 change,
10469 EffectiveChange::ControlUpdated { previous: Some(_), current: None, .. }
10470 )));
10471 assert!(changes.iter().any(|change| matches!(
10472 change,
10473 EffectiveChange::ControlRefusalUpdated { previous: None, current, .. }
10474 if *current == refused
10475 )));
10476 let crate::control::ControlCoverage::Observed(coverage) = index.control_coverage() else {
10477 panic!("an observing index reports observed coverage");
10478 };
10479 assert_eq!((coverage.applied, coverage.refused), (0, 1));
10480 assert_eq!(coverage.refusals[0].path, Path::new(".gitignore"));
10481
10482 let lifted = index.apply_ok(&Observation::new(vec![Op::ControlRemove {
10484 path: PathBuf::from(".gitignore"),
10485 }]));
10486 assert!(matches!(
10487 lifted.commit.as_ref().expect("lifting a refusal commits").changes.as_slice(),
10488 [EffectiveChange::ControlRefusalUpdated { previous, current: None, .. }]
10489 if *previous == refused
10490 ));
10491 assert_eq!(
10492 index.control_coverage(),
10493 crate::control::ControlCoverage::Observed(crate::control::ControlObservation {
10494 limits: crate::control::ControlLimits::default(),
10495 applied: 0,
10496 rules: 0,
10497 refused: 0,
10498 refusals: Vec::new(),
10499 })
10500 );
10501 }
10502
10503 #[test]
10510 fn a_batch_that_cannot_change_the_control_table_does_not_copy_it() {
10511 let projection_clones = |index: &mut Index, ops: Vec<Op>| {
10512 CONTROL_PROJECTION_CLONES.with(|clones| clones.set(0));
10513 let outcome = index.apply_ok(&Observation::new(ops));
10514 (CONTROL_PROJECTION_CLONES.with(std::cell::Cell::get), outcome)
10515 };
10516 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
10517
10518 let (clones, _) = projection_clones(
10521 &mut index,
10522 vec![
10523 upsert("cold", EntryKind::Dir, file_attrs(0, 1)),
10524 upsert("cold/file.txt", EntryKind::File, file_attrs(1, 1)),
10525 ],
10526 );
10527 assert_eq!(clones, 0, "an empty table has nothing a structural batch can change");
10528
10529 let mut over_budget = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
10530 over_budget.push(b'\n');
10531 index.apply_ok(&Observation::new(vec![
10532 upsert("keep", EntryKind::Dir, file_attrs(0, 1)),
10533 upsert("keep/file.txt", EntryKind::File, file_attrs(3, 1)),
10534 upsert("vendor", EntryKind::Dir, file_attrs(0, 1)),
10535 Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
10536 Op::ControlUpsert {
10537 path: PathBuf::from("vendor/.gitignore"),
10538 source: over_budget.clone(),
10539 },
10540 ]));
10541 let coverage = index.control_coverage();
10542
10543 let (clones, outcome) = projection_clones(
10546 &mut index,
10547 vec![
10548 upsert("keep/file.txt", EntryKind::File, file_attrs(4, 1)),
10549 Op::ControlUpsert {
10550 path: PathBuf::from(".gitignore"),
10551 source: b"*.log\n".to_vec(),
10552 },
10553 Op::ControlUpsert { path: PathBuf::from("vendor/.gitignore"), source: over_budget },
10554 ],
10555 );
10556 assert_eq!(clones, 0, "no control op changes anything");
10557 assert_eq!(index.control_coverage(), coverage);
10558 assert!(!outcome.commit.expect("the file's size changed").changes.iter().any(
10559 |change| matches!(
10560 change,
10561 EffectiveChange::ControlUpdated { .. }
10562 | EffectiveChange::ControlRefusalUpdated { .. }
10563 )
10564 ));
10565
10566 let (clones, _) = projection_clones(
10568 &mut index,
10569 vec![Op::ControlRemove { path: PathBuf::from("vendor/.gitignore") }],
10570 );
10571 assert_eq!(clones, 1);
10572 assert_eq!(index.controls().expect("observed").refused_len(), 0);
10573 }
10574
10575 #[test]
10577 fn removing_a_subtree_lifts_the_refusals_beneath_it() {
10578 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
10579 let mut line = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
10580 line.push(b'\n');
10581 index.apply_ok(&Observation::new(vec![
10582 upsert("vendor", EntryKind::Dir, file_attrs(0, 1)),
10583 upsert("vendor/.gitignore", EntryKind::File, file_attrs(16_386, 1)),
10584 Op::ControlUpsert { path: PathBuf::from("vendor/.gitignore"), source: line },
10585 ]));
10586 assert_eq!(index.controls().expect("observed").refused_len(), 1);
10587
10588 let outcome =
10589 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("vendor") }]));
10590
10591 assert_eq!(index.controls().expect("observed").refused_len(), 0);
10592 assert!(outcome.commit.expect("commit").changes.iter().any(|change| matches!(
10593 change,
10594 EffectiveChange::ControlRefusalUpdated { current: None, .. }
10595 )));
10596 }
10597
10598 #[test]
10599 fn one_sweep_reports_one_as_of_time_for_everything_it_verified() {
10600 let mut index = Index::new("/root");
10608 index.set_applying_source(Source::Cached, 1_000);
10609 index.apply_ok(&Observation::new(vec![
10610 Op::Upsert { path: "a".into(), kind: EntryKind::Dir, attrs: file_attrs(0, 1) },
10611 Op::Upsert {
10612 path: "a/kept.txt".into(),
10613 kind: EntryKind::File,
10614 attrs: file_attrs(1, 1),
10615 },
10616 Op::Upsert {
10617 path: "a/changed.txt".into(),
10618 kind: EntryKind::File,
10619 attrs: file_attrs(2, 2),
10620 },
10621 ]));
10622
10623 index.set_applying_source(Source::Revalidated, 2_000);
10625 index.begin_reconcile(Path::new("")).expect("begin reconciliation");
10626 index.apply_ok(&Observation::new(vec![
10627 Op::Upsert {
10628 path: "a/kept.txt".into(),
10629 kind: EntryKind::File,
10630 attrs: file_attrs(1, 1),
10631 },
10632 Op::Upsert {
10633 path: "a/changed.txt".into(),
10634 kind: EntryKind::File,
10635 attrs: file_attrs(9, 2),
10636 },
10637 ]));
10638 index
10639 .finish_reconcile(
10640 Path::new(""),
10641 0,
10642 true,
10643 &[],
10644 &[],
10645 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10646 )
10647 .expect("finish reconciliation");
10648
10649 let kept = index.provenance(Path::new("a/kept.txt")).expect("present");
10650 let changed = index.provenance(Path::new("a/changed.txt")).expect("present");
10651 assert!(kept.is_verified() && changed.is_verified(), "the sweep covered both");
10652 assert_eq!(
10653 kept.observed_at_ns, changed.observed_at_ns,
10654 "one sweep, one as-of time: {kept:?} vs {changed:?}"
10655 );
10656 }
10657
10658 #[test]
10659 fn withdrawn_trust_beats_a_verification_interval() {
10660 let mut index = Index::new("/root");
10665 index.set_applying_source(Source::Cached, 1_000);
10667 index.apply_baseline_ok(&Observation::new(vec![
10668 Op::Upsert { path: PathBuf::from("a"), kind: EntryKind::Dir, attrs: Attrs::default() },
10669 Op::Upsert {
10670 path: PathBuf::from("a/file.txt"),
10671 kind: EntryKind::File,
10672 attrs: Attrs { size: 1, ..Attrs::default() },
10673 },
10674 ]));
10675 assert_eq!(
10676 index.provenance(Path::new("a/file.txt")).expect("present").source,
10677 Source::Cached,
10678 "nothing has checked it yet"
10679 );
10680 index
10682 .finish_reconcile(
10683 Path::new(""),
10684 0,
10685 true,
10686 &[],
10687 &[],
10688 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10689 )
10690 .expect("finish reconciliation");
10691 let path = Path::new("a/file.txt");
10692 assert_eq!(
10693 index.provenance(path).expect("present").source,
10694 Source::Revalidated,
10695 "a completed sweep covers this path"
10696 );
10697
10698 index.mark_unfresh(Path::new("a"), Freshness::Stale);
10700 let provenance = index.provenance(path).expect("present");
10701 assert!(
10702 !provenance.is_verified(),
10703 "an invalidated path must not read as verified: {provenance:?}"
10704 );
10705 assert_eq!(
10706 provenance.status,
10707 Status::Complete,
10708 "withdrawing trust changes how far to believe the value, not how much of \
10709 the subtree it covers: the cached total still accounts for every entry \
10710 beneath this path, and reporting it as Partial would tell a consumer the \
10711 number is still being built when it is merely unverified"
10712 );
10713 }
10714
10715 #[test]
10716 fn verification_intervals_stay_bounded() {
10717 let mut index = Index::new("/root");
10720 for which in 0..(MAX_VERIFIED_INTERVALS * 2) {
10721 index
10722 .finish_reconcile(
10723 &PathBuf::from(format!("dir-{which}")),
10724 0,
10725 true,
10726 &[],
10727 &[],
10728 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10729 )
10730 .expect("finish reconciliation");
10731 }
10732 assert!(
10733 index.verified.len() <= MAX_VERIFIED_INTERVALS,
10734 "interval list grew to {}",
10735 index.verified.len()
10736 );
10737 }
10738
10739 #[test]
10740 fn retained_walk_issues_are_the_same_first_paths_for_every_arrival_order() {
10741 let make = |order: Vec<usize>| {
10742 order
10743 .into_iter()
10744 .map(|number| {
10745 crate::Error::io(
10746 PathBuf::from(format!("/root/file-{number:02}")),
10747 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
10748 )
10749 })
10750 .collect::<Vec<_>>()
10751 };
10752 let mut reverse_order: Vec<_> = (0..66).rev().collect();
10753 reverse_order.push(65);
10754 let mut shuffled_order: Vec<_> = (0..66).step_by(2).collect();
10755 shuffled_order.extend((0..66).skip(1).step_by(2));
10756 shuffled_order.push(65);
10757
10758 let mut reverse = Index::new("/root");
10759 let mut reverse_errors = make(reverse_order);
10760 reverse.record_walk_errors(&mut reverse_errors);
10761 let mut shuffled = Index::new("/root");
10762 let mut shuffled_errors = make(shuffled_order);
10763 shuffled.record_walk_errors(&mut shuffled_errors);
10764
10765 let reverse_paths: Vec<_> =
10766 reverse.issues().iter().map(|issue| issue.path.clone().expect("path")).collect();
10767 let shuffled_paths: Vec<_> =
10768 shuffled.issues().iter().map(|issue| issue.path.clone().expect("path")).collect();
10769 assert_eq!(reverse_paths, shuffled_paths);
10770 assert_eq!(reverse_paths.len(), MAX_RETAINED_ISSUES);
10771 assert_eq!(reverse_paths.first().map(PathBuf::as_path), Some(Path::new("file-00")));
10772 assert_eq!(reverse_paths.last().map(PathBuf::as_path), Some(Path::new("file-63")));
10773 assert_eq!(reverse.state().issues.omitted, 2);
10774 assert_eq!(shuffled.state().issues.omitted, 2);
10775 assert_eq!(reverse.issue_epochs.len(), reverse.issues.len());
10776 assert_eq!(shuffled.issue_epochs.len(), shuffled.issues.len());
10777 }
10778
10779 #[test]
10780 fn repeated_partial_root_pass_replaces_bounded_issues_and_omitted_count() {
10781 let root = Path::new("/root");
10782 let mut index = Index::new(root);
10783 let run = |index: &mut Index, range: std::ops::Range<usize>| {
10784 let errors: Vec<_> = range
10785 .clone()
10786 .map(|number| {
10787 crate::Error::io(
10788 root.join(format!("file-{number:02}")),
10789 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
10790 )
10791 })
10792 .collect();
10793 let failed: Vec<_> =
10794 range.map(|number| PathBuf::from(format!("file-{number:02}"))).collect();
10795 let (started, _) = index.begin_reconcile(Path::new("")).expect("begin");
10796 index
10797 .finish_reconcile(
10798 Path::new(""),
10799 started,
10800 false,
10801 &[],
10802 &failed,
10803 ReconcileErrors { errors: &errors, terminal: None, disproves_old: true },
10804 )
10805 .expect("finish");
10806 };
10807
10808 run(&mut index, 0..66);
10809 assert_eq!(index.state().issues.omitted, 2);
10810 run(&mut index, 10..76);
10811
10812 assert_eq!(index.state().issues.omitted, 2, "a retry replaces the old omission count");
10813 assert_eq!(index.omitted_issue_epochs.len(), 1, "sequential failures use one bucket");
10814 assert_eq!(index.issues().len(), crate::MAX_RETAINED_ISSUES);
10815 assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("file-10")));
10816 assert_eq!(index.issues()[63].path.as_deref(), Some(Path::new("file-73")));
10817 }
10818
10819 #[test]
10820 fn partial_pass_preserves_an_issue_published_after_it_began() {
10821 let root = Path::new("/root");
10822 let mut index = Index::new(root);
10823 let (started, _) = index.begin_reconcile(Path::new("")).expect("begin");
10824 index.mark_unfresh(Path::new("concurrent"), Freshness::Stale);
10825 index.retain_issue(Issue::from_error_under(
10826 root,
10827 &crate::Error::io(
10828 root.join("concurrent"),
10829 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "concurrent"),
10830 ),
10831 ));
10832 let pass_error = crate::Error::io(
10833 root.join("pass"),
10834 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "pass"),
10835 );
10836
10837 index
10838 .finish_reconcile(
10839 Path::new(""),
10840 started,
10841 false,
10842 &[],
10843 &[PathBuf::from("pass")],
10844 ReconcileErrors { errors: &[pass_error], terminal: None, disproves_old: true },
10845 )
10846 .expect("finish");
10847
10848 let paths: Vec<_> =
10849 index.issues().iter().filter_map(|issue| issue.path.as_deref()).collect();
10850 assert_eq!(paths, [Path::new("concurrent"), Path::new("pass")]);
10851 }
10852
10853 #[test]
10854 fn older_root_closer_preserves_newer_pass_omissions_and_partial_coverage() {
10855 let root = Path::new("/root");
10856 let mut index = Index::new(root);
10857 for number in 0..66 {
10858 let path = PathBuf::from(format!("a-{number:02}"));
10859 index.retain_issue(Issue::observation_gap(
10860 &path,
10861 crate::InvalidateReason::WatchOverflow,
10862 ));
10863 }
10864 index.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
10865 assert_eq!(index.state.issues.omitted, 2);
10866
10867 let (older, _) = index.begin_reconcile(Path::new("")).expect("begin older pass");
10868 let (newer, _) = index.begin_reconcile(Path::new("")).expect("begin newer pass");
10869 let newer_errors = ["z-one", "z-two"].map(|path| {
10870 crate::Error::io(
10871 root.join(path),
10872 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
10873 )
10874 });
10875 index
10876 .finish_reconcile(
10877 Path::new(""),
10878 newer,
10879 false,
10880 &[],
10881 &[PathBuf::from("z-one"), PathBuf::from("z-two")],
10882 ReconcileErrors { errors: &newer_errors, terminal: None, disproves_old: true },
10883 )
10884 .expect("finish newer pass");
10885 assert_eq!(index.state.issues.omitted, 2);
10886
10887 index
10888 .finish_reconcile(
10889 Path::new(""),
10890 older,
10891 true,
10892 &[],
10893 &[],
10894 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10895 )
10896 .expect("finish older pass");
10897
10898 assert_eq!(index.state.issues.omitted, 2, "the older closer cannot erase newer omissions");
10899 assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10900 }
10901
10902 #[test]
10903 fn an_unvisited_aborted_scope_does_not_disprove_its_old_issue() {
10904 let root = Path::new("/root");
10905 let mut index = Index::new(root);
10906 index.retain_issue(Issue::from_error_under(
10907 root,
10908 &crate::Error::io(
10909 root.join("later/blocked"),
10910 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "old failure"),
10911 ),
10912 ));
10913 let (started, _) = index.begin_reconcile(Path::new("later")).expect("begin later scope");
10914
10915 index
10916 .finish_reconcile(
10917 Path::new("later"),
10918 started,
10919 false,
10920 &[],
10921 &[],
10922 ReconcileErrors { errors: &[], terminal: None, disproves_old: false },
10923 )
10924 .expect("close skipped scope");
10925
10926 assert_eq!(index.issues().len(), 1);
10927 assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("later/blocked")));
10928 }
10929 #[test]
10930 fn older_pass_cannot_publish_errors_after_newer_clean_verification() {
10931 let root = Path::new("/root");
10932 let mut index = Index::new(root);
10933 let (older, _) = index.begin_reconcile(Path::new("")).expect("begin older pass");
10934 let (newer, _) = index.begin_reconcile(Path::new("")).expect("begin newer pass");
10935 index
10936 .finish_reconcile(
10937 Path::new(""),
10938 newer,
10939 true,
10940 &[],
10941 &[],
10942 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10943 )
10944 .expect("finish newer pass");
10945 let stale_error = crate::Error::io(
10946 root.join("stale"),
10947 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "older failure"),
10948 );
10949
10950 index
10951 .finish_reconcile(
10952 Path::new(""),
10953 older,
10954 false,
10955 &[],
10956 &[PathBuf::from("stale")],
10957 ReconcileErrors { errors: &[stale_error], terminal: None, disproves_old: true },
10958 )
10959 .expect("finish superseded older pass");
10960
10961 assert!(index.issues().is_empty());
10962 assert_eq!(index.state.coverage, Coverage::Complete);
10963 assert_eq!(index.state.freshness, Freshness::Fresh);
10964 }
10965
10966 #[test]
10967 fn newer_unchanged_verification_refuses_an_older_conditional_fact() {
10968 let mut index = Index::new("/root");
10969 index
10970 .apply(&Observation::new(vec![Op::Upsert {
10971 path: PathBuf::from("same"),
10972 kind: EntryKind::File,
10973 attrs: file_attrs(1, 1),
10974 }]))
10975 .expect("fixture");
10976 let handle = IndexHandle::new(index);
10977 let baseline = handle.expectation(Path::new("same")).expect("baseline");
10978 let (older, _) = handle.begin_reconcile(Path::new("")).expect("begin older pass");
10979 let (newer, _) = handle.begin_reconcile(Path::new("")).expect("begin newer pass");
10980 handle
10981 .finish_reconcile(
10982 Path::new(""),
10983 newer,
10984 true,
10985 &[],
10986 &[],
10987 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10988 )
10989 .expect("finish unchanged newer pass");
10990
10991 let stale = handle
10992 .apply_reconcile(
10993 older,
10994 &Observation::from_ops(vec![ObservationOp::if_state(
10995 Op::Remove { path: PathBuf::from("same") },
10996 baseline,
10997 )]),
10998 )
10999 .expect("arbitrate older fact");
11000
11001 assert_eq!(stale.stats.stale, 1);
11002 assert!(stale.commit.is_none());
11003 assert!(handle.attrs(Path::new("same")).expect("attrs").is_some());
11004 }
11005
11006 #[test]
11007 fn newer_disjoint_verification_does_not_refuse_an_older_fact() {
11008 let mut index = Index::new("/root");
11009 index
11010 .apply(&Observation::new(vec![
11011 Op::Upsert {
11012 path: PathBuf::from("a"),
11013 kind: EntryKind::File,
11014 attrs: file_attrs(1, 1),
11015 },
11016 Op::Upsert {
11017 path: PathBuf::from("b"),
11018 kind: EntryKind::File,
11019 attrs: file_attrs(1, 1),
11020 },
11021 ]))
11022 .expect("fixture");
11023 let handle = IndexHandle::new(index);
11024 let baseline = handle.expectation(Path::new("a")).expect("baseline");
11025 let (older, _) = handle.begin_reconcile(Path::new("a")).expect("begin a");
11026 let (newer, _) = handle.begin_reconcile(Path::new("b")).expect("begin b");
11027 handle
11028 .finish_reconcile(
11029 Path::new("b"),
11030 newer,
11031 true,
11032 &[],
11033 &[],
11034 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
11035 )
11036 .expect("finish b");
11037
11038 let applied = handle
11039 .apply_reconcile(
11040 older,
11041 &Observation::from_ops(vec![ObservationOp::if_state(
11042 Op::Remove { path: PathBuf::from("a") },
11043 baseline,
11044 )]),
11045 )
11046 .expect("apply disjoint a fact");
11047
11048 assert_eq!(applied.stats.removed, 1);
11049 assert_eq!(applied.stats.stale, 0);
11050 assert!(handle.attrs(Path::new("a")).expect("attrs").is_none());
11051 }
11052 #[test]
11053 fn newer_child_verification_preserves_older_sibling_failure() {
11054 let root = Path::new("/root");
11055 let mut index = Index::new(root);
11056 index.apply_ok(&Observation::new(
11057 ["a", "a/old", "b", "b/blocked", "healthy"]
11058 .map(|path| Op::Upsert {
11059 path: PathBuf::from(path),
11060 kind: EntryKind::Dir,
11061 attrs: Attrs::default(),
11062 })
11063 .to_vec(),
11064 ));
11065 index.set_initial_scan_freshness(&[]);
11066 let (older, _) = index.begin_reconcile(Path::new("")).expect("older root");
11067 let (newer, _) = index.begin_reconcile(Path::new("a")).expect("newer child");
11068 index
11069 .finish_reconcile(
11070 Path::new("a"),
11071 newer,
11072 true,
11073 &[],
11074 &[],
11075 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
11076 )
11077 .expect("verify a");
11078 let errors = ["a/old", "b/blocked"].map(|path| {
11079 crate::Error::io(
11080 root.join(path),
11081 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed read"),
11082 )
11083 });
11084 index
11085 .finish_reconcile(
11086 Path::new(""),
11087 older,
11088 false,
11089 &[],
11090 &[PathBuf::from("a/old"), PathBuf::from("b/blocked")],
11091 ReconcileErrors { errors: &errors, terminal: None, disproves_old: true },
11092 )
11093 .expect("finish older root");
11094 for path in ["", "a", "a/old", "b", "healthy"] {
11095 assert_eq!(index.directory_complete(Path::new(path)), Some(true), "{path}");
11096 }
11097 assert_eq!(index.directory_complete(Path::new("b/blocked")), Some(false));
11098 assert_eq!(index.issues().len(), 1);
11099 assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("b/blocked")));
11100 assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
11101 assert_eq!(index.freshness_at(Path::new("a")), Freshness::Fresh);
11102 assert_eq!(index.freshness_at(Path::new("b/blocked")), Freshness::Partial);
11103 }
11104 #[test]
11105 fn failure_published_after_a_newer_pass_began_keeps_its_issue_with_its_mark() {
11106 let root = Path::new("/root");
11112 let mut index = Index::new(root);
11113 index.apply_ok(&Observation::new(
11114 ["x", "x/deep", "healthy"]
11115 .map(|path| Op::Upsert {
11116 path: PathBuf::from(path),
11117 kind: EntryKind::Dir,
11118 attrs: Attrs::default(),
11119 })
11120 .to_vec(),
11121 ));
11122 index.set_initial_scan_freshness(&[]);
11123 let (older_root, _) = index.begin_reconcile(Path::new("")).expect("older root");
11124 let (newer_child, _) = index.begin_reconcile(Path::new("x")).expect("newer child");
11125 let error = crate::Error::io(
11126 root.join("x"),
11127 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed read"),
11128 );
11129 index
11130 .finish_reconcile(
11131 Path::new(""),
11132 older_root,
11133 false,
11134 &[],
11135 &[PathBuf::from("x")],
11136 ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
11137 )
11138 .expect("older root fails on x");
11139 assert_eq!(index.freshness_at(Path::new("x")), Freshness::Partial);
11140 assert_eq!(index.issues().len(), 1);
11141
11142 index
11143 .finish_reconcile(
11144 Path::new("x"),
11145 newer_child,
11146 true,
11147 &[],
11148 &[],
11149 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
11150 )
11151 .expect("newer child verifies clean");
11152
11153 let explained = !index.issues().is_empty();
11154 let partial = index.freshness_at(Path::new("x")) == Freshness::Partial;
11155 assert_eq!(
11156 partial,
11157 explained,
11158 "a surviving partial mark and its issue must be kept or dropped together: \
11159 partial={partial}, issues={:?}",
11160 index.issues()
11161 );
11162 assert!(partial, "the mark minted after the child pass began is the newer claim");
11163 assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("x")));
11164 assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
11165 assert_eq!(index.state.freshness, Freshness::Partial);
11166 let request = crate::query::Request::new(
11167 crate::query::Basis::held_by(&index),
11168 crate::query::Query::default(),
11169 std::time::UNIX_EPOCH,
11170 );
11171 let status = crate::query::TreeStatus::of(&index, &request);
11172 assert!(!status.complete);
11173 assert_eq!(status.coverage, Coverage::Partial(CoverageReason::Inaccessible));
11174 assert_eq!(status.errors.len(), 1, "an incomplete status names its cause");
11175 }
11176 #[test]
11177 fn cold_scan_failure_does_not_verify_unknown_descendants_or_unscoped_work() {
11178 let mut index = Index::new("/root");
11179 index.apply_ok(&Observation::new(
11180 ["blocked", "blocked/nested", "healthy"]
11181 .map(|path| Op::Upsert {
11182 path: PathBuf::from(path),
11183 kind: EntryKind::Dir,
11184 attrs: Attrs::default(),
11185 })
11186 .to_vec(),
11187 ));
11188 let error = crate::Error::io(
11189 PathBuf::from("/root/blocked"),
11190 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed listing"),
11191 );
11192 index.set_initial_scan_freshness(&[error]);
11193 assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
11194 assert_eq!(index.directory_complete(Path::new("")), Some(true));
11195 assert_eq!(index.freshness_at(Path::new("")), Freshness::Partial);
11196 for path in ["blocked", "blocked/nested"] {
11197 assert_eq!(index.directory_complete(Path::new(path)), Some(false), "{path}");
11198 assert_eq!(index.freshness_at(Path::new(path)), Freshness::Partial, "{path}");
11199 }
11200 index.set_initial_scan_freshness(&[crate::Error::Snapshot("unscoped failure".into())]);
11201 assert_eq!(index.directory_complete(Path::new("healthy")), Some(false));
11202 assert_eq!(index.freshness_at(Path::new("healthy")), Freshness::Partial);
11203 }
11204
11205 #[test]
11206 fn unscoped_failure_publishes_listing_withdrawal_when_root_state_is_unchanged() {
11207 let mut index = Index::new("/root");
11208 index.apply_ok(&Observation::new(vec![Op::Upsert {
11209 path: PathBuf::from("healthy"),
11210 kind: EntryKind::Dir,
11211 attrs: Attrs::default(),
11212 }]));
11213 index.set_initial_scan_freshness(&[]);
11214 index.mark_unfresh(Path::new("elsewhere"), Freshness::Partial);
11215 index.state.freshness = Freshness::Partial;
11216 index.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
11217 let error = crate::Error::Snapshot("unscoped failure".into());
11218 index.retain_issue(Issue::from_error_under(&index.root_path, &error));
11219 let progress = index.state.progress;
11220 let (epoch, _) = index.begin_reconcile(Path::new("")).expect("begin partial root");
11221 let before = index.state;
11222 let clock = index.clock;
11223 let finished = index
11224 .finish_reconcile(
11225 Path::new(""),
11226 epoch,
11227 false,
11228 &[],
11229 &[],
11230 ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
11231 )
11232 .expect("finish failure");
11233 assert_eq!(index.state, before, "aggregate root state remains identical");
11234 assert_eq!(index.state.progress, progress, "discovery progress is cumulative");
11235 assert_eq!(index.directory_complete(Path::new("healthy")), Some(false));
11236 let commit = finished.commit.expect("withdrawal must publish even without another effect");
11237 assert!(index.clock > clock);
11238 assert_eq!(commit.clock, index.clock);
11239 assert!(
11240 commit
11241 .state
11242 .iter()
11243 .all(|effect| matches!(effect, StateTransition::DirectoryIncomplete { .. }))
11244 );
11245 assert!(
11246 commit
11247 .state
11248 .contains(&StateTransition::DirectoryIncomplete { path: PathBuf::from("healthy") })
11249 );
11250 assert!(commit.impact.dirty_paths.contains(&PathBuf::from("healthy")));
11251 }
11252
11253 #[test]
11254 fn omitted_failed_entry_withdraws_parent_listing_without_tainting_siblings() {
11255 let mut index = Index::new("/root");
11256 index.apply_ok(&Observation::new(vec![Op::Upsert {
11257 path: PathBuf::from("healthy"),
11258 kind: EntryKind::Dir,
11259 attrs: Attrs::default(),
11260 }]));
11261 let error = crate::Error::io(
11262 PathBuf::from("/root/missing"),
11263 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "metadata failed"),
11264 );
11265 index.set_initial_scan_freshness(&[error]);
11266 assert_eq!(index.directory_complete(Path::new("")), Some(false));
11267 assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
11268 index.set_initial_scan_freshness(&[]);
11269 let (epoch, _) = index.begin_reconcile(Path::new("")).expect("begin root");
11270 let error = crate::Error::io(
11271 PathBuf::from("/root/missing"),
11272 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "metadata failed"),
11273 );
11274 index
11275 .finish_reconcile(
11276 Path::new(""),
11277 epoch,
11278 false,
11279 &[],
11280 &[PathBuf::from("missing")],
11281 ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
11282 )
11283 .expect("partial root");
11284 assert_eq!(index.directory_complete(Path::new("")), Some(false));
11285 assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
11286 }
11287
11288 #[test]
11289 fn complete_older_root_does_not_verify_a_newer_failed_child() {
11290 let root = Path::new("/root");
11291 let mut index = Index::new(root);
11292 let (older, _) = index.begin_reconcile(Path::new("")).expect("older root");
11293 let (newer, _) = index.begin_reconcile(Path::new("child")).expect("newer child");
11294 let error = crate::Error::io(
11295 root.join("child"),
11296 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "new failure"),
11297 );
11298 index
11299 .finish_reconcile(
11300 Path::new("child"),
11301 newer,
11302 false,
11303 &[],
11304 &[PathBuf::from("child")],
11305 ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
11306 )
11307 .expect("failed child");
11308 let finish = index
11309 .finish_reconcile(
11310 Path::new(""),
11311 older,
11312 true,
11313 &[],
11314 &[],
11315 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
11316 )
11317 .expect("complete older walk");
11318 assert_eq!(index.issues().len(), 1);
11319 assert_eq!(index.freshness_at(Path::new("child")), Freshness::Partial);
11320 assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
11321 assert!(!finish.commit.iter().flat_map(|commit| commit.state.iter()).any(|state| {
11322 matches!(state, StateTransition::Verified { path } if path.as_os_str().is_empty())
11323 }));
11324 }
11325
11326 #[test]
11327 fn reconciliation_scope_budget_preserves_issues_and_newer_facts() {
11328 let mut index = Index::new("/root");
11329 index.apply_ok(&Observation::new(vec![Op::Upsert {
11330 path: PathBuf::from("kept"),
11331 kind: EntryKind::File,
11332 attrs: file_attrs(1, 1),
11333 }]));
11334 index.retain_issue(Issue::provider_failure(
11335 Some(Path::new("unvisited")),
11336 "earlier failure".into(),
11337 ));
11338 let (older, _) = index.begin_reconcile(Path::new("")).expect("older pass");
11339 let budget = index.active_reconciles[&older].scope_budget;
11340 assert_eq!(budget, 2, "root and kept file define the evidence budget");
11341 for number in 0..100 {
11342 let path = PathBuf::from(format!("missing-{number}"));
11343 let (newer, _) = index.begin_reconcile(&path).expect("newer pass");
11344 index
11345 .finish_reconcile(
11346 &path,
11347 newer,
11348 true,
11349 &[],
11350 &[],
11351 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
11352 )
11353 .expect("newer verification");
11354 if let ReconcileEvidence::Scopes(scopes) = &index.active_reconciles[&older].evidence {
11355 assert!(scopes.len() <= budget);
11356 }
11357 }
11358 assert!(matches!(index.active_reconciles[&older].evidence, ReconcileEvidence::Retry));
11359 index.apply_ok(&Observation::new(vec![Op::Upsert {
11360 path: PathBuf::from("kept"),
11361 kind: EntryKind::File,
11362 attrs: file_attrs(9, 2),
11363 }]));
11364 let finished = index
11365 .finish_reconcile(
11366 Path::new(""),
11367 older,
11368 true,
11369 &[],
11370 &[],
11371 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
11372 )
11373 .expect("close interrupted pass");
11374 assert!(finished.retry);
11375 assert!(finished.commit.is_some());
11376 assert_eq!(index.total_scalars().bytes, 9);
11377 assert!(
11378 index
11379 .issues()
11380 .iter()
11381 .any(|issue| issue.path.as_deref() == Some(Path::new("unvisited")))
11382 );
11383 assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
11384 assert!(index.active_reconciles.is_empty(), "closed passes retain no shadow history");
11385 }
11386
11387 fn sized(size: u64) -> Attrs {
11390 Attrs { size, allocated: size, mtime_ns: 1, ctime_ns: 1, inode: size ^ 7, dev: 1 }
11391 }
11392
11393 fn assert_unrepresentable(error: &crate::Error, path: &str, counter: &str) {
11394 match error {
11395 crate::Error::UnrepresentableTotal { path: at, counter: which } => {
11396 assert_eq!(at, Path::new(path));
11397 assert_eq!(*which, counter);
11398 }
11399 other => panic!("expected an unrepresentable-total refusal, got {other:?}"),
11400 }
11401 }
11402
11403 fn observable_state(index: &Index) -> (u64, Clock, RollUp, Vec<PathBuf>, usize) {
11405 let mut paths: Vec<PathBuf> = Vec::new();
11406 let mut pending = vec![EntryId::ROOT];
11407 while let Some(id) = pending.pop() {
11408 paths.push(index.path_of(id).expect("live entry"));
11409 if index.entry(id).kind.is_dir() {
11410 pending.extend(index.child_ids(id));
11411 }
11412 }
11413 paths.sort();
11414 (index.len(), index.clock(), index.total(), paths, index.journal.len())
11415 }
11416
11417 #[test]
11418 fn a_batch_whose_byte_total_would_exceed_u64_is_refused_before_any_mutation() {
11419 let mut index = Index::new("/root");
11420 let before = observable_state(&index);
11421 let error = index
11422 .apply(&Observation::new(vec![
11423 upsert("a", EntryKind::File, sized(u64::MAX)),
11424 upsert("b", EntryKind::File, sized(1)),
11425 ]))
11426 .expect_err("two files summing past u64::MAX cannot be represented");
11427 assert_unrepresentable(&error, "b", "bytes");
11428 assert_eq!(observable_state(&index), before, "a refused batch changes nothing");
11429
11430 index.apply_ok(&Observation::new(vec![upsert("a", EntryKind::File, sized(u64::MAX))]));
11433 let before = observable_state(&index);
11434 assert_eq!(before.2.bytes, u64::MAX);
11435 let error = index
11436 .apply(&Observation::new(vec![
11437 upsert("dir", EntryKind::Dir, Attrs::default()),
11438 upsert("dir/b", EntryKind::File, sized(1)),
11439 ]))
11440 .expect_err("one more byte is unrepresentable");
11441 assert_unrepresentable(&error, "dir/b", "bytes");
11442 assert_eq!(observable_state(&index), before);
11443 assert!(index.lookup(Path::new("dir")).is_none(), "the batch is fault-atomic");
11444
11445 let error = index
11447 .apply(&Observation::new(vec![upsert(
11448 "c",
11449 EntryKind::File,
11450 Attrs { size: 0, allocated: 1, ..sized(0) },
11451 )]))
11452 .expect_err("allocated bytes overflow on their own");
11453 assert_unrepresentable(&error, "c", "allocated bytes");
11454 assert_eq!(observable_state(&index), before);
11455 }
11456
11457 #[test]
11458 fn exact_fit_replacement_removal_and_kind_change_batches_are_accepted() {
11459 let mut index = Index::new("/root");
11461 index.apply_ok(&Observation::new(vec![upsert("a", EntryKind::File, sized(u64::MAX))]));
11462 index.apply_ok(&Observation::new(vec![
11463 upsert("a", EntryKind::File, sized(1)),
11464 upsert("b", EntryKind::File, sized(u64::MAX - 1)),
11465 ]));
11466 assert_eq!(index.total().bytes, u64::MAX);
11467 assert_eq!(index.total().files, 2);
11468
11469 let before = observable_state(&index);
11473 let error = index
11474 .apply(&Observation::new(vec![
11475 upsert("c", EntryKind::File, sized(5)),
11476 Op::Remove { path: PathBuf::from("b") },
11477 ]))
11478 .expect_err("an insertion before the removal that makes room for it");
11479 assert_unrepresentable(&error, "c", "bytes");
11480 assert_eq!(observable_state(&index), before);
11481 index.apply_ok(&Observation::new(vec![
11482 Op::Remove { path: PathBuf::from("b") },
11483 upsert("c", EntryKind::File, sized(u64::MAX - 1)),
11484 ]));
11485 assert_eq!(index.total().bytes, u64::MAX);
11486
11487 let mut index = Index::new("/root");
11489 index.apply_ok(&Observation::new(vec![
11490 upsert("d", EntryKind::Dir, Attrs::default()),
11491 upsert("d/f", EntryKind::File, sized(u64::MAX)),
11492 ]));
11493 index.apply_ok(&Observation::new(vec![
11494 upsert("d", EntryKind::File, sized(5)),
11495 upsert("e", EntryKind::File, sized(u64::MAX - 5)),
11496 ]));
11497 assert_eq!(index.total().bytes, u64::MAX);
11498 assert_eq!((index.total().files, index.total().dirs), (2, 0));
11499
11500 let mut index = Index::new("/root");
11502 index.apply_ok(&Observation::new(vec![
11503 upsert("x", EntryKind::File, sized(u64::MAX)),
11504 upsert("x", EntryKind::File, sized(1)),
11505 upsert("y", EntryKind::File, sized(u64::MAX - 1)),
11506 ]));
11507 assert_eq!(index.total().bytes, u64::MAX);
11508
11509 let mut index = Index::new("/root");
11511 index.apply_ok(&Observation::new(vec![
11512 upsert("d", EntryKind::Dir, Attrs::default()),
11513 upsert("d/f", EntryKind::File, sized(u64::MAX - 10)),
11514 ]));
11515 index.apply_ok(&Observation::new(vec![
11516 upsert("d/g", EntryKind::File, sized(10)),
11517 Op::Remove { path: PathBuf::from("d") },
11518 upsert("z", EntryKind::File, sized(u64::MAX)),
11519 ]));
11520 assert_eq!(index.total().bytes, u64::MAX);
11521 assert_eq!((index.total().files, index.total().dirs), (1, 0));
11522 }
11523
11524 #[test]
11525 fn a_stale_conditional_upsert_does_not_count_toward_the_projected_total() {
11526 let mut index = Index::new("/root");
11527 index.apply_ok(&Observation::new(vec![upsert("a", EntryKind::File, sized(5))]));
11528 let stale = index.expectation(Path::new("b"));
11529 index.apply_ok(&Observation::new(vec![upsert("b", EntryKind::File, sized(1))]));
11530 let outcome = index.apply_ok(&Observation::from_ops(vec![
11531 ObservationOp::if_state(upsert("b", EntryKind::File, sized(u64::MAX)), stale),
11532 ObservationOp::unconditional(upsert("c", EntryKind::File, sized(u64::MAX - 6))),
11533 ]));
11534 assert_eq!(outcome.stats.stale, 1);
11535 assert_eq!(index.total().bytes, u64::MAX);
11536 }
11537
11538 #[test]
11539 fn the_baseline_and_scanner_lanes_refuse_unrepresentable_totals() {
11540 let mut index = Index::new("/root");
11541 let before = observable_state(&index);
11542 let error = index
11543 .apply_baseline(&Observation::new(vec![
11544 upsert("a", EntryKind::File, sized(u64::MAX)),
11545 upsert("b", EntryKind::File, sized(1)),
11546 ]))
11547 .expect_err("the baseline lane refuses too");
11548 assert_unrepresentable(&error, "b", "bytes");
11549 assert_eq!(observable_state(&index), before);
11550
11551 let error = index
11552 .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
11553 upsert("dir", EntryKind::Dir, Attrs::default()),
11554 upsert("dir/a", EntryKind::File, sized(u64::MAX)),
11555 upsert("dir/b", EntryKind::File, sized(1)),
11556 ]))
11557 .expect_err("the scanner lane refuses too");
11558 assert_unrepresentable(&error, "dir/b", "bytes");
11559 assert_eq!(observable_state(&index), before);
11560
11561 index
11563 .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
11564 upsert("dir", EntryKind::Dir, Attrs::default()),
11565 upsert("dir/a", EntryKind::File, sized(u64::MAX - 1)),
11566 upsert("dir/b", EntryKind::File, sized(1)),
11567 ]))
11568 .expect("an exact fit");
11569 assert_eq!(index.total().bytes, u64::MAX);
11570 }
11571
11572 fn tree_for_analysis() -> Index {
11576 let mut index = Index::new("/root");
11577 let mut ops = vec![upsert("z.txt", EntryKind::File, file_attrs(3, 1))];
11578 for directory in ["a", "b", "c"] {
11579 ops.push(upsert(directory, EntryKind::Dir, Attrs::default()));
11580 ops.push(upsert(&format!("{directory}/deep"), EntryKind::Dir, Attrs::default()));
11581 for file in 0..4 {
11582 ops.push(upsert(
11583 &format!("{directory}/f{file}.rs"),
11584 EntryKind::File,
11585 file_attrs(10 + file, 1),
11586 ));
11587 }
11588 ops.push(upsert(&format!("{directory}/deep/d.md"), EntryKind::File, file_attrs(7, 2)));
11589 ops.push(upsert(&format!("{directory}/link"), EntryKind::Symlink, Attrs::default()));
11590 }
11591 index.apply_ok(&Observation::new(ops));
11592 index
11593 }
11594
11595 #[test]
11596 fn analysis_candidates_come_in_bounded_batches_in_walk_order() {
11597 let index = tree_for_analysis();
11598 let request = crate::content::AnalysisRequest {
11599 profile: AnalysisSet::NONE.with_lines(),
11600 ..crate::content::AnalysisRequest::default()
11601 };
11602 let whole: Vec<PathBuf> = index
11603 .pending_analysis_candidates(request)
11604 .into_iter()
11605 .map(|candidate| candidate.relative_path)
11606 .collect();
11607 assert_eq!(whole.len(), 16, "one root file and five files under each directory");
11608 assert_eq!(index.count_pending_analysis_candidates(request), 16);
11609
11610 for limit in [1, 3, 5, 16, 17, usize::MAX] {
11611 let mut walk = AnalysisWalk::start();
11612 let mut collected = Vec::new();
11613 let mut batch_count = 0;
11614 loop {
11615 let batch = index.next_analysis_candidates(request, &mut walk, limit);
11616 if batch.is_empty() {
11617 break;
11618 }
11619 assert!(batch.len() <= limit, "a batch is bounded by its limit");
11620 batch_count += 1;
11621 collected.extend(batch.into_iter().map(|candidate| candidate.relative_path));
11622 }
11623 assert_eq!(
11624 collected, whole,
11625 "batches of {limit} walk the same files in the same order"
11626 );
11627 assert_eq!(batch_count, whole.len().div_ceil(limit.min(whole.len())));
11628 assert!(
11629 index.next_analysis_candidates(request, &mut walk, limit).is_empty(),
11630 "a finished walk stays finished"
11631 );
11632 }
11633 }
11634
11635 #[test]
11640 fn a_long_listing_is_resumed_where_the_last_batch_stopped() {
11641 let names: Vec<String> = (0..11).map(|file| format!("f{file:02}.rs")).collect();
11642 let request = crate::content::AnalysisRequest {
11643 profile: AnalysisSet::NONE.with_lines(),
11644 ..crate::content::AnalysisRequest::default()
11645 };
11646 let sorted = {
11647 let mut builder = DetachedIndexBuilder::new(
11648 "/root",
11649 ScanScope::default(),
11650 crate::classify::TypeRegistry::compiled_shared(),
11651 );
11652 let mut listing = crate::scan::DetachedDirectory {
11653 path: PathBuf::new(),
11654 children: (0_u32..)
11655 .zip(&names)
11656 .map(|(position, name)| crate::scan::DetachedChild {
11657 name: OsString::from(name),
11658 kind: EntryKind::File,
11659 attrs: file_attrs(10, 1),
11660 position,
11661 })
11662 .collect(),
11663 control: None,
11664 };
11665 builder.push_directory(&mut listing).expect("listing");
11666 builder.finish()
11667 };
11668 let mut mapped = Index::new("/root");
11669 mapped.apply_ok(&Observation::new(
11670 names.iter().map(|name| upsert(name, EntryKind::File, file_attrs(10, 1))).collect(),
11671 ));
11672 for (storage, index) in [("sorted", &sorted), ("mapped", &mapped)] {
11673 ANALYSIS_CHILD_STEPS.with(|steps| steps.set(0));
11674 let mut walk = AnalysisWalk::start();
11675 let mut walked = Vec::new();
11676 let mut batches = 0;
11677 loop {
11678 let batch = index.next_analysis_candidates(request, &mut walk, 3);
11679 if batch.is_empty() {
11680 break;
11681 }
11682 batches += 1;
11683 walked.extend(batch.into_iter().map(|candidate| candidate.relative_path));
11684 }
11685 assert_eq!(batches, 4, "{storage}: eleven files in batches of three");
11686 assert_eq!(
11687 walked,
11688 names.iter().map(PathBuf::from).collect::<Vec<_>>(),
11689 "{storage}: every file once, in listing order"
11690 );
11691 assert_eq!(
11692 ANALYSIS_CHILD_STEPS.with(std::cell::Cell::get),
11693 11,
11694 "{storage}: each child is stepped over once across all four batches"
11695 );
11696 }
11697 }
11698
11699 #[test]
11700 fn batches_skip_what_the_content_tier_already_holds() {
11701 let root = tempfile::tempdir().expect("root");
11702 for name in ["one.rs", "two.rs", "three.rs"] {
11703 std::fs::write(root.path().join(name), b"fn main() {}\n").expect("file");
11704 }
11705 let (mut index, _) =
11706 crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
11707 let request =
11708 crate::content::AnalysisRequest { profile: AnalysisSet::NONE.with_lines(), workers: 1 };
11709 assert_eq!(index.count_pending_analysis_candidates(request), 3);
11710 crate::content::analyze_index(&mut index, request);
11711 assert_eq!(index.count_pending_analysis_candidates(request), 0);
11712 assert!(index.next_analysis_candidates(request, &mut AnalysisWalk::start(), 2).is_empty());
11713
11714 std::fs::write(root.path().join("two.rs"), b"fn main() { changed(); }\n").expect("grow");
11715 let (rescanned, _) =
11716 crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default())
11717 .expect("rescan");
11718 index
11719 .apply(&Observation::new(vec![upsert(
11720 "two.rs",
11721 EntryKind::File,
11722 *rescanned.attrs(Path::new("two.rs")).expect("attrs"),
11723 )]))
11724 .expect("apply");
11725 assert_eq!(index.count_pending_analysis_candidates(request), 1);
11726 let batch = index.next_analysis_candidates(request, &mut AnalysisWalk::start(), 2);
11727 assert_eq!(
11728 batch.iter().map(|candidate| candidate.relative_path.clone()).collect::<Vec<_>>(),
11729 [PathBuf::from("two.rs")]
11730 );
11731 }
11732}