1use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
33use std::ffi::{OsStr, OsString};
34use std::path::{Component, Path, PathBuf};
35use std::sync::{Arc, RwLock};
36
37use crate::content::{
38 AnalysisApplyOutcome, AnalysisCandidate, AnalysisObservation, AnalysisSet, ContentIndex,
39 ContentRollUp, RestoreCandidate,
40};
41use crate::engine_contract::{
42 Attrs, Clock, Commit, Coverage, CoverageReason, DiscoveryProgress, EffectiveChange,
43 EntryIdentity, EntryKind, Expectation, Freshness, Impact, ImpactDomain, IndexState,
44 InvalidateReason, Issue, LifecyclePhase, MAX_DIRTY_PATHS, MAX_RETAINED_ISSUES, Observation,
45 ObservationOp, Op, PathExpectation, PathState, Provenance, ScanScope, Source, StateTransition,
46 Status, Work,
47};
48
49const MAX_VERIFIED_INTERVALS: usize = 256;
55
56fn same_issue_cause(left: &Issue, right: &Issue) -> bool {
57 left.kind == right.kind && left.path == right.path && (right.path.is_some() || left == right)
58}
59
60fn compare_issues(left: &Issue, right: &Issue) -> std::cmp::Ordering {
61 left.path
62 .cmp(&right.path)
63 .then_with(|| issue_kind_rank(left.kind).cmp(&issue_kind_rank(right.kind)))
64 .then_with(|| left.message.cmp(&right.message))
65 .then_with(|| left.os_error.cmp(&right.os_error))
66}
67
68const fn issue_kind_rank(kind: crate::IssueKind) -> u8 {
69 match kind {
70 crate::IssueKind::Permission => 0,
71 crate::IssueKind::Disappeared => 1,
72 crate::IssueKind::InvalidMetadata => 2,
73 crate::IssueKind::ResourceBudget => 3,
74 crate::IssueKind::ObservationGap => 4,
75 crate::IssueKind::ProviderFailure => 5,
76 }
77}
78
79#[cfg(test)]
80std::thread_local! {
81 pub(crate) static RECLASSIFY_VISITS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
86
87 pub(crate) static CONTROL_PROJECTION_CLONES: std::cell::Cell<u64> =
92 const { std::cell::Cell::new(0) };
93}
94
95pub const DEFAULT_JOURNAL_CAPACITY_BYTES: usize = 8 * 1024 * 1024;
106
107#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
109pub struct EntryId {
110 slot: u32,
111 generation: u64,
112}
113
114impl EntryId {
115 pub const ROOT: EntryId = EntryId { slot: 0, generation: 0 };
117
118 #[inline]
119 const fn idx(self) -> usize {
120 self.slot as usize
121 }
122}
123
124type ExtId = u32;
126
127#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
129pub struct ExtTally {
130 pub files: u64,
132 pub bytes: u64,
134 pub allocated: u64,
140}
141
142#[derive(Clone, PartialEq, Eq, Debug, Default)]
156pub struct RollUp {
157 pub files: u64,
159 pub dirs: u64,
161 pub bytes: u64,
163 pub allocated: u64,
165 pub newest_mtime_ns: i64,
167 pub by_ext: BTreeMap<String, ExtTally>,
169}
170
171#[derive(Clone, PartialEq, Eq, Debug, Default)]
173pub struct PartitionRollUp {
174 pub all: RollUp,
176 pub unignored: RollUp,
178}
179
180#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
182pub struct RollUpSummary {
183 pub files: u64,
185 pub dirs: u64,
187 pub bytes: u64,
189 pub allocated: u64,
191 pub newest_mtime_ns: Option<i64>,
193}
194
195#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
197pub struct PartitionRollUpSummary {
198 pub all: RollUpSummary,
200 pub unignored: RollUpSummary,
202}
203
204#[derive(Clone, PartialEq, Eq, Debug, Default)]
211struct InternedRollUp {
212 files: u64,
213 dirs: u64,
214 bytes: u64,
215 allocated: u64,
216 newest_mtime_ns: i64,
217 by_ext: BTreeMap<ExtId, ExtTally>,
218}
219
220#[derive(Clone, PartialEq, Eq, Debug, Default)]
225struct InternedPartitionRollUp {
226 all: InternedRollUp,
227 unignored: InternedRollUp,
228}
229
230impl std::ops::Deref for InternedPartitionRollUp {
231 type Target = InternedRollUp;
232
233 fn deref(&self) -> &Self::Target {
234 &self.all
235 }
236}
237
238impl std::ops::DerefMut for InternedPartitionRollUp {
239 fn deref_mut(&mut self) -> &mut Self::Target {
240 &mut self.all
241 }
242}
243
244impl InternedPartitionRollUp {
245 fn merge(&mut self, other: &Self) {
246 self.all.merge(&other.all);
247 self.unignored.merge(&other.unignored);
248 }
249
250 fn unmerge(&mut self, other: &Self) {
251 self.all.unmerge(&other.all);
252 self.unignored.unmerge(&other.unignored);
253 }
254}
255
256fn rollup_summary(rollup: &InternedRollUp) -> RollUpSummary {
257 RollUpSummary {
258 files: rollup.files,
259 dirs: rollup.dirs,
260 bytes: rollup.bytes,
261 allocated: rollup.allocated,
262 newest_mtime_ns: (rollup.files > 0).then_some(rollup.newest_mtime_ns),
263 }
264}
265
266fn partition_summary(rollup: &InternedPartitionRollUp) -> PartitionRollUpSummary {
267 PartitionRollUpSummary {
268 all: rollup_summary(&rollup.all),
269 unignored: rollup_summary(&rollup.unignored),
270 }
271}
272
273#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
278pub(crate) struct RollUpScalars {
279 pub(crate) files: u64,
280 pub(crate) dirs: u64,
281 pub(crate) bytes: u64,
282 pub(crate) allocated: u64,
283 pub(crate) newest_mtime_ns: i64,
284}
285
286impl From<&InternedRollUp> for RollUpScalars {
287 fn from(rollup: &InternedRollUp) -> Self {
288 Self {
289 files: rollup.files,
290 dirs: rollup.dirs,
291 bytes: rollup.bytes,
292 allocated: rollup.allocated,
293 newest_mtime_ns: rollup.newest_mtime_ns,
294 }
295 }
296}
297
298impl InternedRollUp {
299 fn merge(&mut self, other: &InternedRollUp) {
302 let had_files = self.files > 0;
303 self.files += other.files;
304 self.dirs += other.dirs;
305 self.bytes += other.bytes;
306 self.allocated += other.allocated;
307 if other.files > 0 {
308 self.newest_mtime_ns = if had_files {
309 self.newest_mtime_ns.max(other.newest_mtime_ns)
310 } else {
311 other.newest_mtime_ns
312 };
313 }
314 for (ext, tally) in &other.by_ext {
315 let slot = self.by_ext.entry(*ext).or_default();
316 slot.files += tally.files;
317 slot.bytes += tally.bytes;
318 slot.allocated += tally.allocated;
319 }
320 }
321
322 fn unmerge(&mut self, other: &InternedRollUp) {
328 self.files = self.files.saturating_sub(other.files);
329 self.dirs = self.dirs.saturating_sub(other.dirs);
330 self.bytes = self.bytes.saturating_sub(other.bytes);
331 self.allocated = self.allocated.saturating_sub(other.allocated);
332 for (ext, tally) in &other.by_ext {
333 if let Some(slot) = self.by_ext.get_mut(ext) {
334 slot.files = slot.files.saturating_sub(tally.files);
335 slot.bytes = slot.bytes.saturating_sub(tally.bytes);
336 slot.allocated = slot.allocated.saturating_sub(tally.allocated);
337 if slot.files == 0 && slot.bytes == 0 && slot.allocated == 0 {
338 self.by_ext.remove(ext);
339 }
340 }
341 }
342 }
343}
344
345#[derive(Clone, Debug)]
346enum DirectoryChildren {
347 Sorted(Vec<EntryId>),
349 Mutable(BTreeMap<OsString, EntryId>),
351}
352
353impl DirectoryChildren {
354 #[cfg(test)]
355 fn is_sorted(&self) -> bool {
356 matches!(self, Self::Sorted(_))
357 }
358
359 #[cfg(test)]
360 fn is_mutable(&self) -> bool {
361 matches!(self, Self::Mutable(_))
362 }
363
364 fn ids(&self) -> ChildIds<'_> {
365 match self {
366 Self::Sorted(ids) => ChildIds::Sorted(ids.iter()),
367 Self::Mutable(children) => ChildIds::Mutable(children.values()),
368 }
369 }
370}
371
372#[derive(Clone, Debug)]
373struct DirectoryEntry {
374 children: DirectoryChildren,
375 rollup: InternedPartitionRollUp,
376 children_revision: u64,
377 children_complete: bool,
378}
379
380impl DirectoryEntry {
381 fn new(children_complete: bool) -> Self {
382 Self {
383 children: DirectoryChildren::Mutable(BTreeMap::new()),
384 rollup: InternedPartitionRollUp::default(),
385 children_revision: 0,
386 children_complete,
387 }
388 }
389}
390
391#[derive(Clone, Debug)]
392struct Entry {
393 parent: Option<EntryId>,
394 name: OsString,
395 ext_id: Option<ExtId>,
399 ignored: bool,
401 source: Source,
407 kind: EntryKind,
408 attrs: Attrs,
409 revision: u64,
412 directory: Option<Box<DirectoryEntry>>,
416}
417
418struct NewEntry {
419 parent: Option<EntryId>,
420 name: OsString,
421 ext_id: Option<ExtId>,
422 ignored: bool,
423 source: Source,
424 kind: EntryKind,
425 attrs: Attrs,
426}
427
428impl Entry {
429 fn new(entry: NewEntry, children_complete: bool) -> Self {
430 let NewEntry { parent, name, ext_id, ignored, source, kind, attrs } = entry;
431 Self {
432 parent,
433 name,
434 ext_id,
435 ignored,
436 source,
437 kind,
438 attrs,
439 revision: 0,
440 directory: kind.is_dir().then(|| Box::new(DirectoryEntry::new(children_complete))),
441 }
442 }
443
444 fn new_detached(new_entry: NewEntry, children_complete: bool) -> Self {
445 let mut entry = Self::new(new_entry, children_complete);
446 if let Some(directory) = entry.directory.as_deref_mut() {
447 directory.children = DirectoryChildren::Sorted(Vec::new());
448 }
449 entry
450 }
451
452 fn directory(&self) -> &DirectoryEntry {
453 self.directory.as_deref().expect("directory entry must retain directory state")
454 }
455
456 fn directory_mut(&mut self) -> &mut DirectoryEntry {
457 self.directory.as_deref_mut().expect("directory entry must retain directory state")
458 }
459
460 fn rollup(&self) -> &InternedPartitionRollUp {
461 &self.directory().rollup
462 }
463
464 fn rollup_mut(&mut self) -> &mut InternedPartitionRollUp {
465 &mut self.directory_mut().rollup
466 }
467}
468
469#[derive(Clone, PartialEq, Eq, Debug, Default)]
471pub(crate) struct PortableChildren {
472 pub(crate) directories: BTreeMap<String, EntryId>,
473 pub(crate) nondirectories: BTreeMap<String, EntryId>,
474}
475
476#[derive(Clone, PartialEq, Eq, Debug, Default)]
483struct ServingIndexes {
484 portable_children: BTreeMap<PathBuf, PortableChildren>,
485 portable_entries: BTreeMap<crate::PortablePath, EntryId>,
486 recent_files: BTreeSet<RecentKey>,
487 semantic_names: Vec<Option<String>>,
488 semantic_ids: BTreeMap<String, u32>,
489 semantic_refcounts: Vec<u64>,
490 free_semantic_ids: Vec<u32>,
491 semantic_by_directory: BTreeMap<EntryId, InternedSemanticPartitions>,
492 exact_name_ids: BTreeMap<String, u32>,
493 exact_names: Vec<String>,
494 exact_name_by_directory: BTreeMap<EntryId, InternedSemanticPartitions>,
495}
496
497#[derive(Clone, PartialEq, Eq, Debug, Default)]
498struct InternedSemanticPartitions {
499 all: BTreeMap<u32, ExtTally>,
500 unignored: BTreeMap<u32, ExtTally>,
501}
502
503#[derive(Clone, PartialEq, Eq, Debug)]
505struct RecentKey {
506 mtime_ns: i64,
507 portable_path: crate::PortablePath,
508 id: EntryId,
509}
510
511impl PartialOrd for RecentKey {
512 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
513 Some(self.cmp(other))
514 }
515}
516
517impl Ord for RecentKey {
518 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
519 other
520 .mtime_ns
521 .cmp(&self.mtime_ns)
522 .then_with(|| self.portable_path.cmp(&other.portable_path))
523 .then_with(|| self.id.cmp(&other.id))
524 }
525}
526
527impl ServingIndexes {
528 fn for_types(types: &crate::classify::TypeRegistry) -> Self {
529 let exact_names: Vec<_> = types
530 .exact_filenames()
531 .map(str::to_ascii_lowercase)
532 .collect::<BTreeSet<_>>()
533 .into_iter()
534 .collect();
535 let exact_name_ids = exact_names
536 .iter()
537 .enumerate()
538 .map(|(index, name)| {
539 let id = u32::try_from(index)
540 .expect("a registry declares fewer than four billion exact filenames");
541 (name.clone(), id)
542 })
543 .collect();
544 Self { exact_name_ids, exact_names, ..Self::default() }
545 }
546
547 fn exact_name_id(&self, name: &OsStr) -> Option<u32> {
548 let name = name.to_str()?;
549 if let Some(id) = self.exact_name_ids.get(name) {
550 return Some(*id);
551 }
552 name.bytes()
553 .any(|byte| byte.is_ascii_uppercase())
554 .then(|| name.to_ascii_lowercase())
555 .and_then(|name| self.exact_name_ids.get(&name).copied())
556 }
557
558 fn intern_semantic(&mut self, name: &str) -> u32 {
559 if let Some(id) = self.semantic_ids.get(name).copied() {
560 let refcount = self
561 .semantic_refcounts
562 .get_mut(id as usize)
563 .expect("a live semantic id has a refcount");
564 *refcount = refcount.checked_add(1).expect("semantic refcount exhausted");
565 return id;
566 }
567 let id = if let Some(id) = self.free_semantic_ids.pop() {
568 self.semantic_names[id as usize] = Some(name.to_string());
569 self.semantic_refcounts[id as usize] = 1;
570 id
571 } else {
572 let id = u32::try_from(self.semantic_names.len())
573 .expect("fewer than four billion semantic types are live");
574 self.semantic_names.push(Some(name.to_string()));
575 self.semantic_refcounts.push(1);
576 id
577 };
578 self.semantic_ids.insert(name.to_string(), id);
579 id
580 }
581
582 fn release_semantic(&mut self, id: u32, count: u64) {
583 let slot = self
584 .semantic_refcounts
585 .get_mut(id as usize)
586 .expect("a live semantic id has a refcount");
587 *slot = slot.checked_sub(count).expect("semantic reference released twice");
588 if *slot != 0 {
589 return;
590 }
591 let name =
592 self.semantic_names[id as usize].take().expect("a referenced semantic id has a name");
593 let removed = self.semantic_ids.remove(&name);
594 debug_assert_eq!(removed, Some(id), "the semantic interner's two maps disagreed");
595 self.free_semantic_ids.push(id);
596 }
597}
598
599fn merge_semantic(map: &mut BTreeMap<u32, ExtTally>, id: u32, attrs: Attrs) {
600 let tally = map.entry(id).or_default();
601 tally.files = tally.files.saturating_add(1);
602 tally.bytes = tally.bytes.saturating_add(attrs.size);
603 tally.allocated = tally.allocated.saturating_add(attrs.allocated);
604}
605
606fn unmerge_semantic(map: &mut BTreeMap<u32, ExtTally>, id: u32, attrs: Attrs) {
607 let tally = map.get_mut(&id).expect("a semantic contribution must exist before removal");
608 tally.files = tally.files.saturating_sub(1);
609 tally.bytes = tally.bytes.saturating_sub(attrs.size);
610 tally.allocated = tally.allocated.saturating_sub(attrs.allocated);
611 if tally.files == 0 && tally.bytes == 0 && tally.allocated == 0 {
612 map.remove(&id);
613 }
614}
615
616fn unmerge_semantic_map(
617 destination: &mut BTreeMap<u32, ExtTally>,
618 contribution: &BTreeMap<u32, ExtTally>,
619) {
620 for (id, removed) in contribution {
621 let tally = destination
622 .get_mut(id)
623 .expect("a semantic subtree contribution must exist before removal");
624 tally.files = tally.files.saturating_sub(removed.files);
625 tally.bytes = tally.bytes.saturating_sub(removed.bytes);
626 tally.allocated = tally.allocated.saturating_sub(removed.allocated);
627 if tally.files == 0 && tally.bytes == 0 && tally.allocated == 0 {
628 destination.remove(id);
629 }
630 }
631}
632
633#[derive(Clone, Debug)]
634enum Slot {
635 Occupied { generation: u64, entry: Entry },
636 Free { generation: u64, next_free: Option<u32> },
637}
638
639fn retained_parent(arena: &[Slot], id: EntryId) -> Option<EntryId> {
640 match arena.get(id.idx()) {
641 Some(Slot::Occupied { generation, entry }) if *generation == id.generation => entry.parent,
642 Some(Slot::Occupied { .. } | Slot::Free { .. }) | None => {
643 panic!("internal entry handle must be live: {id:?}")
644 }
645 }
646}
647
648#[derive(Clone, PartialEq, Eq, Debug, Default)]
650#[must_use]
651pub struct Since {
652 pub commits: Vec<Commit>,
654 pub clock: Clock,
656 pub state: IndexState,
658 pub truncated: bool,
661}
662
663#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
665pub struct ApplyStats {
666 pub inserted: u64,
668 pub updated: u64,
670 pub removed: u64,
672 pub unchanged: u64,
674 pub invalidated: u64,
676 pub controls: u64,
678 pub reclassified: u64,
680 pub stale: u64,
683 pub resource_refused: u64,
686}
687
688impl ApplyStats {
689 pub const fn mutated(&self) -> bool {
691 self.inserted > 0
692 || self.updated > 0
693 || self.removed > 0
694 || self.invalidated > 0
695 || self.controls > 0
696 || self.reclassified > 0
697 }
698}
699
700#[derive(Clone, PartialEq, Eq, Debug, Default)]
702pub struct ApplyOutcome {
703 pub stats: ApplyStats,
705 pub commit: Option<Commit>,
707}
708
709#[derive(Clone, PartialEq, Eq, Debug)]
714pub struct ChildSnapshot {
715 pub id: EntryId,
717 pub name: OsString,
719 pub kind: EntryKind,
721 pub attrs: Attrs,
723 pub ignored: Option<bool>,
729 pub rollup: Option<RollUp>,
731 pub partitions: Option<PartitionRollUp>,
735}
736
737impl std::ops::Deref for ApplyOutcome {
738 type Target = ApplyStats;
739
740 fn deref(&self) -> &Self::Target {
741 &self.stats
742 }
743}
744
745impl ApplyOutcome {
746 fn from_commit(stats: ApplyStats, commit: Option<Commit>) -> Self {
747 Self { stats, commit }
748 }
749}
750
751#[derive(Clone, Copy)]
752enum BatchProvenance {
753 Baseline,
754 Opened,
755 Public,
756}
757
758fn record_batch(provenance: BatchProvenance, observed: usize, stats: ApplyStats) {
759 let observed = u64::try_from(observed).unwrap_or(u64::MAX);
760 let accepted = observed.saturating_sub(stats.stale);
761 crate::counters::bump(|counts| match provenance {
762 BatchProvenance::Baseline => {
763 counts.baseline_batches = counts.baseline_batches.saturating_add(1);
764 counts.baseline_accepted_ops = counts.baseline_accepted_ops.saturating_add(accepted);
765 }
766 BatchProvenance::Opened => {
767 counts.opened_batches = counts.opened_batches.saturating_add(1);
768 counts.opened_accepted_ops = counts.opened_accepted_ops.saturating_add(accepted);
769 }
770 BatchProvenance::Public => {
771 counts.public_batches = counts.public_batches.saturating_add(1);
772 counts.public_accepted_ops = counts.public_accepted_ops.saturating_add(accepted);
773 }
774 });
775}
776
777fn elapsed_micros(started: std::time::Instant) -> u64 {
778 u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX)
779}
780
781#[derive(Clone, Debug)]
783struct PreparedObservation {
784 ops: Vec<ObservationOp>,
785 ancestry: PreparedAncestry,
786 #[cfg(test)]
787 reject_before_apply: bool,
788}
789
790#[derive(Clone, Debug)]
791enum PreparedAncestry {
792 General,
793 Scanner { parents: Vec<ResolvedParent>, has_batch_parents: bool },
794}
795
796#[derive(Clone, Copy, Debug)]
798enum ResolvedParent {
799 Existing(EntryId),
801 Earlier(usize),
803}
804
805#[derive(Default)]
806struct ExactConsequences {
807 changes: Vec<EffectiveChange>,
808 state: Vec<StateTransition>,
809}
810
811impl ExactConsequences {
812 fn is_empty(&self) -> bool {
813 self.changes.is_empty() && self.state.is_empty()
814 }
815}
816
817#[derive(Default)]
818struct NoConsequences;
819
820trait ConsequenceSink {
826 fn change(&mut self, change: impl FnOnce() -> EffectiveChange);
827 fn state(&mut self, transition: impl FnOnce() -> StateTransition);
828}
829
830impl ConsequenceSink for ExactConsequences {
831 #[inline]
832 fn change(&mut self, change: impl FnOnce() -> EffectiveChange) {
833 self.changes.push(change());
834 }
835
836 #[inline]
837 fn state(&mut self, transition: impl FnOnce() -> StateTransition) {
838 self.state.push(transition());
839 }
840}
841
842impl ConsequenceSink for NoConsequences {
843 #[inline]
844 fn change(&mut self, _change: impl FnOnce() -> EffectiveChange) {}
845
846 #[inline]
847 fn state(&mut self, _transition: impl FnOnce() -> StateTransition) {}
848}
849
850#[derive(Clone, Debug)]
852pub struct Index {
853 root_path: PathBuf,
854 scope: ScanScope,
855 arena: Vec<Slot>,
856 free_head: Option<u32>,
857 live: u64,
858 clock: Clock,
859 journal: VecDeque<Commit>,
860 journal_cost: usize,
861 journal_capacity_bytes: usize,
862 journal_floor: Clock,
864 pending_invalidations: Vec<(PathBuf, InvalidateReason)>,
865 applying_source: Source,
871 scanned_at_ns: i64,
873 captured_at_ns: i64,
876 writing_pass_started_at_ns: i64,
886 persistence_owed: bool,
894 active_root_reconciles: BTreeMap<u64, i64>,
896 active_reconciles: BTreeMap<u64, ActiveReconcile>,
898 verified: Vec<(PathBuf, i64)>,
906 ext_names: Vec<Option<String>>,
909 ext_ids: BTreeMap<String, ExtId>,
911 ext_refcounts: Vec<u64>,
918 free_ext_ids: Vec<ExtId>,
920 content: Option<Box<ContentIndex>>,
922 types: std::sync::Arc<crate::classify::TypeRegistry>,
929 controls: crate::control::ControlTable,
931 freshness_epoch: u64,
932 freshness_marks: BTreeMap<PathBuf, FreshnessMark>,
933 state: IndexState,
936 issues: Vec<Issue>,
938 issue_epochs: Vec<u64>,
941 omitted_issue_epochs: BTreeMap<u64, u64>,
945 serving: Option<Box<ServingIndexes>>,
950}
951
952#[derive(Clone, Copy)]
953pub(crate) struct ReconcileErrors<'a> {
954 pub(crate) errors: &'a [crate::Error],
955 pub(crate) terminal: Option<&'a crate::Error>,
956 pub(crate) disproves_old: bool,
957}
958
959#[derive(Clone, Debug)]
960struct ActiveReconcile {
961 path: PathBuf,
962 scope_budget: usize,
965 evidence: ReconcileEvidence,
966}
967
968#[derive(Clone, Debug)]
969enum ReconcileEvidence {
970 Scopes(BTreeSet<PathBuf>),
971 Retry,
972}
973
974impl ActiveReconcile {
975 fn supersede(&mut self, path: &Path) {
976 if self.path.starts_with(path) {
977 self.evidence = ReconcileEvidence::Scopes(BTreeSet::from([path.to_path_buf()]));
978 return;
979 }
980 let ReconcileEvidence::Scopes(scopes) = &mut self.evidence else {
981 return;
982 };
983 if scopes.iter().any(|newer| path.starts_with(newer)) {
984 return;
985 }
986 scopes.retain(|newer| !newer.starts_with(path));
987 if scopes.len() == self.scope_budget {
988 self.evidence = ReconcileEvidence::Retry;
990 } else {
991 scopes.insert(path.to_path_buf());
992 }
993 }
994
995 fn refuses(&self, observation: &Observation, index: &Index) -> bool {
996 match &self.evidence {
997 ReconcileEvidence::Retry => true,
998 ReconcileEvidence::Scopes(scopes) => observation.ops.iter().any(|op| {
999 scopes
1000 .iter()
1001 .any(|path| op.op.path().starts_with(path) || path.starts_with(op.op.path()))
1002 && !index.holds_target(&op.op, index.path_state(op.op.path()))
1003 }),
1004 }
1005 }
1006}
1007
1008pub(crate) struct ReconcileFinish {
1009 pub(crate) commit: Option<Commit>,
1010 pub(crate) retry: bool,
1011}
1012
1013enum ChildIds<'a> {
1014 Sorted(std::slice::Iter<'a, EntryId>),
1015 Mutable(std::collections::btree_map::Values<'a, OsString, EntryId>),
1016}
1017
1018impl Iterator for ChildIds<'_> {
1019 type Item = EntryId;
1020
1021 fn next(&mut self) -> Option<Self::Item> {
1022 match self {
1023 Self::Sorted(ids) => ids.next().copied(),
1024 Self::Mutable(ids) => ids.next().copied(),
1025 }
1026 }
1027
1028 fn size_hint(&self) -> (usize, Option<usize>) {
1029 let len = self.len();
1030 (len, Some(len))
1031 }
1032}
1033
1034impl DoubleEndedIterator for ChildIds<'_> {
1035 fn next_back(&mut self) -> Option<Self::Item> {
1036 match self {
1037 Self::Sorted(ids) => ids.next_back().copied(),
1038 Self::Mutable(ids) => ids.next_back().copied(),
1039 }
1040 }
1041}
1042
1043impl ExactSizeIterator for ChildIds<'_> {
1044 fn len(&self) -> usize {
1045 match self {
1046 Self::Sorted(ids) => ids.len(),
1047 Self::Mutable(ids) => ids.len(),
1048 }
1049 }
1050}
1051
1052enum IndexChildren<'a> {
1053 Empty,
1054 Sorted { index: &'a Index, ids: std::slice::Iter<'a, EntryId> },
1055 Mutable(std::collections::btree_map::Iter<'a, OsString, EntryId>),
1056}
1057
1058impl<'a> IndexChildren<'a> {
1059 fn new(index: &'a Index, entry: &'a Entry) -> Self {
1060 match entry.directory.as_deref().map(|directory| &directory.children) {
1061 None => Self::Empty,
1062 Some(DirectoryChildren::Sorted(ids)) => Self::Sorted { index, ids: ids.iter() },
1063 Some(DirectoryChildren::Mutable(children)) => Self::Mutable(children.iter()),
1064 }
1065 }
1066}
1067
1068impl<'a> Iterator for IndexChildren<'a> {
1069 type Item = (&'a OsStr, EntryId);
1070
1071 fn next(&mut self) -> Option<Self::Item> {
1072 match self {
1073 Self::Empty => None,
1074 Self::Sorted { index, ids } => {
1075 let id = *ids.next()?;
1076 Some((index.entry(id).name.as_os_str(), id))
1077 }
1078 Self::Mutable(children) => children.next().map(|(name, id)| (name.as_os_str(), *id)),
1079 }
1080 }
1081
1082 fn size_hint(&self) -> (usize, Option<usize>) {
1083 let len = self.len();
1084 (len, Some(len))
1085 }
1086}
1087
1088impl DoubleEndedIterator for IndexChildren<'_> {
1089 fn next_back(&mut self) -> Option<Self::Item> {
1090 match self {
1091 Self::Empty => None,
1092 Self::Sorted { index, ids } => {
1093 let id = *ids.next_back()?;
1094 Some((index.entry(id).name.as_os_str(), id))
1095 }
1096 Self::Mutable(children) => {
1097 children.next_back().map(|(name, id)| (name.as_os_str(), *id))
1098 }
1099 }
1100 }
1101}
1102
1103impl ExactSizeIterator for IndexChildren<'_> {
1104 fn len(&self) -> usize {
1105 match self {
1106 Self::Empty => 0,
1107 Self::Sorted { ids, .. } => ids.len(),
1108 Self::Mutable(children) => children.len(),
1109 }
1110 }
1111}
1112
1113#[derive(Clone, Copy, Debug)]
1114struct FreshnessMark {
1115 state: Freshness,
1116 epoch: u64,
1117}
1118
1119#[derive(Clone, Debug)]
1121pub struct IndexHandle {
1122 inner: Arc<RwLock<Index>>,
1123}
1124
1125#[derive(Clone, Debug, Default)]
1130pub(crate) struct DiscoveryCommit {
1131 pub(crate) directory_complete: Option<PathBuf>,
1132 pub(crate) transition: Option<DiscoveryTransition>,
1133}
1134
1135#[derive(Clone, Debug)]
1136pub(crate) enum DiscoveryTransition {
1137 Begin,
1138 Finish,
1139 BudgetRefused(Issue),
1140 Inaccessible { issues: Vec<Issue>, omitted: u64 },
1141 Cancelled,
1142 Failed(Issue),
1143}
1144
1145#[derive(Clone, Debug)]
1147#[cfg_attr(not(feature = "watch"), allow(dead_code))]
1148pub(crate) enum ObservationTransition {
1149 Reconciling,
1151 Watching { issues: Vec<Issue>, omitted: u64 },
1156 Unreadable { issues: Vec<Issue>, omitted: u64 },
1162 Failed(Issue),
1164}
1165
1166impl IndexHandle {
1167 pub fn new(index: Index) -> Self {
1169 Self { inner: Arc::new(RwLock::new(index)) }
1170 }
1171
1172 fn read_index(&self) -> crate::Result<std::sync::RwLockReadGuard<'_, Index>> {
1173 self.inner.read().map_err(|_| crate::Error::IndexLockPoisoned)
1174 }
1175
1176 fn write_index(&self) -> crate::Result<std::sync::RwLockWriteGuard<'_, Index>> {
1177 self.inner.write().map_err(|_| crate::Error::IndexLockPoisoned)
1178 }
1179
1180 pub(crate) fn read_with<T>(&self, read: impl FnOnce(&Index) -> T) -> crate::Result<T> {
1182 let index = self.read_index()?;
1183 Ok(read(&index))
1184 }
1185
1186 #[cfg(test)]
1187 pub(crate) fn poison_for_test(&self) {
1188 let handle = self.clone();
1189 std::thread::spawn(move || handle.panic_holding_the_write_lock_for_test())
1190 .join()
1191 .expect_err("injected index panic");
1192 }
1193
1194 #[cfg(test)]
1197 pub(crate) fn panic_holding_the_write_lock_for_test(&self) -> ! {
1198 let _guard = self.inner.write().expect("test index write lock");
1199 panic!("inject index poison");
1200 }
1201
1202 pub fn apply(&self, observation: &Observation) -> crate::Result<ApplyOutcome> {
1204 let prepared = prepare_observation(observation)?;
1205 let outcome = self.write_index()?.commit_prepared(prepared, true)?;
1206 record_batch(BatchProvenance::Public, observation.len(), outcome.stats);
1207 Ok(outcome)
1208 }
1209
1210 pub(crate) fn apply_discovery(
1211 &self,
1212 observation: &Observation,
1213 discovery: DiscoveryCommit,
1214 ) -> crate::Result<ApplyOutcome> {
1215 let prepared = prepare_observation(observation)?;
1216 let outcome = self.write_index()?.commit_prepared_with(
1217 prepared,
1218 true,
1219 Some(discovery),
1220 None,
1221 None,
1222 true,
1223 )?;
1224 record_batch(BatchProvenance::Opened, observation.len(), outcome.stats);
1225 Ok(outcome)
1226 }
1227
1228 #[cfg(feature = "watch")]
1229 pub(crate) fn apply_opened(
1230 &self,
1231 observation: &Observation,
1232 max_files: Option<u64>,
1233 ) -> crate::Result<ApplyOutcome> {
1234 let prepared = prepare_observation(observation)?;
1235 let outcome = self
1236 .write_index()?
1237 .commit_prepared_with(prepared, true, None, None, max_files, true)?;
1238 record_batch(BatchProvenance::Opened, observation.len(), outcome.stats);
1239 Ok(outcome)
1240 }
1241
1242 pub(crate) fn apply_scanner_discovery_bounded(
1243 &self,
1244 batch: crate::scan::ScannerBatch,
1245 discovery: DiscoveryCommit,
1246 max_files: Option<u64>,
1247 ) -> crate::Result<ApplyOutcome> {
1248 let observed = batch.len();
1249 let mut index = self.write_index()?;
1250 let prepared = index.prepare_scanner_batch(batch)?;
1251 let outcome =
1252 index.commit_prepared_with(prepared, true, Some(discovery), None, max_files, true)?;
1253 record_batch(BatchProvenance::Opened, observed, outcome.stats);
1254 Ok(outcome)
1255 }
1256
1257 pub(crate) fn transition_discovery(
1258 &self,
1259 transition: DiscoveryTransition,
1260 ) -> crate::Result<ApplyOutcome> {
1261 self.apply_discovery(
1262 &Observation::new(Vec::new()),
1263 DiscoveryCommit { directory_complete: None, transition: Some(transition) },
1264 )
1265 }
1266
1267 #[cfg(feature = "watch")]
1268 pub(crate) fn transition_observation(
1269 &self,
1270 transition: ObservationTransition,
1271 ) -> crate::Result<ApplyOutcome> {
1272 let prepared = prepare_observation(&Observation::default())?;
1273 let outcome = self.write_index()?.commit_prepared_with(
1274 prepared,
1275 true,
1276 None,
1277 Some(transition),
1278 None,
1279 true,
1280 )?;
1281 record_batch(BatchProvenance::Opened, 0, outcome.stats);
1282 Ok(outcome)
1283 }
1284
1285 pub fn root_path(&self) -> crate::Result<PathBuf> {
1287 Ok(self.read_index()?.root_path().to_path_buf())
1288 }
1289
1290 pub fn scope(&self) -> crate::Result<ScanScope> {
1292 Ok(self.read_index()?.scope())
1293 }
1294
1295 pub fn freshness(&self) -> crate::Result<Freshness> {
1297 Ok(self.read_index()?.freshness())
1298 }
1299
1300 pub fn freshness_at(&self, path: &Path) -> crate::Result<Freshness> {
1302 Ok(self.read_index()?.freshness_at(path))
1303 }
1304
1305 pub fn clock(&self) -> crate::Result<Clock> {
1307 Ok(self.read_index()?.clock())
1308 }
1309
1310 pub fn len(&self) -> crate::Result<u64> {
1312 Ok(self.read_index()?.len())
1313 }
1314
1315 pub fn is_empty(&self) -> crate::Result<bool> {
1317 Ok(self.read_index()?.is_empty())
1318 }
1319
1320 pub fn total(&self) -> crate::Result<RollUp> {
1322 Ok(self.read_index()?.total())
1323 }
1324
1325 pub(crate) fn state(&self) -> crate::Result<IndexState> {
1327 Ok(self.read_index()?.state())
1328 }
1329
1330 #[allow(dead_code)] pub(crate) fn issues(&self) -> crate::Result<Vec<Issue>> {
1332 Ok(self.read_index()?.issues().to_vec())
1333 }
1334
1335 #[allow(dead_code)] pub(crate) fn directory_complete(&self, path: &Path) -> crate::Result<Option<bool>> {
1338 Ok(self.read_index()?.directory_complete(path))
1339 }
1340
1341 pub fn rollup(&self, path: &Path) -> crate::Result<Option<RollUp>> {
1343 Ok(self.read_index()?.rollup(path))
1344 }
1345
1346 pub fn attrs(&self, path: &Path) -> crate::Result<Option<Attrs>> {
1348 Ok(self.read_index()?.attrs(path).copied())
1349 }
1350
1351 pub fn kind(&self, path: &Path) -> crate::Result<Option<EntryKind>> {
1353 Ok(self.read_index()?.kind(path))
1354 }
1355
1356 pub fn path_state(&self, path: &Path) -> crate::Result<PathState> {
1358 Ok(self.read_index()?.path_state(path))
1359 }
1360
1361 pub fn expectation(&self, path: &Path) -> crate::Result<PathExpectation> {
1363 Ok(self.read_index()?.expectation(path))
1364 }
1365
1366 pub fn since(&self, clock: Clock) -> crate::Result<Since> {
1368 Ok(self.read_index()?.since(clock))
1369 }
1370
1371 pub fn children(&self, path: &Path) -> crate::Result<Option<Vec<ChildSnapshot>>> {
1377 let index = self.read_index()?;
1378 let Some(children) = index.children(path) else {
1379 return Ok(None);
1380 };
1381 let observed = index.observes_controls();
1382 Ok(Some(
1383 children
1384 .map(|(name, id)| {
1385 let entry = index.entry(id);
1386 ChildSnapshot {
1387 id,
1388 name: name.to_os_string(),
1389 kind: entry.kind,
1390 attrs: entry.attrs,
1391 ignored: observed.then_some(entry.ignored),
1392 rollup: entry
1393 .kind
1394 .is_dir()
1395 .then(|| index.named_rollup(&entry.rollup().all)),
1396 partitions: (observed && entry.kind.is_dir())
1397 .then(|| index.named_partitions(entry.rollup())),
1398 }
1399 })
1400 .collect(),
1401 ))
1402 }
1403
1404 pub fn snapshot(&self) -> crate::Result<Index> {
1407 let mut snapshot = self.read_index()?.clone();
1408 snapshot.serving = None;
1409 Ok(snapshot)
1410 }
1411
1412 pub(crate) fn child_states(
1413 &self,
1414 path: &Path,
1415 ) -> crate::Result<BTreeMap<OsString, PathExpectation>> {
1416 let index = self.read_index()?;
1417 Ok(collect_child_expectations(&index, path))
1418 }
1419
1420 pub(crate) fn listing_baseline(
1423 &self,
1424 path: &Path,
1425 ) -> crate::Result<(BTreeMap<OsString, PathExpectation>, bool)> {
1426 let index = self.read_index()?;
1427 Ok((collect_child_expectations(&index, path), index.directory_complete(path) != Some(true)))
1428 }
1429
1430 pub(crate) fn has_control(&self, path: &Path) -> crate::Result<bool> {
1431 Ok(self.read_index()?.control_table().contains(path))
1432 }
1433
1434 pub(crate) fn take_pending_invalidations(
1435 &self,
1436 ) -> crate::Result<Vec<(PathBuf, InvalidateReason)>> {
1437 Ok(self.write_index()?.take_pending_invalidations())
1438 }
1439
1440 pub(crate) fn restore_pending_invalidations(
1441 &self,
1442 invalidations: Vec<(PathBuf, InvalidateReason)>,
1443 ) -> crate::Result<()> {
1444 self.write_index()?.restore_pending_invalidations(invalidations);
1445 Ok(())
1446 }
1447
1448 pub(crate) fn begin_reconcile(&self, path: &Path) -> crate::Result<(u64, Option<Commit>)> {
1449 self.write_index()?.begin_reconcile(path)
1450 }
1451
1452 pub(crate) fn finish_reconcile(
1453 &self,
1454 path: &Path,
1455 started_at: u64,
1456 complete: bool,
1457 listed_incomplete: &[PathBuf],
1458 failed_paths: &[PathBuf],
1459 errors: ReconcileErrors<'_>,
1460 ) -> crate::Result<ReconcileFinish> {
1461 self.write_index()?.finish_reconcile(
1462 path,
1463 started_at,
1464 complete,
1465 listed_incomplete,
1466 failed_paths,
1467 errors,
1468 )
1469 }
1470
1471 pub(crate) fn apply_reconcile(
1472 &self,
1473 started_at: u64,
1474 observation: &Observation,
1475 ) -> crate::Result<ApplyOutcome> {
1476 self.apply_reconcile_with(started_at, observation, None, BatchProvenance::Public)
1477 }
1478
1479 pub(crate) fn apply_opened_reconcile(
1480 &self,
1481 started_at: u64,
1482 observation: &Observation,
1483 max_files: Option<u64>,
1484 ) -> crate::Result<ApplyOutcome> {
1485 self.apply_reconcile_with(started_at, observation, max_files, BatchProvenance::Opened)
1486 }
1487
1488 fn apply_reconcile_with(
1489 &self,
1490 started_at: u64,
1491 observation: &Observation,
1492 max_files: Option<u64>,
1493 provenance: BatchProvenance,
1494 ) -> crate::Result<ApplyOutcome> {
1495 let prepared = prepare_observation(observation)?;
1496 let mut index = self.write_index()?;
1497 if index
1498 .active_reconciles
1499 .get(&started_at)
1500 .is_some_and(|active| active.refuses(observation, &index))
1501 {
1502 let stats = ApplyStats {
1503 stale: u64::try_from(observation.len()).unwrap_or(u64::MAX),
1504 ..ApplyStats::default()
1505 };
1506 record_batch(provenance, observation.len(), stats);
1507 return Ok(ApplyOutcome { stats, commit: None });
1508 }
1509 let outcome = index.commit_prepared_with(
1510 prepared,
1511 true,
1512 None,
1513 None,
1514 max_files,
1515 matches!(provenance, BatchProvenance::Opened),
1516 )?;
1517 record_batch(provenance, observation.len(), outcome.stats);
1518 Ok(outcome)
1519 }
1520
1521 #[cfg(feature = "watch")]
1522 pub(crate) fn apply_if_clock(
1523 &self,
1524 clock: Clock,
1525 observation: &Observation,
1526 ) -> crate::Result<Option<ApplyOutcome>> {
1527 let prepared = prepare_observation(observation)?;
1528 let mut index = self.write_index()?;
1529 if index.clock() != clock {
1530 return Ok(None);
1531 }
1532 index.commit_prepared(prepared, true).map(Some)
1533 }
1534
1535 #[cfg(feature = "watch")]
1536 pub(crate) fn apply_opened_if_clock(
1537 &self,
1538 clock: Clock,
1539 observation: &Observation,
1540 max_files: Option<u64>,
1541 ) -> crate::Result<Option<ApplyOutcome>> {
1542 let prepared = prepare_observation(observation)?;
1543 let mut index = self.write_index()?;
1544 if index.clock() != clock {
1545 return Ok(None);
1546 }
1547 index.commit_prepared_with(prepared, true, None, None, max_files, true).map(Some)
1548 }
1549
1550 #[cfg(feature = "watch")]
1551 pub(crate) fn unknown_ancestry(
1552 &self,
1553 observation: &Observation,
1554 ) -> crate::Result<Vec<(PathBuf, PathBuf)>> {
1555 let prepared = prepare_observation(observation)?;
1556 let index = self.read_index()?;
1557 let accepted = index.accepted_operations(&prepared.ops);
1558 Ok(index.unknown_ancestry(&prepared.ops, &accepted))
1559 }
1560
1561 #[cfg(feature = "watch")]
1562 pub(crate) fn watch_boundary(&self) -> crate::Result<(PathBuf, ScanScope, Clock)> {
1563 let index = self.read_index()?;
1564 Ok((index.root_path().to_path_buf(), index.scope(), index.clock()))
1565 }
1566
1567 #[cfg(feature = "watch")]
1568 pub(crate) fn invalidate_root(&self, reason: InvalidateReason) -> crate::Result<ApplyOutcome> {
1569 self.apply(&Observation::new(vec![Op::InvalidateSubtree { path: PathBuf::new(), reason }]))
1570 }
1571}
1572
1573pub(crate) struct DetachedIndexBuilder {
1579 index: Index,
1580 directory_ids: HashMap<PathBuf, EntryId>,
1581 repeated_directories: Vec<PathBuf>,
1584 inserted: u64,
1585}
1586
1587impl DetachedIndexBuilder {
1588 pub(crate) fn new(
1589 root_path: impl Into<PathBuf>,
1590 scope: ScanScope,
1591 types: std::sync::Arc<crate::classify::TypeRegistry>,
1592 ) -> Self {
1593 let mut index = Index::new_with_scope_and_types(root_path, scope, types);
1594 index.entry_mut(EntryId::ROOT).directory_mut().children =
1595 DirectoryChildren::Sorted(Vec::new());
1596 Self {
1597 index,
1598 directory_ids: HashMap::from([(PathBuf::new(), EntryId::ROOT)]),
1599 repeated_directories: Vec::new(),
1600 inserted: 0,
1601 }
1602 }
1603
1604 pub(crate) fn with_control_limits(mut self, limits: crate::control::ControlLimits) -> Self {
1606 self.index.set_control_limits(limits);
1607 self
1608 }
1609
1610 pub(crate) fn push_directory(
1619 &mut self,
1620 directory: crate::scan::DetachedDirectory,
1621 ) -> crate::Result<()> {
1622 let crate::scan::DetachedDirectory { path, mut children, control } = directory;
1623 let Some(parent) = self.directory_ids.remove(&path) else {
1627 if self.repeated_directories.iter().any(|repeated| path.starts_with(repeated)) {
1628 return Ok(());
1629 }
1630 return Err(crate::Error::UnknownAncestry { path, reconcile_from: PathBuf::new() });
1631 };
1632
1633 if let Some(control) = control {
1638 match control {
1639 Op::ControlUpsert { path, source } => {
1640 self.index.controls.upsert(&path, source)?;
1641 }
1642 Op::ControlRemove { path } => {
1643 self.index.controls.remove(&path)?;
1644 }
1645 _ => unreachable!("detached directory retains only fixed-control operations"),
1646 }
1647 self.inserted = self.inserted.saturating_add(1);
1648 }
1649
1650 children.sort_unstable_by(|left, right| {
1654 left.name.cmp(&right.name).then(left.position.cmp(&right.position))
1655 });
1656 let repeated_directories = &mut self.repeated_directories;
1657 children.dedup_by(|later, kept| {
1658 if later.name != kept.name {
1659 return false;
1660 }
1661 if later.kind.is_dir() || kept.kind.is_dir() {
1662 let repeated = path.join(&kept.name);
1663 if repeated_directories.last() != Some(&repeated) {
1664 repeated_directories.push(repeated);
1665 }
1666 }
1667 std::mem::swap(later, kept);
1668 true
1669 });
1670
1671 let parent_ignored = self.index.entry(parent).ignored;
1672 let mut match_path =
1673 (!parent_ignored && !self.index.controls.is_empty()).then(|| path.clone());
1674 self.index.reserve_detached_children(parent, children.len());
1675 for child in children {
1676 let crate::scan::DetachedChild { name, kind, attrs, .. } = child;
1677 crate::counters::bump(|counts| counts.upserts += 1);
1678 let ext_id = (kind == EntryKind::File)
1679 .then(|| self.index.intern_ext(&crate::classify::ext_bucket(&name)));
1680 let (ignored, child_path) = if let Some(scratch) = &mut match_path {
1681 scratch.push(&name);
1682 let ignored = self.index.controls.matcher_for(scratch).is_ignored(kind.is_dir());
1683 let child_path = kind.is_dir().then(|| scratch.clone());
1684 let popped = scratch.pop();
1685 debug_assert!(popped);
1686 (ignored, child_path)
1687 } else {
1688 (parent_ignored, kind.is_dir().then(|| path.join(&name)))
1689 };
1690 let child_id = self.index.alloc(Entry::new_detached(
1691 NewEntry {
1692 parent: Some(parent),
1693 name,
1694 ext_id,
1695 ignored,
1696 source: Source::Scanned,
1697 kind,
1698 attrs,
1699 },
1700 false,
1701 ));
1702 self.index.push_detached_child(parent, child_id);
1703 let direct = self.index.contribution(child_id);
1707 crate::counters::bump(|counts| counts.rollup_merges += 1);
1708 self.index.entry_mut(parent).rollup_mut().merge(&direct);
1709 if let Some(child_path) = child_path {
1710 self.directory_ids.insert(child_path, child_id);
1711 }
1712 self.inserted = self.inserted.saturating_add(1);
1713 }
1714 Ok(())
1715 }
1716
1717 pub(crate) fn finish(mut self) -> Index {
1719 for slot in (1..self.index.arena.len()).rev() {
1723 let id = EntryId {
1724 slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
1725 generation: 0,
1726 };
1727 if !self.index.entry(id).kind.is_dir() {
1728 continue;
1729 }
1730 let parent = self.index.entry(id).parent.expect("every non-root entry has a parent");
1731 crate::counters::bump(|counts| counts.rollup_merges += 1);
1733 self.index.merge_detached_descendants(parent, id);
1734 }
1735
1736 crate::counters::bump(|counts| {
1737 counts.baseline_batches = counts.baseline_batches.saturating_add(1);
1738 counts.baseline_accepted_ops =
1739 counts.baseline_accepted_ops.saturating_add(self.inserted);
1740 });
1741 self.index.establish_baseline();
1742 self.index
1743 }
1744}
1745
1746impl Index {
1747 pub fn new(root_path: impl Into<PathBuf>) -> Self {
1756 Self::new_with_scope(root_path, ScanScope::default())
1757 }
1758
1759 pub fn new_with_scope(root_path: impl Into<PathBuf>, scope: ScanScope) -> Self {
1766 Self::new_with_scope_and_types(
1767 root_path,
1768 scope,
1769 crate::classify::TypeRegistry::compiled_shared(),
1770 )
1771 }
1772
1773 pub fn new_with_config(root_path: impl Into<PathBuf>, config: &crate::ScanConfig) -> Self {
1779 let mut index =
1780 Self::new_with_scope_and_types(root_path, config.scope(), config.types_shared());
1781 index.set_control_limits(config.control_limits);
1782 index
1783 }
1784
1785 pub(crate) fn new_with_scope_and_types(
1787 root_path: impl Into<PathBuf>,
1788 scope: ScanScope,
1789 types: std::sync::Arc<crate::classify::TypeRegistry>,
1790 ) -> Self {
1791 Self::new_with_scope_types_and_journal_capacity_bytes(
1792 root_path,
1793 scope,
1794 types,
1795 DEFAULT_JOURNAL_CAPACITY_BYTES,
1796 )
1797 }
1798
1799 pub(crate) fn new_with_scope_types_and_journal_capacity_bytes(
1800 root_path: impl Into<PathBuf>,
1801 scope: ScanScope,
1802 types: std::sync::Arc<crate::classify::TypeRegistry>,
1803 journal_capacity_bytes: usize,
1804 ) -> Self {
1805 assert_eq!(
1806 scope.type_rules_fingerprint,
1807 types.fingerprint(),
1808 "an index's registry must match its semantic scope"
1809 );
1810 Self::new_with_journal_capacity_bytes(root_path, scope, journal_capacity_bytes, types, None)
1811 }
1812
1813 pub(crate) fn new_opened_with_scope_types_and_journal_capacity_bytes(
1815 root_path: impl Into<PathBuf>,
1816 scope: ScanScope,
1817 types: std::sync::Arc<crate::classify::TypeRegistry>,
1818 journal_capacity_bytes: usize,
1819 ) -> Self {
1820 assert_eq!(
1821 scope.type_rules_fingerprint,
1822 types.fingerprint(),
1823 "an index's registry must match its semantic scope"
1824 );
1825 let serving = ServingIndexes::for_types(&types);
1826 Self::new_with_journal_capacity_bytes(
1827 root_path,
1828 scope,
1829 journal_capacity_bytes,
1830 types,
1831 Some(Box::new(serving)),
1832 )
1833 }
1834
1835 fn new_with_journal_capacity_bytes(
1836 root_path: impl Into<PathBuf>,
1837 scope: ScanScope,
1838 journal_capacity_bytes: usize,
1839 types: std::sync::Arc<crate::classify::TypeRegistry>,
1840 serving: Option<Box<ServingIndexes>>,
1841 ) -> Self {
1842 let root = Entry::new(
1843 NewEntry {
1844 parent: None,
1845 name: OsString::new(),
1846 ext_id: None,
1847 ignored: false,
1848 source: Source::Scanned,
1849 kind: EntryKind::Dir,
1850 attrs: Attrs::default(),
1851 },
1852 true,
1853 );
1854 let constructed_at_ns = Self::now_unix_nanos();
1855 Self {
1856 root_path: root_path.into(),
1857 scope,
1858 arena: vec![Slot::Occupied { generation: 0, entry: root }],
1859 free_head: None,
1860 live: 1,
1861 clock: Clock::ZERO,
1862 journal: VecDeque::new(),
1863 journal_cost: 0,
1864 journal_capacity_bytes,
1865 journal_floor: Clock::ZERO,
1866 pending_invalidations: Vec::new(),
1867 freshness_epoch: 0,
1868 freshness_marks: BTreeMap::new(),
1869 state: IndexState::default(),
1870 issues: Vec::new(),
1871 issue_epochs: Vec::new(),
1872 omitted_issue_epochs: BTreeMap::new(),
1873 serving,
1874 applying_source: Source::Scanned,
1875 scanned_at_ns: constructed_at_ns,
1876 captured_at_ns: 0,
1877 writing_pass_started_at_ns: constructed_at_ns,
1878 persistence_owed: true,
1879 active_root_reconciles: BTreeMap::new(),
1880 active_reconciles: BTreeMap::new(),
1881 verified: Vec::new(),
1882 ext_names: Vec::new(),
1883 ext_ids: BTreeMap::new(),
1884 ext_refcounts: Vec::new(),
1885 free_ext_ids: Vec::new(),
1886 content: None,
1887 types,
1888 controls: crate::control::ControlTable::default(),
1889 }
1890 }
1891
1892 pub fn types(&self) -> &crate::classify::TypeRegistry {
1894 self.types.as_ref()
1895 }
1896
1897 pub fn controls(&self) -> crate::Result<&crate::control::ControlTable> {
1906 self.require_observed_controls()?;
1907 Ok(&self.controls)
1908 }
1909
1910 pub const fn observes_controls(&self) -> bool {
1913 self.scope.observes_controls()
1914 }
1915
1916 pub fn control_identity(&self) -> crate::ControlTierIdentity {
1918 if self.observes_controls() {
1919 crate::ControlTierIdentity::Observed { limits: self.controls.limits() }
1920 } else {
1921 crate::ControlTierIdentity::NotObserved
1922 }
1923 }
1924
1925 pub fn snapshot_identity(&self) -> crate::SnapshotIdentity {
1927 crate::SnapshotIdentity {
1928 entries: crate::EntryTierIdentity::of_scope(self.scope),
1929 controls: self.control_identity(),
1930 }
1931 }
1932
1933 fn require_observed_controls(&self) -> crate::Result<()> {
1934 if self.observes_controls() { Ok(()) } else { Err(crate::Error::ControlStateNotObserved) }
1935 }
1936
1937 fn carries_unobserved_control_input(&self, ops: &[ObservationOp]) -> bool {
1946 !self.observes_controls()
1947 && ops.iter().any(|observed| {
1948 matches!(observed.op, Op::ControlUpsert { .. } | Op::ControlRemove { .. })
1949 })
1950 }
1951
1952 pub(crate) fn control_table(&self) -> &crate::control::ControlTable {
1957 &self.controls
1958 }
1959
1960 pub fn control_coverage(&self) -> crate::control::ControlCoverage {
1967 if self.observes_controls() {
1968 crate::control::ControlCoverage::Observed(self.controls.observation())
1969 } else {
1970 crate::control::ControlCoverage::NotObserved
1971 }
1972 }
1973
1974 pub(crate) fn set_control_limits(&mut self, limits: crate::control::ControlLimits) {
1978 debug_assert!(self.controls.is_vacant(), "the control limits are set before any control");
1979 self.controls = crate::control::ControlTable::with_limits(limits);
1980 }
1981
1982 pub(crate) fn require_control_limits_in_scope(
1994 &self,
1995 limits: crate::control::ControlLimits,
1996 ) -> crate::Result<()> {
1997 if self.scope.observes_controls()
1998 && (crate::ControlTierIdentity::Observed { limits }).ignore_rules_fingerprint()
1999 != self.scope.ignore_rules_fingerprint
2000 {
2001 return Err(crate::Error::ControlLimitsOutsideScope { limits });
2002 }
2003 Ok(())
2004 }
2005
2006 pub(crate) fn install_controls(
2008 &mut self,
2009 controls: crate::control::ControlTable,
2010 ) -> crate::Result<()> {
2011 self.require_control_limits_in_scope(controls.limits())?;
2012 if controls.limits().budget.is_some_and(|budget| controls.retained_cost() > budget) {
2015 return Err(crate::Error::Snapshot(
2016 "a snapshot's control table exceeds its own control budget".into(),
2017 ));
2018 }
2019 if !controls.is_vacant() {
2023 self.require_observed_controls()?;
2024 }
2025 let unchanged = controls.is_empty() && self.controls.is_empty();
2029 self.controls = controls;
2030 if unchanged {
2031 return Ok(());
2032 }
2033 let mut stats = ApplyStats::default();
2034 let mut effects = NoConsequences;
2035 self.reclassify_controlled_subtrees(&[PathBuf::new()], &mut stats, &mut effects);
2036 Ok(())
2037 }
2038
2039 pub(crate) fn types_shared(&self) -> std::sync::Arc<crate::classify::TypeRegistry> {
2041 std::sync::Arc::clone(&self.types)
2042 }
2043
2044 pub fn classify(&self, relative_path: &Path) -> crate::classify::Classification {
2046 crate::classify::classify_with(&self.types, relative_path, None)
2047 }
2048
2049 #[cfg(test)]
2050 fn with_journal_capacity_bytes(
2051 root_path: impl Into<PathBuf>,
2052 journal_capacity_bytes: usize,
2053 ) -> Self {
2054 Self::new_with_journal_capacity_bytes(
2055 root_path,
2056 ScanScope::default(),
2057 journal_capacity_bytes,
2058 crate::classify::TypeRegistry::compiled_shared(),
2059 None,
2060 )
2061 }
2062
2063 pub fn root_path(&self) -> &Path {
2065 &self.root_path
2066 }
2067
2068 pub const fn scope(&self) -> ScanScope {
2070 self.scope
2071 }
2072
2073 pub fn freshness(&self) -> Freshness {
2075 self.freshness_at(Path::new(""))
2076 }
2077
2078 fn published_freshness(&self) -> Freshness {
2087 let derived = self.freshness();
2088 if self.state.phase == LifecyclePhase::Reconciling && derived == Freshness::Fresh {
2089 Freshness::Reconciling
2090 } else {
2091 derived
2092 }
2093 }
2094
2095 pub(crate) const fn state(&self) -> IndexState {
2097 self.state
2098 }
2099
2100 #[allow(dead_code)] pub(crate) fn issues(&self) -> &[Issue] {
2102 &self.issues
2103 }
2104
2105 #[allow(dead_code)] pub(crate) fn directory_complete(&self, path: &Path) -> Option<bool> {
2108 let id = self.lookup(path)?;
2109 let entry = self.entry(id);
2110 (entry.kind == EntryKind::Dir).then(|| entry.directory().children_complete)
2111 }
2112
2113 pub fn freshness_at(&self, path: &Path) -> Freshness {
2115 self.freshness_marks
2116 .iter()
2117 .filter(|(marked, _)| path.starts_with(marked) || marked.starts_with(path))
2118 .map(|(_, mark)| mark.state)
2119 .max_by_key(|state| state.rank())
2120 .unwrap_or(Freshness::Fresh)
2121 }
2122
2123 pub fn clock(&self) -> Clock {
2125 self.clock
2126 }
2127
2128 pub fn len(&self) -> u64 {
2130 self.live
2131 }
2132
2133 pub fn is_empty(&self) -> bool {
2135 self.live <= 1
2136 }
2137
2138 pub fn total(&self) -> RollUp {
2140 self.named_rollup(&self.entry(EntryId::ROOT).rollup().all)
2141 }
2142
2143 pub fn partition_total(&self) -> crate::Result<PartitionRollUp> {
2151 self.require_observed_controls()?;
2152 Ok(self.named_partitions(self.entry(EntryId::ROOT).rollup()))
2153 }
2154
2155 pub(crate) fn total_scalars(&self) -> RollUpScalars {
2157 RollUpScalars::from(&self.entry(EntryId::ROOT).rollup().all)
2158 }
2159
2160 pub fn apply(&mut self, observation: &Observation) -> crate::Result<ApplyOutcome> {
2169 let prepared = prepare_observation(observation)?;
2170 let outcome = self.commit_prepared(prepared, true)?;
2171 record_batch(BatchProvenance::Public, observation.len(), outcome.stats);
2172 Ok(outcome)
2173 }
2174
2175 fn commit_prepared(
2177 &mut self,
2178 prepared: PreparedObservation,
2179 journal: bool,
2180 ) -> crate::Result<ApplyOutcome> {
2181 self.commit_prepared_with(prepared, journal, None, None, None, false)
2182 }
2183
2184 fn commit_prepared_with(
2185 &mut self,
2186 prepared: PreparedObservation,
2187 journal: bool,
2188 discovery: Option<DiscoveryCommit>,
2189 observation: Option<ObservationTransition>,
2190 max_files: Option<u64>,
2191 track_file_progress: bool,
2192 ) -> crate::Result<ApplyOutcome> {
2193 if prepared.ops.is_empty()
2194 && discovery.as_ref().is_none_or(|discovery| {
2195 discovery.directory_complete.is_none() && discovery.transition.is_none()
2196 })
2197 && observation.is_none()
2198 {
2199 return Ok(ApplyOutcome::default());
2200 }
2201
2202 if discovery.is_some()
2208 && matches!(self.state.phase, LifecyclePhase::Stopped | LifecyclePhase::Failed)
2209 {
2210 return Err(crate::Error::OpenedIndexStopped);
2211 }
2212
2213 let mut discovery = discovery;
2214 if let Some(path) =
2215 discovery.as_mut().and_then(|discovery| discovery.directory_complete.as_mut())
2216 {
2217 let canonical = canonical_relative_path(path)?;
2221 let Some(id) = self.lookup(&canonical) else {
2222 return Err(crate::Error::InvalidDirectoryCompletion(canonical));
2223 };
2224 if self.entry(id).kind != EntryKind::Dir {
2225 return Err(crate::Error::InvalidDirectoryCompletion(canonical));
2226 }
2227 *path = canonical;
2228 }
2229
2230 #[cfg(test)]
2231 if prepared.reject_before_apply {
2232 return Err(crate::Error::CommitRejected("injected reducer preflight"));
2233 }
2234
2235 let Some(next_clock) = self.clock.checked_next() else {
2236 let mut probe = self.clone();
2240 probe.clock = Clock(self.clock.0 - 1);
2241 let outcome = probe.commit_prepared_with(
2242 prepared,
2243 false,
2244 discovery,
2245 observation,
2246 max_files,
2247 track_file_progress,
2248 )?;
2249 return if outcome.commit.is_some() {
2250 Err(crate::Error::ClockExhausted)
2251 } else {
2252 Ok(outcome)
2253 };
2254 };
2255
2256 let observed = u64::try_from(prepared.ops.len()).unwrap_or(u64::MAX);
2257 let mut effects = ExactConsequences::default();
2258 let stats = self.reduce_prepared(
2259 &prepared,
2260 discovery,
2261 observation,
2262 max_files,
2263 track_file_progress,
2264 &mut effects,
2265 )?;
2266
2267 if effects.is_empty() {
2268 return Ok(ApplyOutcome::from_commit(stats, None));
2269 }
2270
2271 let commit =
2272 self.publish_effects(next_clock, effects, commit_work(observed, stats), journal);
2273 Ok(ApplyOutcome::from_commit(stats, Some(commit)))
2274 }
2275
2276 fn reduce_prepared<C: ConsequenceSink>(
2281 &mut self,
2282 prepared: &PreparedObservation,
2283 discovery: Option<DiscoveryCommit>,
2284 observation: Option<ObservationTransition>,
2285 max_files: Option<u64>,
2286 track_file_progress: bool,
2287 effects: &mut C,
2288 ) -> crate::Result<ApplyStats> {
2289 if self.carries_unobserved_control_input(&prepared.ops) {
2290 return Err(crate::Error::ControlStateNotObserved);
2291 }
2292 if matches!(prepared.ancestry, PreparedAncestry::Scanner { .. }) {
2293 debug_assert!(observation.is_none());
2294 return self.reduce_scanner_prepared(
2295 prepared,
2296 discovery,
2297 max_files,
2298 track_file_progress,
2299 effects,
2300 );
2301 }
2302 let mut stats = ApplyStats::default();
2303 let mut parent_memo = ParentMemo::default();
2304 let accepted = self.accepted_operations(&prepared.ops);
2305 stats.stale = u64::try_from(accepted.iter().filter(|accepted| !**accepted).count())
2306 .unwrap_or(u64::MAX);
2307 self.validate_known_ancestry(&prepared.ops, &accepted)?;
2308 let projected_controls = self.projected_controls(&prepared.ops, &accepted)?;
2309
2310 for (observed, accepted) in prepared.ops.iter().zip(accepted) {
2311 if !accepted {
2312 continue;
2313 }
2314 let op = &observed.op;
2315 if let (Some(max_files), Op::Upsert { path, kind, .. }) = (max_files, op) {
2316 if self.files_after_upsert(path, *kind) > max_files {
2317 stats.resource_refused = stats.resource_refused.saturating_add(1);
2318 continue;
2319 }
2320 }
2321 match op {
2322 Op::Upsert { path, kind, attrs } => {
2323 self.apply_upsert(path, *kind, *attrs, &mut stats, effects, &mut parent_memo);
2324 }
2325 Op::Remove { path } => {
2326 parent_memo.clear();
2332 self.apply_remove(path, &mut stats, effects);
2333 }
2334 Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {
2335 parent_memo.clear();
2339 }
2340 Op::InvalidateSubtree { path, reason } => {
2341 parent_memo.clear();
2342 let previous_index_state = self.state;
2343 let previous = self.freshness_at(path);
2344 self.pending_invalidations.push((path.clone(), *reason));
2345 self.mark_unfresh(path, Freshness::Stale);
2346 let current = self.freshness_at(path);
2347 self.state.freshness = self.published_freshness();
2348 if matches!(
2349 reason,
2350 InvalidateReason::WatchOverflow
2351 | InvalidateReason::UnpairedRename
2352 | InvalidateReason::WatchSetupRace
2353 | InvalidateReason::VerificationFailed
2354 | InvalidateReason::UnknownAncestry
2355 | InvalidateReason::WatchContention
2356 ) {
2357 self.retain_issue(Issue::observation_gap(path, *reason));
2358 }
2359 stats.invalidated += 1;
2360 effects.change(|| EffectiveChange::Invalidated {
2361 path: path.clone(),
2362 reason: *reason,
2363 });
2364 if previous != current {
2365 effects.state(|| StateTransition::Freshness {
2366 path: path.clone(),
2367 previous,
2368 current,
2369 });
2370 }
2371 if previous_index_state != self.state {
2372 effects.state(|| StateTransition::IndexState {
2373 previous: previous_index_state,
2374 current: self.state,
2375 });
2376 }
2377 }
2378 }
2379 }
2380
2381 self.finish_reduction(
2382 projected_controls,
2383 &mut stats,
2384 discovery,
2385 observation,
2386 max_files,
2387 track_file_progress,
2388 effects,
2389 );
2390
2391 Ok(stats)
2392 }
2393
2394 fn reduce_scanner_prepared<C: ConsequenceSink>(
2396 &mut self,
2397 prepared: &PreparedObservation,
2398 discovery: Option<DiscoveryCommit>,
2399 max_files: Option<u64>,
2400 track_file_progress: bool,
2401 effects: &mut C,
2402 ) -> crate::Result<ApplyStats> {
2403 let PreparedAncestry::Scanner { parents, has_batch_parents } = &prepared.ancestry else {
2404 unreachable!("scanner reduction requires scanner ancestry");
2405 };
2406 debug_assert_eq!(prepared.ops.len(), parents.len());
2407 let projection_started = crate::counters::enabled().then(std::time::Instant::now);
2408 let projected_controls =
2409 self.projected_controls_from(prepared.ops.iter().map(|observed| &observed.op))?;
2410 if let Some(started) = projection_started {
2411 let elapsed = elapsed_micros(started);
2412 crate::counters::bump(|counts| {
2413 counts.scanner_control_projection_us =
2414 counts.scanner_control_projection_us.saturating_add(elapsed);
2415 });
2416 }
2417 let mut stats = ApplyStats::default();
2418 let mut applied_ids = has_batch_parents.then(|| vec![None; prepared.ops.len()]);
2419
2420 for (op_index, (observed, parent)) in prepared.ops.iter().zip(parents.iter()).enumerate() {
2421 match &observed.op {
2422 Op::Upsert { path, kind, attrs } => {
2423 if let Some(max_files) = max_files {
2424 if self.files_after_upsert(path, *kind) > max_files {
2425 stats.resource_refused = stats.resource_refused.saturating_add(1);
2426 continue;
2427 }
2428 }
2429 let parent = match parent {
2430 ResolvedParent::Existing(parent) => *parent,
2431 ResolvedParent::Earlier(parent_op) => applied_ids
2432 .as_ref()
2433 .and_then(|ids| ids.get(*parent_op))
2434 .copied()
2435 .flatten()
2436 .expect("a proved parent directory was applied earlier"),
2437 };
2438 let name = path.file_name().expect("scanner upserts are not root mutations");
2439 crate::counters::bump(|counts| counts.upserts += 1);
2440 self.upsert_beneath(parent, name, path, *kind, *attrs, &mut stats, effects);
2441 if kind.is_dir() {
2442 if let Some(ids) = &mut applied_ids {
2443 ids[op_index] = self.child(parent, name);
2444 }
2445 }
2446 }
2447 Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {}
2448 Op::Remove { .. } | Op::InvalidateSubtree { .. } => {
2449 unreachable!("scanner preparation rejects non-discovery operations");
2450 }
2451 }
2452 }
2453
2454 self.finish_reduction(
2455 projected_controls,
2456 &mut stats,
2457 discovery,
2458 None,
2459 max_files,
2460 track_file_progress,
2461 effects,
2462 );
2463
2464 Ok(stats)
2465 }
2466
2467 #[allow(clippy::too_many_arguments)] fn finish_reduction<C: ConsequenceSink>(
2469 &mut self,
2470 projected_controls: Option<crate::control::ControlTable>,
2471 stats: &mut ApplyStats,
2472 discovery: Option<DiscoveryCommit>,
2473 observation: Option<ObservationTransition>,
2474 max_files: Option<u64>,
2475 track_file_progress: bool,
2476 effects: &mut C,
2477 ) {
2478 self.apply_control_transition(projected_controls, stats, effects);
2479 let mut discovery = discovery;
2480 if stats.resource_refused > 0 {
2481 let max_files = max_files.expect("resource refusal requires a file limit");
2482 let discovery = discovery.get_or_insert_with(DiscoveryCommit::default);
2483 discovery.directory_complete = None;
2484 discovery.transition =
2485 Some(DiscoveryTransition::BudgetRefused(Issue::resource_budget(max_files)));
2486 }
2487 self.apply_opened_state(discovery, observation, track_file_progress, effects);
2488 }
2489
2490 fn apply_opened_state<C: ConsequenceSink>(
2491 &mut self,
2492 discovery: Option<DiscoveryCommit>,
2493 observation: Option<ObservationTransition>,
2494 track_file_progress: bool,
2495 effects: &mut C,
2496 ) {
2497 if discovery.is_none() && observation.is_none() && !track_file_progress {
2498 return;
2499 }
2500 let previous = self.state;
2501 if track_file_progress {
2502 self.state.progress.files_retained = self.total_scalars().files;
2503 }
2504
2505 if let Some(discovery) = discovery {
2506 if let Some(path) = discovery.directory_complete {
2507 let id = self.lookup(&path).expect("discovery completion was preflighted");
2508 if !self.entry(id).directory().children_complete {
2509 self.entry_mut(id).directory_mut().children_complete = true;
2510 self.state.progress.directories_complete =
2511 self.state.progress.directories_complete.saturating_add(1);
2512 effects.state(|| StateTransition::DirectoryComplete { path });
2513 }
2514 }
2515
2516 if let Some(transition) = discovery.transition {
2517 match transition {
2518 DiscoveryTransition::Begin => {
2519 for slot in &mut self.arena {
2520 if let Slot::Occupied { entry, .. } = slot {
2521 if entry.kind == EntryKind::Dir {
2522 entry.directory_mut().children_complete = false;
2523 }
2524 }
2525 }
2526 self.state = IndexState {
2527 phase: LifecyclePhase::Discovering,
2528 coverage: Coverage::Partial(CoverageReason::Building),
2529 freshness: Freshness::Fresh,
2530 source: Source::Scanned,
2531 progress: DiscoveryProgress::default(),
2532 issues: crate::IssueSummary::default(),
2533 };
2534 self.issues.clear();
2535 self.issue_epochs.clear();
2536 self.omitted_issue_epochs.clear();
2537 }
2538 DiscoveryTransition::Finish => {
2539 self.state.phase = LifecyclePhase::Ready;
2540 if self.state.coverage == Coverage::Partial(CoverageReason::Building) {
2541 self.state.coverage = Coverage::Complete;
2542 }
2543 self.state.freshness = if self.state.coverage == Coverage::Complete {
2544 Freshness::Fresh
2545 } else {
2546 Freshness::Partial
2547 };
2548 }
2549 DiscoveryTransition::BudgetRefused(issue) => {
2550 let already_stopped_for_budget = self.state.phase
2551 == LifecyclePhase::Stopped
2552 && self.state.coverage == Coverage::Partial(CoverageReason::Budget);
2553 self.state.phase = LifecyclePhase::Stopped;
2554 self.state.coverage = Coverage::Partial(CoverageReason::Budget);
2555 self.state.freshness = Freshness::Fresh;
2556 if !already_stopped_for_budget {
2557 self.retain_issue(issue);
2558 }
2559 }
2560 DiscoveryTransition::Inaccessible { issues, omitted } => {
2561 if self.state.coverage != Coverage::Partial(CoverageReason::Budget) {
2562 self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
2563 self.state.freshness = Freshness::Partial;
2564 }
2565 for issue in issues {
2566 self.retain_issue(issue);
2567 }
2568 self.retain_omitted(omitted);
2569 }
2570 DiscoveryTransition::Cancelled => {
2571 self.state.phase = LifecyclePhase::Stopped;
2572 if self.state.coverage != Coverage::Complete {
2573 self.state.coverage = Coverage::Partial(CoverageReason::Cancelled);
2574 }
2575 }
2576 DiscoveryTransition::Failed(issue) => {
2577 self.state.phase = LifecyclePhase::Failed;
2578 self.state.coverage = Coverage::Partial(CoverageReason::Failed);
2579 self.state.freshness = Freshness::Partial;
2580 self.retain_issue(issue);
2581 }
2582 }
2583 }
2584 }
2585
2586 if let Some(observation) = observation {
2587 match observation {
2588 ObservationTransition::Reconciling => {
2589 if self.state.phase == LifecyclePhase::Ready {
2590 self.state.phase = LifecyclePhase::Reconciling;
2591 self.state.freshness = Freshness::Reconciling;
2592 }
2593 }
2594 ObservationTransition::Watching { issues, omitted } => {
2595 if self.state.phase == LifecyclePhase::Reconciling {
2596 self.state.phase = LifecyclePhase::Watching;
2597 if !issues.is_empty() || omitted > 0 {
2598 self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
2599 for issue in issues {
2600 self.retain_issue(issue);
2601 }
2602 self.retain_omitted(omitted);
2603 } else if self.state.coverage
2604 == Coverage::Partial(CoverageReason::Inaccessible)
2605 {
2606 self.state.coverage = Coverage::Complete;
2612 }
2613 self.state.freshness = self.freshness();
2614 if self.state.coverage != Coverage::Complete {
2615 self.state.freshness = Freshness::Partial;
2616 }
2617 }
2618 }
2619 ObservationTransition::Unreadable { issues, omitted } => {
2620 if matches!(self.state.phase, LifecyclePhase::Watching | LifecyclePhase::Ready)
2623 {
2624 for issue in issues {
2625 self.retain_issue(issue);
2626 }
2627 self.retain_omitted(omitted);
2628 }
2629 }
2630 ObservationTransition::Failed(issue) => {
2631 if self.state.phase != LifecyclePhase::Stopped {
2632 self.state.phase = LifecyclePhase::Failed;
2633 self.state.freshness = Freshness::Partial;
2634 self.retain_issue(issue);
2635 }
2636 }
2637 }
2638 }
2639
2640 if previous != self.state {
2641 effects.state(|| StateTransition::IndexState { previous, current: self.state });
2642 }
2643 }
2644
2645 fn files_after_upsert(&self, path: &Path, kind: EntryKind) -> u64 {
2648 let current_total = self.total_scalars().files;
2649 let Some(id) = self.lookup(path) else {
2650 return current_total.saturating_add(u64::from(kind == EntryKind::File));
2651 };
2652 let current = self.entry(id);
2653 if current.kind == kind {
2654 return current_total;
2655 }
2656 let removed = match current.kind {
2657 EntryKind::File => 1,
2658 EntryKind::Dir => current.rollup().all.files,
2659 _ => 0,
2660 };
2661 current_total.saturating_sub(removed).saturating_add(u64::from(kind == EntryKind::File))
2662 }
2663
2664 fn retain_issue(&mut self, issue: Issue) {
2675 self.retain_issue_at(issue, self.freshness_epoch);
2676 }
2677
2678 fn retain_issue_at(&mut self, issue: Issue, epoch: u64) {
2679 let repeat = self.issues.iter().position(|retained| same_issue_cause(retained, &issue));
2680 if let Some(position) = repeat {
2681 self.issue_epochs[position] = epoch;
2682 } else {
2683 let position = self
2684 .issues
2685 .binary_search_by(|retained| compare_issues(retained, &issue))
2686 .unwrap_or_else(|position| position);
2687 if position < MAX_RETAINED_ISSUES {
2688 self.issues.insert(position, issue);
2689 self.issue_epochs.insert(position, epoch);
2690 if self.issues.len() > MAX_RETAINED_ISSUES {
2691 self.issues.pop();
2692 let omitted_epoch =
2693 self.issue_epochs.pop().expect("issue epochs stay parallel");
2694 self.retain_omitted_at(1, omitted_epoch);
2695 }
2696 } else {
2697 self.retain_omitted_at(1, epoch);
2698 }
2699 self.state.issues.retained = u64::try_from(self.issues.len()).unwrap_or(u64::MAX);
2700 }
2701 }
2702
2703 fn retain_omitted(&mut self, count: u64) {
2704 if count == 0 {
2705 return;
2706 }
2707 let epoch =
2708 self.active_reconciles.keys().next_back().copied().unwrap_or(self.freshness_epoch);
2709 self.retain_omitted_at(count, epoch);
2710 }
2711
2712 fn retain_omitted_at(&mut self, count: u64, epoch: u64) {
2713 let retained = self.omitted_issue_epochs.entry(epoch).or_default();
2714 *retained = retained.saturating_add(count);
2715 self.state.issues.omitted = self.state.issues.omitted.saturating_add(count);
2716 }
2717
2718 fn drop_disproven_omitted(&mut self, started_at: u64) {
2719 self.omitted_issue_epochs.retain(|epoch, _| *epoch >= started_at);
2720 self.state.issues.omitted =
2721 self.omitted_issue_epochs.values().fold(0_u64, |sum, count| sum.saturating_add(*count));
2722 }
2723
2724 fn compact_omitted_epochs(&mut self) {
2725 let mut compact = BTreeMap::new();
2726 for (epoch, count) in std::mem::take(&mut self.omitted_issue_epochs) {
2727 let owner =
2728 self.active_reconciles.range(..=epoch).next_back().map_or(0, |(epoch, _)| *epoch);
2729 let retained = compact.entry(owner).or_insert(0_u64);
2730 *retained = retained.saturating_add(count);
2731 }
2732 self.omitted_issue_epochs = compact;
2733 }
2734
2735 fn drop_disproven_issues(&mut self, path: &Path, started_at: u64) {
2748 let mut position = 0;
2749 while position < self.issues.len() {
2750 let issue = &self.issues[position];
2751 let disproven = issue.kind != crate::IssueKind::ObservationGap
2752 && issue.path.as_deref().is_some_and(|issue_path| issue_path.starts_with(path))
2753 && self.issue_epochs[position] < started_at;
2754 if disproven {
2755 self.issues.remove(position);
2756 self.issue_epochs.remove(position);
2757 } else {
2758 position += 1;
2759 }
2760 }
2761 self.state.issues.retained = u64::try_from(self.issues.len()).unwrap_or(u64::MAX);
2762 }
2763
2764 fn publish_effects(
2769 &mut self,
2770 next_clock: Clock,
2771 effects: ExactConsequences,
2772 work: Work,
2773 journal: bool,
2774 ) -> Commit {
2775 debug_assert!(!effects.is_empty());
2776 if crate::counters::enabled() {
2777 let effect_paths = u64::try_from(effects.changes.len()).unwrap_or(u64::MAX);
2778 let effect_path_bytes = effects.changes.iter().fold(0_u64, |total, change| {
2779 total.saturating_add(
2780 u64::try_from(change.path().as_os_str().as_encoded_bytes().len())
2781 .unwrap_or(u64::MAX),
2782 )
2783 });
2784 crate::counters::bump(|counts| {
2785 counts.effect_paths = counts.effect_paths.saturating_add(effect_paths);
2786 counts.effect_path_bytes =
2787 counts.effect_path_bytes.saturating_add(effect_path_bytes);
2788 });
2789 }
2790 let commit = Commit {
2791 clock: next_clock,
2792 impact: derive_impact(&effects.changes, &effects.state),
2793 changes: effects.changes,
2794 state: effects.state,
2795 work,
2796 };
2797 self.clock = next_clock;
2798 if journal {
2799 self.retain_commit(&commit);
2800 }
2801 commit
2802 }
2803
2804 fn retain_commit(&mut self, commit: &Commit) {
2805 let cost = commit.retained_cost();
2806 if cost > self.journal_capacity_bytes {
2807 let dropped = u64::try_from(self.journal.len()).unwrap_or(u64::MAX);
2808 crate::counters::bump(|counts| {
2809 counts.journal_oversized_commits =
2810 counts.journal_oversized_commits.saturating_add(1);
2811 counts.journal_dropped_commits =
2812 counts.journal_dropped_commits.saturating_add(dropped);
2813 });
2814 self.journal.clear();
2815 self.journal_cost = 0;
2816 self.journal_floor = commit.clock;
2817 return;
2818 }
2819
2820 while self.journal_cost + cost > self.journal_capacity_bytes {
2821 if let Some(dropped) = self.journal.pop_front() {
2822 crate::counters::bump(|counts| {
2823 counts.journal_dropped_commits =
2824 counts.journal_dropped_commits.saturating_add(1);
2825 });
2826 self.journal_cost -= dropped.retained_cost();
2827 self.journal_floor = dropped.clock;
2828 }
2829 }
2830 self.journal_cost += cost;
2831 self.journal.push_back(commit.clone());
2832 crate::counters::bump(|counts| {
2833 counts.journal_cloned_commits = counts.journal_cloned_commits.saturating_add(1);
2834 counts.journal_retained_commits = counts.journal_retained_commits.saturating_add(1);
2835 });
2836 }
2837
2838 pub(crate) fn apply_baseline(
2840 &mut self,
2841 observation: &Observation,
2842 ) -> crate::Result<ApplyStats> {
2843 let prepared = prepare_observation(observation)?;
2844 #[cfg(test)]
2845 if prepared.reject_before_apply {
2846 return Err(crate::Error::CommitRejected("injected reducer preflight"));
2847 }
2848 let mut effects = NoConsequences;
2849 let stats = self.reduce_prepared(&prepared, None, None, None, false, &mut effects)?;
2850 record_batch(BatchProvenance::Baseline, observation.len(), stats);
2851 self.establish_baseline();
2852 Ok(stats)
2853 }
2854
2855 #[cfg(test)]
2857 pub(crate) fn apply_scanner_baseline(
2858 &mut self,
2859 batch: crate::scan::ScannerBatch,
2860 ) -> crate::Result<ApplyStats> {
2861 let observed = batch.len();
2862 let prepare_started = crate::counters::enabled().then(std::time::Instant::now);
2863 let prepared = self.prepare_scanner_batch(batch)?;
2864 if let Some(started) = prepare_started {
2865 let elapsed = elapsed_micros(started);
2866 crate::counters::bump(|counts| {
2867 counts.scanner_prepare_us = counts.scanner_prepare_us.saturating_add(elapsed);
2868 });
2869 }
2870 let mut effects = NoConsequences;
2871 let reduce_started = crate::counters::enabled().then(std::time::Instant::now);
2872 let stats = self.reduce_prepared(&prepared, None, None, None, false, &mut effects)?;
2874 if let Some(started) = reduce_started {
2875 let elapsed = elapsed_micros(started);
2876 crate::counters::bump(|counts| {
2877 counts.scanner_reduce_us = counts.scanner_reduce_us.saturating_add(elapsed);
2878 });
2879 }
2880 record_batch(BatchProvenance::Baseline, observed, stats);
2881 self.establish_baseline();
2882 Ok(stats)
2883 }
2884
2885 #[cfg(test)]
2886 pub(crate) fn apply_ok(&mut self, observation: &Observation) -> ApplyOutcome {
2887 self.apply(observation).expect("test observation must be valid")
2888 }
2889
2890 #[cfg(test)]
2891 pub(crate) fn apply_baseline_ok(&mut self, observation: &Observation) -> ApplyStats {
2892 self.apply_baseline(observation).expect("test baseline must be valid")
2893 }
2894
2895 pub(crate) fn establish_baseline(&mut self) {
2897 self.clock = Clock::ZERO;
2898 self.journal.clear();
2899 self.journal_cost = 0;
2900 self.journal_floor = Clock::ZERO;
2901 self.pending_invalidations.clear();
2902 }
2903
2904 pub(crate) fn mark_unverified(&mut self) {
2910 self.freshness_marks.clear();
2911 self.mark_unfresh(Path::new(""), Freshness::Stale);
2912 self.state.source = Source::Cached;
2913 self.state.freshness = Freshness::Stale;
2914 }
2915
2916 pub(crate) fn set_initial_freshness(&mut self, complete: bool) {
2917 self.freshness_marks.clear();
2918 if complete {
2919 for slot in &mut self.arena {
2920 if let Slot::Occupied { entry, .. } = slot {
2921 if entry.kind == EntryKind::Dir {
2922 entry.directory_mut().children_complete = true;
2923 }
2924 }
2925 }
2926 self.state.phase = LifecyclePhase::Ready;
2927 self.state.coverage = Coverage::Complete;
2928 self.state.freshness = Freshness::Fresh;
2929 } else {
2930 self.mark_unfresh(Path::new(""), Freshness::Partial);
2931 self.state.phase = LifecyclePhase::Ready;
2932 self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
2933 self.state.freshness = Freshness::Partial;
2934 }
2935 }
2936
2937 pub(crate) fn set_initial_scan_freshness(&mut self, errors: &[crate::Error]) {
2942 self.set_initial_freshness(errors.is_empty());
2943 if errors.is_empty() {
2944 return;
2945 }
2946 for slot in &mut self.arena {
2947 if let Slot::Occupied { entry, .. } = slot {
2948 if entry.kind.is_dir() {
2949 entry.directory_mut().children_complete = false;
2950 }
2951 }
2952 }
2953 let mut failed = Vec::with_capacity(errors.len());
2954 for error in errors {
2955 let Some(path) = Issue::from_error_under(&self.root_path, error).path else {
2956 return;
2957 };
2958 if path.is_absolute() || path.as_os_str().is_empty() {
2959 return;
2960 }
2961 failed.push(path);
2962 }
2963 failed.sort();
2964 failed.dedup();
2965 let listings: Vec<_> =
2966 failed.iter().map(|path| self.failed_listing_boundary(path)).collect();
2967 self.freshness_marks.clear();
2968 for path in &failed {
2969 self.mark_unfresh(path, Freshness::Partial);
2970 }
2971 for slot in 0..self.arena.len() {
2975 let Slot::Occupied { generation, entry } = &self.arena[slot] else {
2976 continue;
2977 };
2978 if !entry.kind.is_dir() {
2979 continue;
2980 }
2981 let id = EntryId {
2982 slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
2983 generation: *generation,
2984 };
2985 let Some(path) = self.path_of(id) else {
2986 continue;
2987 };
2988 self.entry_mut(id).directory_mut().children_complete =
2989 !listings.iter().zip(&failed).any(|(boundary, failure)| {
2990 path == *boundary || (boundary == failure && path.starts_with(boundary))
2991 });
2992 }
2993 }
2994
2995 fn failed_listing_boundary(&self, failed: &Path) -> PathBuf {
2999 let mut boundary = failed.to_path_buf();
3000 loop {
3001 if self.lookup(&boundary).is_some_and(|id| self.entry(id).kind.is_dir()) {
3002 return boundary;
3003 }
3004 if !boundary.pop() {
3005 return PathBuf::new();
3006 }
3007 }
3008 }
3009
3010 pub(crate) fn record_walk_errors(&mut self, errors: &mut Vec<crate::Error>) {
3011 self.issues.clear();
3012 self.issue_epochs.clear();
3013 self.omitted_issue_epochs.clear();
3014 self.state.issues = crate::IssueSummary::default();
3015 let root = self.root_path.clone();
3016 crate::scan::normalize_walk_errors(&root, errors);
3017 for error in errors {
3018 self.retain_issue(Issue::from_error_under(&root, error));
3019 }
3020 }
3021
3022 pub(crate) fn begin_reconcile(&mut self, path: &Path) -> crate::Result<(u64, Option<Commit>)> {
3023 let path = canonical_relative_path(path)?;
3024 let next_clock = self.clock.checked_next().ok_or(crate::Error::ClockExhausted)?;
3025 let previous_index_state = self.state;
3026 let previous = self.freshness_at(&path);
3027 let epoch = self.mark_unfresh(&path, Freshness::Reconciling);
3028 self.active_reconciles.insert(
3029 epoch,
3030 ActiveReconcile {
3031 path: path.clone(),
3032 scope_budget: usize::try_from(self.len()).unwrap_or(usize::MAX),
3033 evidence: ReconcileEvidence::Scopes(BTreeSet::new()),
3034 },
3035 );
3036 if path.as_os_str().is_empty() {
3037 self.active_root_reconciles.insert(epoch, Self::now_unix_nanos());
3038 }
3039 let current = self.freshness_at(&path);
3040 self.state.freshness = self.published_freshness();
3041 let commit = if previous == current && previous_index_state == self.state {
3042 None
3043 } else {
3044 let mut state = Vec::new();
3045 if previous != current {
3046 state.push(StateTransition::Freshness { path, previous, current });
3047 }
3048 if previous_index_state != self.state {
3049 state.push(StateTransition::IndexState {
3050 previous: previous_index_state,
3051 current: self.state,
3052 });
3053 }
3054 let effects = ExactConsequences { state, ..ExactConsequences::default() };
3055 Some(self.publish_effects(next_clock, effects, Work::default(), true))
3056 };
3057 Ok((epoch, commit))
3058 }
3059
3060 pub(crate) fn finish_reconcile(
3069 &mut self,
3070 path: &Path,
3071 started_at: u64,
3072 complete: bool,
3073 listed_incomplete: &[PathBuf],
3074 failed_paths: &[PathBuf],
3075 errors: ReconcileErrors<'_>,
3076 ) -> crate::Result<ReconcileFinish> {
3077 let path = canonical_relative_path(path)?;
3078 let next_clock = self.clock.checked_next().ok_or(crate::Error::ClockExhausted)?;
3079 let previous_index_state = self.state;
3080 let previous = self.freshness_at(&path);
3081 let evidence = self.active_reconciles.get(&started_at).map_or_else(
3083 || ReconcileEvidence::Scopes(BTreeSet::new()),
3084 |active| active.evidence.clone(),
3085 );
3086 let superseded = match evidence {
3087 ReconcileEvidence::Scopes(scopes) => scopes,
3088 ReconcileEvidence::Retry => {
3089 self.active_root_reconciles.remove(&started_at);
3090 self.active_reconciles.remove(&started_at);
3091 self.compact_omitted_epochs();
3092 self.mark_unfresh(&path, Freshness::Partial);
3093 if self.state.coverage == Coverage::Complete {
3094 self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
3095 }
3096 self.state.freshness = self.published_freshness();
3097 self.retain_issue(Issue::provider_failure(
3098 Some(&path),
3099 "reconciliation interrupted by newer verification; retry this scope"
3100 .to_string(),
3101 ));
3102 let current = self.freshness_at(&path);
3103 let mut state = Vec::new();
3104 if previous != current {
3105 state.push(StateTransition::Freshness { path, previous, current });
3106 }
3107 if previous_index_state != self.state {
3108 state.push(StateTransition::IndexState {
3109 previous: previous_index_state,
3110 current: self.state,
3111 });
3112 }
3113 let commit = if state.is_empty() {
3114 None
3115 } else {
3116 let effects = ExactConsequences { state, ..ExactConsequences::default() };
3117 Some(self.publish_effects(next_clock, effects, Work::default(), true))
3118 };
3119 return Ok(ReconcileFinish { commit, retry: true });
3120 }
3121 };
3122 let fully_superseded = superseded.iter().any(|newer| path.starts_with(newer));
3123 if errors.disproves_old && !fully_superseded {
3124 for (epoch, active) in &mut self.active_reconciles {
3125 if *epoch < started_at
3126 && (active.path.starts_with(&path) || path.starts_with(&active.path))
3127 {
3128 active.supersede(&path);
3129 }
3130 }
3131 }
3132 self.freshness_marks
3133 .retain(|marked, mark| !marked.starts_with(&path) || mark.epoch > started_at);
3134 if fully_superseded {
3135 self.active_root_reconciles.remove(&started_at);
3136 self.active_reconciles.remove(&started_at);
3137 self.compact_omitted_epochs();
3138 let current = self.freshness_at(&path);
3139 self.state.freshness = self.published_freshness();
3140 if self.state.coverage == Coverage::Partial(CoverageReason::Inaccessible) {
3141 self.state.freshness = Freshness::Partial;
3142 }
3143 let mut state = Vec::new();
3144 if previous != current {
3145 state.push(StateTransition::Freshness { path, previous, current });
3146 }
3147 if previous_index_state != self.state {
3148 state.push(StateTransition::IndexState {
3149 previous: previous_index_state,
3150 current: self.state,
3151 });
3152 }
3153 if state.is_empty() {
3154 return Ok(ReconcileFinish { commit: None, retry: false });
3155 }
3156 let effects = ExactConsequences { state, ..ExactConsequences::default() };
3157 return Ok(ReconcileFinish {
3158 commit: Some(self.publish_effects(next_clock, effects, Work::default(), true)),
3159 retry: false,
3160 });
3161 }
3162 let still_owned =
3163 |candidate: &Path| !superseded.iter().any(|newer| candidate.starts_with(newer));
3164 let recordable: Vec<&PathBuf> = listed_incomplete
3171 .iter()
3172 .filter(|directory| {
3173 directory.starts_with(&path)
3174 && still_owned(directory)
3175 && !self.freshness_marks.iter().any(|(marked, mark)| {
3176 mark.epoch > started_at && directory.starts_with(marked)
3177 })
3178 })
3179 .collect();
3180 let scoped_failures: Vec<&PathBuf> = failed_paths
3181 .iter()
3182 .filter(|failed| failed.starts_with(&path) && still_owned(failed))
3183 .collect();
3184 let complete = complete
3188 || (errors.disproves_old
3189 && failed_paths.iter().any(|failed| failed.starts_with(&path))
3190 && scoped_failures.is_empty()
3191 && errors.errors.iter().chain(errors.terminal).all(|error| {
3192 Issue::from_error_under(&self.root_path, error)
3193 .path
3194 .is_some_and(|failed| !failed.starts_with(&path) || !still_owned(&failed))
3195 }));
3196 if errors.disproves_old && self.state.phase != LifecyclePhase::Failed {
3197 self.drop_disproven_issues(&path, started_at);
3198 }
3199 if errors.disproves_old
3200 && self.state.phase != LifecyclePhase::Failed
3201 && path.as_os_str().is_empty()
3202 {
3203 self.drop_disproven_omitted(started_at);
3204 }
3205 let mut state = Vec::new();
3206 if !complete && (!errors.errors.is_empty() || errors.terminal.is_some()) {
3211 let boundaries: Vec<_> = if scoped_failures.is_empty() {
3212 vec![(path.clone(), true)]
3213 } else {
3214 scoped_failures
3215 .iter()
3216 .map(|failed| {
3217 let boundary = self.failed_listing_boundary(failed);
3218 let subtree = boundary == **failed;
3219 (boundary, subtree)
3220 })
3221 .collect()
3222 };
3223 for slot in 0..self.arena.len() {
3224 let Slot::Occupied { generation, entry } = &self.arena[slot] else {
3225 continue;
3226 };
3227 if !entry.kind.is_dir() || !entry.directory().children_complete {
3228 continue;
3229 }
3230 let id = EntryId {
3231 slot: u32::try_from(slot).expect("index arena exceeded u32 capacity"),
3232 generation: *generation,
3233 };
3234 let Some(directory) = self.path_of(id) else {
3235 continue;
3236 };
3237 if boundaries.iter().any(|(boundary, subtree)| {
3238 directory == *boundary || (*subtree && directory.starts_with(boundary))
3239 }) && still_owned(&directory)
3240 && !self.freshness_marks.iter().any(|(marked, mark)| {
3241 mark.epoch > started_at && directory.starts_with(marked)
3242 })
3243 {
3244 self.entry_mut(id).directory_mut().children_complete = false;
3245 state.push(StateTransition::DirectoryIncomplete { path: directory });
3246 }
3247 }
3248 }
3249 let verified_scope = superseded.is_empty()
3252 || (complete
3253 && superseded.iter().all(|newer| self.freshness_at(newer) == Freshness::Fresh));
3254 if verified_scope && (complete || !scoped_failures.is_empty()) {
3255 let now = Self::now_unix_nanos();
3259 self.verified.retain(|(verified_path, _)| !verified_path.starts_with(&path));
3260 self.verified.push((path.clone(), now));
3261 if self.verified.len() > MAX_VERIFIED_INTERVALS {
3262 let excess = self.verified.len() - MAX_VERIFIED_INTERVALS;
3263 self.verified.sort_by_key(|(_, at)| *at);
3264 self.verified.drain(..excess);
3265 }
3266 state.push(StateTransition::Verified { path: path.clone() });
3267 }
3268 if complete {
3269 if path.as_os_str().is_empty() {
3270 if let Some(started) = self.active_root_reconciles.remove(&started_at) {
3271 self.writing_pass_started_at_ns = started;
3272 }
3273 self.state.source = self.applying_source;
3274 }
3275 } else {
3276 if scoped_failures.is_empty() {
3277 self.mark_unfresh(&path, Freshness::Partial);
3278 } else {
3279 for failed in scoped_failures {
3280 self.mark_unfresh(failed, Freshness::Partial);
3281 }
3282 }
3283 if !errors.errors.is_empty() || errors.terminal.is_some() {
3284 self.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
3285 }
3286 if path.as_os_str().is_empty() {
3287 if let Some(started) = self.active_root_reconciles.remove(&started_at) {
3288 self.writing_pass_started_at_ns = started;
3289 }
3290 self.state.source = self.applying_source;
3291 }
3292 }
3293 for error in errors.errors.iter().chain(errors.terminal) {
3299 let issue = Issue::from_error_under(&self.root_path, error);
3300 if issue
3301 .path
3302 .as_deref()
3303 .is_none_or(|issue_path| issue_path.starts_with(&path) && still_owned(issue_path))
3304 {
3305 self.retain_issue(issue);
3306 }
3307 }
3308 for directory in recordable {
3309 let Some(id) = self.lookup(directory) else {
3310 continue;
3311 };
3312 let entry = self.entry_mut(id);
3313 if entry.kind != EntryKind::Dir || entry.directory().children_complete {
3314 continue;
3315 }
3316 entry.directory_mut().children_complete = true;
3317 self.state.progress.directories_complete =
3318 self.state.progress.directories_complete.saturating_add(1);
3319 state.push(StateTransition::DirectoryComplete { path: directory.clone() });
3320 }
3321
3322 if complete
3323 && self.state.coverage == Coverage::Partial(CoverageReason::Inaccessible)
3324 && !self.issues.iter().any(|issue| issue.kind != crate::IssueKind::ObservationGap)
3325 && self.state.issues.omitted == 0
3326 && self.arena.iter().all(|slot| {
3327 let Slot::Occupied { entry, .. } = slot else {
3328 return true;
3329 };
3330 entry.kind != EntryKind::Dir || entry.directory().children_complete
3331 })
3332 {
3333 self.state.coverage = Coverage::Complete;
3334 }
3335
3336 let current = self.freshness_at(&path);
3337 self.state.freshness = self.published_freshness();
3338 if self.state.coverage == Coverage::Partial(CoverageReason::Inaccessible) {
3339 self.state.freshness = Freshness::Partial;
3340 }
3341 self.active_reconciles.remove(&started_at);
3342 self.compact_omitted_epochs();
3343 if previous != current {
3344 state.push(StateTransition::Freshness { path: path.clone(), previous, current });
3345 }
3346 if previous_index_state != self.state {
3347 state.push(StateTransition::IndexState {
3348 previous: previous_index_state,
3349 current: self.state,
3350 });
3351 }
3352 if state.is_empty() {
3353 return Ok(ReconcileFinish { commit: None, retry: false });
3354 }
3355 let effects = ExactConsequences { state, ..ExactConsequences::default() };
3356 Ok(ReconcileFinish {
3357 commit: Some(self.publish_effects(next_clock, effects, Work::default(), true)),
3358 retry: false,
3359 })
3360 }
3361
3362 fn verified_at(&self, path: &Path) -> Option<i64> {
3364 self.verified
3365 .iter()
3366 .filter(|(covered, _)| path.starts_with(covered))
3367 .map(|(_, at)| *at)
3368 .max()
3369 }
3370
3371 fn mark_unfresh(&mut self, path: &Path, state: Freshness) -> u64 {
3372 self.freshness_epoch =
3373 self.freshness_epoch.checked_add(1).expect("freshness epoch exhausted");
3374 let epoch = self.freshness_epoch;
3375 self.freshness_marks.insert(path.to_path_buf(), FreshnessMark { state, epoch });
3376 epoch
3377 }
3378
3379 pub fn path_state(&self, path: &Path) -> PathState {
3384 let Some(id) = self.lookup(path) else {
3385 return PathState::Absent;
3386 };
3387 let entry = self.entry(id);
3388 PathState::Present { kind: entry.kind, attrs: entry.attrs }
3389 }
3390
3391 pub fn expectation(&self, path: &Path) -> PathExpectation {
3393 let entry = self.entry_identity(path);
3394 PathExpectation::new(
3395 self.path_state(path),
3396 entry,
3397 entry.is_none().then(|| self.absence_guard_identity(path)).flatten(),
3398 )
3399 }
3400
3401 pub(crate) fn relaxed_expectation(&self, path: &Path) -> PathExpectation {
3402 PathExpectation::new(self.path_state(path), self.entry_identity(path), None)
3403 }
3404
3405 pub fn since(&self, clock: Clock) -> Since {
3407 let commits: Vec<Commit> =
3408 self.journal.iter().filter(|commit| commit.clock > clock).cloned().collect();
3409 Since {
3410 commits,
3411 clock: self.clock,
3412 state: self.state,
3413 truncated: clock < self.journal_floor,
3414 }
3415 }
3416
3417 pub fn take_pending_invalidations(&mut self) -> Vec<(PathBuf, InvalidateReason)> {
3423 std::mem::take(&mut self.pending_invalidations)
3424 }
3425
3426 pub(crate) fn restore_pending_invalidations(
3428 &mut self,
3429 invalidations: Vec<(PathBuf, InvalidateReason)>,
3430 ) {
3431 self.pending_invalidations.extend(invalidations);
3432 }
3433
3434 pub fn lookup(&self, path: &Path) -> Option<EntryId> {
3436 let mut current = EntryId::ROOT;
3437 for part in normalize(path)? {
3438 current = self.child(current, part)?;
3439 }
3440 Some(current)
3441 }
3442
3443 pub fn rollup(&self, path: &Path) -> Option<RollUp> {
3446 let id = self.lookup(path)?;
3447 let entry = self.entry(id);
3448 entry.kind.is_dir().then(|| self.named_rollup(&entry.rollup().all))
3449 }
3450
3451 pub fn partition_rollup(&self, path: &Path) -> crate::Result<Option<PartitionRollUp>> {
3460 self.require_observed_controls()?;
3461 Ok(self
3462 .lookup(path)
3463 .map(|id| self.entry(id))
3464 .filter(|entry| entry.kind.is_dir())
3465 .map(|entry| self.named_partitions(entry.rollup())))
3466 }
3467
3468 pub fn partition_rollup_summary(
3477 &self,
3478 path: &Path,
3479 ) -> crate::Result<Option<PartitionRollUpSummary>> {
3480 self.require_observed_controls()?;
3481 Ok(self
3482 .lookup(path)
3483 .map(|id| self.entry(id))
3484 .filter(|entry| entry.kind.is_dir())
3485 .map(|entry| partition_summary(entry.rollup())))
3486 }
3487
3488 pub(crate) fn opened_is_ignored(&self, id: EntryId) -> bool {
3494 debug_assert!(self.observes_controls(), "an opened root observes control state");
3495 self.entry(id).ignored
3496 }
3497
3498 pub(crate) fn entry_value(&self, path: &Path) -> Option<crate::EntryValue> {
3500 let id = self.lookup(path)?;
3501 Some(self.entry_value_of(id, path))
3502 }
3503
3504 pub(crate) fn entry_value_of(&self, id: EntryId, path: &Path) -> crate::EntryValue {
3505 let entry = self.entry(id);
3506 crate::EntryValue {
3507 path: path.to_path_buf(),
3508 portable_path: crate::opened::read::portable_path(path),
3509 kind: entry.kind,
3510 attrs: entry.attrs,
3511 ignored: entry.ignored,
3512 classification: (entry.kind == EntryKind::File)
3513 .then(|| self.types.classify_name(path.file_name().unwrap_or_default())),
3514 rollup: entry.kind.is_dir().then(|| partition_summary(entry.rollup())),
3515 children_complete: entry.kind.is_dir().then(|| entry.directory().children_complete),
3516 }
3517 }
3518
3519 pub(crate) fn portable_children(&self, path: &Path) -> Option<&PortableChildren> {
3520 self.serving.as_ref()?.portable_children.get(path)
3521 }
3522
3523 pub(crate) fn portable_entries(&self) -> &BTreeMap<crate::PortablePath, EntryId> {
3524 &self.serving.as_ref().expect("opened-root reads require serving indexes").portable_entries
3525 }
3526
3527 #[cfg(test)]
3528 pub(crate) const fn serving_indexes_enabled(&self) -> bool {
3529 self.serving.is_some()
3530 }
3531
3532 fn insert_serving_entry(&mut self, path: &Path, kind: EntryKind, attrs: Attrs, id: EntryId) {
3533 if path.as_os_str().is_empty() || self.serving.is_none() {
3534 return;
3535 }
3536 let file = (kind == EntryKind::File).then(|| {
3537 (
3538 self.classify(path).file_type.as_str().to_string(),
3539 path.file_name(),
3540 self.entry(id).ignored,
3541 self.entry(id).parent,
3542 )
3543 });
3544 let arena = &self.arena;
3545 let Some(serving) = self.serving.as_mut() else {
3546 return;
3547 };
3548 if let Some((name, exact_name, ignored, parent)) = file {
3549 let semantic = serving.intern_semantic(&name);
3550 let mut ancestor = parent;
3551 while let Some(directory) = ancestor {
3552 let partition = serving.semantic_by_directory.entry(directory).or_default();
3553 merge_semantic(&mut partition.all, semantic, attrs);
3554 if !ignored {
3555 merge_semantic(&mut partition.unignored, semantic, attrs);
3556 }
3557 ancestor = retained_parent(arena, directory);
3558 }
3559 if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
3560 let mut ancestor = parent;
3561 while let Some(directory) = ancestor {
3562 let partition = serving.exact_name_by_directory.entry(directory).or_default();
3563 merge_semantic(&mut partition.all, exact_name, attrs);
3564 if !ignored {
3565 merge_semantic(&mut partition.unignored, exact_name, attrs);
3566 }
3567 ancestor = retained_parent(arena, directory);
3568 }
3569 }
3570 }
3571 let portable = crate::opened::read::portable_path(path);
3572 serving.portable_entries.insert(portable.clone(), id);
3573 if kind == EntryKind::File {
3574 serving.recent_files.insert(RecentKey {
3575 mtime_ns: attrs.mtime_ns,
3576 portable_path: portable,
3577 id,
3578 });
3579 }
3580 let parent = path.parent().unwrap_or_else(|| Path::new("")).to_path_buf();
3581 let Some(name) = path.file_name().map(crate::opened::read::portable_component) else {
3582 return;
3583 };
3584 let children = serving.portable_children.entry(parent).or_default();
3585 if kind.is_dir() {
3586 children.directories.insert(name, id);
3587 } else {
3588 children.nondirectories.insert(name, id);
3589 }
3590 }
3591
3592 fn remove_serving_entry(&mut self, path: &Path, kind: EntryKind, attrs: Attrs, id: EntryId) {
3593 if path.as_os_str().is_empty() {
3594 return;
3595 }
3596 let Some(serving) = self.serving.as_mut() else {
3597 return;
3598 };
3599 let portable = crate::opened::read::portable_path(path);
3600 serving.portable_entries.remove(&portable);
3601 if kind == EntryKind::File {
3602 serving.recent_files.remove(&RecentKey {
3603 mtime_ns: attrs.mtime_ns,
3604 portable_path: portable,
3605 id,
3606 });
3607 }
3608 let parent = path.parent().unwrap_or_else(|| Path::new("")).to_path_buf();
3609 let remove_parent = if let Some(children) = serving.portable_children.get_mut(&parent) {
3610 if let Some(name) = path.file_name().map(crate::opened::read::portable_component) {
3611 if kind.is_dir() {
3612 children.directories.remove(&name);
3613 } else {
3614 children.nondirectories.remove(&name);
3615 }
3616 }
3617 children.directories.is_empty() && children.nondirectories.is_empty()
3618 } else {
3619 false
3620 };
3621 if remove_parent {
3622 serving.portable_children.remove(&parent);
3623 }
3624 if kind.is_dir() {
3625 serving.portable_children.remove(path);
3626 }
3627 }
3628
3629 fn remove_serving_file_semantics(&mut self, path: &Path, id: EntryId, attrs: Attrs) {
3630 if self.serving.is_none() || self.entry(id).kind != EntryKind::File {
3634 return;
3635 }
3636 let name = self.classify(path).file_type.as_str().to_string();
3637 let exact_name = path.file_name();
3638 let ignored = self.entry(id).ignored;
3639 let parent = self.entry(id).parent;
3640 let arena = &self.arena;
3641 let serving = self.serving.as_mut().expect("checked above");
3642 let semantic = *serving
3643 .semantic_ids
3644 .get(&name)
3645 .expect("every served file has an interned semantic type");
3646 let mut empty = Vec::new();
3647 let mut ancestor = parent;
3648 while let Some(directory) = ancestor {
3649 let partition = serving
3650 .semantic_by_directory
3651 .get_mut(&directory)
3652 .expect("every served file contributes to every ancestor");
3653 unmerge_semantic(&mut partition.all, semantic, attrs);
3654 if !ignored {
3655 unmerge_semantic(&mut partition.unignored, semantic, attrs);
3656 }
3657 if partition.all.is_empty() && partition.unignored.is_empty() {
3658 empty.push(directory);
3659 }
3660 ancestor = retained_parent(arena, directory);
3661 }
3662 for ancestor in empty {
3663 serving.semantic_by_directory.remove(&ancestor);
3664 }
3665 serving.release_semantic(semantic, 1);
3666 if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
3667 let mut exact_empty = Vec::new();
3668 let mut ancestor = parent;
3669 while let Some(directory) = ancestor {
3670 let partition = serving
3671 .exact_name_by_directory
3672 .get_mut(&directory)
3673 .expect("every declared exact-name file contributes to every ancestor");
3674 unmerge_semantic(&mut partition.all, exact_name, attrs);
3675 if !ignored {
3676 unmerge_semantic(&mut partition.unignored, exact_name, attrs);
3677 }
3678 if partition.all.is_empty() && partition.unignored.is_empty() {
3679 exact_empty.push(directory);
3680 }
3681 ancestor = retained_parent(arena, directory);
3682 }
3683 for ancestor in exact_empty {
3684 serving.exact_name_by_directory.remove(&ancestor);
3685 }
3686 }
3687 }
3688
3689 fn remove_serving_subtree_semantics(&mut self, root: EntryId, path: &Path) {
3690 if self.serving.is_none() {
3691 return;
3692 }
3693 match self.entry(root).kind {
3694 EntryKind::File => {
3695 let attrs = self.entry(root).attrs;
3696 self.remove_serving_file_semantics(path, root, attrs);
3697 }
3698 EntryKind::Dir => {
3699 let parent = self.entry(root).parent;
3700 let mut stack = vec![root];
3701 let mut directories = Vec::new();
3702 while let Some(id) = stack.pop() {
3703 let entry = self.entry(id);
3704 if !entry.kind.is_dir() {
3705 continue;
3706 }
3707 directories.push(id);
3708 stack.extend(self.child_ids(id));
3709 }
3710 let arena = &self.arena;
3711 let serving = self.serving.as_mut().expect("checked above");
3712 let contribution =
3713 serving.semantic_by_directory.get(&root).cloned().unwrap_or_default();
3714 let exact_contribution =
3715 serving.exact_name_by_directory.get(&root).cloned().unwrap_or_default();
3716 let mut empty = Vec::new();
3717 if !contribution.all.is_empty() || !contribution.unignored.is_empty() {
3718 let mut ancestor = parent;
3719 while let Some(directory) = ancestor {
3720 let partition = serving
3721 .semantic_by_directory
3722 .get_mut(&directory)
3723 .expect("a semantic subtree contributes to every ancestor");
3724 unmerge_semantic_map(&mut partition.all, &contribution.all);
3725 unmerge_semantic_map(&mut partition.unignored, &contribution.unignored);
3726 if partition.all.is_empty() && partition.unignored.is_empty() {
3727 empty.push(directory);
3728 }
3729 ancestor = retained_parent(arena, directory);
3730 }
3731 }
3732 let mut exact_empty = Vec::new();
3733 if !exact_contribution.all.is_empty() || !exact_contribution.unignored.is_empty() {
3734 let mut ancestor = parent;
3735 while let Some(directory) = ancestor {
3736 let partition = serving
3737 .exact_name_by_directory
3738 .get_mut(&directory)
3739 .expect("an exact-name subtree contributes to every ancestor");
3740 unmerge_semantic_map(&mut partition.all, &exact_contribution.all);
3741 unmerge_semantic_map(
3742 &mut partition.unignored,
3743 &exact_contribution.unignored,
3744 );
3745 if partition.all.is_empty() && partition.unignored.is_empty() {
3746 exact_empty.push(directory);
3747 }
3748 ancestor = retained_parent(arena, directory);
3749 }
3750 }
3751 for ancestor in empty {
3752 serving.semantic_by_directory.remove(&ancestor);
3753 }
3754 for ancestor in exact_empty {
3755 serving.exact_name_by_directory.remove(&ancestor);
3756 }
3757 for directory in directories {
3758 serving.semantic_by_directory.remove(&directory);
3759 serving.exact_name_by_directory.remove(&directory);
3760 }
3761 for (semantic, tally) in contribution.all {
3762 serving.release_semantic(semantic, tally.files);
3763 }
3764 }
3765 EntryKind::Symlink | EntryKind::Other => {}
3766 }
3767 }
3768
3769 fn move_serving_file_partition(
3770 &mut self,
3771 path: &Path,
3772 id: EntryId,
3773 previous_ignored: bool,
3774 current_ignored: bool,
3775 ) {
3776 if previous_ignored == current_ignored
3777 || self.serving.is_none()
3778 || self.entry(id).kind != EntryKind::File
3779 {
3780 return;
3781 }
3782 let name = self.classify(path).file_type.as_str().to_string();
3783 let exact_name = path.file_name();
3784 let attrs = self.entry(id).attrs;
3785 let parent = self.entry(id).parent;
3786 let arena = &self.arena;
3787 let serving = self.serving.as_mut().expect("checked above");
3788 let semantic = *serving
3789 .semantic_ids
3790 .get(&name)
3791 .expect("every served file has an interned semantic type");
3792 let mut ancestor = parent;
3793 while let Some(directory) = ancestor {
3794 let partition = serving
3795 .semantic_by_directory
3796 .get_mut(&directory)
3797 .expect("every served file contributes to every ancestor");
3798 if current_ignored {
3799 unmerge_semantic(&mut partition.unignored, semantic, attrs);
3800 } else {
3801 merge_semantic(&mut partition.unignored, semantic, attrs);
3802 }
3803 ancestor = retained_parent(arena, directory);
3804 }
3805 if let Some(exact_name) = exact_name.and_then(|name| serving.exact_name_id(name)) {
3806 let mut ancestor = parent;
3807 while let Some(directory) = ancestor {
3808 let partition = serving
3809 .exact_name_by_directory
3810 .get_mut(&directory)
3811 .expect("every declared exact-name file contributes to every ancestor");
3812 if current_ignored {
3813 unmerge_semantic(&mut partition.unignored, exact_name, attrs);
3814 } else {
3815 merge_semantic(&mut partition.unignored, exact_name, attrs);
3816 }
3817 ancestor = retained_parent(arena, directory);
3818 }
3819 }
3820 }
3821
3822 pub fn attrs(&self, path: &Path) -> Option<&Attrs> {
3824 Some(&self.entry(self.lookup(path)?).attrs)
3825 }
3826
3827 pub fn kind(&self, path: &Path) -> Option<EntryKind> {
3829 Some(self.entry(self.lookup(path)?).kind)
3830 }
3831
3832 pub fn is_ignored(&self, path: &Path) -> crate::Result<Option<bool>> {
3844 self.require_observed_controls()?;
3845 Ok(self.lookup(path).map(|id| self.entry(id).ignored))
3846 }
3847
3848 pub fn children(
3852 &self,
3853 path: &Path,
3854 ) -> Option<impl DoubleEndedIterator<Item = (&OsStr, EntryId)> + ExactSizeIterator + '_> {
3855 let id = self.lookup(path)?;
3856 let entry = self.entry(id);
3857 entry.kind.is_dir().then(|| IndexChildren::new(self, entry))
3858 }
3859
3860 pub fn children_of(
3864 &self,
3865 id: EntryId,
3866 ) -> Option<impl DoubleEndedIterator<Item = (&OsStr, EntryId)> + ExactSizeIterator + '_> {
3867 Some(IndexChildren::new(self, self.try_entry(id)?))
3868 }
3869
3870 pub fn path_of(&self, id: EntryId) -> Option<PathBuf> {
3872 let mut parts = Vec::new();
3873 let mut current = Some(id);
3874 while let Some(node) = current {
3875 let entry = self.try_entry(node)?;
3876 if entry.parent.is_some() {
3877 parts.push(entry.name.as_os_str());
3878 }
3879 current = entry.parent;
3880 }
3881 parts.reverse();
3882 Some(parts.iter().collect())
3883 }
3884
3885 pub fn rollup_of(&self, id: EntryId) -> Option<RollUp> {
3887 let entry = self.try_entry(id)?;
3888 entry.kind.is_dir().then(|| self.named_rollup(&entry.rollup().all))
3889 }
3890
3891 pub(crate) fn partition_scalars_of(
3898 &self,
3899 id: EntryId,
3900 ) -> Option<(RollUpScalars, RollUpScalars)> {
3901 let entry = self.try_entry(id)?;
3902 entry.kind.is_dir().then(|| {
3903 let rollup = entry.rollup();
3904 (RollUpScalars::from(&rollup.all), RollUpScalars::from(&rollup.unignored))
3905 })
3906 }
3907
3908 pub(crate) fn ignored_bit_of(&self, id: EntryId) -> Option<bool> {
3914 Some(self.try_entry(id)?.ignored)
3915 }
3916
3917 pub(crate) fn directory_complete_of(&self, id: EntryId) -> Option<bool> {
3921 let entry = self.try_entry(id)?;
3922 (entry.kind == EntryKind::Dir).then(|| entry.directory().children_complete)
3923 }
3924
3925 pub fn attrs_of(&self, id: EntryId) -> Option<&Attrs> {
3927 Some(&self.try_entry(id)?.attrs)
3928 }
3929
3930 pub fn kind_of(&self, id: EntryId) -> Option<EntryKind> {
3932 Some(self.try_entry(id)?.kind)
3933 }
3934
3935 pub fn name_of(&self, id: EntryId) -> Option<&OsStr> {
3937 Some(&self.try_entry(id)?.name)
3938 }
3939
3940 pub fn content(&self) -> Option<&ContentIndex> {
3942 self.content.as_deref()
3943 }
3944
3945 pub fn content_rollup(&self, path: &Path) -> Option<&ContentRollUp> {
3947 self.content()?.rollup(path)
3948 }
3949
3950 pub fn content_set(&self) -> AnalysisSet {
3953 self.content().and_then(ContentIndex::profile).unwrap_or(AnalysisSet::NONE)
3954 }
3955
3956 pub(crate) fn content_has_pending(&self, profile: AnalysisSet) -> bool {
3962 if !profile.is_enabled() {
3963 return false;
3964 }
3965 let wanted = self.content_identity(profile);
3966 let Some(content) = self.content().and_then(|content| content.admit(&wanted)) else {
3967 return true;
3968 };
3969 u64::try_from(content.len()).unwrap_or(u64::MAX) < self.entry(EntryId::ROOT).rollup().files
3970 }
3971
3972 pub fn content_identity(&self, analysis: AnalysisSet) -> crate::ContentTierIdentity {
3975 crate::ContentTierIdentity::for_request(
3976 crate::EntryTierIdentity::of_scope(self.scope),
3977 analysis,
3978 )
3979 }
3980
3981 pub(crate) fn prepare_content_analysis(&mut self, request: crate::content::AnalysisRequest) {
3984 if !request.profile.is_enabled() {
3985 return;
3986 }
3987 let identity = self.content_identity(request.profile);
3988 self.content.get_or_insert_with(|| Box::new(ContentIndex::default())).prepare(identity);
3989 }
3990
3991 pub(crate) fn set_content_tier_state(
3992 &mut self,
3993 source: Source,
3994 freshness: Freshness,
3995 observed_at_ns: Option<i64>,
3996 ) {
3997 if let Some(content) = self.content.as_deref_mut() {
3998 content.set_state(crate::content::ContentTierState {
3999 source,
4000 freshness,
4001 observed_at_ns,
4002 });
4003 }
4004 }
4005
4006 pub(crate) fn analysis_candidates(&self, profile: AnalysisSet) -> Vec<AnalysisCandidate> {
4016 let root_files = self.entry(EntryId::ROOT).rollup().files;
4017 let mut candidates = Vec::with_capacity(usize::try_from(root_files).unwrap_or(0));
4018 self.for_each_analysis_file(profile, |id, revision, attrs, relative_path| {
4019 candidates.push(AnalysisCandidate {
4020 entry_id: id,
4021 revision,
4022 absolute_path: self.root_path.join(&relative_path),
4023 classification: self.classify(&relative_path),
4024 relative_path,
4025 attrs,
4026 });
4027 });
4028 candidates
4029 }
4030
4031 pub(crate) fn restore_analysis_candidates(
4037 &self,
4038 profile: AnalysisSet,
4039 ) -> (HashMap<PathBuf, RestoreCandidate>, u64) {
4040 let root_files = self.entry(EntryId::ROOT).rollup().files;
4041 let mut candidates = HashMap::with_capacity(usize::try_from(root_files).unwrap_or(0));
4042 let mut visited = 0_u64;
4043 self.for_each_analysis_file(profile, |id, revision, attrs, relative_path| {
4044 visited = visited.saturating_add(1);
4045 candidates.insert(
4046 relative_path.clone(),
4047 RestoreCandidate { entry_id: id, revision, relative_path, attrs },
4048 );
4049 });
4050 (candidates, visited)
4051 }
4052
4053 fn for_each_analysis_file(
4054 &self,
4055 profile: AnalysisSet,
4056 mut visit: impl FnMut(EntryId, u64, Attrs, PathBuf),
4057 ) {
4058 if !profile.is_enabled() {
4059 return;
4060 }
4061 let mut stack = vec![(EntryId::ROOT, PathBuf::new())];
4064 while let Some((parent, parent_path)) = stack.pop() {
4065 for (name, id) in self.children_of(parent).into_iter().flatten() {
4066 let entry = self.entry(id);
4067 if entry.kind == EntryKind::Dir {
4068 stack.push((id, parent_path.join(name)));
4069 continue;
4070 }
4071 if entry.kind != EntryKind::File {
4072 continue;
4073 }
4074 let revision = entry.revision;
4075 let attrs = entry.attrs;
4076 visit(id, revision, attrs, parent_path.join(name));
4077 }
4078 }
4079 }
4080
4081 pub(crate) fn pending_analysis_candidates(
4085 &self,
4086 request: crate::content::AnalysisRequest,
4087 ) -> Vec<AnalysisCandidate> {
4088 let wanted = self.content_identity(request.profile);
4089 let held = self.content().and_then(|content| content.admit(&wanted));
4092 self.analysis_candidates(request.profile)
4093 .into_iter()
4094 .filter(|candidate| {
4095 held.and_then(|content| content.file(&candidate.relative_path)).is_none_or(
4096 |record| {
4097 record.fingerprint != candidate.attrs.fingerprint() || !record.is_reusable()
4098 },
4099 )
4100 })
4101 .collect()
4102 }
4103
4104 pub(crate) fn apply_analysis(
4114 &mut self,
4115 observation: AnalysisObservation,
4116 ) -> AnalysisApplyOutcome {
4117 self.apply_analysis_record(observation)
4118 }
4119
4120 pub(crate) fn apply_restored_analysis(
4125 &mut self,
4126 candidate: RestoreCandidate,
4127 analysis: crate::stored_state::AdmittedRecord<'_>,
4128 ) -> AnalysisApplyOutcome {
4129 let Some(entry) = self.try_entry(candidate.entry_id) else {
4130 return AnalysisApplyOutcome::Stale;
4131 };
4132 if entry.kind != EntryKind::File
4133 || entry.revision != candidate.revision
4134 || entry.attrs.fingerprint() != candidate.attrs.fingerprint()
4135 {
4136 return AnalysisApplyOutcome::Stale;
4137 }
4138 let Some(content) = self.content.as_mut() else {
4139 return AnalysisApplyOutcome::Stale;
4140 };
4141 if content.commit_without_rollup(candidate.relative_path, analysis) {
4142 AnalysisApplyOutcome::Applied
4143 } else {
4144 AnalysisApplyOutcome::Stale
4145 }
4146 }
4147
4148 pub(crate) fn rebuild_content_rollups(&mut self) {
4149 if let Some(content) = self.content.as_mut() {
4150 content.rebuild_rollups();
4151 }
4152 }
4153
4154 fn apply_analysis_record(&mut self, observation: AnalysisObservation) -> AnalysisApplyOutcome {
4155 let candidate = &observation.candidate;
4156 let Some(entry) = self.try_entry(candidate.entry_id) else {
4157 return AnalysisApplyOutcome::Stale;
4158 };
4159 if entry.kind != EntryKind::File
4160 || entry.revision != candidate.revision
4161 || entry.attrs.fingerprint() != candidate.attrs.fingerprint()
4162 || self.classify(&candidate.relative_path) != candidate.classification
4163 {
4164 return AnalysisApplyOutcome::Stale;
4165 }
4166 let Some(content) = self.content.as_mut() else {
4167 return AnalysisApplyOutcome::Stale;
4168 };
4169 if content.commit(
4170 candidate.relative_path.clone(),
4171 observation.profile,
4172 &observation.provenance,
4173 observation.analysis,
4174 ) {
4175 AnalysisApplyOutcome::Applied
4176 } else {
4177 AnalysisApplyOutcome::Stale
4178 }
4179 }
4180
4181 pub fn clear_content(&mut self) {
4183 self.content = None;
4184 }
4185
4186 fn try_entry(&self, id: EntryId) -> Option<&Entry> {
4189 match self.arena.get(id.idx())? {
4190 Slot::Occupied { generation, entry } if *generation == id.generation => Some(entry),
4191 Slot::Occupied { .. } | Slot::Free { .. } => None,
4192 }
4193 }
4194
4195 fn expectation_matches(&self, op: &Op, expected: PathExpectation) -> bool {
4196 let current = self.path_state(op.path());
4197 if self.holds_target(op, current) {
4203 return true;
4204 }
4205 if current != expected.state {
4206 return false;
4207 }
4208
4209 let require_structure = match (op, expected.state) {
4210 (Op::Remove { .. }, _) => true,
4211 (Op::Upsert { kind, .. }, PathState::Present { kind: baseline, .. }) => {
4212 *kind != baseline
4213 }
4214 (
4215 Op::Upsert { .. }
4216 | Op::ControlUpsert { .. }
4217 | Op::ControlRemove { .. }
4218 | Op::InvalidateSubtree { .. },
4219 _,
4220 ) => false,
4221 };
4222 if !same_target(self.entry_identity(op.path()), expected.entry(), require_structure) {
4223 return false;
4224 }
4225
4226 match expected.absence_guard() {
4227 Some(expected) => self
4228 .absence_guard_identity(op.path())
4229 .is_some_and(|current| current.same_absence_guard(expected)),
4230 None => true,
4231 }
4232 }
4233
4234 fn holds_target(&self, op: &Op, current: PathState) -> bool {
4243 match op {
4244 Op::Upsert { kind, attrs, .. } => {
4245 current == PathState::Present { kind: *kind, attrs: *attrs }
4246 }
4247 Op::Remove { .. } => current == PathState::Absent,
4248 Op::ControlUpsert { path, source } => self.controls.source_is(path, source),
4249 Op::ControlRemove { path } => !self.controls.contains(path),
4250 Op::InvalidateSubtree { .. } => false,
4251 }
4252 }
4253
4254 fn absence_guard_identity(&self, path: &Path) -> Option<EntryIdentity> {
4255 let parts = normalize(path)?;
4256 let (_, ancestors) = parts.split_last()?;
4257 let mut current = EntryId::ROOT;
4258 for part in ancestors {
4259 let Some(child) = self.child(current, part) else {
4260 break;
4261 };
4262 current = child;
4263 }
4264 Some(self.identity(current))
4265 }
4266
4267 fn validate_known_ancestry(
4273 &self,
4274 ops: &[ObservationOp],
4275 accepted: &[bool],
4276 ) -> crate::Result<()> {
4277 if let Some((path, reconcile_from)) =
4278 self.unknown_ancestry(ops, accepted).into_iter().next()
4279 {
4280 return Err(crate::Error::UnknownAncestry { path, reconcile_from });
4281 }
4282 Ok(())
4283 }
4284
4285 fn accepted_operations(&self, ops: &[ObservationOp]) -> Vec<bool> {
4286 ops.iter()
4287 .map(|observed| match observed.expectation {
4288 Expectation::Any => true,
4289 Expectation::State(expected) => self.expectation_matches(&observed.op, expected),
4290 })
4291 .collect()
4292 }
4293
4294 fn prepare_scanner_batch(
4310 &self,
4311 batch: crate::scan::ScannerBatch,
4312 ) -> crate::Result<PreparedObservation> {
4313 let ops = batch.into_ops();
4314 let mut parents = Vec::with_capacity(ops.len());
4315 let mut last_parent: Option<(&Path, ResolvedParent)> = None;
4316 let mut has_batch_parents = false;
4317 let mut path_comparisons = 0_u64;
4318 let mut replaces_kind = false;
4319
4320 for (op_index, observed) in ops.iter().enumerate() {
4321 if !matches!(observed.expectation, Expectation::Any) {
4322 return Err(crate::Error::UnsupportedScanConfig(
4323 "scanner batches contain unconditional discoveries only",
4324 ));
4325 }
4326 let op = &observed.op;
4327 let path = op.path();
4328 if path.as_os_str().is_empty() {
4329 return Err(crate::Error::UnsupportedScanConfig(
4330 "scanner batches cannot mutate the index root",
4331 ));
4332 }
4333 for component in path.components() {
4334 match component {
4335 Component::Normal(_) => {}
4336 Component::CurDir => {
4337 return Err(crate::Error::UnsupportedScanConfig(
4338 "scanner batches require canonical relative paths",
4339 ));
4340 }
4341 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
4342 return Err(crate::Error::PathEscapesRoot(path.to_path_buf()));
4343 }
4344 }
4345 }
4346 match op {
4347 Op::Upsert { .. } => {}
4348 Op::ControlUpsert { .. } | Op::ControlRemove { .. }
4349 if crate::control::is_control_file(path) => {}
4350 Op::ControlUpsert { .. } | Op::ControlRemove { .. } => {
4351 return Err(crate::Error::InvalidControlPath(path.to_path_buf()));
4352 }
4353 Op::Remove { .. } | Op::InvalidateSubtree { .. } => {
4354 return Err(crate::Error::UnsupportedScanConfig(
4355 "scanner batches contain discoveries only",
4356 ));
4357 }
4358 }
4359 if replaces_kind {
4360 continue;
4363 }
4364
4365 let parent_path = path.parent().expect("a non-root relative path has a parent");
4366 if last_parent.is_some() {
4367 path_comparisons = path_comparisons.saturating_add(1);
4368 }
4369 let same_parent = last_parent
4370 .filter(|(previous, _)| *previous == parent_path)
4371 .map(|(_, parent)| parent);
4372 let parent = if let Some(parent) = same_parent {
4373 parent
4374 } else {
4375 self.lookup(parent_path)
4376 .filter(|id| self.entry(*id).kind.is_dir())
4377 .map(ResolvedParent::Existing)
4378 .or_else(|| Self::earlier_scanner_parent(&ops, op_index, parent_path))
4379 .ok_or_else(|| crate::Error::UnknownAncestry {
4380 path: path.to_path_buf(),
4381 reconcile_from: PathBuf::new(),
4382 })?
4383 };
4384 if let (Op::Upsert { kind, .. }, ResolvedParent::Existing(parent)) = (op, parent) {
4385 if path.file_name().is_some_and(|name| {
4386 self.child(parent, name).is_some_and(|child| self.entry(child).kind != *kind)
4387 }) {
4388 replaces_kind = true;
4389 continue;
4390 }
4391 }
4392 has_batch_parents |= matches!(parent, ResolvedParent::Earlier(_));
4393 parents.push(parent);
4394 last_parent = Some((parent_path, parent));
4395 }
4396
4397 if replaces_kind {
4398 return prepare_observation(&Observation::from_ops(ops));
4399 }
4400
4401 crate::counters::bump(|counts| {
4402 counts.ancestry_path_comparisons =
4403 counts.ancestry_path_comparisons.saturating_add(path_comparisons);
4404 counts.ancestry_parent_proofs = counts
4405 .ancestry_parent_proofs
4406 .saturating_add(u64::try_from(ops.len()).unwrap_or(u64::MAX));
4407 });
4408 Ok(PreparedObservation {
4409 ops,
4410 ancestry: PreparedAncestry::Scanner { parents, has_batch_parents },
4411 #[cfg(test)]
4412 reject_before_apply: false,
4413 })
4414 }
4415
4416 fn earlier_scanner_parent(
4418 ops: &[ObservationOp],
4419 before: usize,
4420 parent_path: &Path,
4421 ) -> Option<ResolvedParent> {
4422 let (op_index, op) = ops[..before].iter().enumerate().rev().find(
4423 |(_, observed)| matches!(&observed.op, Op::Upsert { path, .. } if path == parent_path),
4424 )?;
4425 let Op::Upsert { kind, .. } = &op.op else {
4426 unreachable!("the search selected an upsert");
4427 };
4428 kind.is_dir().then_some(ResolvedParent::Earlier(op_index))
4429 }
4430
4431 fn projected_controls(
4437 &self,
4438 ops: &[ObservationOp],
4439 accepted: &[bool],
4440 ) -> crate::Result<Option<crate::control::ControlTable>> {
4441 self.projected_controls_from(
4442 ops.iter()
4443 .zip(accepted)
4444 .filter_map(|(observed, accepted)| accepted.then_some(&observed.op)),
4445 )
4446 }
4447
4448 fn projected_controls_from<'a>(
4450 &self,
4451 ops: impl Iterator<Item = &'a Op> + Clone,
4452 ) -> crate::Result<Option<crate::control::ControlTable>> {
4453 if self.controls_unchanged_by(ops.clone()) {
4454 return Ok(None);
4455 }
4456 let mut projected = self.controls.clone();
4457 #[cfg(test)]
4458 CONTROL_PROJECTION_CLONES.with(|clones| clones.set(clones.get() + 1));
4459 let mut structure = StructuralOverlay::default();
4460 for op in ops {
4461 match op {
4462 Op::Upsert { path, kind, .. } => {
4463 if crate::control::is_control_file(path) && *kind != EntryKind::File {
4464 projected.remove(path)?;
4465 }
4466 if structure.kind(self, path) == Some(EntryKind::Dir) && !kind.is_dir() {
4467 projected.remove_subtree(path);
4468 }
4469 structure.upsert(self, path, *kind);
4470 }
4471 Op::Remove { path } => {
4472 if crate::control::is_control_file(path) {
4473 projected.remove(path)?;
4474 }
4475 projected.remove_subtree(path);
4476 structure.remove(self, path);
4477 }
4478 Op::ControlUpsert { path, source } => {
4479 projected.upsert(path, source.clone())?;
4480 }
4481 Op::ControlRemove { path } => {
4482 projected.remove(path)?;
4483 }
4484 Op::InvalidateSubtree { .. } => {}
4485 }
4486 }
4487 Ok(Some(projected))
4488 }
4489
4490 fn controls_unchanged_by<'a>(&self, ops: impl Iterator<Item = &'a Op>) -> bool {
4504 if self.controls.is_vacant() {
4510 return ops.into_iter().all(|op| match op {
4511 Op::ControlUpsert { .. } => false,
4512 Op::ControlRemove { path } => self.controls.remove_is_inert(path),
4513 Op::Upsert { .. } | Op::Remove { .. } | Op::InvalidateSubtree { .. } => true,
4514 });
4515 }
4516
4517 ops.into_iter().all(|op| match op {
4518 Op::ControlUpsert { path, source } => self.controls.upsert_is_inert(path, source),
4519 Op::ControlRemove { path } => self.controls.remove_is_inert(path),
4520 Op::Upsert { path, kind, .. } => {
4521 let drops_control = *kind != EntryKind::File
4524 && crate::control::is_control_file(path)
4525 && self.controls.contains(path);
4526 let prunes_subtree = !kind.is_dir() && self.controls.has_record_at_or_below(path);
4527 !drops_control && !prunes_subtree
4528 }
4529 Op::Remove { path } => {
4530 let drops_control =
4531 crate::control::is_control_file(path) && self.controls.contains(path);
4532 !drops_control && !self.controls.has_record_at_or_below(path)
4533 }
4534 Op::InvalidateSubtree { .. } => true,
4535 })
4536 }
4537
4538 fn apply_control_transition<C: ConsequenceSink>(
4539 &mut self,
4540 projected: Option<crate::control::ControlTable>,
4541 stats: &mut ApplyStats,
4542 effects: &mut C,
4543 ) {
4544 let Some(projected) = projected else {
4545 return;
4546 };
4547 let changes = projected.changes_from(&self.controls);
4548 let refusals = projected.refusal_changes_from(&self.controls);
4549 if changes.is_empty() && refusals.is_empty() {
4550 return;
4551 }
4552 let affected: Vec<PathBuf> = changes
4553 .iter()
4554 .filter_map(|(path, _, _)| crate::control::ControlTable::affected_subtree(path).ok())
4555 .collect();
4556 self.controls = projected;
4557 stats.controls = u64::try_from(changes.len() + refusals.len()).unwrap_or(u64::MAX);
4558 for (path, previous, current) in changes {
4559 effects.change(|| EffectiveChange::ControlUpdated { path, previous, current });
4560 }
4561 for (path, previous, current) in refusals {
4564 effects.change(|| EffectiveChange::ControlRefusalUpdated { path, previous, current });
4565 }
4566 self.reclassify_controlled_subtrees(&affected, stats, effects);
4567 }
4568
4569 fn reclassify_controlled_subtrees<C: ConsequenceSink>(
4572 &mut self,
4573 affected: &[PathBuf],
4574 stats: &mut ApplyStats,
4575 effects: &mut C,
4576 ) {
4577 let mut roots: Vec<PathBuf> = affected.to_vec();
4578 roots.sort();
4579 roots.dedup();
4580 let mut collapsed = Vec::new();
4581 for root in roots {
4582 if collapsed.iter().any(|ancestor: &PathBuf| root.starts_with(ancestor)) {
4583 continue;
4584 }
4585 collapsed.push(root);
4586 }
4587
4588 let mut moved = false;
4589 for root in collapsed {
4590 let Some(root_id) = self.lookup(&root) else {
4591 continue;
4592 };
4593 let children: Vec<(PathBuf, EntryId)> = self
4594 .children_of(root_id)
4595 .expect("controlled subtree root is live")
4596 .map(|(name, id)| (root.join(name), id))
4597 .collect();
4598 let mut queue = VecDeque::from(children);
4599 while let Some((path, id)) = queue.pop_front() {
4600 #[cfg(test)]
4601 RECLASSIFY_VISITS.with(|visits| visits.set(visits.get() + 1));
4602 let entry = self.entry(id);
4603 let parent_ignored = entry.parent.is_some_and(|parent| self.entry(parent).ignored);
4604 let current = entry.ignored;
4605 let next = parent_ignored
4606 || self.controls.matcher_for(&path).is_ignored(entry.kind.is_dir());
4607 let descendants: Vec<(PathBuf, EntryId)> = self
4608 .children_of(id)
4609 .expect("controlled subtree entry is live")
4610 .map(|(name, child)| (path.join(name), child))
4611 .collect();
4612 if current != next {
4613 self.move_serving_file_partition(&path, id, current, next);
4614 self.entry_mut(id).ignored = next;
4615 stats.reclassified += 1;
4616 effects.change(|| EffectiveChange::Reclassified {
4617 path: path.clone(),
4618 previous_ignored: current,
4619 current_ignored: next,
4620 });
4621 moved = true;
4622 }
4623 queue.extend(descendants);
4624 }
4625 }
4626 if moved {
4627 self.rebuild_unignored_rollups();
4628 }
4629 }
4630
4631 fn rebuild_unignored_rollups(&mut self) {
4632 let mut order = Vec::with_capacity(usize::try_from(self.live).unwrap_or(0));
4633 let mut stack = vec![EntryId::ROOT];
4634 while let Some(id) = stack.pop() {
4635 order.push(id);
4636 if self.entry(id).kind.is_dir() {
4637 stack.extend(self.child_ids(id));
4638 }
4639 if self.entry(id).kind.is_dir() {
4640 self.entry_mut(id).rollup_mut().unignored = InternedRollUp::default();
4641 }
4642 }
4643 for id in order.into_iter().rev() {
4644 let Some(parent) = self.entry(id).parent else {
4645 continue;
4646 };
4647 let contribution = self.contribution(id).unignored;
4648 self.entry_mut(parent).rollup_mut().unignored.merge(&contribution);
4649 }
4650 }
4651
4652 fn unknown_ancestry(
4653 &self,
4654 ops: &[ObservationOp],
4655 accepted: &[bool],
4656 ) -> Vec<(PathBuf, PathBuf)> {
4657 let mut structure = StructuralOverlay::default();
4658 let mut unknown = Vec::new();
4659 let mut overlay_inserts = 0_u64;
4660 let mut path_comparisons = 0_u64;
4661 let mut parent_proofs = 0_u64;
4662 let count_preflight = crate::counters::enabled();
4663 let mut proven_dir: Option<PathBuf> = None;
4672 let mut ancestor = PathBuf::new();
4673 for (observed, accepted) in ops.iter().zip(accepted) {
4674 if !accepted {
4675 continue;
4676 }
4677 match &observed.op {
4678 Op::Upsert { path, .. } | Op::ControlUpsert { path, .. }
4679 if !path.as_os_str().is_empty() =>
4680 {
4681 if count_preflight && proven_dir.is_some() {
4682 path_comparisons = path_comparisons.saturating_add(1);
4683 }
4684 let same_proven_parent = matches!(
4685 (path.parent(), proven_dir.as_deref()),
4686 (Some(parent), Some(proven)) if parent == proven
4687 );
4688 if same_proven_parent {
4689 if count_preflight {
4690 parent_proofs = parent_proofs.saturating_add(1);
4691 }
4692 } else {
4693 let mut reconcile_from = PathBuf::new();
4694 let mut ancestry_known = true;
4695 let parts = normalize(path).expect("prepared paths are canonical");
4696 let (_, ancestors) = parts.split_last().expect("non-root path has a name");
4697 ancestor.clear();
4698 for part in ancestors {
4699 ancestor.push(part);
4700 if structure.kind(self, &ancestor) != Some(EntryKind::Dir) {
4701 unknown.push((path.clone(), reconcile_from));
4702 ancestry_known = false;
4703 break;
4704 }
4705 reconcile_from.clone_from(&ancestor);
4706 }
4707 if !ancestry_known {
4708 proven_dir = None;
4709 continue;
4710 }
4711 if count_preflight {
4712 parent_proofs = parent_proofs.saturating_add(1);
4713 }
4714 match &mut proven_dir {
4715 Some(proven) => {
4716 proven.clear();
4717 path.parent().unwrap_or(Path::new("")).clone_into(proven);
4718 }
4719 None => {
4720 proven_dir =
4721 Some(path.parent().unwrap_or(Path::new("")).to_path_buf());
4722 }
4723 }
4724 }
4725 if let Op::Upsert { kind, .. } = &observed.op {
4726 structure.upsert(self, path, *kind);
4727 if count_preflight {
4728 overlay_inserts = overlay_inserts.saturating_add(1);
4729 }
4730 if !kind.is_dir() {
4731 if proven_dir.as_deref().is_some_and(|proven| proven.starts_with(path))
4735 {
4736 proven_dir = None;
4737 }
4738 }
4739 }
4740 }
4741 Op::Remove { path } if !path.as_os_str().is_empty() => {
4742 structure.remove(self, path);
4743 if proven_dir.as_deref().is_some_and(|proven| proven.starts_with(path)) {
4744 proven_dir = None;
4745 }
4746 }
4747 Op::Upsert { .. }
4748 | Op::Remove { .. }
4749 | Op::ControlUpsert { .. }
4750 | Op::ControlRemove { .. }
4751 | Op::InvalidateSubtree { .. } => {}
4752 }
4753 }
4754 if count_preflight {
4755 crate::counters::bump(|counts| {
4756 counts.ancestry_overlay_inserts =
4757 counts.ancestry_overlay_inserts.saturating_add(overlay_inserts);
4758 counts.ancestry_path_comparisons =
4759 counts.ancestry_path_comparisons.saturating_add(path_comparisons);
4760 counts.ancestry_parent_proofs =
4761 counts.ancestry_parent_proofs.saturating_add(parent_proofs);
4762 });
4763 }
4764 unknown
4765 }
4766
4767 fn entry_identity(&self, path: &Path) -> Option<EntryIdentity> {
4768 Some(self.identity(self.lookup(path)?))
4769 }
4770
4771 fn identity(&self, id: EntryId) -> EntryIdentity {
4772 let entry = self.entry(id);
4773 EntryIdentity::new(
4774 id.slot,
4775 id.generation,
4776 entry.revision,
4777 entry.directory.as_deref().map_or(0, |directory| directory.children_revision),
4778 entry.kind.is_dir(),
4779 )
4780 }
4781
4782 fn bump_revision(entry: &mut Entry) {
4783 entry.revision = entry.revision.checked_add(1).expect("entry revision exhausted");
4784 }
4785
4786 fn bump_children_revision(entry: &mut Entry) {
4787 let directory = entry.directory_mut();
4788 directory.children_revision =
4789 directory.children_revision.checked_add(1).expect("entry children revision exhausted");
4790 }
4791
4792 fn child(&self, parent: EntryId, name: &OsStr) -> Option<EntryId> {
4793 let children = &self.entry(parent).directory.as_deref()?.children;
4794 match children {
4795 DirectoryChildren::Sorted(ids) => ids
4796 .binary_search_by(|id| self.entry(*id).name.as_os_str().cmp(name))
4797 .ok()
4798 .map(|position| ids[position]),
4799 DirectoryChildren::Mutable(children) => children.get(name).copied(),
4800 }
4801 }
4802
4803 fn child_ids(&self, parent: EntryId) -> ChildIds<'_> {
4804 self.entry(parent).directory().children.ids()
4805 }
4806
4807 fn promote_children(&mut self, parent: EntryId) {
4813 let ids = match &mut self.entry_mut(parent).directory_mut().children {
4814 DirectoryChildren::Sorted(ids) => std::mem::take(ids),
4815 DirectoryChildren::Mutable(_) => return,
4816 };
4817 let expected = ids.len();
4818 let children =
4819 ids.into_iter().map(|id| (self.entry(id).name.clone(), id)).collect::<BTreeMap<_, _>>();
4820 assert_eq!(
4821 children.len(),
4822 expected,
4823 "compact child names must remain unique before promotion"
4824 );
4825 self.entry_mut(parent).directory_mut().children = DirectoryChildren::Mutable(children);
4826 }
4827
4828 fn insert_child(&mut self, parent: EntryId, name: OsString, child: EntryId) {
4829 self.promote_children(parent);
4830 let entry = self.entry_mut(parent);
4831 let DirectoryChildren::Mutable(children) = &mut entry.directory_mut().children else {
4832 unreachable!("child promotion produces mutable storage")
4833 };
4834 children.insert(name, child);
4835 Self::bump_children_revision(entry);
4836 }
4837
4838 fn reserve_detached_children(&mut self, parent: EntryId, additional: usize) {
4839 let entry = self.entry_mut(parent);
4840 let DirectoryChildren::Sorted(children) = &mut entry.directory_mut().children else {
4841 unreachable!("detached directories retain sorted child storage")
4842 };
4843 children.reserve(additional);
4844 }
4845
4846 fn push_detached_child(&mut self, parent: EntryId, child: EntryId) {
4847 let entry = self.entry_mut(parent);
4848 let DirectoryChildren::Sorted(children) = &mut entry.directory_mut().children else {
4849 unreachable!("detached directories retain sorted child storage")
4850 };
4851 children.push(child);
4852 Self::bump_children_revision(entry);
4853 }
4854
4855 fn merge_detached_descendants(&mut self, parent: EntryId, child: EntryId) {
4857 debug_assert!(parent.idx() < child.idx(), "cold parents must precede descendants");
4858 let (parents, children) = self.arena.split_at_mut(child.idx());
4859 let child_rollup = match &children[0] {
4860 Slot::Occupied { generation, entry } if *generation == child.generation => {
4861 entry.rollup()
4862 }
4863 Slot::Occupied { .. } | Slot::Free { .. } => {
4864 panic!("detached child handle must be live: {child:?}")
4865 }
4866 };
4867 let parent_entry = match &mut parents[parent.idx()] {
4868 Slot::Occupied { generation, entry } if *generation == parent.generation => entry,
4869 Slot::Occupied { .. } | Slot::Free { .. } => {
4870 panic!("detached parent handle must be live: {parent:?}")
4871 }
4872 };
4873 parent_entry.rollup_mut().merge(child_rollup);
4874 }
4875
4876 fn remove_child(&mut self, parent: EntryId, name: &OsStr) {
4877 self.promote_children(parent);
4878 let entry = self.entry_mut(parent);
4879 let DirectoryChildren::Mutable(children) = &mut entry.directory_mut().children else {
4880 unreachable!("child promotion produces mutable storage")
4881 };
4882 if children.remove(name).is_some() {
4883 Self::bump_children_revision(entry);
4884 }
4885 }
4886
4887 fn entry(&self, id: EntryId) -> &Entry {
4888 self.try_entry(id).expect("internal entry handle must be live")
4889 }
4890
4891 fn entry_mut(&mut self, id: EntryId) -> &mut Entry {
4892 match self.arena.get_mut(id.idx()) {
4893 Some(Slot::Occupied { generation, entry }) if *generation == id.generation => entry,
4894 Some(Slot::Occupied { .. } | Slot::Free { .. }) | None => {
4895 panic!("internal entry handle must be live: {id:?}")
4896 }
4897 }
4898 }
4899
4900 fn alloc(&mut self, entry: Entry) -> EntryId {
4901 crate::counters::bump(|c| c.entries_allocated += 1);
4902 self.live += 1;
4903 if let Some(free_slot) = self.free_head {
4904 let free_idx = free_slot as usize;
4905 let (generation, next) = match &self.arena[free_idx] {
4906 Slot::Free { generation, next_free } => (*generation, *next_free),
4907 Slot::Occupied { .. } => unreachable!("free list pointed at a live slot"),
4908 };
4909 self.free_head = next;
4910 self.arena[free_idx] = Slot::Occupied { generation, entry };
4911 return EntryId { slot: free_slot, generation };
4912 }
4913 let slot = u32::try_from(self.arena.len()).expect("index arena exceeded u32 capacity");
4914 let id = EntryId { slot, generation: 0 };
4915 self.arena.push(Slot::Occupied { generation: 0, entry });
4916 id
4917 }
4918
4919 fn free(&mut self, id: EntryId) {
4920 let next_generation = match &self.arena[id.idx()] {
4921 Slot::Occupied { generation, .. } if *generation == id.generation => {
4922 generation.checked_add(1).expect("entry generation exhausted")
4923 }
4924 Slot::Occupied { .. } | Slot::Free { .. } => {
4925 panic!("internal entry handle must be live: {id:?}")
4926 }
4927 };
4928 self.arena[id.idx()] =
4929 Slot::Free { generation: next_generation, next_free: self.free_head };
4930 self.free_head = Some(id.slot);
4931 self.live -= 1;
4932 }
4933
4934 fn now_unix_nanos() -> i64 {
4938 std::time::SystemTime::now()
4939 .duration_since(std::time::UNIX_EPOCH)
4940 .ok()
4941 .and_then(|since| i64::try_from(since.as_nanos()).ok())
4942 .unwrap_or(0)
4943 }
4944
4945 pub fn provenance(&self, path: &Path) -> Option<Provenance> {
4965 let id = self.lookup(path)?;
4966 Some(self.provenance_of(id))
4967 }
4968
4969 fn provenance_of(&self, id: EntryId) -> Provenance {
4970 let entry = self.entry(id);
4971 let status = self.status_of(id);
4972 if entry.source >= Source::Revalidated {
4993 if let Some(path) = self.path_of(id) {
4994 if self.freshness_at(&path) == Freshness::Fresh {
4995 if let Some(verified_at) = self.verified_at(&path) {
4996 return Provenance {
4997 source: Source::Revalidated,
4998 observed_at_ns: verified_at,
4999 status,
5000 };
5001 }
5002 }
5003 }
5004 }
5005 Provenance { source: entry.source, observed_at_ns: self.observed_at(entry.source), status }
5006 }
5007
5008 fn status_of(&self, id: EntryId) -> Status {
5021 let Some(path) = self.path_of(id) else {
5022 return Status::Complete;
5023 };
5024 match self.freshness_at(&path) {
5025 Freshness::Fresh | Freshness::Reconciling | Freshness::Stale => Status::Complete,
5026 Freshness::Partial => Status::Partial,
5027 }
5028 }
5029
5030 const fn observed_at(&self, source: Source) -> i64 {
5032 match source {
5033 Source::Cached | Source::JournalScoped => self.captured_at_ns,
5034 Source::Scanned | Source::Revalidated => self.scanned_at_ns,
5035 }
5036 }
5037
5038 pub(crate) const fn writing_pass_started_at_ns(&self) -> i64 {
5041 self.writing_pass_started_at_ns
5042 }
5043
5044 pub(crate) fn set_writing_pass_started_at_ns(&mut self, writing_pass_started_at_ns: i64) {
5046 self.writing_pass_started_at_ns = writing_pass_started_at_ns;
5047 }
5048
5049 pub(crate) const fn persistence_owed(&self) -> bool {
5051 self.persistence_owed
5052 }
5053
5054 pub(crate) fn set_persistence_owed(&mut self, owed: bool) {
5057 self.persistence_owed = owed;
5058 }
5059
5060 pub(crate) fn set_applying_source(&mut self, source: Source, captured_at_ns: i64) -> Source {
5066 let previous = self.applying_source;
5067 self.applying_source = source;
5068 if source == Source::Cached {
5069 self.state.source = Source::Cached;
5070 }
5071 if captured_at_ns != 0 {
5072 self.captured_at_ns = captured_at_ns;
5073 }
5074 previous
5075 }
5076
5077 fn intern_ext(&mut self, name: &str) -> ExtId {
5083 if let Some(&id) = self.ext_ids.get(name) {
5084 let refcount =
5085 self.ext_refcounts.get_mut(id as usize).expect("a live id has a refcount");
5086 *refcount = refcount.checked_add(1).expect("extension refcount exhausted");
5087 return id;
5088 }
5089 let id = if let Some(id) = self.free_ext_ids.pop() {
5090 let slot = id as usize;
5091 self.ext_names[slot] = Some(name.to_string());
5092 self.ext_refcounts[slot] = 1;
5093 id
5094 } else {
5095 let id = ExtId::try_from(self.ext_names.len()).expect("extension interner exhausted");
5096 self.ext_names.push(Some(name.to_string()));
5097 self.ext_refcounts.push(1);
5098 id
5099 };
5100 self.ext_ids.insert(name.to_string(), id);
5101 id
5102 }
5103
5104 fn release_ext(&mut self, id: ExtId) {
5106 let slot = id as usize;
5107 let refcount = self.ext_refcounts.get_mut(slot).expect("a live id has a refcount");
5108 debug_assert!(*refcount > 0, "extension reference released twice");
5109 *refcount -= 1;
5110 if *refcount != 0 {
5111 return;
5112 }
5113 let name = self.ext_names[slot].take().expect("a live id has a name");
5114 let removed = self.ext_ids.remove(&name);
5115 debug_assert_eq!(removed, Some(id), "the interner's two maps disagreed");
5116 self.free_ext_ids.push(id);
5117 }
5118
5119 fn named_rollup(&self, rollup: &InternedRollUp) -> RollUp {
5121 let by_ext = rollup
5122 .by_ext
5123 .iter()
5124 .map(|(id, tally)| {
5125 let name = self
5126 .ext_names
5127 .get(*id as usize)
5128 .and_then(Option::as_ref)
5129 .expect("a live roll-up's extension id has a name");
5130 (name.clone(), *tally)
5131 })
5132 .collect();
5133 RollUp {
5134 files: rollup.files,
5135 dirs: rollup.dirs,
5136 bytes: rollup.bytes,
5137 allocated: rollup.allocated,
5138 newest_mtime_ns: rollup.newest_mtime_ns,
5139 by_ext,
5140 }
5141 }
5142
5143 fn named_partitions(&self, rollup: &InternedPartitionRollUp) -> PartitionRollUp {
5144 PartitionRollUp {
5145 all: self.named_rollup(&rollup.all),
5146 unignored: self.named_rollup(&rollup.unignored),
5147 }
5148 }
5149
5150 fn contribution(&self, id: EntryId) -> InternedPartitionRollUp {
5152 let entry = self.entry(id);
5153 match entry.kind {
5154 EntryKind::Dir => {
5155 let mut all = entry.rollup().all.clone();
5156 all.dirs += 1;
5157 let mut unignored = InternedRollUp::default();
5158 if !entry.ignored {
5159 unignored = entry.rollup().unignored.clone();
5160 unignored.dirs += 1;
5161 }
5162 InternedPartitionRollUp { all, unignored }
5163 }
5164 EntryKind::File => {
5165 let mut all = InternedRollUp {
5166 files: 1,
5167 dirs: 0,
5168 bytes: entry.attrs.size,
5169 allocated: entry.attrs.allocated,
5170 newest_mtime_ns: entry.attrs.mtime_ns,
5171 by_ext: BTreeMap::new(),
5172 };
5173 if let Some(ext_id) = entry.ext_id {
5174 all.by_ext.insert(
5175 ext_id,
5176 ExtTally {
5177 files: 1,
5178 bytes: entry.attrs.size,
5179 allocated: entry.attrs.allocated,
5180 },
5181 );
5182 }
5183 let unignored = if entry.ignored { InternedRollUp::default() } else { all.clone() };
5184 InternedPartitionRollUp { all, unignored }
5185 }
5186 EntryKind::Symlink | EntryKind::Other => InternedPartitionRollUp::default(),
5187 }
5188 }
5189
5190 fn merge_upward(
5191 &mut self,
5192 from_parent: Option<EntryId>,
5193 contribution: &InternedPartitionRollUp,
5194 ) {
5195 let mut current = from_parent;
5196 while let Some(id) = current {
5197 crate::counters::bump(|c| c.rollup_merges += 1);
5200 let entry = self.entry_mut(id);
5201 entry.rollup_mut().merge(contribution);
5202 current = entry.parent;
5203 }
5204 }
5205
5206 fn unmerge_upward(
5207 &mut self,
5208 from_parent: Option<EntryId>,
5209 contribution: &InternedPartitionRollUp,
5210 ) {
5211 let mut current = from_parent;
5212 while let Some(id) = current {
5213 let entry = self.entry_mut(id);
5214 entry.rollup_mut().unmerge(contribution);
5215 current = entry.parent;
5216 }
5217 }
5218
5219 fn recompute_newest_upward(&mut self, from: Option<EntryId>) {
5227 let mut current = from;
5228 while let Some(id) = current {
5229 let mut newest: Option<i64> = None;
5230 for child in self.child_ids(id) {
5231 let child_entry = self.entry(child);
5232 let candidate = match child_entry.kind {
5233 EntryKind::Dir => (child_entry.rollup().files > 0)
5234 .then_some(child_entry.rollup().newest_mtime_ns),
5235 EntryKind::File => Some(child_entry.attrs.mtime_ns),
5236 EntryKind::Symlink | EntryKind::Other => None,
5237 };
5238 if let Some(candidate) = candidate {
5239 newest = Some(newest.map_or(candidate, |current| current.max(candidate)));
5240 }
5241 }
5242 let newest = newest.unwrap_or(0);
5243 self.entry_mut(id).rollup_mut().all.newest_mtime_ns = newest;
5244
5245 let mut newest_unignored: Option<i64> = None;
5246 for child in self.child_ids(id) {
5247 let child_entry = self.entry(child);
5248 let candidate = match child_entry.kind {
5249 EntryKind::Dir => (!child_entry.ignored
5250 && child_entry.rollup().unignored.files > 0)
5251 .then_some(child_entry.rollup().unignored.newest_mtime_ns),
5252 EntryKind::File => (!child_entry.ignored).then_some(child_entry.attrs.mtime_ns),
5253 EntryKind::Symlink | EntryKind::Other => None,
5254 };
5255 if let Some(candidate) = candidate {
5256 newest_unignored =
5257 Some(newest_unignored.map_or(candidate, |current| current.max(candidate)));
5258 }
5259 }
5260 let entry = self.entry_mut(id);
5261 entry.rollup_mut().unignored.newest_mtime_ns = newest_unignored.unwrap_or(0);
5262 current = entry.parent;
5263 }
5264 }
5265
5266 fn resolve_dir_chain(&self, parts: &[&OsStr]) -> EntryId {
5268 let mut current = EntryId::ROOT;
5269 for part in parts {
5270 current = self
5271 .child(current, part)
5272 .expect("validated ancestry remains present under the writer lock");
5273 debug_assert!(self.entry(current).kind.is_dir());
5274 }
5275 current
5276 }
5277
5278 fn apply_upsert<C: ConsequenceSink>(
5279 &mut self,
5280 path: &Path,
5281 kind: EntryKind,
5282 attrs: Attrs,
5283 stats: &mut ApplyStats,
5284 effects: &mut C,
5285 parent_memo: &mut ParentMemo,
5286 ) -> bool {
5287 crate::counters::bump(|c| c.upserts += 1);
5293 if let (Some(dir), Some(name)) = (path.parent(), path.file_name()) {
5294 if let Some(parent) = parent_memo.get(dir) {
5295 crate::counters::bump(|c| c.parent_memo_hits += 1);
5296 return self.upsert_beneath(parent, name, path, kind, attrs, stats, effects);
5297 }
5298 }
5299 crate::counters::bump(|c| c.parent_resolutions += 1);
5300
5301 let Some(parts) = normalize(path) else {
5302 return false;
5303 };
5304 let source = self.applying_source;
5305
5306 let Some((name, ancestors)) = parts.split_last() else {
5307 if self.entry(EntryId::ROOT).attrs == attrs {
5313 self.entry_mut(EntryId::ROOT).source = source;
5314 stats.unchanged += 1;
5315 return false;
5316 }
5317 let root = self.entry_mut(EntryId::ROOT);
5318 let previous = root.attrs;
5319 root.attrs = attrs;
5320 root.source = source;
5321 Self::bump_revision(root);
5322 stats.updated += 1;
5323 effects.change(|| EffectiveChange::Updated {
5324 path: PathBuf::new(),
5325 kind: EntryKind::Dir,
5326 previous,
5327 current: attrs,
5328 });
5329 return true;
5330 };
5331 let parent = self.resolve_dir_chain(ancestors);
5332 if let Some(dir) = path.parent() {
5333 parent_memo.set(dir, parent);
5334 }
5335 self.upsert_beneath(parent, name, path, kind, attrs, stats, effects)
5336 }
5337
5338 #[allow(clippy::too_many_arguments)]
5345 fn upsert_beneath<C: ConsequenceSink>(
5346 &mut self,
5347 parent: EntryId,
5348 name: &OsStr,
5349 path: &Path,
5350 kind: EntryKind,
5351 attrs: Attrs,
5352 stats: &mut ApplyStats,
5353 effects: &mut C,
5354 ) -> bool {
5355 let source = self.applying_source;
5356 let existing = self.child(parent, name);
5357
5358 if let Some(id) = existing {
5359 let entry = self.entry(id);
5360 if entry.kind == kind {
5361 if entry.attrs == attrs {
5362 self.entry_mut(id).source = source;
5368 stats.unchanged += 1;
5369 return false;
5370 }
5371 if kind.is_dir() {
5372 let entry = self.entry_mut(id);
5375 let previous = entry.attrs;
5376 entry.attrs = attrs;
5377 entry.source = source;
5378 Self::bump_revision(entry);
5379 stats.updated += 1;
5380 effects.change(|| EffectiveChange::Updated {
5381 path: path.to_path_buf(),
5382 kind,
5383 previous,
5384 current: attrs,
5385 });
5386 return true;
5387 }
5388 let previous_attrs = entry.attrs;
5389 self.invalidate_content(path);
5390 if kind == EntryKind::File {
5391 self.remove_serving_file_semantics(path, id, previous_attrs);
5392 }
5393 self.remove_serving_entry(path, kind, previous_attrs, id);
5394 let old = self.contribution(id);
5395 self.unmerge_upward(Some(parent), &old);
5396 let entry = self.entry_mut(id);
5397 let previous = entry.attrs;
5398 entry.attrs = attrs;
5399 entry.source = source;
5400 Self::bump_revision(entry);
5401 let new = self.contribution(id);
5402 self.merge_upward(Some(parent), &new);
5403 self.insert_serving_entry(path, kind, attrs, id);
5404 if new.newest_mtime_ns < old.newest_mtime_ns {
5405 self.recompute_newest_upward(Some(parent));
5406 }
5407 stats.updated += 1;
5408 effects.change(|| EffectiveChange::Updated {
5409 path: path.to_path_buf(),
5410 kind,
5411 previous,
5412 current: attrs,
5413 });
5414 return true;
5415 }
5416 self.remove_entry(id, stats, effects);
5424 }
5425
5426 let ext_id =
5427 (kind == EntryKind::File).then(|| self.intern_ext(&crate::classify::ext_bucket(name)));
5428 let ignored =
5429 self.entry(parent).ignored || self.controls.matcher_for(path).is_ignored(kind.is_dir());
5430 let id = self.alloc(Entry::new(
5431 NewEntry {
5432 parent: Some(parent),
5433 name: name.to_os_string(),
5434 ext_id,
5435 ignored,
5436 source,
5437 kind,
5438 attrs,
5439 },
5440 false,
5441 ));
5442 self.insert_child(parent, name.to_os_string(), id);
5443 let contribution = self.contribution(id);
5444 self.merge_upward(Some(parent), &contribution);
5445 stats.inserted += 1;
5446 effects.change(|| EffectiveChange::Inserted { path: path.to_path_buf(), kind, attrs });
5447 self.insert_serving_entry(path, kind, attrs, id);
5448 true
5449 }
5450
5451 pub(crate) fn insert_loaded_child(
5471 &mut self,
5472 parent: EntryId,
5473 name: OsString,
5474 kind: EntryKind,
5475 attrs: Attrs,
5476 ) -> Option<EntryId> {
5477 let parent_entry = self.try_entry(parent)?;
5478 if parent_entry.kind != EntryKind::Dir || self.child(parent, &name).is_some() {
5479 return None;
5480 }
5481 let source = self.applying_source;
5482 let ext_id =
5483 (kind == EntryKind::File).then(|| self.intern_ext(&crate::classify::ext_bucket(&name)));
5484 let id = self.alloc(Entry::new(
5485 NewEntry {
5486 parent: Some(parent),
5487 name: name.clone(),
5488 ext_id,
5489 ignored: false,
5490 source,
5491 kind,
5492 attrs,
5493 },
5494 true,
5495 ));
5496 self.insert_child(parent, name, id);
5497 let contribution = self.contribution(id);
5501 self.merge_upward(Some(parent), &contribution);
5502 if self.serving.is_some() {
5505 let path = self.path_of(id).expect("a newly loaded entry has a path");
5506 self.insert_serving_entry(&path, kind, attrs, id);
5507 }
5508 Some(id)
5509 }
5510
5511 fn apply_remove<C: ConsequenceSink>(
5512 &mut self,
5513 path: &Path,
5514 stats: &mut ApplyStats,
5515 effects: &mut C,
5516 ) -> bool {
5517 let Some(id) = self.lookup(path) else {
5518 stats.unchanged += 1;
5519 return false;
5520 };
5521 if id == EntryId::ROOT {
5522 stats.unchanged += 1;
5523 return false;
5524 }
5525 self.remove_entry(id, stats, effects);
5526 true
5527 }
5528
5529 fn remove_entry<C: ConsequenceSink>(
5530 &mut self,
5531 id: EntryId,
5532 stats: &mut ApplyStats,
5533 effects: &mut C,
5534 ) {
5535 let removed_root = self.path_of(id).expect("a live entry has a path");
5536 self.invalidate_content(&removed_root);
5537 self.remove_serving_subtree_semantics(id, &removed_root);
5538 let parent = self.entry(id).parent;
5539 let name = self.entry(id).name.clone();
5540 let contribution = self.contribution(id);
5541
5542 self.unmerge_upward(parent, &contribution);
5543 if let Some(parent) = parent {
5544 self.remove_child(parent, &name);
5545 }
5546
5547 let mut queue = VecDeque::from([(id, removed_root)]);
5550 while let Some((node, path)) = queue.pop_front() {
5551 let entry = self.entry(node);
5552 let kind = entry.kind;
5553 let attrs = entry.attrs;
5554 let children: Vec<(OsString, EntryId)> = self
5555 .children_of(node)
5556 .expect("removed subtree entry is live")
5557 .map(|(name, child)| (name.to_os_string(), child))
5558 .collect();
5559 let ext_id = entry.ext_id;
5560 for (name, child) in children {
5561 queue.push_back((child, path.join(name)));
5562 }
5563 self.remove_serving_entry(&path, kind, attrs, node);
5564 effects.change(|| EffectiveChange::Removed { path, kind, attrs });
5565 if let Some(ext_id) = ext_id {
5568 self.release_ext(ext_id);
5569 }
5570 self.free(node);
5571 stats.removed += 1;
5572 }
5573
5574 self.recompute_newest_upward(parent);
5576 }
5577
5578 fn invalidate_content(&mut self, path: &Path) {
5579 if let Some(content) = self.content.as_mut() {
5580 content.invalidate(path);
5581 }
5582 }
5583}
5584
5585pub(crate) fn collect_child_expectations(
5593 index: &Index,
5594 path: &Path,
5595) -> BTreeMap<OsString, PathExpectation> {
5596 index.children(path).map_or_else(BTreeMap::new, |children| {
5597 children
5598 .map(|(name, id)| {
5599 let entry = index.entry(id);
5600 let expectation = PathExpectation::new(
5601 PathState::Present { kind: entry.kind, attrs: entry.attrs },
5602 Some(index.identity(id)),
5603 None,
5604 );
5605 (name.to_os_string(), expectation)
5606 })
5607 .collect()
5608 })
5609}
5610
5611#[derive(Default)]
5639struct ParentMemo {
5640 entry: Option<(PathBuf, EntryId)>,
5641}
5642
5643impl ParentMemo {
5644 fn get(&self, dir: &Path) -> Option<EntryId> {
5646 self.entry.as_ref().filter(|(cached, _)| cached == dir).map(|&(_, id)| id)
5647 }
5648
5649 fn set(&mut self, dir: &Path, id: EntryId) {
5650 match &mut self.entry {
5651 Some((cached, cached_id)) => {
5654 cached.clear();
5655 cached.push(dir);
5656 *cached_id = id;
5657 }
5658 slot => *slot = Some((dir.to_path_buf(), id)),
5659 }
5660 }
5661
5662 fn clear(&mut self) {
5663 self.entry = None;
5664 }
5665}
5666
5667#[derive(Default)]
5669struct StructuralOverlay {
5670 entries: HashMap<PathBuf, EntryKind>,
5673 removed_roots: Vec<PathBuf>,
5674}
5675
5676impl StructuralOverlay {
5677 fn kind(&self, index: &Index, path: &Path) -> Option<EntryKind> {
5678 self.entries.get(path).copied().or_else(|| {
5679 (!self.removed_roots.iter().any(|removed| path.starts_with(removed)))
5680 .then(|| index.kind(path))
5681 .flatten()
5682 })
5683 }
5684
5685 fn upsert(&mut self, index: &Index, path: &Path, kind: EntryKind) {
5686 if self.kind(index, path).is_some_and(|current| current != kind) {
5687 self.remove(index, path);
5688 }
5689 self.entries.insert(path.to_path_buf(), kind);
5690 }
5691
5692 fn remove(&mut self, index: &Index, path: &Path) {
5693 if self.kind(index, path).is_none() {
5694 return;
5695 }
5696 self.entries.retain(|candidate, _| !candidate.starts_with(path));
5697 self.removed_roots.retain(|candidate| !candidate.starts_with(path));
5698 if !self.removed_roots.iter().any(|removed| path.starts_with(removed)) {
5699 self.removed_roots.push(path.to_path_buf());
5700 }
5701 }
5702}
5703
5704fn normalize(path: &Path) -> Option<Vec<&OsStr>> {
5705 let mut parts = Vec::new();
5706 for component in path.components() {
5707 match component {
5708 Component::Normal(part) => parts.push(part),
5709 Component::CurDir => {}
5710 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
5711 }
5712 }
5713 Some(parts)
5714}
5715
5716fn prepare_observation(observation: &Observation) -> crate::Result<PreparedObservation> {
5717 let mut ops = Vec::with_capacity(observation.len());
5718 for observed in &observation.ops {
5719 let path = canonical_relative_path(observed.op.path())?;
5720 let op = match &observed.op {
5721 Op::Upsert { kind, attrs, .. } => Op::Upsert { path, kind: *kind, attrs: *attrs },
5722 Op::Remove { .. } => Op::Remove { path },
5723 Op::ControlUpsert { source, .. } => {
5724 if !crate::control::is_control_file(&path) {
5725 return Err(crate::Error::InvalidControlPath(path));
5726 }
5727 Op::ControlUpsert { path, source: source.clone() }
5728 }
5729 Op::ControlRemove { .. } => {
5730 if !crate::control::is_control_file(&path) {
5731 return Err(crate::Error::InvalidControlPath(path));
5732 }
5733 Op::ControlRemove { path }
5734 }
5735 Op::InvalidateSubtree { reason, .. } => Op::InvalidateSubtree { path, reason: *reason },
5736 };
5737 ops.push(ObservationOp { op, expectation: observed.expectation });
5738 }
5739 Ok(PreparedObservation {
5740 ops,
5741 ancestry: PreparedAncestry::General,
5742 #[cfg(test)]
5743 reject_before_apply: false,
5744 })
5745}
5746
5747fn canonical_relative_path(path: &Path) -> crate::Result<PathBuf> {
5748 let mut canonical = PathBuf::with_capacity(path.as_os_str().as_encoded_bytes().len());
5749 for component in path.components() {
5750 match component {
5751 Component::Normal(part) => canonical.push(part),
5752 Component::CurDir => {}
5753 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
5754 return Err(crate::Error::PathEscapesRoot(path.to_path_buf()));
5755 }
5756 }
5757 }
5758 Ok(canonical)
5759}
5760
5761fn derive_impact(changes: &[EffectiveChange], state: &[StateTransition]) -> Impact {
5762 let mut domains = BTreeSet::new();
5763 let mut paths = BTreeSet::new();
5764 let mut all_dirty = false;
5765 let mut ancestor_visits = 0_u64;
5766 let count_impact = crate::counters::enabled();
5767
5768 for change in changes {
5769 match change {
5770 EffectiveChange::Inserted { .. } | EffectiveChange::Removed { .. } => {
5771 domains.extend([
5772 ImpactDomain::Topology,
5773 ImpactDomain::Metadata,
5774 ImpactDomain::Classification,
5775 ImpactDomain::Aggregates,
5776 ImpactDomain::Content,
5777 ]);
5778 }
5779 EffectiveChange::Updated { .. } => {
5780 domains.extend([
5781 ImpactDomain::Metadata,
5782 ImpactDomain::Aggregates,
5783 ImpactDomain::Content,
5784 ]);
5785 }
5786 EffectiveChange::ControlUpdated { .. } | EffectiveChange::Reclassified { .. } => {
5787 domains.extend([ImpactDomain::Classification, ImpactDomain::Aggregates]);
5788 }
5789 EffectiveChange::ControlRefusalUpdated { .. } => {
5790 domains.insert(ImpactDomain::Classification);
5791 }
5792 EffectiveChange::Invalidated { .. } => {
5793 domains.insert(ImpactDomain::State);
5794 }
5795 }
5796 insert_dirty_ancestors(
5797 change.path(),
5798 &mut paths,
5799 &mut all_dirty,
5800 count_impact,
5801 &mut ancestor_visits,
5802 );
5803 }
5804 for transition in state {
5805 domains.insert(ImpactDomain::State);
5806 insert_dirty_ancestors(
5807 transition.path(),
5808 &mut paths,
5809 &mut all_dirty,
5810 count_impact,
5811 &mut ancestor_visits,
5812 );
5813 }
5814
5815 if count_impact {
5816 let candidates =
5817 u64::try_from(changes.len().saturating_add(state.len())).unwrap_or(u64::MAX);
5818 let retained_dirty_paths = u64::try_from(paths.len()).unwrap_or(u64::MAX);
5819 crate::counters::bump(|counts| {
5820 counts.impact_candidates = counts.impact_candidates.saturating_add(candidates);
5821 counts.impact_ancestor_visits =
5822 counts.impact_ancestor_visits.saturating_add(ancestor_visits);
5823 counts.impact_retained_dirty_paths =
5824 counts.impact_retained_dirty_paths.saturating_add(retained_dirty_paths);
5825 counts.impact_all_dirty = counts.impact_all_dirty.saturating_add(u64::from(all_dirty));
5826 });
5827 }
5828
5829 Impact {
5830 domains: domains.into_iter().collect(),
5831 dirty_paths: if all_dirty { Vec::new() } else { paths.into_iter().collect() },
5832 all_dirty,
5833 }
5834}
5835
5836fn commit_work(observations: u64, stats: ApplyStats) -> Work {
5837 Work {
5838 observations,
5839 unchanged: stats.unchanged,
5840 stale: stats.stale,
5841 resource_refused: stats.resource_refused,
5842 ..Work::default()
5843 }
5844}
5845
5846fn insert_dirty_ancestors(
5847 path: &Path,
5848 paths: &mut BTreeSet<PathBuf>,
5849 all_dirty: &mut bool,
5850 count_impact: bool,
5851 ancestor_visits: &mut u64,
5852) {
5853 if *all_dirty {
5854 return;
5855 }
5856 for ancestor in path.ancestors() {
5857 if count_impact {
5858 *ancestor_visits = ancestor_visits.saturating_add(1);
5859 }
5860 paths.insert(ancestor.to_path_buf());
5861 if paths.len() > MAX_DIRTY_PATHS {
5862 paths.clear();
5863 *all_dirty = true;
5864 return;
5865 }
5866 }
5867}
5868
5869fn same_target(
5870 current: Option<EntryIdentity>,
5871 expected: Option<EntryIdentity>,
5872 require_structure: bool,
5873) -> bool {
5874 match (current, expected) {
5875 (Some(current), Some(expected)) => current.same_target(expected, require_structure),
5876 (None, None) => true,
5877 (Some(_), None) | (None, Some(_)) => false,
5878 }
5879}
5880
5881#[cfg(test)]
5882mod tests {
5883 use super::*;
5884 use crate::engine_contract::ObservationOp;
5885 use std::sync::{Arc, Barrier};
5886
5887 #[test]
5888 fn reusable_entry_keeps_directory_state_out_of_line() {
5889 let entry_bytes = std::mem::size_of::<Entry>();
5890 let slot_bytes = std::mem::size_of::<Slot>();
5891
5892 assert!(
5893 entry_bytes <= 136,
5894 "common entry storage must not inline directory-only maps and roll-ups: {entry_bytes} bytes"
5895 );
5896 assert!(
5897 slot_bytes <= entry_bytes + 16,
5898 "the arena slot must not add a second per-entry allocation: entry={entry_bytes}, slot={slot_bytes}"
5899 );
5900 }
5901
5902 #[test]
5903 fn detached_children_store_each_name_once_and_promote_on_mutation() {
5904 let mut builder = DetachedIndexBuilder::new(
5905 "/root",
5906 ScanScope::default(),
5907 crate::classify::TypeRegistry::compiled_shared(),
5908 );
5909 builder
5910 .push_directory(crate::scan::DetachedDirectory {
5911 path: PathBuf::new(),
5912 children: vec![
5913 crate::scan::DetachedChild {
5914 name: OsString::from("dir"),
5915 kind: EntryKind::Dir,
5916 attrs: Attrs::default(),
5917 position: 0,
5918 },
5919 crate::scan::DetachedChild {
5920 name: OsString::from("z.txt"),
5921 kind: EntryKind::File,
5922 attrs: file_attrs(1, 1),
5923 position: 1,
5924 },
5925 ],
5926 control: None,
5927 })
5928 .expect("detached root listing");
5929 builder
5930 .push_directory(crate::scan::DetachedDirectory {
5931 path: PathBuf::from("dir"),
5932 children: vec![
5933 crate::scan::DetachedChild {
5934 name: OsString::from("z.txt"),
5935 kind: EntryKind::File,
5936 attrs: file_attrs(2, 2),
5937 position: 0,
5938 },
5939 crate::scan::DetachedChild {
5940 name: OsString::from("a.txt"),
5941 kind: EntryKind::File,
5942 attrs: file_attrs(3, 3),
5943 position: 1,
5944 },
5945 ],
5946 control: None,
5947 })
5948 .expect("detached child listing");
5949 let mut index = builder.finish();
5950 let directory = index.lookup(Path::new("dir")).expect("detached directory");
5951
5952 assert!(index.entry(EntryId::ROOT).directory().children.is_sorted());
5953 assert!(index.entry(directory).directory().children.is_sorted());
5954 assert_eq!(
5955 index
5956 .children(Path::new("dir"))
5957 .expect("directory children")
5958 .map(|(name, _)| name.to_os_string())
5959 .collect::<Vec<_>>(),
5960 [OsString::from("a.txt"), OsString::from("z.txt")]
5961 );
5962
5963 index.apply_ok(&Observation::new(vec![upsert(
5964 "dir/m.txt",
5965 EntryKind::File,
5966 file_attrs(4, 4),
5967 )]));
5968
5969 assert!(index.entry(EntryId::ROOT).directory().children.is_sorted());
5970 assert!(index.entry(directory).directory().children.is_mutable());
5971 assert_eq!(
5972 index
5973 .children(Path::new("dir"))
5974 .expect("directory children")
5975 .map(|(name, _)| name.to_os_string())
5976 .collect::<Vec<_>>(),
5977 [OsString::from("a.txt"), OsString::from("m.txt"), OsString::from("z.txt")]
5978 );
5979 }
5980
5981 #[test]
5982 fn detached_builder_tolerates_a_duplicate_readdir_name() {
5983 let mut builder = DetachedIndexBuilder::new(
5984 "/root",
5985 ScanScope::default(),
5986 crate::classify::TypeRegistry::compiled_shared(),
5987 );
5988 let twice = |position, mtime_ns| crate::scan::DetachedChild {
5989 name: OsString::from("twice.txt"),
5990 kind: EntryKind::File,
5991 attrs: file_attrs(1, mtime_ns),
5992 position,
5993 };
5994 let result = builder.push_directory(crate::scan::DetachedDirectory {
5995 path: PathBuf::new(),
5996 children: vec![twice(0, 1), twice(1, 2)],
5997 control: None,
5998 });
5999 assert!(result.is_ok(), "a duplicate listing name must not fail the scan: {result:?}");
6000 let detached = builder.finish();
6001 assert_eq!(detached.total().files, 1);
6002 assert_eq!(detached.attrs(Path::new("twice.txt")), Some(&file_attrs(1, 2)));
6003
6004 let mut streaming = Index::new("/root");
6006 streaming
6007 .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
6008 upsert("twice.txt", EntryKind::File, file_attrs(1, 1)),
6009 upsert("twice.txt", EntryKind::File, file_attrs(1, 2)),
6010 ]))
6011 .expect("streaming tolerates a re-upsert");
6012 assert_eq!(streaming.total(), detached.total());
6013 assert_eq!(streaming.attrs(Path::new("twice.txt")), detached.attrs(Path::new("twice.txt")));
6014 }
6015
6016 #[test]
6017 fn detached_builder_accepts_the_repeated_walk_of_a_duplicated_directory() {
6018 let child = |name: &str, kind, attrs, position| crate::scan::DetachedChild {
6019 name: OsString::from(name),
6020 kind,
6021 attrs,
6022 position,
6023 };
6024 let listing = |path: &str, children| crate::scan::DetachedDirectory {
6025 path: PathBuf::from(path),
6026 children,
6027 control: None,
6028 };
6029 let mut builder = DetachedIndexBuilder::new(
6030 "/root",
6031 ScanScope::default(),
6032 crate::classify::TypeRegistry::compiled_shared(),
6033 );
6034 builder
6037 .push_directory(listing(
6038 "",
6039 vec![
6040 child("dir", EntryKind::Dir, file_attrs(0, 1), 0),
6041 child("swapped", EntryKind::Dir, file_attrs(0, 1), 1),
6042 child("dir", EntryKind::Dir, file_attrs(0, 2), 2),
6043 child("swapped", EntryKind::File, file_attrs(5, 2), 3),
6044 ],
6045 ))
6046 .expect("root listing with repeated names");
6047 for _ in 0..2 {
6049 builder
6050 .push_directory(listing(
6051 "dir",
6052 vec![child("nested", EntryKind::Dir, file_attrs(0, 3), 0)],
6053 ))
6054 .expect("each walk of the repeated directory");
6055 builder
6056 .push_directory(listing(
6057 "dir/nested",
6058 vec![child("file.txt", EntryKind::File, file_attrs(4, 4), 0)],
6059 ))
6060 .expect("each walk below the repeated directory");
6061 }
6062 builder
6064 .push_directory(listing(
6065 "swapped",
6066 vec![child("stale.txt", EntryKind::File, file_attrs(6, 5), 0)],
6067 ))
6068 .expect("the superseded directory's walk");
6069 let error = builder
6071 .push_directory(listing("elsewhere", Vec::new()))
6072 .expect_err("a listing whose parent was never listed");
6073 assert!(matches!(
6074 error,
6075 crate::Error::UnknownAncestry { path, .. } if path == Path::new("elsewhere")
6076 ));
6077
6078 let index = builder.finish();
6079 assert_eq!(index.attrs(Path::new("dir")), Some(&file_attrs(0, 2)));
6080 assert_eq!(index.kind(Path::new("swapped")), Some(EntryKind::File));
6081 assert!(index.lookup(Path::new("swapped/stale.txt")).is_none());
6082 let total = index.total();
6083 assert_eq!((total.files, total.dirs, total.bytes), (2, 2, 9));
6084 }
6085
6086 fn file_attrs(size: u64, mtime_ns: i64) -> Attrs {
6087 Attrs {
6088 size,
6089 allocated: size.div_ceil(512) * 512,
6090 mtime_ns,
6091 ctime_ns: mtime_ns,
6092 inode: size.wrapping_mul(31).wrapping_add(mtime_ns.unsigned_abs()),
6093 dev: 1,
6094 }
6095 }
6096
6097 fn upsert(path: &str, kind: EntryKind, attrs: Attrs) -> Op {
6098 Op::Upsert { path: PathBuf::from(path), kind, attrs }
6099 }
6100
6101 fn assert_serving_indexes(index: &Index) {
6102 let serving = index.serving.as_ref().expect("test index has serving state");
6103 let mut entries = BTreeMap::new();
6104 let mut children = BTreeMap::<PathBuf, PortableChildren>::new();
6105 let mut recent_files = BTreeSet::new();
6106 let mut semantic_by_directory =
6107 BTreeMap::<EntryId, (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>)>::new();
6108 let declared_exact_names: BTreeSet<_> =
6109 index.types.exact_filenames().map(str::to_ascii_lowercase).collect();
6110 let mut exact_name_by_directory =
6111 BTreeMap::<EntryId, (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>)>::new();
6112 let mut semantic_refcounts = BTreeMap::<String, u64>::new();
6113 let mut pending = vec![(EntryId::ROOT, PathBuf::new(), vec![EntryId::ROOT])];
6114 while let Some((parent_id, parent_path, ancestors)) = pending.pop() {
6115 let facts: Vec<_> = index
6116 .children_of(parent_id)
6117 .expect("live directory")
6118 .map(|(name, id)| (name.to_os_string(), id))
6119 .collect();
6120 for (name, id) in facts {
6121 let path = parent_path.join(&name);
6122 let kind = index.kind_of(id).expect("live child");
6123 let portable = crate::opened::read::portable_path(&path);
6124 entries.insert(portable.clone(), id);
6125 if kind == EntryKind::File {
6126 recent_files.insert(RecentKey {
6127 mtime_ns: index.attrs(&path).expect("live child has attributes").mtime_ns,
6128 portable_path: portable,
6129 id,
6130 });
6131 }
6132 if kind == EntryKind::File {
6133 let semantic = index.classify(&path).file_type.as_str().to_string();
6134 *semantic_refcounts.entry(semantic.clone()).or_default() += 1;
6135 let attrs = *index.attrs(&path).expect("live child has attributes");
6136 let ignored = index.entry(id).ignored;
6137 for ancestor in &ancestors {
6138 let partition = semantic_by_directory.entry(*ancestor).or_default();
6139 let all = partition.0.entry(semantic.clone()).or_default();
6140 all.files += 1;
6141 all.bytes += attrs.size;
6142 all.allocated += attrs.allocated;
6143 if !ignored {
6144 let unignored = partition.1.entry(semantic.clone()).or_default();
6145 unignored.files += 1;
6146 unignored.bytes += attrs.size;
6147 unignored.allocated += attrs.allocated;
6148 }
6149 }
6150 if let Some(exact_name) = name
6151 .to_str()
6152 .map(str::to_ascii_lowercase)
6153 .filter(|name| declared_exact_names.contains(name))
6154 {
6155 for ancestor in &ancestors {
6156 let partition = exact_name_by_directory.entry(*ancestor).or_default();
6157 let all = partition.0.entry(exact_name.clone()).or_default();
6158 all.files += 1;
6159 all.bytes += attrs.size;
6160 all.allocated += attrs.allocated;
6161 if !ignored {
6162 let unignored = partition.1.entry(exact_name.clone()).or_default();
6163 unignored.files += 1;
6164 unignored.bytes += attrs.size;
6165 unignored.allocated += attrs.allocated;
6166 }
6167 }
6168 }
6169 }
6170 let partition = children.entry(parent_path.clone()).or_default();
6171 let portable_name = crate::opened::read::portable_component(&name);
6172 if kind.is_dir() {
6173 partition.directories.insert(portable_name, id);
6174 } else {
6175 partition.nondirectories.insert(portable_name, id);
6176 }
6177 if kind.is_dir() {
6178 let mut child_ancestors = ancestors.clone();
6179 child_ancestors.insert(0, id);
6180 pending.push((id, path, child_ancestors));
6181 }
6182 }
6183 }
6184
6185 assert_eq!(serving.portable_entries, entries);
6186 assert_eq!(serving.recent_files, recent_files);
6187 let actual_semantics: BTreeMap<_, _> = serving
6188 .semantic_by_directory
6189 .iter()
6190 .map(|(directory, partitions)| {
6191 let named = |source: &BTreeMap<u32, ExtTally>| {
6192 source
6193 .iter()
6194 .map(|(semantic, tally)| {
6195 let name = serving.semantic_names[*semantic as usize]
6196 .as_ref()
6197 .expect("live semantic has a name")
6198 .clone();
6199 (name, *tally)
6200 })
6201 .collect()
6202 };
6203 (*directory, (named(&partitions.all), named(&partitions.unignored)))
6204 })
6205 .collect();
6206 assert_eq!(actual_semantics, semantic_by_directory);
6207 let actual_exact_names: BTreeMap<_, _> = serving
6208 .exact_name_by_directory
6209 .iter()
6210 .map(|(directory, partitions)| {
6211 let named = |source: &BTreeMap<u32, ExtTally>| {
6212 source
6213 .iter()
6214 .map(|(exact_name, tally)| {
6215 (serving.exact_names[*exact_name as usize].clone(), *tally)
6216 })
6217 .collect()
6218 };
6219 (*directory, (named(&partitions.all), named(&partitions.unignored)))
6220 })
6221 .collect();
6222 assert_eq!(actual_exact_names, exact_name_by_directory);
6223 assert_eq!(
6224 serving.exact_names.iter().cloned().collect::<BTreeSet<_>>(),
6225 declared_exact_names
6226 );
6227 assert_eq!(
6228 serving.exact_name_ids,
6229 serving
6230 .exact_names
6231 .iter()
6232 .enumerate()
6233 .map(|(position, name)| {
6234 (
6235 name.clone(),
6236 u32::try_from(position).expect("the exact-name vocabulary fits u32"),
6237 )
6238 })
6239 .collect()
6240 );
6241 assert!(serving.exact_name_by_directory.len() <= index.arena.len());
6242 assert!(serving.exact_name_by_directory.values().all(|partitions| {
6243 partitions.all.len() <= serving.exact_names.len()
6244 && partitions.unignored.len() <= serving.exact_names.len()
6245 && partitions
6246 .all
6247 .keys()
6248 .chain(partitions.unignored.keys())
6249 .all(|name| (*name as usize) < serving.exact_names.len())
6250 }));
6251 let actual_refcounts: BTreeMap<_, _> = serving
6252 .semantic_ids
6253 .iter()
6254 .map(|(name, semantic)| (name.clone(), serving.semantic_refcounts[*semantic as usize]))
6255 .collect();
6256 assert_eq!(actual_refcounts, semantic_refcounts);
6257 assert_eq!(serving.portable_children, children);
6258
6259 assert_eq!(
6263 u64::try_from(serving.portable_entries.len()).expect("entry count fits u64"),
6264 index.len().saturating_sub(1),
6265 "every retained non-root entry has exactly one portable name"
6266 );
6267 }
6268
6269 #[test]
6270 fn portable_indexes_conserve_insert_kind_change_and_subtree_removal() {
6271 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6272 "/root",
6273 ScanScope::default(),
6274 crate::classify::TypeRegistry::compiled_shared(),
6275 DEFAULT_JOURNAL_CAPACITY_BYTES,
6276 );
6277 index.apply_ok(&Observation::new(vec![
6278 upsert("dir", EntryKind::Dir, Attrs::default()),
6279 upsert("dir/a", EntryKind::File, file_attrs(1, 1)),
6280 upsert("replace", EntryKind::File, file_attrs(2, 2)),
6281 ]));
6282 assert_serving_indexes(&index);
6283
6284 index.apply_ok(&Observation::new(vec![
6285 upsert("replace", EntryKind::Dir, Attrs::default()),
6286 upsert("replace/child", EntryKind::File, file_attrs(3, 3)),
6287 ]));
6288 assert_serving_indexes(&index);
6289
6290 index.apply_ok(&Observation::new(vec![upsert("dir/a", EntryKind::File, file_attrs(4, 9))]));
6291 assert_serving_indexes(&index);
6292 assert_eq!(
6293 index
6294 .serving
6295 .as_ref()
6296 .expect("opened test index")
6297 .recent_files
6298 .iter()
6299 .map(|entry| entry.portable_path.as_str())
6300 .collect::<Vec<_>>(),
6301 vec!["dir/a", "replace/child"]
6302 );
6303
6304 index.apply_ok(&Observation::new(vec![
6310 upsert("dir/current", EntryKind::Symlink, file_attrs(5, 5)),
6311 upsert("dir/pipe", EntryKind::Other, file_attrs(6, 6)),
6312 ]));
6313 assert_serving_indexes(&index);
6314 index.apply_ok(&Observation::new(vec![
6315 upsert("dir/current", EntryKind::Symlink, file_attrs(7, 7)),
6316 upsert("dir/pipe", EntryKind::Other, file_attrs(8, 8)),
6317 ]));
6318 assert_serving_indexes(&index);
6319
6320 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("replace") }]));
6321 assert_serving_indexes(&index);
6322 assert_eq!(
6323 index.portable_entries().keys().map(crate::PortablePath::as_str).collect::<Vec<_>>(),
6324 vec!["dir", "dir/a", "dir/current", "dir/pipe"]
6325 );
6326 }
6327
6328 #[test]
6337 fn non_file_attrs_updates_leave_file_semantics_untouched() {
6338 for kind in [EntryKind::Symlink, EntryKind::Other] {
6339 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6340 "/root",
6341 ScanScope::default(),
6342 crate::classify::TypeRegistry::compiled_shared(),
6343 DEFAULT_JOURNAL_CAPACITY_BYTES,
6344 );
6345 index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(1, 1))]));
6346 assert_serving_indexes(&index);
6347 index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(1, 2))]));
6348 assert_serving_indexes(&index);
6349
6350 index.apply_ok(&Observation::new(vec![upsert(
6351 "notes",
6352 EntryKind::File,
6353 file_attrs(5, 3),
6354 )]));
6355 assert_serving_indexes(&index);
6356 index.apply_ok(&Observation::new(vec![upsert("current", kind, file_attrs(4, 4))]));
6357 assert_serving_indexes(&index);
6358
6359 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("notes") }]));
6360 assert_serving_indexes(&index);
6361 }
6362 }
6363
6364 #[test]
6377 fn escaping_touches_only_invalid_bytes_and_percent() {
6378 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6379 "/root",
6380 ScanScope::default(),
6381 crate::classify::TypeRegistry::compiled_shared(),
6382 DEFAULT_JOURNAL_CAPACITY_BYTES,
6383 );
6384 index.apply_ok(&Observation::new(vec![
6385 upsert("dir", EntryKind::Dir, Attrs::default()),
6386 upsert("dir/plain.txt", EntryKind::File, file_attrs(1, 1)),
6387 upsert("café", EntryKind::Dir, Attrs::default()),
6388 upsert("café/naïve.txt", EntryKind::File, file_attrs(2, 2)),
6389 upsert("日本語.md", EntryKind::File, file_attrs(3, 3)),
6390 upsert("a b", EntryKind::Dir, Attrs::default()),
6391 upsert("a b/c d.txt", EntryKind::File, file_attrs(5, 5)),
6392 upsert("100%.txt", EntryKind::File, file_attrs(4, 4)),
6393 ]));
6394
6395 let names: Vec<_> =
6396 index.portable_entries().keys().map(crate::PortablePath::as_str).collect();
6397 assert_eq!(
6398 names,
6399 vec![
6400 "100%25.txt",
6401 "a b",
6402 "a b/c d.txt",
6403 "café",
6404 "café/naïve.txt",
6405 "dir",
6406 "dir/plain.txt",
6407 "日本語.md",
6408 ],
6409 "only the literal percent is rewritten; separators, spaces and non-ASCII are not"
6410 );
6411 }
6412
6413 #[test]
6414 fn declared_exact_names_roll_up_by_ancestor_and_partition() {
6415 let types = Arc::new(
6416 crate::classify::TypeRegistry::from_manifest(
6417 "[[kind]]\nid = \"make\"\nfamily = \"code\"\nfilenames = [\"Makefile\"]\n",
6418 )
6419 .expect("custom registry"),
6420 );
6421 let scope =
6422 ScanScope { type_rules_fingerprint: types.fingerprint(), ..ScanScope::default() };
6423 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6424 "/root",
6425 scope,
6426 types,
6427 DEFAULT_JOURNAL_CAPACITY_BYTES,
6428 );
6429 index.apply_ok(&Observation::new(vec![
6430 upsert("Makefile", EntryKind::File, file_attrs(2, 1)),
6431 upsert("dir", EntryKind::Dir, Attrs::default()),
6432 upsert("dir/makefile", EntryKind::File, file_attrs(3, 2)),
6433 upsert("dir/notes", EntryKind::File, file_attrs(5, 3)),
6434 ]));
6435
6436 let serving = index.serving.as_ref().expect("opened test index");
6437 let exact_name = serving.exact_name_ids["makefile"];
6438 let root = &serving.exact_name_by_directory[&EntryId::ROOT];
6439 assert_eq!(root.all[&exact_name], ExtTally { files: 2, bytes: 5, allocated: 1_024 });
6440 assert_eq!(root.unignored, root.all);
6441
6442 let directory = index.lookup(Path::new("dir")).expect("directory");
6443 let nested = &serving.exact_name_by_directory[&directory];
6444 assert_eq!(nested.all[&exact_name], ExtTally { files: 1, bytes: 3, allocated: 512 });
6445 assert_eq!(nested.unignored, nested.all);
6446 }
6447
6448 #[test]
6449 #[ignore = "manual opened-root commit-cost evidence"]
6450 fn measure_opened_serving_commit_cost() {
6451 const DIRECTORY_COUNT: usize = 100;
6452 const FILES_PER_DIRECTORY: usize = 100;
6453 const SAMPLE_COUNT: usize = 7;
6454
6455 let mut operations = Vec::with_capacity(
6456 DIRECTORY_COUNT.saturating_mul(FILES_PER_DIRECTORY.saturating_add(1)),
6457 );
6458 for directory in 0..DIRECTORY_COUNT {
6459 let parent = format!("d{directory:03}");
6460 operations.push(upsert(&parent, EntryKind::Dir, Attrs::default()));
6461 for file in 0..FILES_PER_DIRECTORY {
6462 let size = u64::try_from(file).expect("the probe file count fits u64") + 1;
6463 let mtime = i64::try_from(file).expect("the probe file count fits i64");
6464 let name = if file == 0 {
6465 format!("{parent}/Makefile")
6466 } else {
6467 format!("{parent}/f{file:03}.rs")
6468 };
6469 operations.push(upsert(&name, EntryKind::File, file_attrs(size, mtime)));
6470 }
6471 }
6472 let observation = Observation::new(operations);
6473 let types = crate::classify::TypeRegistry::compiled_shared();
6474 let scope =
6475 ScanScope { type_rules_fingerprint: types.fingerprint(), ..ScanScope::default() };
6476 let measure = |opened: bool| {
6477 let mut index = if opened {
6478 Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6479 "/root",
6480 scope,
6481 Arc::clone(&types),
6482 DEFAULT_JOURNAL_CAPACITY_BYTES,
6483 )
6484 } else {
6485 Index::new_with_scope_types_and_journal_capacity_bytes(
6486 "/root",
6487 scope,
6488 Arc::clone(&types),
6489 DEFAULT_JOURNAL_CAPACITY_BYTES,
6490 )
6491 };
6492 let started = std::time::Instant::now();
6493 index.apply_ok(&observation);
6494 let elapsed = started.elapsed();
6495 std::hint::black_box(index.len());
6496 (elapsed, index)
6497 };
6498
6499 let _ = measure(false);
6500 let _ = measure(true);
6501 let mut detached = Vec::with_capacity(SAMPLE_COUNT);
6502 let mut opened = Vec::with_capacity(SAMPLE_COUNT);
6503 let mut last_opened = None;
6504 for sample in 0..SAMPLE_COUNT {
6505 if sample % 2 == 0 {
6506 detached.push(measure(false).0);
6507 let (duration, index) = measure(true);
6508 opened.push(duration);
6509 last_opened = Some(index);
6510 } else {
6511 let (duration, index) = measure(true);
6512 opened.push(duration);
6513 last_opened = Some(index);
6514 detached.push(measure(false).0);
6515 }
6516 }
6517 detached.sort_unstable();
6518 opened.sort_unstable();
6519 let detached_median = detached[SAMPLE_COUNT / 2];
6520 let opened_median = opened[SAMPLE_COUNT / 2];
6521 let ratio = opened_median.as_secs_f64() / detached_median.as_secs_f64();
6522
6523 let index = last_opened.expect("an opened sample ran");
6524 let serving = index.serving.as_ref().expect("opened sample has serving indexes");
6525 let semantic_rows: usize = serving
6526 .semantic_by_directory
6527 .values()
6528 .map(|partitions| partitions.all.len() + partitions.unignored.len())
6529 .sum();
6530 let exact_name_rows: usize = serving
6531 .exact_name_by_directory
6532 .values()
6533 .map(|partitions| partitions.all.len() + partitions.unignored.len())
6534 .sum();
6535 eprintln!(
6536 "entries={} detached_median_us={} opened_median_us={} ratio={ratio:.3} \
6537 portable_rows={} child_rows={} recent_rows={} semantic_rows={} exact_name_rows={} \
6538 exact_name_vocabulary={}",
6539 index.len(),
6540 detached_median.as_micros(),
6541 opened_median.as_micros(),
6542 serving.portable_entries.len(),
6543 serving
6544 .portable_children
6545 .values()
6546 .map(|children| children.directories.len() + children.nondirectories.len())
6547 .sum::<usize>(),
6548 serving.recent_files.len(),
6549 semantic_rows,
6550 exact_name_rows,
6551 serving.exact_names.len(),
6552 );
6553 }
6554
6555 #[test]
6556 fn detached_indexes_never_allocate_or_populate_serving_state() {
6557 let mut index = Index::new("/root");
6558 index.apply_ok(&Observation::new(vec![
6559 upsert("dir", EntryKind::Dir, Attrs::default()),
6560 upsert("dir/a", EntryKind::File, file_attrs(1, 1)),
6561 ]));
6562
6563 assert!(index.serving.is_none());
6564 assert!(index.portable_children(Path::new("dir")).is_none());
6565 }
6566
6567 #[test]
6568 fn insert_loaded_child_skips_serving_path_when_serving_is_off() {
6569 let mut index = Index::new("/root");
6570 let id = index
6571 .insert_loaded_child(
6572 EntryId::ROOT,
6573 OsString::from("a.rs"),
6574 EntryKind::File,
6575 file_attrs(4, 1),
6576 )
6577 .expect("parent is a live directory");
6578 assert!(!index.serving_indexes_enabled());
6579 assert_eq!(index.path_of(id), Some(PathBuf::from("a.rs")));
6580 assert_eq!(index.lookup(Path::new("a.rs")), Some(id));
6581 assert_eq!(index.total().files, 1);
6582 }
6583
6584 #[test]
6585 fn insert_loaded_child_fills_serving_when_enabled() {
6586 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6587 "/root",
6588 ScanScope::default(),
6589 crate::classify::TypeRegistry::compiled_shared(),
6590 DEFAULT_JOURNAL_CAPACITY_BYTES,
6591 );
6592 let id = index
6593 .insert_loaded_child(
6594 EntryId::ROOT,
6595 OsString::from("a.rs"),
6596 EntryKind::File,
6597 file_attrs(4, 1),
6598 )
6599 .expect("parent is a live directory");
6600 assert!(index.serving_indexes_enabled());
6601 assert_eq!(index.path_of(id), Some(PathBuf::from("a.rs")));
6602 let serving = index.serving.as_ref().expect("opened test index");
6603 assert!(serving.portable_entries.keys().any(|path| path.as_str() == "a.rs"));
6604 }
6605
6606 #[test]
6607 fn opened_entry_values_project_name_identity_without_retaining_it_on_detached_entries() {
6608 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6609 "/root",
6610 ScanScope::default(),
6611 crate::classify::TypeRegistry::compiled_shared(),
6612 DEFAULT_JOURNAL_CAPACITY_BYTES,
6613 );
6614 index.apply_ok(&Observation::new(vec![upsert(
6615 "bundle.umd.min.js",
6616 EntryKind::File,
6617 file_attrs(7, 1),
6618 )]));
6619
6620 let row = index.entry_value(Path::new("bundle.umd.min.js")).expect("entry value");
6621 let identity = row.classification.expect("regular files carry name identity");
6622 assert_eq!(identity.logical_extension(), Some(".min.js"));
6623 assert_eq!(identity.canonical_extension(), Some(".js"));
6624 assert_eq!(identity.kind_id(), Some("javascript"));
6625 assert_eq!(identity.content_family(), crate::classify::ContentFamily::Code);
6626 }
6627
6628 #[test]
6629 fn a_shared_snapshot_drops_opened_root_serving_state() {
6630 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
6631 "/root",
6632 ScanScope::default(),
6633 crate::classify::TypeRegistry::compiled_shared(),
6634 DEFAULT_JOURNAL_CAPACITY_BYTES,
6635 );
6636 index.apply_ok(&Observation::new(vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))]));
6637 assert!(index.serving_indexes_enabled());
6638
6639 let snapshot = IndexHandle::new(index).snapshot().expect("detached snapshot");
6640
6641 assert!(!snapshot.serving_indexes_enabled());
6642 assert_eq!(snapshot.total().files, 1);
6643 }
6644
6645 #[test]
6646 #[should_panic(expected = "an index's registry must match its semantic scope")]
6647 fn an_index_cannot_claim_a_registry_different_from_its_scope() {
6648 let types = Arc::new(
6649 crate::classify::TypeRegistry::from_manifest(
6650 "[[kind]]\nid = \"notes\"\nfamily = \"prose\"\nextensions = [\"rs\"]\n",
6651 )
6652 .expect("custom registry"),
6653 );
6654
6655 let _ = Index::new_with_scope_and_types("/root", ScanScope::default(), types);
6656 }
6657
6658 #[test]
6665 fn parent_memo_does_not_survive_removing_the_directory_it_remembers() {
6666 let mut index = Index::new(PathBuf::from("/root"));
6667 index.apply_ok(&Observation::new(vec![
6668 upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
6669 upsert("dir/a.txt", EntryKind::File, file_attrs(10, 1)),
6670 ]));
6671
6672 index.apply_ok(&Observation::new(vec![
6676 upsert("dir/b.txt", EntryKind::File, file_attrs(20, 1)),
6677 Op::Remove { path: PathBuf::from("dir") },
6678 upsert("dir", EntryKind::Dir, file_attrs(0, 2)),
6679 upsert("dir/c.txt", EntryKind::File, file_attrs(30, 2)),
6680 ]));
6681
6682 let children = index.children(Path::new("dir")).expect("dir survives");
6683 let names: Vec<_> = children.map(|(name, _)| name.to_os_string()).collect();
6684 assert_eq!(names, vec![OsString::from("c.txt")], "only the re-added child remains");
6685 assert_eq!(index.total().bytes, 30, "totals match the surviving child");
6686 }
6687
6688 #[test]
6689 fn a_kind_change_mid_batch_leaves_the_memo_usable_for_the_next_sibling() {
6690 let mut index = Index::new(PathBuf::from("/root"));
6691 index.apply_ok(&Observation::new(vec![
6692 upsert("swap", EntryKind::Dir, file_attrs(0, 1)),
6693 upsert("swap/inner", EntryKind::Dir, file_attrs(0, 1)),
6694 upsert("swap/inner/deep.txt", EntryKind::File, file_attrs(40, 1)),
6695 ]));
6696
6697 index.apply_ok(&Observation::new(vec![
6703 upsert("swap/inner/other.txt", EntryKind::File, file_attrs(50, 1)),
6704 upsert("swap/inner", EntryKind::File, file_attrs(60, 2)),
6705 upsert("swap/sibling.txt", EntryKind::File, file_attrs(70, 2)),
6706 ]));
6707
6708 let children = index.children(Path::new("swap")).expect("swap survives");
6709 let names: Vec<_> = children.map(|(name, _)| name.to_os_string()).collect();
6710 assert_eq!(names, vec![OsString::from("inner"), OsString::from("sibling.txt")]);
6711 assert_eq!(index.total().files, 2, "inner counts once, as a file");
6712 assert_eq!(index.total().bytes, 130, "the dropped subtree's bytes are gone");
6713 }
6714
6715 #[test]
6716 fn parent_memo_distinguishes_directories_that_share_a_name_prefix() {
6717 let mut index = Index::new(PathBuf::from("/root"));
6718 index.apply_ok(&Observation::new(vec![
6721 upsert("src", EntryKind::Dir, file_attrs(0, 1)),
6722 upsert("src2", EntryKind::Dir, file_attrs(0, 1)),
6723 upsert("src/one.txt", EntryKind::File, file_attrs(11, 1)),
6724 upsert("src2/two.txt", EntryKind::File, file_attrs(22, 1)),
6725 upsert("src/three.txt", EntryKind::File, file_attrs(33, 1)),
6726 ]));
6727
6728 let in_src: Vec<_> = index
6729 .children(Path::new("src"))
6730 .expect("src")
6731 .map(|(name, _)| name.to_os_string())
6732 .collect();
6733 let in_src2: Vec<_> = index
6734 .children(Path::new("src2"))
6735 .expect("src2")
6736 .map(|(name, _)| name.to_os_string())
6737 .collect();
6738 assert_eq!(in_src, vec![OsString::from("one.txt"), OsString::from("three.txt")]);
6739 assert_eq!(in_src2, vec![OsString::from("two.txt")]);
6740 }
6741
6742 #[test]
6743 fn parent_memo_leaves_root_level_entries_alone() {
6744 let mut index = Index::new(PathBuf::from("/root"));
6747 index.apply_ok(&Observation::new(vec![
6748 upsert("a.txt", EntryKind::File, file_attrs(5, 1)),
6749 upsert("b.txt", EntryKind::File, file_attrs(6, 1)),
6750 upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
6751 upsert("dir/c.txt", EntryKind::File, file_attrs(7, 1)),
6752 upsert("d.txt", EntryKind::File, file_attrs(8, 1)),
6753 ]));
6754
6755 let top: Vec<_> = index
6756 .children(Path::new(""))
6757 .expect("root children")
6758 .map(|(name, _)| name.to_os_string())
6759 .collect();
6760 assert_eq!(
6761 top,
6762 vec![
6763 OsString::from("a.txt"),
6764 OsString::from("b.txt"),
6765 OsString::from("d.txt"),
6766 OsString::from("dir"),
6767 ]
6768 );
6769 assert_eq!(index.total().files, 4);
6770 assert_eq!(index.total().bytes, 26);
6771 }
6772
6773 #[test]
6774 fn shared_queries_return_owned_values_and_release_the_lock() {
6775 let handle = IndexHandle::new(index_with_sample_tree());
6776 let retained_total = handle.total().expect("total");
6777 let retained_history = handle.since(Clock::ZERO).expect("history");
6778 let retained_children = handle.children(Path::new("src")).expect("children");
6779 let retained_snapshot = handle.snapshot().expect("snapshot");
6780
6781 let writer = handle.clone();
6782 let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
6783 let thread = std::thread::spawn(move || {
6784 let result = writer.apply(&Observation::new(vec![upsert(
6785 "concurrent.txt",
6786 EntryKind::File,
6787 file_attrs(7, 30),
6788 )]));
6789 done_tx.send(result).expect("report writer result");
6790 });
6791
6792 let outcome = done_rx
6793 .recv_timeout(std::time::Duration::from_secs(5))
6794 .expect("owned query results must not retain the read lock")
6795 .expect("writer apply");
6796 thread.join().expect("writer thread");
6797
6798 assert_eq!(outcome.inserted, 1);
6799 assert_eq!(retained_total.files, 3);
6800 assert!(!retained_history.commits.is_empty());
6801 assert_eq!(retained_children.expect("src directory").len(), 2);
6802 assert!(retained_snapshot.lookup(Path::new("concurrent.txt")).is_none());
6803 assert!(handle.kind(Path::new("concurrent.txt")).expect("query").is_some());
6804 }
6805
6806 #[test]
6807 fn cloned_indexes_are_independent_detached_images() {
6808 let original = index_with_sample_tree();
6809 let original_clock = original.clock();
6810 let mut detached = original.clone();
6811
6812 detached.apply_ok(&Observation::new(vec![upsert(
6813 "detached-only.txt",
6814 EntryKind::File,
6815 file_attrs(7, 30),
6816 )]));
6817
6818 assert_eq!(original.clock(), original_clock);
6819 assert!(original.lookup(Path::new("detached-only.txt")).is_none());
6820 assert!(detached.lookup(Path::new("detached-only.txt")).is_some());
6821 assert_eq!(detached.total().files, original.total().files + 1);
6822 }
6823
6824 #[test]
6825 fn captured_child_expectations_match_individual_path_lookups() {
6826 let index = index_with_sample_tree();
6827 let captured = collect_child_expectations(&index, Path::new("src"));
6828
6829 assert!(!captured.is_empty());
6830 for (name, expectation) in captured {
6831 assert_eq!(expectation, index.expectation(&Path::new("src").join(name)));
6832 }
6833 }
6834
6835 #[test]
6836 fn index_and_shared_handle_are_send_and_sync() {
6837 fn assert_send_sync<T: Send + Sync>() {}
6838
6839 assert_send_sync::<Index>();
6840 assert_send_sync::<IndexHandle>();
6841 }
6842
6843 #[test]
6844 fn simultaneous_writers_commit_unique_contiguous_clocks_in_journal_order() {
6845 let writer_count: usize = 8;
6846 let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
6847 let barrier: Arc<Barrier> = Arc::new(Barrier::new(writer_count));
6848 let (result_tx, result_rx) = std::sync::mpsc::sync_channel(writer_count);
6849
6850 std::thread::scope(|scope| {
6851 for worker_id in 0..writer_count {
6852 let worker: IndexHandle = handle.clone();
6853 let start: Arc<Barrier> = Arc::clone(&barrier);
6854 let results = result_tx.clone();
6855 scope.spawn(move || {
6856 let ordinal: u64 = u64::try_from(worker_id + 1).expect("small worker count");
6857 let path: String = format!("writer-{ordinal}.txt");
6858 start.wait();
6859 let outcome: crate::Result<ApplyOutcome> =
6860 worker.apply(&Observation::new(vec![upsert(
6861 &path,
6862 EntryKind::File,
6863 file_attrs(ordinal, i64::try_from(ordinal).expect("small ordinal")),
6864 )]));
6865 results.send((path, outcome)).expect("report writer result");
6866 });
6867 }
6868 });
6869 drop(result_tx);
6870
6871 let mut committed_clocks: Vec<u64> = Vec::with_capacity(writer_count);
6872 for (path, outcome) in result_rx {
6873 let commit = outcome.expect("writer apply").commit.expect("unique upsert must commit");
6874 committed_clocks.push(commit.clock.0);
6875 assert!(handle.kind(Path::new(&path)).expect("query committed path").is_some());
6876 }
6877 committed_clocks.sort_unstable();
6878
6879 let last_clock: u64 = u64::try_from(writer_count).expect("small writer count");
6880 let expected_clocks: Vec<u64> = (1..=last_clock).collect();
6881 assert_eq!(committed_clocks, expected_clocks);
6882 assert_eq!(handle.clock().expect("clock"), Clock(last_clock));
6883
6884 let journal_clocks: Vec<u64> = handle
6885 .since(Clock::ZERO)
6886 .expect("journal")
6887 .commits
6888 .iter()
6889 .map(|commit| commit.clock.0)
6890 .collect();
6891 assert_eq!(journal_clocks, expected_clocks);
6892 }
6893
6894 #[test]
6895 fn readers_observe_only_complete_states_around_a_large_batch() {
6896 let file_count: u64 = 2_048;
6897 let expected_bytes: u64 = file_count * (file_count + 1) / 2;
6898 let operations: Vec<Op> =
6899 std::iter::once(upsert("batch", EntryKind::Dir, file_attrs(0, 1)))
6900 .chain((1..=file_count).map(|ordinal| {
6901 upsert(
6902 &format!("batch/file-{ordinal}.bin"),
6903 EntryKind::File,
6904 file_attrs(ordinal, i64::try_from(ordinal).expect("small ordinal")),
6905 )
6906 }))
6907 .collect();
6908 let observation: Observation = Observation::new(operations);
6909 let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
6910 let before: Index = handle.snapshot().expect("before snapshot");
6911 assert_eq!(before.total().files, 0);
6912
6913 let barrier: Arc<Barrier> = Arc::new(Barrier::new(2));
6914 let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
6915 std::thread::scope(|scope| {
6916 let writer: IndexHandle = handle.clone();
6917 let writer_start: Arc<Barrier> = Arc::clone(&barrier);
6918 scope.spawn(move || {
6919 writer_start.wait();
6920 let result: crate::Result<ApplyOutcome> = writer.apply(&observation);
6921 done_tx.send(result).expect("report batch result");
6922 });
6923
6924 let reader: IndexHandle = handle.clone();
6925 let reader_start: Arc<Barrier> = Arc::clone(&barrier);
6926 scope.spawn(move || {
6927 reader_start.wait();
6928 let deadline: std::time::Instant =
6929 std::time::Instant::now() + std::time::Duration::from_secs(10);
6930 loop {
6931 assert!(
6932 std::time::Instant::now() < deadline,
6933 "reader did not observe batch completion before the deadline"
6934 );
6935 let image: Index = reader.snapshot().expect("coherent reader snapshot");
6936 let total = image.total();
6937 match total.files {
6938 0 => {
6939 assert_eq!(total.bytes, 0);
6940 assert_eq!(image.len(), 1);
6941 }
6942 count if count == file_count => {
6943 assert_eq!(total.bytes, expected_bytes);
6944 assert_eq!(total.dirs, 1);
6945 assert_eq!(image.len(), file_count + 2);
6946 }
6947 partial => panic!("reader observed partial batch with {partial} files"),
6948 }
6949
6950 match done_rx.try_recv() {
6951 Ok(result) => {
6952 assert_eq!(result.expect("batch apply").inserted, file_count + 1);
6953 break;
6954 }
6955 Err(std::sync::mpsc::TryRecvError::Empty) => {}
6956 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
6957 panic!("batch writer disconnected")
6958 }
6959 }
6960 }
6961 });
6962 });
6963
6964 let after: Index = handle.snapshot().expect("after snapshot");
6965 assert_eq!(after.total().files, file_count);
6966 assert_eq!(after.total().bytes, expected_bytes);
6967 assert_eq!(after.len(), file_count + 2);
6968 }
6969
6970 #[test]
6971 fn poisoned_shared_lock_returns_typed_errors() {
6972 let handle: IndexHandle = IndexHandle::new(Index::new("/root"));
6973 let poisoner: IndexHandle = handle.clone();
6974 let panic_result: std::thread::Result<()> = std::thread::spawn(move || {
6975 let _poison_guard = poisoner.write_index().expect("initial write lock");
6976 panic!("intentional lock poison");
6977 })
6978 .join();
6979 assert!(panic_result.is_err());
6980
6981 assert!(matches!(handle.total(), Err(crate::Error::IndexLockPoisoned)));
6982 assert!(matches!(
6983 handle.apply(&Observation::new(vec![upsert(
6984 "never-applied.txt",
6985 EntryKind::File,
6986 file_attrs(1, 1),
6987 )])),
6988 Err(crate::Error::IndexLockPoisoned)
6989 ));
6990 }
6991
6992 #[test]
6993 fn clock_exhaustion_rejects_before_any_mutation() {
6994 let mut index = index_with_sample_tree();
6995 index.clock = Clock(u64::MAX);
6996 let before_total = index.total();
6997 let before_len = index.len();
6998
6999 let error = index
7000 .apply(&Observation::new(vec![upsert(
7001 "too-late.txt",
7002 EntryKind::File,
7003 file_attrs(1, 1),
7004 )]))
7005 .expect_err("clock exhaustion must be typed");
7006
7007 assert!(matches!(error, crate::Error::ClockExhausted));
7008 assert_eq!(index.clock(), Clock(u64::MAX));
7009 assert_eq!(index.len(), before_len);
7010 assert_eq!(index.total(), before_total);
7011 assert!(index.lookup(Path::new("too-late.txt")).is_none());
7012 }
7013
7014 #[test]
7015 fn terminal_clock_still_accepts_no_op_and_stale_observations() {
7016 let mut index = index_with_sample_tree();
7017 let current = *index.attrs(Path::new("src/main.rs")).expect("sample attributes");
7018 let stale_baseline = index.expectation(Path::new("src/main.rs"));
7019 index.apply_ok(&Observation::new(vec![upsert(
7020 "src/main.rs",
7021 EntryKind::File,
7022 file_attrs(99, 99),
7023 )]));
7024 index.clock = Clock(u64::MAX);
7025 let before_total = index.total();
7026 let before_len = index.len();
7027 let before_journal = index.journal.clone();
7028
7029 let no_op = index
7030 .apply(&Observation::new(vec![upsert(
7031 "src/main.rs",
7032 EntryKind::File,
7033 file_attrs(99, 99),
7034 )]))
7035 .expect("a no-op needs no new clock");
7036 let stale = index
7037 .apply(&Observation::from_ops(vec![ObservationOp::if_state(
7038 upsert("src/main.rs", EntryKind::File, current),
7039 stale_baseline,
7040 )]))
7041 .expect("a rejected stale observation needs no new clock");
7042
7043 assert_eq!(no_op.unchanged, 1);
7044 assert!(no_op.commit.is_none());
7045 assert_eq!(stale.stale, 1);
7046 assert!(stale.commit.is_none());
7047 assert_eq!(index.clock(), Clock(u64::MAX));
7048 assert_eq!(index.len(), before_len);
7049 assert_eq!(index.total(), before_total);
7050 assert_eq!(index.journal, before_journal);
7051 }
7052
7053 #[test]
7054 fn delayed_conditional_observation_cannot_overwrite_newer_state() {
7055 let mut index = Index::new("/root");
7056 index.apply_ok(&Observation::new(vec![upsert(
7057 "file.txt",
7058 EntryKind::File,
7059 file_attrs(10, 1),
7060 )]));
7061
7062 let baseline = index.expectation(Path::new("file.txt"));
7063 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7064 upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
7065 baseline,
7066 )]);
7067
7068 index.apply_ok(&Observation::new(vec![upsert(
7069 "file.txt",
7070 EntryKind::File,
7071 file_attrs(30, 3),
7072 )]));
7073 let outcome = index.apply_ok(&delayed);
7074
7075 assert_eq!(outcome.stats.stale, 1);
7076 assert!(outcome.commit.is_none());
7077 assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 30);
7078 }
7079
7080 #[test]
7081 fn delayed_absent_child_cannot_replace_a_newer_parent_file() {
7082 let mut index = Index::new("/root");
7083 index.apply_ok(&Observation::new(vec![upsert("parent", EntryKind::Dir, file_attrs(0, 1))]));
7084 let child_baseline = index.expectation(Path::new("parent/child.txt"));
7085 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7086 upsert("parent/child.txt", EntryKind::File, file_attrs(10, 2)),
7087 child_baseline,
7088 )]);
7089
7090 index.apply_ok(&Observation::new(vec![upsert(
7091 "parent",
7092 EntryKind::File,
7093 file_attrs(20, 3),
7094 )]));
7095 let outcome = index.apply_ok(&delayed);
7096
7097 assert_eq!(outcome.stats.stale, 1);
7098 assert!(outcome.commit.is_none());
7099 assert_eq!(index.kind(Path::new("parent")), Some(EntryKind::File));
7100 assert!(index.lookup(Path::new("parent/child.txt")).is_none());
7101 }
7102
7103 #[test]
7104 fn conditional_observation_rejects_present_state_aba() {
7105 let mut index = Index::new("/root");
7106 index.apply_ok(&Observation::new(vec![upsert(
7107 "file.txt",
7108 EntryKind::File,
7109 file_attrs(10, 1),
7110 )]));
7111 let baseline = index.expectation(Path::new("file.txt"));
7112 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7113 upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
7114 baseline,
7115 )]);
7116
7117 index.apply_ok(&Observation::new(vec![upsert(
7118 "file.txt",
7119 EntryKind::File,
7120 file_attrs(30, 3),
7121 )]));
7122 index.apply_ok(&Observation::new(vec![upsert(
7123 "file.txt",
7124 EntryKind::File,
7125 file_attrs(10, 1),
7126 )]));
7127 let outcome = index.apply_ok(&delayed);
7128
7129 assert_eq!(outcome.stats.stale, 1);
7130 assert!(outcome.commit.is_none());
7131 assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 10);
7132 }
7133
7134 #[test]
7135 fn conditional_observation_rejects_absent_state_aba() {
7136 let mut index = Index::new("/root");
7137 let baseline = index.expectation(Path::new("file.txt"));
7138 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7139 upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
7140 baseline,
7141 )]);
7142
7143 index.apply_ok(&Observation::new(vec![upsert(
7144 "file.txt",
7145 EntryKind::File,
7146 file_attrs(30, 3),
7147 )]));
7148 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("file.txt") }]));
7149 let outcome = index.apply_ok(&delayed);
7150
7151 assert_eq!(outcome.stats.stale, 1);
7152 assert!(outcome.commit.is_none());
7153 assert!(index.lookup(Path::new("file.txt")).is_none());
7154 }
7155
7156 #[test]
7161 fn convergent_conditional_upsert_applies_as_unchanged_not_stale() {
7162 let mut index = Index::new("/root");
7163 index.apply_ok(&Observation::new(vec![upsert(
7164 "file.txt",
7165 EntryKind::File,
7166 file_attrs(10, 1),
7167 )]));
7168 let baseline = index.expectation(Path::new("file.txt"));
7169 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7170 upsert("file.txt", EntryKind::File, file_attrs(20, 2)),
7171 baseline,
7172 )]);
7173
7174 index.apply_ok(&Observation::new(vec![upsert(
7175 "file.txt",
7176 EntryKind::File,
7177 file_attrs(20, 2),
7178 )]));
7179 let outcome = index.apply_ok(&delayed);
7180
7181 assert_eq!(outcome.stats.stale, 0);
7182 assert_eq!(outcome.stats.unchanged, 1);
7183 assert!(outcome.commit.is_none());
7184 assert_eq!(index.attrs(Path::new("file.txt")).expect("file").size, 20);
7185 }
7186
7187 #[test]
7188 fn convergent_conditional_remove_applies_as_unchanged_not_stale() {
7189 let mut index = Index::new("/root");
7190 index.apply_ok(&Observation::new(vec![
7191 upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
7192 upsert("dir/file.txt", EntryKind::File, file_attrs(10, 1)),
7193 ]));
7194 let baseline = index.expectation(Path::new("dir/file.txt"));
7195 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7196 Op::Remove { path: PathBuf::from("dir/file.txt") },
7197 baseline,
7198 )]);
7199
7200 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("dir/file.txt") }]));
7201 let outcome = index.apply_ok(&delayed);
7202
7203 assert_eq!(outcome.stats.stale, 0);
7204 assert_eq!(outcome.stats.unchanged, 1);
7205 assert!(outcome.commit.is_none());
7206 assert!(index.lookup(Path::new("dir/file.txt")).is_none());
7207 }
7208
7209 #[test]
7214 fn convergent_conditional_control_ops_apply_as_unchanged_not_stale() {
7215 let path = PathBuf::from(".gitignore");
7216 let rules =
7217 |source: &[u8]| Op::ControlUpsert { path: path.clone(), source: source.to_vec() };
7218 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
7219 index.apply_ok(&Observation::new(vec![
7220 upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
7221 rules(b"before"),
7222 ]));
7223 let baseline = index.expectation(&path);
7224 index.apply_ok(&Observation::new(vec![
7225 upsert(".gitignore", EntryKind::File, file_attrs(7, 2)),
7226 rules(b"changed"),
7227 ]));
7228
7229 let outcome = index.apply_ok(&Observation::from_ops(vec![
7230 ObservationOp::if_state(
7231 upsert(".gitignore", EntryKind::File, file_attrs(7, 2)),
7232 baseline,
7233 ),
7234 ObservationOp::if_state(rules(b"changed"), baseline),
7235 ]));
7236 assert_eq!(outcome.stats.stale, 0);
7237 assert!(outcome.commit.is_none());
7238 let diverged = index.apply_ok(&Observation::from_ops(vec![ObservationOp::if_state(
7239 rules(b"other"),
7240 baseline,
7241 )]));
7242 assert_eq!(diverged.stats.stale, 1);
7243 assert!(index.controls().expect("control state observed").source_is(&path, b"changed"));
7244
7245 let baseline = index.expectation(&path);
7246 index.apply_ok(&Observation::new(vec![Op::Remove { path: path.clone() }]));
7247 let outcome = index.apply_ok(&Observation::from_ops(vec![
7248 ObservationOp::if_state(Op::Remove { path: path.clone() }, baseline),
7249 ObservationOp::if_state(Op::ControlRemove { path: path.clone() }, baseline),
7250 ]));
7251 assert_eq!(outcome.stats.stale, 0);
7252 assert!(outcome.commit.is_none());
7253 assert!(!index.controls().expect("control state observed").contains(&path));
7254 }
7255
7256 #[test]
7257 fn unrelated_mutation_does_not_stale_an_absent_path() {
7258 let mut index = Index::new("/root");
7259 index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
7260 let baseline = index.expectation(Path::new("dir/new.txt"));
7261 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7262 upsert("dir/new.txt", EntryKind::File, file_attrs(20, 2)),
7263 baseline,
7264 )]);
7265
7266 index.apply_ok(&Observation::new(vec![upsert(
7267 "other.txt",
7268 EntryKind::File,
7269 file_attrs(30, 3),
7270 )]));
7271 let outcome = index.apply_ok(&delayed);
7272
7273 assert_eq!(outcome.stats.stale, 0);
7274 assert_eq!(outcome.stats.inserted, 1);
7275 assert_eq!(index.attrs(Path::new("dir/new.txt")).expect("file").size, 20);
7276 }
7277
7278 #[test]
7279 fn directory_metadata_change_does_not_stale_an_absent_child() {
7280 let mut index = Index::new("/root");
7281 index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
7282 let baseline = index.expectation(Path::new("dir/new.txt"));
7283 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7284 upsert("dir/new.txt", EntryKind::File, file_attrs(20, 2)),
7285 baseline,
7286 )]);
7287
7288 index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 3))]));
7289 let outcome = index.apply_ok(&delayed);
7290
7291 assert_eq!(outcome.stats.stale, 0);
7292 assert_eq!(outcome.stats.inserted, 1);
7293 }
7294
7295 #[test]
7296 fn file_parent_metadata_change_stales_an_absent_child() {
7297 let mut index = Index::new("/root");
7298 index.apply_ok(&Observation::new(vec![upsert(
7299 "parent",
7300 EntryKind::File,
7301 file_attrs(10, 1),
7302 )]));
7303 let baseline = index.expectation(Path::new("parent/child.txt"));
7304 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7305 upsert("parent/child.txt", EntryKind::File, file_attrs(20, 2)),
7306 baseline,
7307 )]);
7308
7309 index.apply_ok(&Observation::new(vec![upsert(
7310 "parent",
7311 EntryKind::File,
7312 file_attrs(30, 3),
7313 )]));
7314 let outcome = index.apply_ok(&delayed);
7315
7316 assert_eq!(outcome.stats.stale, 1);
7317 assert_eq!(index.kind(Path::new("parent")), Some(EntryKind::File));
7318 assert!(index.lookup(Path::new("parent/child.txt")).is_none());
7319 }
7320
7321 #[test]
7322 fn delayed_directory_remove_cannot_delete_a_newer_child() {
7323 let mut index = Index::new("/root");
7324 index.apply_ok(&Observation::new(vec![upsert("dir", EntryKind::Dir, file_attrs(0, 1))]));
7325 let baseline = index.expectation(Path::new("dir"));
7326 let delayed = Observation::from_ops(vec![ObservationOp::if_state(
7327 Op::Remove { path: PathBuf::from("dir") },
7328 baseline,
7329 )]);
7330
7331 index.apply_ok(&Observation::new(vec![upsert(
7332 "dir/new.txt",
7333 EntryKind::File,
7334 file_attrs(20, 2),
7335 )]));
7336 let outcome = index.apply_ok(&delayed);
7337
7338 assert_eq!(outcome.stats.stale, 1);
7339 assert!(index.lookup(Path::new("dir/new.txt")).is_some());
7340 }
7341
7342 #[test]
7343 fn conditional_batch_is_validated_at_one_boundary() {
7344 let mut index = Index::new("/root");
7345 let first = index.expectation(Path::new("first.txt"));
7346 let second = index.expectation(Path::new("second.txt"));
7347
7348 let outcome = index.apply_ok(&Observation::from_ops(vec![
7349 ObservationOp::if_state(upsert("first.txt", EntryKind::File, file_attrs(10, 1)), first),
7350 ObservationOp::if_state(
7351 upsert("second.txt", EntryKind::File, file_attrs(20, 2)),
7352 second,
7353 ),
7354 ]));
7355
7356 assert_eq!(outcome.stats.inserted, 2);
7357 assert_eq!(outcome.stats.stale, 0);
7358 }
7359
7360 #[test]
7361 fn public_observation_outputs_use_canonical_encoded_paths() {
7362 let separator = std::path::MAIN_SEPARATOR;
7363 let dotted = PathBuf::from(format!("dotted{separator}.{separator}file.txt"));
7364 let dotted_canonical = PathBuf::from(format!("dotted{separator}file.txt"));
7365 let repeated = PathBuf::from(format!("repeated{separator}{separator}file.txt"));
7366 let repeated_canonical = PathBuf::from(format!("repeated{separator}file.txt"));
7367 let mut index = Index::new("/root");
7368
7369 let outcome = index.apply_ok(&Observation::new(vec![
7370 upsert("dotted", EntryKind::Dir, file_attrs(0, 1)),
7371 Op::Upsert { path: dotted, kind: EntryKind::File, attrs: file_attrs(10, 2) },
7372 upsert("repeated", EntryKind::Dir, file_attrs(0, 3)),
7373 Op::Upsert { path: repeated, kind: EntryKind::File, attrs: file_attrs(20, 4) },
7374 ]));
7375 let commit = outcome.commit.as_ref().expect("one exact commit");
7376
7377 for (actual, canonical) in [
7378 (commit.changes[1].path(), dotted_canonical.as_path()),
7379 (commit.changes[3].path(), repeated_canonical.as_path()),
7380 ] {
7381 assert_eq!(
7382 actual.as_os_str().as_encoded_bytes(),
7383 canonical.as_os_str().as_encoded_bytes()
7384 );
7385 }
7386
7387 for canonical in [&dotted_canonical, &repeated_canonical] {
7388 let dirty = commit
7389 .impact
7390 .dirty_paths
7391 .iter()
7392 .find(|candidate| candidate.as_path() == canonical)
7393 .expect("changed path is dirty");
7394 assert_eq!(
7395 dirty.as_os_str().as_encoded_bytes(),
7396 canonical.as_os_str().as_encoded_bytes()
7397 );
7398 }
7399 }
7400
7401 #[test]
7408 fn trailing_path_spellings_leave_errors_and_changes_canonical() {
7409 let separator = std::path::MAIN_SEPARATOR;
7410 let canonical = format!("a{separator}b");
7411 for spelling in [format!("a{separator}b{separator}"), format!("a{separator}b{separator}.")]
7412 {
7413 let file = |mtime_ns| Op::Upsert {
7414 path: PathBuf::from(&spelling),
7415 kind: EntryKind::File,
7416 attrs: file_attrs(1, mtime_ns),
7417 };
7418 let mut index = Index::new("/root");
7419
7420 let error = index
7421 .apply(&Observation::new(vec![file(1)]))
7422 .expect_err("a child of an unknown directory is refused");
7423 let crate::Error::UnknownAncestry { path, .. } = error else {
7424 panic!("expected unknown ancestry for {spelling:?}, got {error}");
7425 };
7426 assert_eq!(path.as_os_str().as_encoded_bytes(), canonical.as_bytes(), "{spelling:?}");
7427
7428 let outcome = index.apply_ok(&Observation::new(vec![
7429 upsert("a", EntryKind::Dir, file_attrs(0, 1)),
7430 file(2),
7431 ]));
7432 let commit = outcome.commit.as_ref().expect("one exact commit");
7433 assert_eq!(
7434 commit.changes[1].path().as_os_str().as_encoded_bytes(),
7435 canonical.as_bytes(),
7436 "{spelling:?}"
7437 );
7438 }
7439 }
7440
7441 #[test]
7442 fn malformed_batch_is_rejected_before_any_index_mutation() {
7443 let invalid_paths = [
7444 PathBuf::from("../escape"),
7445 PathBuf::from(format!("{}absolute", std::path::MAIN_SEPARATOR)),
7446 ];
7447
7448 for invalid_path in invalid_paths {
7449 for invalid_first in [false, true] {
7450 let mut index = index_with_sample_tree();
7451 let before_clock = index.clock;
7452 let before_live = index.live;
7453 let before_total = index.total();
7454 let before_journal = index.journal.clone();
7455 let before_journal_cost = index.journal_cost;
7456 let before_journal_floor = index.journal_floor;
7457 let before_invalidations = index.pending_invalidations.clone();
7458 let before_freshness_epoch = index.freshness_epoch;
7459 let before_freshness = index.freshness();
7460 let valid = upsert("new.txt", EntryKind::File, file_attrs(99, 99));
7461 let invalid = Op::InvalidateSubtree {
7462 path: invalid_path.clone(),
7463 reason: InvalidateReason::Requested,
7464 };
7465 let ops = if invalid_first { vec![invalid, valid] } else { vec![valid, invalid] };
7466
7467 let error = index.apply(&Observation::new(ops)).expect_err("malformed batch");
7468
7469 assert!(
7470 matches!(error, crate::Error::PathEscapesRoot(path) if path == invalid_path)
7471 );
7472 assert_eq!(index.clock, before_clock);
7473 assert_eq!(index.live, before_live);
7474 assert_eq!(index.total(), before_total);
7475 assert_eq!(index.journal, before_journal);
7476 assert_eq!(index.journal_cost, before_journal_cost);
7477 assert_eq!(index.journal_floor, before_journal_floor);
7478 assert_eq!(index.pending_invalidations, before_invalidations);
7479 assert_eq!(index.freshness_epoch, before_freshness_epoch);
7480 assert_eq!(index.freshness(), before_freshness);
7481 assert!(index.lookup(Path::new("new.txt")).is_none());
7482 }
7483 }
7484 }
7485
7486 #[cfg(windows)]
7487 #[test]
7488 fn windows_prefix_is_rejected_before_mutation() {
7489 let mut index = index_with_sample_tree();
7490 let before_clock = index.clock();
7491
7492 let error = index
7493 .apply(&Observation::new(vec![Op::Remove { path: PathBuf::from(r"C:\escape") }]))
7494 .expect_err("prefixed path");
7495
7496 assert!(
7497 matches!(error, crate::Error::PathEscapesRoot(path) if path == Path::new(r"C:\escape"))
7498 );
7499 assert_eq!(index.clock(), before_clock);
7500 }
7501
7502 fn index_with_sample_tree() -> Index {
7503 let mut index = Index::new("/root");
7504 index.apply_ok(&Observation::new(vec![
7505 upsert("src", EntryKind::Dir, Attrs::default()),
7506 upsert("src/main.rs", EntryKind::File, file_attrs(100, 10)),
7507 upsert("src/lib.rs", EntryKind::File, file_attrs(200, 20)),
7508 upsert("docs", EntryKind::Dir, Attrs::default()),
7509 upsert("docs/guide.md", EntryKind::File, file_attrs(300, 30)),
7510 ]));
7511 index
7512 }
7513
7514 #[test]
7515 fn the_extension_interner_reclaims_ids_after_churn() {
7516 let mut index = Index::new("/root");
7520 for sequence in 0..128 {
7521 let path = PathBuf::from(format!("build.out-{sequence}"));
7522 index.apply_ok(&Observation::new(vec![Op::Upsert {
7523 path: path.clone(),
7524 kind: EntryKind::File,
7525 attrs: file_attrs(1, sequence),
7526 }]));
7527 index.apply_ok(&Observation::new(vec![Op::Remove { path }]));
7528 }
7529
7530 assert!(index.ext_ids.is_empty(), "no extension survives the file that named it");
7531 assert_eq!(index.ext_names.len(), 1, "128 dead extensions reuse one interner slot");
7532 assert!(index.total().by_ext.is_empty());
7533 }
7534
7535 #[test]
7536 fn a_reclaimed_extension_id_does_not_alias_a_live_tally() {
7537 let mut index = Index::new("/root");
7540 index.apply_ok(&Observation::new(vec![
7541 upsert("keep.rs", EntryKind::File, file_attrs(10, 1)),
7542 upsert("drop.tmp", EntryKind::File, file_attrs(20, 2)),
7543 ]));
7544 let retained = index.total();
7545 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("drop.tmp") }]));
7546 index.apply_ok(&Observation::new(vec![upsert(
7547 "next.bak",
7548 EntryKind::File,
7549 file_attrs(30, 3),
7550 )]));
7551
7552 let tallies = index.total().by_ext;
7553 assert_eq!(tallies[".rs"], ExtTally { files: 1, bytes: 10, allocated: 512 });
7554 assert_eq!(tallies[".bak"], ExtTally { files: 1, bytes: 30, allocated: 512 });
7555 assert!(!tallies.contains_key(".tmp"), "the removed extension is gone");
7556 assert_eq!(
7557 retained.by_ext[".tmp"],
7558 ExtTally { files: 1, bytes: 20, allocated: 512 },
7559 "an owned roll-up stays self-describing after its interner slot is reused"
7560 );
7561 assert!(!retained.by_ext.contains_key(".bak"));
7562 }
7563
7564 #[test]
7565 fn rollups_aggregate_up_the_tree() {
7566 let index = index_with_sample_tree();
7567
7568 let total = index.total();
7569 assert_eq!(total.files, 3);
7570 assert_eq!(total.dirs, 2);
7571 assert_eq!(total.bytes, 600);
7572 assert_eq!(total.newest_mtime_ns, 30);
7573
7574 let src = index.rollup(Path::new("src")).expect("src is a directory");
7575 assert_eq!(src.files, 2);
7576 assert_eq!(src.dirs, 0);
7577 assert_eq!(src.bytes, 300);
7578 assert_eq!(src.newest_mtime_ns, 20);
7579 }
7580
7581 #[test]
7582 fn symlinks_and_special_nodes_do_not_contribute_regular_file_tallies() {
7583 let mut index = Index::new("/root");
7584 index.apply_ok(&Observation::new(vec![
7585 upsert("regular.txt", EntryKind::File, file_attrs(10, 10)),
7586 upsert("link.rs", EntryKind::Symlink, file_attrs(99, 99)),
7587 upsert("socket.md", EntryKind::Other, file_attrs(88, 88)),
7588 ]));
7589
7590 let total = index.total();
7591 assert_eq!(total.files, 1);
7592 assert_eq!(total.bytes, 10);
7593 assert_eq!(total.allocated, 512);
7594 assert_eq!(total.newest_mtime_ns, 10);
7595 assert_eq!(total.by_ext[".txt"], ExtTally { files: 1, bytes: 10, allocated: 512 });
7596 assert!(!total.by_ext.contains_key(".rs"));
7597 assert!(!total.by_ext.contains_key(".md"));
7598
7599 index.apply_ok(&Observation::new(vec![upsert(
7600 "link.rs",
7601 EntryKind::File,
7602 file_attrs(99, 99),
7603 )]));
7604 assert_eq!(index.total().files, 2);
7605 assert_eq!(index.total().bytes, 109);
7606
7607 index.apply_ok(&Observation::new(vec![upsert(
7608 "link.rs",
7609 EntryKind::Symlink,
7610 file_attrs(99, 99),
7611 )]));
7612 assert_eq!(index.total().files, 1);
7613 assert_eq!(index.total().bytes, 10);
7614 }
7615
7616 #[test]
7617 fn per_extension_tallies_roll_up_hierarchically() {
7618 let index = index_with_sample_tree();
7619
7620 let total = index.total();
7621 assert_eq!(total.by_ext[".rs"], ExtTally { files: 2, bytes: 300, allocated: 1024 });
7622 assert_eq!(total.by_ext[".md"], ExtTally { files: 1, bytes: 300, allocated: 512 });
7623
7624 let src = index.rollup(Path::new("src")).expect("src is a directory");
7626 assert_eq!(src.by_ext[".rs"], ExtTally { files: 2, bytes: 300, allocated: 1024 });
7627 assert!(!src.by_ext.contains_key(".md"));
7628 }
7629
7630 #[test]
7631 fn per_extension_allocated_tracks_apparent_bytes_separately() {
7632 let mut index = Index::new("/root");
7635 index.apply_ok(&Observation::new(vec![
7636 upsert("a.rs", EntryKind::File, file_attrs(1, 10)),
7638 upsert("b.rs", EntryKind::File, file_attrs(2, 20)),
7639 ]));
7640
7641 let rs = index.total().by_ext[".rs"];
7642 assert_eq!((rs.files, rs.bytes), (2, 3));
7643 assert_eq!(rs.allocated, 1024, "each file occupies one 512-byte block");
7644
7645 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("b.rs") }]));
7647 let rs = index.total().by_ext[".rs"];
7648 assert_eq!((rs.files, rs.bytes, rs.allocated), (1, 1, 512));
7649 }
7650
7651 #[test]
7652 fn upsert_with_matching_fingerprint_is_a_no_op() {
7653 let mut index = index_with_sample_tree();
7654 let before = index.total();
7655 let mark = index.clock();
7656
7657 let stats = index.apply_ok(&Observation::new(vec![upsert(
7658 "src/main.rs",
7659 EntryKind::File,
7660 file_attrs(100, 10),
7661 )]));
7662
7663 assert_eq!(stats.unchanged, 1);
7664 assert_eq!(stats.updated, 0);
7665 assert_eq!(index.total(), before);
7666 assert_eq!(index.clock(), mark);
7667 assert!(index.since(mark).commits.is_empty());
7668 }
7669
7670 #[test]
7671 fn commit_contains_only_effective_mutations() {
7672 let mut index = index_with_sample_tree();
7673 let outcome = index.apply_ok(&Observation::new(vec![
7674 upsert("src/main.rs", EntryKind::File, file_attrs(100, 10)),
7675 upsert("new.txt", EntryKind::File, file_attrs(4, 4)),
7676 Op::Remove { path: PathBuf::from("missing.txt") },
7677 ]));
7678
7679 assert_eq!(outcome.stats.unchanged, 2);
7680 let commit = outcome.commit.expect("one effective insert");
7681 assert_eq!(commit.changes.len(), 1);
7682 assert_eq!(commit.changes[0].path(), Path::new("new.txt"));
7683 }
7684
7685 #[test]
7686 fn mutation_counters_match_exact_batch_and_consequence_totals() {
7687 struct DisableCounters;
7688
7689 impl Drop for DisableCounters {
7690 fn drop(&mut self) {
7691 crate::counters::enable(false);
7692 crate::counters::test_thread_reset();
7693 }
7694 }
7695
7696 let _serial = crate::counters::test_serial();
7697 crate::counters::enable(true);
7698 let _disable = DisableCounters;
7699 let observation = Observation::new(vec![
7700 upsert("a", EntryKind::Dir, Attrs::default()),
7701 upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
7702 upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
7703 ]);
7704
7705 crate::counters::test_thread_reset();
7706 let mut baseline = Index::new("/root");
7707 baseline.apply_baseline_ok(&observation);
7708 let counts = crate::counters::test_thread_snapshot();
7709 assert_eq!(counts.baseline_batches, 1);
7710 assert_eq!(counts.baseline_accepted_ops, 3);
7711 assert_eq!(counts.opened_batches, 0);
7712 assert_eq!(counts.public_batches, 0);
7713 assert_eq!(counts.ancestry_overlay_inserts, 3);
7714 assert_eq!(counts.ancestry_path_comparisons, 2);
7715 assert_eq!(counts.ancestry_parent_proofs, 3);
7716 assert_eq!(counts.effect_paths, 0);
7717 assert_eq!(counts.effect_path_bytes, 0);
7718 assert_eq!(counts.impact_candidates, 0);
7719 assert_eq!(counts.impact_ancestor_visits, 0);
7720 assert_eq!(counts.impact_retained_dirty_paths, 0);
7721 assert_eq!(counts.impact_all_dirty, 0);
7722 assert_eq!(counts.journal_cloned_commits, 0);
7723 assert_eq!(counts.journal_retained_commits, 0);
7724
7725 crate::counters::test_thread_reset();
7726 let mut public = Index::new("/root");
7727 public.apply_ok(&observation);
7728 let counts = crate::counters::test_thread_snapshot();
7729 assert_eq!(counts.baseline_batches, 0);
7730 assert_eq!(counts.opened_batches, 0);
7731 assert_eq!(counts.public_batches, 1);
7732 assert_eq!(counts.public_accepted_ops, 3);
7733 assert_eq!(counts.effect_paths, 3);
7734 assert_eq!(counts.journal_cloned_commits, 1);
7735 assert_eq!(counts.journal_retained_commits, 1);
7736 assert_eq!(counts.journal_oversized_commits, 0);
7737 assert_eq!(counts.journal_dropped_commits, 0);
7738
7739 crate::counters::test_thread_reset();
7740 let opened = IndexHandle::new(Index::new("/root"));
7741 opened
7742 .apply_discovery(&observation, DiscoveryCommit::default())
7743 .expect("opened discovery batch");
7744 let counts = crate::counters::test_thread_snapshot();
7745 assert_eq!(counts.baseline_batches, 0);
7746 assert_eq!(counts.opened_batches, 1);
7747 assert_eq!(counts.opened_accepted_ops, 3);
7748 assert_eq!(counts.public_batches, 0);
7749
7750 crate::counters::test_thread_reset();
7751 let mut scanner = Index::new("/root");
7752 scanner
7753 .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
7754 upsert("a", EntryKind::Dir, Attrs::default()),
7755 upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
7756 upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
7757 ]))
7758 .expect("private scanner batch");
7759 let counts = crate::counters::test_thread_snapshot();
7760 assert_eq!(counts.baseline_batches, 1);
7761 assert_eq!(counts.baseline_accepted_ops, 3);
7762 assert_eq!(counts.ancestry_overlay_inserts, 0);
7763 assert_eq!(counts.ancestry_path_comparisons, 2);
7764 assert_eq!(counts.ancestry_parent_proofs, 3);
7765 assert_eq!(counts.parent_resolutions, 0);
7766 assert_eq!(counts.parent_memo_hits, 0);
7767 assert_eq!(scanner.total().dirs, 1);
7768 assert_eq!(scanner.total().files, 2);
7769
7770 crate::counters::test_thread_reset();
7771 let opened_scanner = IndexHandle::new(Index::new("/root"));
7772 opened_scanner
7773 .apply_scanner_discovery_bounded(
7774 crate::scan::ScannerBatch::from_ops(vec![
7775 upsert("a", EntryKind::Dir, Attrs::default()),
7776 upsert("a/f1", EntryKind::File, file_attrs(1, 1)),
7777 upsert("a/f2", EntryKind::File, file_attrs(2, 2)),
7778 ]),
7779 DiscoveryCommit::default(),
7780 None,
7781 )
7782 .expect("opened scanner batch");
7783 let counts = crate::counters::test_thread_snapshot();
7784 assert_eq!(counts.opened_batches, 1);
7785 assert_eq!(counts.opened_accepted_ops, 3);
7786 assert_eq!(counts.ancestry_overlay_inserts, 0);
7787 assert_eq!(counts.effect_paths, 3);
7788 assert_eq!(counts.journal_retained_commits, 1);
7789
7790 let two_commits = 2 * commit_cost(vec![upsert("one", EntryKind::File, file_attrs(1, 1))]);
7793 crate::counters::test_thread_reset();
7794 let mut bounded = Index::new("/root");
7795 bounded.journal_capacity_bytes = two_commits;
7796 bounded.apply_ok(&Observation::new(vec![upsert("one", EntryKind::File, file_attrs(1, 1))]));
7797 bounded.apply_ok(&Observation::new(vec![upsert("two", EntryKind::File, file_attrs(2, 2))]));
7798 bounded.journal_capacity_bytes = 1;
7799 bounded.apply_ok(&Observation::new(vec![upsert(
7800 "three",
7801 EntryKind::File,
7802 file_attrs(3, 3),
7803 )]));
7804 let counts = crate::counters::test_thread_snapshot();
7805 assert_eq!(counts.journal_cloned_commits, 2);
7806 assert_eq!(counts.journal_retained_commits, 2);
7807 assert_eq!(counts.journal_oversized_commits, 1);
7808 assert_eq!(counts.journal_dropped_commits, 2);
7809 }
7810
7811 #[test]
7812 fn detached_and_exact_reducers_produce_the_same_facts_and_stats() {
7813 fn fact_image(index: &Index) -> Vec<(PathBuf, EntryKind, Attrs, bool, Source, bool)> {
7814 let mut image = Vec::new();
7815 let mut frontier = VecDeque::from([EntryId::ROOT]);
7816 while let Some(id) = frontier.pop_front() {
7817 let entry = index.entry(id);
7818 if entry.kind.is_dir() {
7819 frontier.extend(index.child_ids(id));
7820 }
7821 image.push((
7822 index.path_of(id).expect("live entry path"),
7823 entry.kind,
7824 entry.attrs,
7825 entry.ignored,
7826 entry.source,
7827 entry.directory.as_deref().is_none_or(|directory| directory.children_complete),
7828 ));
7829 }
7830 image.sort_by(|left, right| left.0.cmp(&right.0));
7831 image
7832 }
7833
7834 fn assert_same_facts(detached: &Index, exact: &Index) {
7835 assert_eq!(fact_image(detached), fact_image(exact));
7836 assert_eq!(detached.total(), exact.total());
7837 let partitions =
7838 |index: &Index| index.named_partitions(index.entry(EntryId::ROOT).rollup());
7839 assert_eq!(partitions(detached), partitions(exact));
7840 let controls = |index: &Index| {
7841 index
7842 .controls
7843 .sources()
7844 .map(|(path, source)| (path, source.to_vec()))
7845 .collect::<Vec<_>>()
7846 };
7847 assert_eq!(controls(detached), controls(exact));
7848 assert_eq!(detached.state, exact.state);
7849 assert_eq!(detached.issues, exact.issues);
7850 let freshness = |index: &Index| {
7851 index
7852 .freshness_marks
7853 .iter()
7854 .map(|(path, mark)| (path.clone(), mark.state, mark.epoch))
7855 .collect::<Vec<_>>()
7856 };
7857 assert_eq!(freshness(detached), freshness(exact));
7858 assert_eq!(detached.verified, exact.verified);
7859 assert_eq!(detached.pending_invalidations, exact.pending_invalidations);
7860 }
7861
7862 let mut detached = Index::new("/root");
7863 let mut exact = detached.clone();
7864 let batches = [
7865 Observation::new(vec![
7866 upsert("a", EntryKind::Dir, file_attrs(0, 1)),
7867 upsert("a/one.rs", EntryKind::File, file_attrs(10, 2)),
7868 upsert("a/two.txt", EntryKind::File, file_attrs(20, 3)),
7869 ]),
7870 Observation::new(vec![
7871 upsert("a/one.rs", EntryKind::File, file_attrs(30, 4)),
7872 Op::Remove { path: PathBuf::from("a/two.txt") },
7873 Op::InvalidateSubtree {
7874 path: PathBuf::from("a"),
7875 reason: InvalidateReason::VerificationFailed,
7876 },
7877 ]),
7878 ];
7879
7880 for batch in batches {
7881 let detached_stats = detached.apply_baseline(&batch).expect("detached batch");
7882 let exact_outcome = exact.apply(&batch).expect("exact batch");
7883 assert_eq!(detached_stats, exact_outcome.stats);
7884 exact.establish_baseline();
7885 assert_same_facts(&detached, &exact);
7886 }
7887 }
7888
7889 #[test]
7890 fn exact_commit_records_verified_ancestry_and_kind_replacement() {
7891 let mut index = Index::new("/root");
7892 let inserted = index.apply_ok(&Observation::new(vec![
7893 upsert("unknown", EntryKind::Dir, file_attrs(0, 1)),
7894 upsert("unknown/deep", EntryKind::Dir, file_attrs(0, 2)),
7895 upsert("unknown/deep/file.txt", EntryKind::File, file_attrs(10, 3)),
7896 ]));
7897 let inserted = inserted.commit.expect("ancestry commit");
7898 assert_eq!(
7899 inserted.changes.iter().map(EffectiveChange::path).collect::<Vec<_>>(),
7900 [Path::new("unknown"), Path::new("unknown/deep"), Path::new("unknown/deep/file.txt")]
7901 );
7902 assert!(
7903 inserted
7904 .changes
7905 .iter()
7906 .all(|change| matches!(change, EffectiveChange::Inserted { .. }))
7907 );
7908
7909 let replaced = index.apply_ok(&Observation::new(vec![upsert(
7910 "unknown",
7911 EntryKind::File,
7912 file_attrs(20, 2),
7913 )]));
7914 let replaced = replaced.commit.expect("replacement commit");
7915 assert_eq!(
7916 replaced.changes,
7917 vec![
7918 EffectiveChange::Removed {
7919 path: "unknown".into(),
7920 kind: EntryKind::Dir,
7921 attrs: file_attrs(0, 1),
7922 },
7923 EffectiveChange::Removed {
7924 path: "unknown/deep".into(),
7925 kind: EntryKind::Dir,
7926 attrs: file_attrs(0, 2),
7927 },
7928 EffectiveChange::Removed {
7929 path: "unknown/deep/file.txt".into(),
7930 kind: EntryKind::File,
7931 attrs: file_attrs(10, 3),
7932 },
7933 EffectiveChange::Inserted {
7934 path: "unknown".into(),
7935 kind: EntryKind::File,
7936 attrs: file_attrs(20, 2),
7937 },
7938 ]
7939 );
7940 assert_eq!(
7941 replaced.impact.domains,
7942 vec![
7943 ImpactDomain::Topology,
7944 ImpactDomain::Metadata,
7945 ImpactDomain::Classification,
7946 ImpactDomain::Aggregates,
7947 ImpactDomain::Content,
7948 ]
7949 );
7950 assert_eq!(
7951 replaced.impact.dirty_paths,
7952 vec![
7953 PathBuf::new(),
7954 "unknown".into(),
7955 "unknown/deep".into(),
7956 "unknown/deep/file.txt".into(),
7957 ]
7958 );
7959 }
7960
7961 #[test]
7962 fn rejected_prepared_commit_is_fault_atomic() {
7963 let mut index = index_with_sample_tree();
7964 let before_clock = index.clock();
7965 let before_total = index.total();
7966 let before_len = index.len();
7967 let before_history = index.since(Clock::ZERO);
7968 let mut prepared = prepare_observation(&Observation::new(vec![upsert(
7969 "new/deep.txt",
7970 EntryKind::File,
7971 file_attrs(99, 99),
7972 )]))
7973 .expect("valid preparation");
7974 prepared.reject_before_apply = true;
7975
7976 let error = index.commit_prepared(prepared, true).expect_err("injected preflight");
7977
7978 assert!(matches!(error, crate::Error::CommitRejected("injected reducer preflight")));
7979 assert_eq!(index.clock(), before_clock);
7980 assert_eq!(index.total(), before_total);
7981 assert_eq!(index.len(), before_len);
7982 assert_eq!(index.since(Clock::ZERO), before_history);
7983 assert!(index.lookup(Path::new("new")).is_none());
7984 }
7985
7986 #[test]
7987 fn reconciliation_state_moves_through_exact_commits() {
7988 let mut index = Index::new("/root");
7989 let (started, start) = index.begin_reconcile(Path::new("src")).expect("begin");
7990 let start = start.expect("start commit");
7991 assert!(start.changes.is_empty());
7992 assert_eq!(
7993 start.state,
7994 vec![
7995 StateTransition::Freshness {
7996 path: "src".into(),
7997 previous: Freshness::Fresh,
7998 current: Freshness::Reconciling,
7999 },
8000 StateTransition::IndexState {
8001 previous: IndexState::default(),
8002 current: IndexState {
8003 freshness: Freshness::Reconciling,
8004 ..IndexState::default()
8005 },
8006 },
8007 ]
8008 );
8009
8010 let finish = index
8011 .finish_reconcile(
8012 Path::new("src"),
8013 started,
8014 true,
8015 &[],
8016 &[],
8017 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
8018 )
8019 .expect("finish")
8020 .commit
8021 .expect("finish commit");
8022 assert!(finish.changes.is_empty());
8023 assert_eq!(
8024 finish.state,
8025 vec![
8026 StateTransition::Verified { path: "src".into() },
8027 StateTransition::Freshness {
8028 path: "src".into(),
8029 previous: Freshness::Reconciling,
8030 current: Freshness::Fresh,
8031 },
8032 StateTransition::IndexState {
8033 previous: IndexState {
8034 freshness: Freshness::Reconciling,
8035 ..IndexState::default()
8036 },
8037 current: IndexState::default(),
8038 },
8039 ]
8040 );
8041 }
8042
8043 #[cfg(feature = "watch")]
8044 #[test]
8045 fn observation_failure_cannot_overwrite_a_terminal_resource_stop() {
8046 let handle = IndexHandle::new(Index::new("/root"));
8047 handle
8048 .transition_discovery(DiscoveryTransition::BudgetRefused(Issue::resource_budget(0)))
8049 .expect("stop for budget");
8050 let stopped = handle.state().expect("stopped state");
8051 let clock = handle.clock().expect("stopped clock");
8052
8053 let outcome = handle
8054 .transition_observation(ObservationTransition::Failed(Issue::from_error(
8055 &crate::Error::WatchStopped,
8056 )))
8057 .expect("late observer failure is ignored");
8058
8059 assert_eq!(outcome.commit, None);
8060 assert_eq!(handle.state().expect("terminal state"), stopped);
8061 assert_eq!(handle.clock().expect("terminal clock"), clock);
8062 }
8063
8064 #[test]
8066 fn a_terminal_root_refuses_every_discovery_commit() {
8067 let terminals = [
8068 DiscoveryTransition::BudgetRefused(Issue::resource_budget(1)),
8069 DiscoveryTransition::Failed(Issue::from_error(&crate::Error::OpenedIndexClosed)),
8070 ];
8071 for terminal in terminals {
8072 let handle = IndexHandle::new(Index::new("/root"));
8073 handle.transition_discovery(DiscoveryTransition::Begin).expect("begin");
8074 handle
8075 .apply_discovery(
8076 &Observation::new(vec![upsert("dir", EntryKind::Dir, Attrs::default())]),
8077 DiscoveryCommit::default(),
8078 )
8079 .expect("listing before the stop");
8080 handle.transition_discovery(terminal.clone()).expect("terminal transition");
8081 let state = handle.state().expect("terminal state");
8082 let clock = handle.clock().expect("terminal clock");
8083
8084 let late = [
8085 (
8086 vec![upsert("dir/late.txt", EntryKind::File, file_attrs(1, 1))],
8087 DiscoveryCommit {
8088 directory_complete: Some(PathBuf::from("dir")),
8089 transition: None,
8090 },
8091 ),
8092 (
8093 Vec::new(),
8094 DiscoveryCommit {
8095 directory_complete: None,
8096 transition: Some(DiscoveryTransition::Finish),
8097 },
8098 ),
8099 (
8100 Vec::new(),
8101 DiscoveryCommit {
8102 directory_complete: None,
8103 transition: Some(DiscoveryTransition::Inaccessible {
8104 issues: vec![Issue::resource_budget(2)],
8105 omitted: 0,
8106 }),
8107 },
8108 ),
8109 ];
8110 for (ops, discovery) in late {
8111 assert!(
8112 matches!(
8113 handle.apply_discovery(&Observation::new(ops), discovery.clone()),
8114 Err(crate::Error::OpenedIndexStopped)
8115 ),
8116 "after {terminal:?}, {discovery:?} was accepted"
8117 );
8118 }
8119 assert_eq!(handle.state().expect("state"), state, "after {terminal:?}");
8120 assert_eq!(handle.clock().expect("clock"), clock, "after {terminal:?}");
8121 assert_eq!(handle.kind(Path::new("dir/late.txt")).expect("lookup"), None);
8122 }
8123 }
8124
8125 #[test]
8126 fn replaying_the_same_delta_twice_changes_nothing() {
8127 let mut index = Index::new("/root");
8128 let delta = Observation::new(vec![
8129 upsert("a", EntryKind::Dir, Attrs::default()),
8130 upsert("a/f.txt", EntryKind::File, file_attrs(10, 1)),
8131 ]);
8132 index.apply_ok(&delta);
8133 let after_first = index.total();
8134 let stats = index.apply_ok(&delta);
8135
8136 assert_eq!(stats.unchanged, 2);
8137 assert_eq!(index.total(), after_first);
8138 assert_eq!(index.len(), 3);
8139 }
8140
8141 #[test]
8142 fn changed_size_updates_every_ancestor() {
8143 let mut index = index_with_sample_tree();
8144 index.apply_ok(&Observation::new(vec![upsert(
8145 "src/main.rs",
8146 EntryKind::File,
8147 file_attrs(150, 11),
8148 )]));
8149
8150 assert_eq!(index.rollup(Path::new("src")).expect("dir").bytes, 350);
8151 assert_eq!(index.total().bytes, 650);
8152 assert_eq!(index.total().by_ext[".rs"], ExtTally { files: 2, bytes: 350, allocated: 1024 });
8153 }
8154
8155 #[test]
8156 fn allocated_size_change_updates_rollups_even_when_fingerprint_matches() {
8157 let mut index = Index::new("/root");
8158 let original = Attrs { allocated: 512, ..file_attrs(100, 10) };
8159 index.apply_ok(&Observation::new(vec![upsert("file.bin", EntryKind::File, original)]));
8160
8161 let repacked = Attrs { allocated: 4096, ..original };
8162 let outcome =
8163 index.apply_ok(&Observation::new(vec![upsert("file.bin", EntryKind::File, repacked)]));
8164
8165 assert_eq!(outcome.stats.updated, 1);
8166 assert_eq!(outcome.stats.unchanged, 0);
8167 assert_eq!(index.total().allocated, 4096);
8168 assert_eq!(index.attrs(Path::new("file.bin")), Some(&repacked));
8169 }
8170
8171 #[test]
8172 fn newest_mtime_preserves_pre_epoch_values_through_updates_and_removals() {
8173 let mut index = Index::new("/root");
8174 index.apply_ok(&Observation::new(vec![
8175 upsert("newer.txt", EntryKind::File, file_attrs(10, -10)),
8176 upsert("older.txt", EntryKind::File, file_attrs(20, -20)),
8177 ]));
8178
8179 assert_eq!(index.total().newest_mtime_ns, -10);
8180
8181 index.apply_ok(&Observation::new(vec![upsert(
8182 "newer.txt",
8183 EntryKind::File,
8184 file_attrs(10, -30),
8185 )]));
8186 assert_eq!(index.total().newest_mtime_ns, -20);
8187
8188 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("older.txt") }]));
8189 assert_eq!(index.total().newest_mtime_ns, -30);
8190 }
8191
8192 #[test]
8193 fn removing_a_file_corrects_sums_and_rebuilds_the_max() {
8194 let mut index = index_with_sample_tree();
8195 let stats = index
8197 .apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("docs/guide.md") }]));
8198
8199 assert_eq!(stats.removed, 1);
8200 let total = index.total();
8201 assert_eq!(total.files, 2);
8202 assert_eq!(total.bytes, 300);
8203 assert_eq!(total.newest_mtime_ns, 20, "max must fall back to src/lib.rs");
8204 assert!(!total.by_ext.contains_key(".md"), "emptied tallies are dropped");
8205 }
8206
8207 #[test]
8208 fn removing_a_directory_cascades_to_descendants() {
8209 let mut index = index_with_sample_tree();
8210 let stats = index
8211 .apply(&Observation::new(vec![Op::Remove { path: PathBuf::from("src") }]))
8212 .expect("valid observation");
8213
8214 assert_eq!(stats.removed, 3, "the directory and both files");
8215 let total = index.total();
8216 assert_eq!(total.files, 1);
8217 assert_eq!(total.dirs, 1);
8218 assert_eq!(total.bytes, 300);
8219 assert!(index.lookup(Path::new("src/main.rs")).is_none());
8220 assert!(!total.by_ext.contains_key(".rs"));
8221 }
8222
8223 #[test]
8224 fn freed_slots_are_reused() {
8225 let mut index = index_with_sample_tree();
8226 let before = index.len();
8227 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("src") }]));
8228 index.apply_ok(&Observation::new(vec![
8229 upsert("other", EntryKind::Dir, Attrs::default()),
8230 upsert("other/x.rs", EntryKind::File, file_attrs(1, 1)),
8231 upsert("other/y.rs", EntryKind::File, file_attrs(1, 1)),
8232 ]));
8233 assert_eq!(index.len(), before, "three freed slots, three new entries");
8234 }
8235
8236 #[test]
8237 fn stale_entry_handle_does_not_alias_a_reused_slot() {
8238 let mut index = Index::new("/root");
8239 index.apply_ok(&Observation::new(vec![upsert(
8240 "first.txt",
8241 EntryKind::File,
8242 file_attrs(1, 1),
8243 )]));
8244 let stale = index.lookup(Path::new("first.txt")).expect("first id");
8245 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("first.txt") }]));
8246 index.apply_ok(&Observation::new(vec![upsert(
8247 "second.txt",
8248 EntryKind::File,
8249 file_attrs(2, 2),
8250 )]));
8251 let current = index.lookup(Path::new("second.txt")).expect("second id");
8252
8253 assert_ne!(stale, current, "generation participates in handle identity");
8254 assert!(index.attrs_of(stale).is_none());
8255 assert!(index.kind_of(stale).is_none());
8256 assert!(index.name_of(stale).is_none());
8257 assert!(index.path_of(stale).is_none());
8258 assert!(index.children_of(stale).is_none());
8259 assert!(index.rollup_of(stale).is_none());
8260 assert_eq!(index.attrs_of(current).map(|attrs| attrs.size), Some(2));
8261 }
8262
8263 #[test]
8264 fn parent_first_batch_establishes_exact_ancestry() {
8265 let mut index = Index::new("/root");
8266 let deep = file_attrs(0, 1);
8267 let nested = file_attrs(0, 2);
8268 let tree = file_attrs(0, 3);
8269 index.apply_ok(&Observation::new(vec![
8270 upsert("deep", EntryKind::Dir, deep),
8271 upsert("deep/nested", EntryKind::Dir, nested),
8272 upsert("deep/nested/tree", EntryKind::Dir, tree),
8273 upsert("deep/nested/tree/file.txt", EntryKind::File, file_attrs(42, 7)),
8274 ]));
8275
8276 assert_eq!(index.total().files, 1);
8277 assert_eq!(index.total().dirs, 3);
8278 assert_eq!(index.total().bytes, 42);
8279 assert_eq!(index.rollup(Path::new("deep/nested")).expect("created").files, 1);
8280 assert_eq!(index.attrs(Path::new("deep")), Some(&deep));
8281 assert_eq!(index.attrs(Path::new("deep/nested")), Some(&nested));
8282 assert_eq!(index.attrs(Path::new("deep/nested/tree")), Some(&tree));
8283 }
8284
8285 #[test]
8286 fn scanner_parent_proof_matches_public_parent_first_application() {
8287 let ops = vec![
8288 upsert("deep", EntryKind::Dir, file_attrs(0, 1)),
8289 upsert("deep/nested", EntryKind::Dir, file_attrs(0, 2)),
8290 upsert("deep/nested/file.txt", EntryKind::File, file_attrs(42, 3)),
8291 ];
8292 let mut scanner = Index::new("/root");
8293 let scanner_stats = scanner
8294 .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(ops.clone()))
8295 .expect("scanner proof");
8296 let mut public = Index::new("/root");
8297 let public_stats = public.apply(&Observation::new(ops)).expect("public proof").stats;
8298
8299 assert_eq!(scanner_stats, public_stats);
8300 assert_eq!(scanner.total(), public.total());
8301 assert_eq!(scanner.len(), public.len());
8302 for path in ["deep", "deep/nested", "deep/nested/file.txt"] {
8303 assert_eq!(scanner.kind(Path::new(path)), public.kind(Path::new(path)));
8304 assert_eq!(scanner.attrs(Path::new(path)), public.attrs(Path::new(path)));
8305 }
8306 }
8307
8308 #[test]
8309 fn scanner_parent_proof_rejects_unknown_ancestry_before_mutation() {
8310 let mut index = Index::new("/root");
8311 let before = index.total();
8312 let error = index
8313 .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![upsert(
8314 "missing/child.txt",
8315 EntryKind::File,
8316 file_attrs(1, 1),
8317 )]))
8318 .expect_err("scanner proof must reject an unknown parent");
8319
8320 assert!(matches!(
8321 error,
8322 crate::Error::UnknownAncestry { path, .. }
8323 if path == Path::new("missing/child.txt")
8324 ));
8325 assert_eq!(index.total(), before);
8326 assert_eq!(index.len(), 1);
8327 assert_eq!(index.clock(), Clock::ZERO);
8328 }
8329
8330 #[test]
8331 fn scanner_kind_replacement_is_proved_by_the_general_lane() {
8332 let mut index = Index::new("/root");
8333 index.apply_ok(&Observation::new(vec![
8334 upsert("a", EntryKind::Dir, file_attrs(0, 1)),
8335 upsert("a/old.txt", EntryKind::File, file_attrs(7, 1)),
8336 ]));
8337 let before = index.total();
8338 let before_clock = index.clock();
8339
8340 let error = index
8343 .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![
8344 upsert("a", EntryKind::File, file_attrs(2, 2)),
8345 upsert("a/new.txt", EntryKind::File, file_attrs(3, 2)),
8346 ]))
8347 .expect_err("a child cannot attach after its parent became a file");
8348
8349 assert!(matches!(
8350 error,
8351 crate::Error::UnknownAncestry { path, .. } if path == Path::new("a/new.txt")
8352 ));
8353 assert_eq!(index.total(), before);
8354 assert_eq!(index.clock(), before_clock);
8355 assert_eq!(index.kind(Path::new("a")), Some(EntryKind::Dir));
8356 assert!(index.lookup(Path::new("a/old.txt")).is_some());
8357 assert!(index.lookup(Path::new("a/new.txt")).is_none());
8358
8359 index
8361 .apply_scanner_baseline(crate::scan::ScannerBatch::from_ops(vec![upsert(
8362 "a",
8363 EntryKind::File,
8364 file_attrs(2, 2),
8365 )]))
8366 .expect("a scanner batch can replace an entry's kind");
8367 assert_eq!(index.kind(Path::new("a")), Some(EntryKind::File));
8368 assert!(index.lookup(Path::new("a/old.txt")).is_none());
8369 let total = index.total();
8370 assert_eq!((total.files, total.dirs, total.bytes), (1, 0, 2));
8371 }
8372
8373 #[test]
8374 fn scanner_discovery_survives_a_kind_changed_by_a_concurrent_refresh() {
8375 let handle = IndexHandle::new(Index::new("/root"));
8376 handle
8377 .apply_scanner_discovery_bounded(
8378 crate::scan::ScannerBatch::from_ops(vec![upsert(
8379 "p",
8380 EntryKind::Dir,
8381 Attrs::default(),
8382 )]),
8383 DiscoveryCommit::default(),
8384 None,
8385 )
8386 .expect("root listing");
8387 handle
8389 .apply(&Observation::new(vec![upsert("p/d", EntryKind::File, file_attrs(1, 1))]))
8390 .expect("refresh insert");
8391 let outcome = handle.apply_scanner_discovery_bounded(
8393 crate::scan::ScannerBatch::from_ops(vec![upsert(
8394 "p/d",
8395 EntryKind::Dir,
8396 Attrs::default(),
8397 )]),
8398 DiscoveryCommit { directory_complete: Some(PathBuf::from("p")), transition: None },
8399 None,
8400 );
8401 assert!(outcome.is_ok(), "discovery must not die on a kind race: {outcome:?}");
8402 assert_eq!(handle.kind(Path::new("p/d")).expect("kind read"), Some(EntryKind::Dir));
8405 assert_eq!(handle.directory_complete(Path::new("p")).expect("read"), Some(true));
8406 }
8407
8408 #[test]
8409 fn live_upsert_refuses_unknown_ancestry_without_mutation() {
8410 let mut index = Index::new("/root");
8411 let before = index.clock();
8412
8413 let error = index
8414 .apply(&Observation::new(vec![upsert(
8415 "unknown/deep/file.txt",
8416 EntryKind::File,
8417 file_attrs(10, 1),
8418 )]))
8419 .expect_err("live input must not invent parent metadata");
8420
8421 assert!(matches!(
8422 error,
8423 crate::Error::UnknownAncestry { path, reconcile_from }
8424 if path == Path::new("unknown/deep/file.txt")
8425 && reconcile_from.as_os_str().is_empty()
8426 ));
8427 assert_eq!(index.clock(), before);
8428 assert_eq!(index.len(), 1);
8429 assert!(index.since(before).commits.is_empty());
8430 }
8431
8432 #[test]
8433 fn explicit_kind_replacement_precedes_attaching_a_child() {
8434 let mut index = Index::new("/root");
8435 index.apply_ok(&Observation::new(vec![upsert(
8436 "conflict",
8437 EntryKind::File,
8438 file_attrs(9, 1),
8439 )]));
8440
8441 let outcome = index.apply_ok(&Observation::new(vec![
8442 upsert("conflict", EntryKind::Dir, file_attrs(0, 2)),
8443 upsert("conflict/child.txt", EntryKind::File, file_attrs(4, 2)),
8444 ]));
8445
8446 assert_eq!(index.kind(Path::new("conflict")), Some(EntryKind::Dir));
8447 assert!(index.lookup(Path::new("conflict/child.txt")).is_some());
8448 assert_eq!(index.total().files, 1);
8449 assert_eq!(index.total().dirs, 1);
8450 assert_eq!(index.total().bytes, 4);
8451 assert_eq!(outcome.removed, 1);
8452 }
8453
8454 #[test]
8455 fn kind_change_replaces_the_entry() {
8456 let mut index = Index::new("/root");
8457 index.apply_ok(&Observation::new(vec![upsert(
8458 "thing",
8459 EntryKind::File,
8460 file_attrs(50, 5),
8461 )]));
8462 assert_eq!(index.total().files, 1);
8463
8464 index.apply_ok(&Observation::new(vec![upsert("thing", EntryKind::Dir, Attrs::default())]));
8465 let total = index.total();
8466 assert_eq!(total.files, 0);
8467 assert_eq!(total.dirs, 1);
8468 assert_eq!(total.bytes, 0);
8469 }
8470
8471 #[test]
8472 fn paths_are_reconstructed_from_parent_pointers() {
8473 let index = index_with_sample_tree();
8474 let id = index.lookup(Path::new("src/main.rs")).expect("present");
8475 assert_eq!(index.path_of(id), Some(PathBuf::from("src/main.rs")));
8476 assert_eq!(index.path_of(EntryId::ROOT), Some(PathBuf::new()));
8477 }
8478
8479 #[test]
8480 fn since_returns_commits_after_a_clock() {
8481 let mut index = Index::new("/root");
8482 index.apply_ok(&Observation::new(vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))]));
8483 let mark = index.clock();
8484 index.apply_ok(&Observation::new(vec![upsert("b.txt", EntryKind::File, file_attrs(2, 2))]));
8485
8486 let since = index.since(mark);
8487 assert!(!since.truncated);
8488 assert_eq!(since.commits.len(), 1);
8489 assert_eq!(since.commits[0].changes[0].path(), Path::new("b.txt"));
8490
8491 assert_eq!(index.since(index.clock()).commits.len(), 0);
8492 }
8493
8494 fn commit_cost(ops: Vec<Op>) -> usize {
8496 let mut probe = Index::new("/root");
8497 probe.apply_ok(&Observation::new(ops)).commit.expect("effective commit").retained_cost()
8498 }
8499
8500 #[test]
8501 fn oversized_single_batch_is_not_retained() {
8502 let batch = || {
8503 vec![
8504 upsert("a.txt", EntryKind::File, file_attrs(1, 1)),
8505 upsert("b.txt", EntryKind::File, file_attrs(2, 2)),
8506 upsert("c.txt", EntryKind::File, file_attrs(3, 3)),
8507 ]
8508 };
8509 let mut index = Index::with_journal_capacity_bytes("/root", commit_cost(batch()) - 1);
8510 let outcome = index.apply_ok(&Observation::new(batch()));
8511
8512 assert_eq!(outcome.commit.as_ref().expect("committed").changes.len(), 3);
8513 let since = index.since(Clock::ZERO);
8514 assert!(since.truncated);
8515 assert!(since.commits.is_empty());
8516 }
8517
8518 #[test]
8519 fn journal_eviction_charges_the_complete_retained_payload() {
8520 let first = || {
8521 vec![
8522 upsert("a.txt", EntryKind::File, file_attrs(1, 1)),
8523 upsert("b.txt", EntryKind::File, file_attrs(2, 2)),
8524 ]
8525 };
8526 let second = || {
8527 vec![
8528 upsert("c.txt", EntryKind::File, file_attrs(3, 3)),
8529 upsert("d.txt", EntryKind::File, file_attrs(4, 4)),
8530 ]
8531 };
8532 let capacity = commit_cost(first()) + commit_cost(second()) - 1;
8534 let mut index = Index::with_journal_capacity_bytes("/root", capacity);
8535 index.apply_ok(&Observation::new(first()));
8536 index.apply_ok(&Observation::new(second()));
8537
8538 let since = index.since(Clock::ZERO);
8539 assert!(since.truncated);
8540 assert_eq!(since.commits.len(), 1);
8541 assert_eq!(since.commits[0].changes.len(), 2);
8542 assert_eq!(since.commits[0].changes[0].path(), Path::new("c.txt"));
8543 }
8544
8545 #[test]
8550 fn journal_eviction_is_charged_in_path_bytes() {
8551 let long_name = format!("{}.txt", "n".repeat(4096));
8552 let short = || vec![upsert("a.txt", EntryKind::File, file_attrs(1, 1))];
8553 let long = || vec![upsert(&long_name, EntryKind::File, file_attrs(2, 2))];
8554 let short_cost = commit_cost(short());
8555 let long_cost = commit_cost(long());
8556 assert!(
8557 long_cost > short_cost + 4096,
8558 "path bytes must be charged: short {short_cost}, long {long_cost}"
8559 );
8560
8561 let mut index = Index::with_journal_capacity_bytes("/root", short_cost + long_cost - 1);
8562 index.apply_ok(&Observation::new(short()));
8563 index.apply_ok(&Observation::new(long()));
8564
8565 let since = index.since(Clock::ZERO);
8566 assert!(since.truncated, "an item budget kept both commits; a byte budget cannot");
8567 assert_eq!(since.commits.len(), 1);
8568 assert_eq!(since.commits[0].changes[0].path(), Path::new(&long_name));
8569 }
8570
8571 #[test]
8572 fn impact_drops_an_overflowing_path_set_instead_of_truncating_it() {
8573 let mut index = Index::new("/root");
8574 let ops = (0..=MAX_DIRTY_PATHS)
8575 .map(|which| {
8576 upsert(
8577 &format!("file-{which}.txt"),
8578 EntryKind::File,
8579 file_attrs(u64::try_from(which).expect("bounded"), 1),
8580 )
8581 })
8582 .collect();
8583
8584 let commit =
8585 index.apply_ok(&Observation::new(ops)).commit.expect("overflowing impact commit");
8586
8587 assert!(commit.impact.all_dirty);
8588 assert!(commit.impact.dirty_paths.is_empty(), "a partial path list must not escape");
8589 assert_eq!(commit.changes.len(), MAX_DIRTY_PATHS + 1);
8590 }
8591
8592 #[test]
8593 fn invalidations_are_queued_for_the_scan_layer() {
8594 let mut index = Index::new("/root");
8595 let stats = index.apply_ok(&Observation::new(vec![Op::InvalidateSubtree {
8596 path: PathBuf::from("src"),
8597 reason: InvalidateReason::WatchOverflow,
8598 }]));
8599
8600 assert_eq!(stats.invalidated, 1);
8601 let pending = index.take_pending_invalidations();
8602 assert_eq!(pending.len(), 1);
8603 assert_eq!(pending[0].0, PathBuf::from("src"));
8604 assert_eq!(pending[0].1, InvalidateReason::WatchOverflow);
8605 assert!(index.take_pending_invalidations().is_empty(), "drained once");
8606 }
8607
8608 #[test]
8609 fn paths_escaping_the_root_are_rejected() {
8610 assert!(normalize(Path::new("../escape")).is_none());
8611 assert!(normalize(Path::new("/absolute")).is_none());
8612 assert_eq!(
8613 normalize(Path::new("./a/b")).expect("relative"),
8614 vec![OsString::from("a"), OsString::from("b")]
8615 );
8616
8617 let mut index = Index::new("/root");
8618 let upsert_error = index
8619 .apply(&Observation::new(vec![upsert("../escape", EntryKind::File, file_attrs(1, 1))]))
8620 .expect_err("escaping upsert");
8621 assert!(matches!(upsert_error, crate::Error::PathEscapesRoot(_)));
8622 assert_eq!(index.total().files, 0);
8623
8624 let invalidation_error = index
8625 .apply(&Observation::new(vec![Op::InvalidateSubtree {
8626 path: PathBuf::from("../outside"),
8627 reason: InvalidateReason::Requested,
8628 }]))
8629 .expect_err("escaping invalidation");
8630 assert!(matches!(invalidation_error, crate::Error::PathEscapesRoot(_)));
8631 assert!(index.take_pending_invalidations().is_empty());
8632 assert_eq!(index.freshness(), Freshness::Fresh);
8633 }
8634
8635 #[cfg(unix)]
8636 #[test]
8637 fn distinct_non_utf8_names_have_distinct_identity() {
8638 use std::ffi::OsString;
8639 use std::os::unix::ffi::OsStringExt;
8640
8641 let first = PathBuf::from(OsString::from_vec(vec![b'n', 0x80]));
8642 let second = PathBuf::from(OsString::from_vec(vec![b'n', 0x81]));
8643 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
8644 "/root",
8645 ScanScope::default(),
8646 crate::classify::TypeRegistry::compiled_shared(),
8647 DEFAULT_JOURNAL_CAPACITY_BYTES,
8648 );
8649 index.apply_ok(&Observation::new(vec![
8650 Op::Upsert { path: first.clone(), kind: EntryKind::File, attrs: file_attrs(10, 1) },
8651 Op::Upsert { path: second.clone(), kind: EntryKind::File, attrs: file_attrs(20, 2) },
8652 ]));
8653
8654 assert_eq!(index.total().files, 2);
8655 assert_eq!(index.total().bytes, 30);
8656 assert!(index.lookup(&first).is_some());
8657 assert!(index.lookup(&second).is_some());
8658 assert_serving_indexes(&index);
8659 }
8660
8661 #[cfg(unix)]
8662 #[test]
8663 fn a_non_utf8_parent_still_lists_its_children() {
8664 use std::ffi::OsString;
8665 use std::os::unix::ffi::OsStringExt;
8666
8667 let directory = PathBuf::from(OsString::from_vec(vec![b'd', 0x80]));
8668 let child = directory.join("child");
8669 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
8670 "/root",
8671 ScanScope::default(),
8672 crate::classify::TypeRegistry::compiled_shared(),
8673 DEFAULT_JOURNAL_CAPACITY_BYTES,
8674 );
8675 index.apply_ok(&Observation::new(vec![
8676 Op::Upsert { path: directory.clone(), kind: EntryKind::Dir, attrs: Attrs::default() },
8677 Op::Upsert { path: child, kind: EntryKind::File, attrs: file_attrs(1, 1) },
8678 ]));
8679
8680 assert_serving_indexes(&index);
8681 assert_eq!(
8685 index.portable_children(&directory).map(|children| children.nondirectories.len()),
8686 Some(1)
8687 );
8688 assert!(
8689 index.portable_entries().keys().any(|portable| portable.as_str() == "d%80/child"),
8690 "a child under a non-utf8 directory is listed at its escaped path"
8691 );
8692 }
8693
8694 #[cfg(unix)]
8695 #[test]
8696 fn non_utf8_stem_keeps_ascii_extension_tally() {
8697 use std::ffi::OsString;
8698 use std::os::unix::ffi::OsStringExt;
8699
8700 let path = PathBuf::from(OsString::from_vec(vec![b'n', 0x80, b'.', b'R', b'S']));
8701 let mut index = Index::new("/root");
8702 index.apply_ok(&Observation::new(vec![Op::Upsert {
8703 path,
8704 kind: EntryKind::File,
8705 attrs: file_attrs(10, 1),
8706 }]));
8707
8708 let tallies = index.total().by_ext;
8709 assert_eq!(
8710 tallies.get(".rs"),
8711 Some(&ExtTally { files: 1, bytes: 10, allocated: file_attrs(10, 1).allocated })
8712 );
8713 }
8714
8715 #[test]
8716 fn restore_candidates_count_visited_files_not_pathbuf_aliases() {
8717 use crate::content::AnalysisSet;
8718
8719 let mut index = Index::new("/root");
8720 let attrs = file_attrs(10, 1);
8721 assert!(
8722 index
8723 .insert_loaded_child(EntryId::ROOT, OsString::from("a"), EntryKind::File, attrs)
8724 .is_some()
8725 );
8726 assert!(
8727 index
8728 .insert_loaded_child(EntryId::ROOT, OsString::from("a/"), EntryKind::File, attrs)
8729 .is_some()
8730 );
8731 let (candidates, visited) =
8732 index.restore_analysis_candidates(AnalysisSet::NONE.with_lines());
8733 assert_eq!(visited, 2, "each visited regular file is a completeness slot");
8734 assert_eq!(candidates.len(), 1, "PathBuf keys merge trailing-separator aliases");
8735 }
8736
8737 #[test]
8738 fn restore_candidates_match_analysis_file_identities() {
8739 use crate::content::AnalysisSet;
8740
8741 let mut index = Index::new("/root");
8742 index.apply_ok(&Observation::new(vec![
8743 upsert("src", EntryKind::Dir, file_attrs(0, 1)),
8744 upsert("src/nested", EntryKind::Dir, file_attrs(0, 2)),
8745 upsert("src/nested/lib.rs", EntryKind::File, file_attrs(10, 1)),
8746 upsert("README.md", EntryKind::File, file_attrs(20, 2)),
8747 ]));
8748 let profile = AnalysisSet::NONE.with_lines();
8749 let live = index.analysis_candidates(profile);
8750 let (restore, visited) = index.restore_analysis_candidates(profile);
8751 assert_eq!(restore.len(), live.len());
8752 assert_eq!(visited, u64::try_from(live.len()).expect("candidate count fits u64"));
8753 assert_eq!(restore.len(), 2);
8754 for candidate in &live {
8755 assert_eq!(
8756 index.path_of(candidate.entry_id).as_deref(),
8757 Some(candidate.relative_path.as_path())
8758 );
8759 let restored = restore.get(&candidate.relative_path).expect("same relative path");
8760 assert_eq!(restored.entry_id, candidate.entry_id);
8761 assert_eq!(restored.revision, candidate.revision);
8762 assert_eq!(restored.attrs.fingerprint(), candidate.attrs.fingerprint());
8763 }
8764 }
8765
8766 #[test]
8767 fn content_results_commit_conditionally_and_metadata_changes_invalidate_them() {
8768 use crate::content::{
8769 AnalysisApplyOutcome, AnalysisRequest, AnalysisSet, AnalyzerOutcome, BasicMetrics,
8770 ContentProvenance, FileAnalysis,
8771 };
8772
8773 let mut index = Index::new("/root");
8774 index.apply_ok(&Observation::new(vec![
8775 upsert("src", EntryKind::Dir, file_attrs(0, 1)),
8776 upsert("src/lib.rs", EntryKind::File, file_attrs(10, 1)),
8777 ]));
8778 let profile = AnalysisSet::NONE.with_lines();
8779 let candidate =
8780 index.analysis_candidates(profile).into_iter().next().expect("file candidate");
8781 let analysis = FileAnalysis {
8782 fingerprint: candidate.attrs.fingerprint(),
8783 bytes: candidate.attrs.size,
8784 detection: candidate.classification.clone().into(),
8785 lines: AnalyzerOutcome::analyzed(BasicMetrics {
8786 physical_lines: 2,
8787 nonblank_lines: 2,
8788 ..BasicMetrics::default()
8789 }),
8790 code: None,
8791 words: None,
8792 error: None,
8793 };
8794 let provenance = ContentProvenance::for_request(
8795 AnalysisRequest { profile, ..AnalysisRequest::default() },
8796 crate::classify::type_rule_fingerprint(),
8797 );
8798 let observation = AnalysisObservation {
8799 candidate: candidate.clone(),
8800 profile,
8801 provenance: provenance.clone(),
8802 analysis: analysis.clone(),
8803 };
8804 assert_eq!(
8805 index.apply_analysis(observation.clone()),
8806 AnalysisApplyOutcome::Stale,
8807 "an index prepared for no content identity holds no record"
8808 );
8809 assert!(index.content().is_none());
8810
8811 index.prepare_content_analysis(AnalysisRequest { profile, ..AnalysisRequest::default() });
8812 assert_eq!(index.content_set(), profile);
8813 assert_eq!(index.apply_analysis(observation), AnalysisApplyOutcome::Applied);
8814 assert_eq!(
8815 index
8816 .content_rollup(Path::new(""))
8817 .expect("content root")
8818 .total
8819 .lines
8820 .metrics
8821 .physical_lines,
8822 2
8823 );
8824
8825 index.apply_ok(&Observation::new(vec![upsert(
8826 "src/lib.rs",
8827 EntryKind::File,
8828 file_attrs(20, 2),
8829 )]));
8830 assert!(index.content_rollup(Path::new("")).is_none());
8831 assert_eq!(
8832 index.apply_analysis(AnalysisObservation { candidate, profile, provenance, analysis }),
8833 AnalysisApplyOutcome::Stale
8834 );
8835 }
8836
8837 #[test]
8838 fn operational_content_failures_are_retained_but_retried_until_recovery() {
8839 use crate::content::{
8840 AnalysisApplyOutcome, AnalysisRequest, AnalysisSet, AnalyzerOutcome, BasicMetrics,
8841 ContentProvenance, CoverageReason, FileAnalysis,
8842 };
8843
8844 let mut index = Index::new("/root");
8845 index.apply_ok(&Observation::new(vec![
8846 upsert("src", EntryKind::Dir, file_attrs(0, 1)),
8847 upsert("src/lib.rs", EntryKind::File, file_attrs(10, 1)),
8848 ]));
8849 let profile = AnalysisSet::LINES_ONLY;
8850 let request = AnalysisRequest { profile, ..AnalysisRequest::default() };
8851 index.prepare_content_analysis(request);
8852 let candidate = index.pending_analysis_candidates(request).pop().expect("candidate");
8853 let provenance =
8854 ContentProvenance::for_request(request, crate::classify::type_rule_fingerprint());
8855 let record = |reason, error: &str| FileAnalysis {
8856 fingerprint: candidate.attrs.fingerprint(),
8857 bytes: candidate.attrs.size,
8858 detection: candidate.classification.clone().into(),
8859 lines: AnalyzerOutcome::unavailable(reason),
8860 code: None,
8861 words: None,
8862 error: Some(error.to_owned()),
8863 };
8864
8865 for (reason, error) in [
8866 (CoverageReason::IoError, "read failed"),
8867 (CoverageReason::ChangedDuringRead, "changed during read"),
8868 ] {
8869 assert_eq!(
8870 index.apply_analysis(AnalysisObservation {
8871 candidate: candidate.clone(),
8872 profile,
8873 provenance: provenance.clone(),
8874 analysis: record(reason, error),
8875 }),
8876 AnalysisApplyOutcome::Applied
8877 );
8878 let retained = index.content().expect("content").file(Path::new("src/lib.rs"));
8879 assert_eq!(retained.and_then(FileAnalysis::operational_failure), Some(reason));
8880 assert_eq!(
8881 index.pending_analysis_candidates(request).len(),
8882 1,
8883 "an operational failure must remain pending"
8884 );
8885 }
8886
8887 let recovered = FileAnalysis {
8888 fingerprint: candidate.attrs.fingerprint(),
8889 bytes: candidate.attrs.size,
8890 detection: candidate.classification.clone().into(),
8891 lines: AnalyzerOutcome::analyzed(BasicMetrics {
8892 physical_lines: 1,
8893 nonblank_lines: 1,
8894 raw_words: 1,
8895 ..BasicMetrics::default()
8896 }),
8897 code: None,
8898 words: None,
8899 error: None,
8900 };
8901 assert_eq!(
8902 index.apply_analysis(AnalysisObservation {
8903 candidate,
8904 profile,
8905 provenance,
8906 analysis: recovered,
8907 }),
8908 AnalysisApplyOutcome::Applied
8909 );
8910 assert!(index.pending_analysis_candidates(request).is_empty());
8911 assert_eq!(
8912 index
8913 .content()
8914 .expect("content")
8915 .file(Path::new("src/lib.rs"))
8916 .and_then(FileAnalysis::operational_failure),
8917 None
8918 );
8919 }
8920
8921 #[test]
8924 fn an_index_that_did_not_observe_controls_refuses_ignore_questions() {
8925 let mut index =
8926 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
8927 assert!(!index.observes_controls());
8928 index.apply_ok(&Observation::new(vec![upsert(
8929 "debug.log",
8930 EntryKind::File,
8931 file_attrs(10, 1),
8932 )]));
8933 for path in ["debug.log", "absent.log"] {
8934 assert!(
8935 matches!(
8936 index.is_ignored(Path::new(path)),
8937 Err(crate::Error::ControlStateNotObserved)
8938 ),
8939 "{path}"
8940 );
8941 }
8942 assert!(matches!(index.controls(), Err(crate::Error::ControlStateNotObserved)));
8943
8944 let mut observed =
8945 Index::new_with_scope("/root", crate::test_support::observing_controls());
8946 assert!(observed.observes_controls());
8947 observed.apply_ok(&Observation::new(vec![upsert(
8948 "debug.log",
8949 EntryKind::File,
8950 file_attrs(10, 1),
8951 )]));
8952 assert_eq!(observed.is_ignored(Path::new("debug.log")).ok(), Some(Some(false)));
8953 assert_eq!(observed.is_ignored(Path::new("absent.log")).ok(), Some(None));
8954 assert!(observed.controls().is_ok_and(crate::control::ControlTable::is_empty));
8955 }
8956
8957 #[test]
8964 fn an_index_that_does_not_observe_controls_refuses_control_input() {
8965 let mut index =
8966 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
8967 index.apply_ok(&Observation::new(vec![
8968 upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
8969 upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
8970 ]));
8971 let stale_baseline = index.expectation(Path::new(".gitignore"));
8972 index.apply_ok(&Observation::new(vec![upsert(
8973 ".gitignore",
8974 EntryKind::File,
8975 file_attrs(7, 3),
8976 )]));
8977 let clock = index.clock();
8978 let total = index.total();
8979 let controls = || {
8980 [
8981 Op::ControlUpsert {
8982 path: PathBuf::from(".gitignore"),
8983 source: b"*.log\n".to_vec(),
8984 },
8985 Op::ControlRemove { path: PathBuf::from(".gitignore") },
8986 ]
8987 };
8988
8989 for control in controls() {
8990 let batch = Observation::new(vec![
8991 upsert("new.txt", EntryKind::File, file_attrs(1, 4)),
8992 control.clone(),
8993 ]);
8994 assert!(
8995 matches!(index.apply(&batch), Err(crate::Error::ControlStateNotObserved)),
8996 "{control:?}"
8997 );
8998 assert!(
8999 matches!(index.apply_baseline(&batch), Err(crate::Error::ControlStateNotObserved)),
9000 "{control:?}"
9001 );
9002 let stale = Observation::from_ops(vec![ObservationOp::if_state(
9003 control.clone(),
9004 stale_baseline,
9005 )]);
9006 assert!(
9007 matches!(index.apply(&stale), Err(crate::Error::ControlStateNotObserved)),
9008 "stale {control:?}"
9009 );
9010 }
9011 assert_eq!(index.clock(), clock, "a refused batch commits nothing");
9012 assert_eq!(index.total(), total);
9013 assert!(index.lookup(Path::new("new.txt")).is_none());
9014 assert!(index.control_table().is_empty());
9015
9016 let mut table = crate::control::ControlTable::default();
9017 table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control source");
9018 assert!(matches!(
9019 index.install_controls(table),
9020 Err(crate::Error::ControlStateNotObserved)
9021 ));
9022 assert!(index.control_table().is_empty());
9023
9024 let mut observed =
9025 Index::new_with_scope("/root", crate::test_support::observing_controls());
9026 for control in controls() {
9027 observed
9028 .apply(&Observation::new(vec![
9029 upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9030 control,
9031 ]))
9032 .expect("an observing index accepts control input");
9033 }
9034 }
9035
9036 #[test]
9041 fn an_index_that_did_not_observe_controls_states_no_partition_or_child_ignore_fact() {
9042 let tree = Observation::new(vec![
9043 upsert("dir", EntryKind::Dir, file_attrs(0, 1)),
9044 upsert("dir/debug.log", EntryKind::File, file_attrs(10, 2)),
9045 ]);
9046 let mut unobserved =
9047 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
9048 unobserved.apply_ok(&tree);
9049 assert!(matches!(unobserved.partition_total(), Err(crate::Error::ControlStateNotObserved)));
9050 for path in ["", "dir", "dir/debug.log", "absent"] {
9051 assert!(
9052 matches!(
9053 unobserved.partition_rollup(Path::new(path)),
9054 Err(crate::Error::ControlStateNotObserved)
9055 ),
9056 "{path}"
9057 );
9058 assert!(
9059 matches!(
9060 unobserved.partition_rollup_summary(Path::new(path)),
9061 Err(crate::Error::ControlStateNotObserved)
9062 ),
9063 "{path}"
9064 );
9065 }
9066 assert_eq!(unobserved.total().files, 1, "the all partition still answers");
9067 let children = IndexHandle::new(unobserved)
9068 .children(Path::new(""))
9069 .expect("children read")
9070 .expect("root directory");
9071 assert_eq!(children.len(), 1);
9072 assert_eq!(children[0].ignored, None);
9073 assert_eq!(children[0].partitions, None);
9074 assert_eq!(children[0].rollup.as_ref().map(|rollup| rollup.files), Some(1));
9075
9076 let mut observed =
9077 Index::new_with_scope("/root", crate::test_support::observing_controls());
9078 observed.apply_ok(&tree);
9079 assert_eq!(observed.partition_total().expect("control state observed").unignored.files, 1);
9080 assert!(
9081 observed.partition_rollup(Path::new("dir")).expect("control state observed").is_some()
9082 );
9083 assert_eq!(
9084 observed
9085 .partition_rollup_summary(Path::new("dir/debug.log"))
9086 .expect("control state observed"),
9087 None,
9088 "a file has no partitions"
9089 );
9090 let children = IndexHandle::new(observed)
9091 .children(Path::new(""))
9092 .expect("children read")
9093 .expect("root directory");
9094 assert_eq!(children[0].ignored, Some(false));
9095 assert_eq!(
9096 children[0].partitions.as_ref().map(|partitions| partitions.unignored.files),
9097 Some(1)
9098 );
9099 }
9100
9101 #[test]
9102 fn control_changes_atomically_move_fixed_partitions_without_changing_all() {
9103 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9104 index.apply_ok(&Observation::new(vec![
9105 upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9106 upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
9107 upsert("keep.rs", EntryKind::File, file_attrs(20, 3)),
9108 upsert("docs", EntryKind::Dir, file_attrs(0, 4)),
9109 upsert("docs/other.log", EntryKind::File, file_attrs(30, 5)),
9110 upsert("docs/keep.log", EntryKind::File, file_attrs(40, 6)),
9111 ]));
9112 let before = index.partition_total().expect("control state observed");
9113
9114 let outcome = index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
9115 path: PathBuf::from(".gitignore"),
9116 source: b"*.log\n".to_vec(),
9117 }]));
9118 let partitions = index.partition_total().expect("control state observed");
9119
9120 assert_eq!(partitions.all, before.all, "classification never changes all facts");
9121 assert_eq!(partitions.all.files, 5);
9122 assert_eq!(partitions.unignored.files, 2);
9123 assert_eq!(partitions.unignored.bytes, 26);
9124 assert_eq!(outcome.controls, 1);
9125 assert_eq!(outcome.reclassified, 3);
9126 assert_eq!(
9127 index.is_ignored(Path::new("debug.log")).expect("control state observed"),
9128 Some(true)
9129 );
9130 assert_eq!(
9131 index.is_ignored(Path::new("keep.rs")).expect("control state observed"),
9132 Some(false)
9133 );
9134
9135 let commit = outcome.commit.expect("control and classification commit together");
9136 assert!(matches!(
9137 commit.changes.first(),
9138 Some(EffectiveChange::ControlUpdated { path, previous: None, current: Some(_) })
9139 if path == Path::new(".gitignore")
9140 ));
9141 assert_eq!(
9142 commit
9143 .changes
9144 .iter()
9145 .filter(|change| matches!(change, EffectiveChange::Reclassified { .. }))
9146 .count(),
9147 3
9148 );
9149 }
9150
9151 #[test]
9152 fn serving_semantics_follow_ignore_reclassification_exactly() {
9153 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
9154 "/root",
9155 crate::test_support::observing_controls(),
9156 crate::classify::TypeRegistry::compiled_shared(),
9157 DEFAULT_JOURNAL_CAPACITY_BYTES,
9158 );
9159 index.apply_ok(&Observation::new(vec![
9160 upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9161 upsert("debug.log", EntryKind::File, file_attrs(10, 2)),
9162 upsert("keep.rs", EntryKind::File, file_attrs(20, 3)),
9163 upsert("Makefile", EntryKind::File, file_attrs(30, 4)),
9164 ]));
9165 assert_serving_indexes(&index);
9166
9167 index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
9168 path: PathBuf::from(".gitignore"),
9169 source: b"*.log\nMakefile\n".to_vec(),
9170 }]));
9171 assert_serving_indexes(&index);
9172
9173 index.apply_ok(&Observation::new(vec![Op::ControlRemove {
9174 path: PathBuf::from(".gitignore"),
9175 }]));
9176 assert_serving_indexes(&index);
9177 }
9178
9179 #[test]
9180 fn nested_negation_edit_and_last_control_deletion_reclassify_exactly() {
9181 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9182 index.apply_ok(&Observation::new(vec![
9183 upsert(".gitignore", EntryKind::File, file_attrs(6, 1)),
9184 upsert("docs", EntryKind::Dir, file_attrs(0, 2)),
9185 upsert("docs/.gitignore", EntryKind::File, file_attrs(10, 3)),
9186 upsert("docs/keep.log", EntryKind::File, file_attrs(40, 4)),
9187 Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
9188 Op::ControlUpsert {
9189 path: PathBuf::from("docs/.gitignore"),
9190 source: b"!keep.log\n".to_vec(),
9191 },
9192 ]));
9193 assert_eq!(
9194 index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
9195 Some(false)
9196 );
9197
9198 let edited = index.apply_ok(&Observation::new(vec![Op::ControlUpsert {
9199 path: PathBuf::from("docs/.gitignore"),
9200 source: b"# no exception\n".to_vec(),
9201 }]));
9202 assert_eq!(edited.reclassified, 1);
9203 assert_eq!(
9204 index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
9205 Some(true)
9206 );
9207
9208 let removed = index
9209 .apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from(".gitignore") }]));
9210 assert_eq!(removed.controls, 1, "removing the retained row removes its control state");
9211 assert_eq!(removed.reclassified, 1);
9212 assert_eq!(
9213 index.controls().expect("control state observed").len(),
9214 1,
9215 "the nested control remains"
9216 );
9217 assert_eq!(
9218 index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
9219 Some(false)
9220 );
9221
9222 index.apply_ok(&Observation::new(vec![Op::Remove {
9223 path: PathBuf::from("docs/.gitignore"),
9224 }]));
9225 assert!(index.controls().expect("control state observed").is_empty());
9226 assert_eq!(
9227 index.is_ignored(Path::new("docs/keep.log")).expect("control state observed"),
9228 Some(false)
9229 );
9230 }
9231
9232 #[test]
9233 fn replacing_batch_ancestors_prunes_retained_and_transient_controls() {
9234 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9235 index
9236 .apply(&Observation::new(vec![
9237 upsert("docs", EntryKind::Dir, file_attrs(0, 1)),
9238 upsert("docs/.gitignore", EntryKind::File, file_attrs(6, 2)),
9239 Op::ControlUpsert { path: "docs/.gitignore".into(), source: b"*.log\n".to_vec() },
9240 ]))
9241 .expect("retained control");
9242
9243 let outcome = index
9244 .apply(&Observation::new(vec![
9245 upsert("scratch", EntryKind::Dir, file_attrs(0, 3)),
9246 upsert("scratch/.gitignore", EntryKind::File, file_attrs(6, 4)),
9247 Op::ControlUpsert {
9248 path: "scratch/.gitignore".into(),
9249 source: b"*.log\n".to_vec(),
9250 },
9251 upsert("scratch", EntryKind::File, file_attrs(1, 5)),
9252 upsert("scratch", EntryKind::Dir, file_attrs(0, 6)),
9253 upsert("scratch/new.log", EntryKind::File, file_attrs(7, 7)),
9254 Op::Remove { path: "docs".into() },
9255 upsert("docs", EntryKind::Dir, file_attrs(0, 8)),
9256 upsert("docs/new.log", EntryKind::File, file_attrs(9, 9)),
9257 ]))
9258 .expect("mixed control and structural batch");
9259
9260 assert!(index.controls().expect("control state observed").is_empty());
9261 assert_eq!(
9262 index.is_ignored(Path::new("scratch/new.log")).expect("control state observed"),
9263 Some(false)
9264 );
9265 assert_eq!(
9266 index.is_ignored(Path::new("docs/new.log")).expect("control state observed"),
9267 Some(false)
9268 );
9269 assert_eq!(outcome.stats.controls, 1, "only the retained control has a net change");
9270 let controls = outcome
9271 .commit
9272 .as_ref()
9273 .expect("one exact commit")
9274 .changes
9275 .iter()
9276 .filter(|change| matches!(change, EffectiveChange::ControlUpdated { .. }))
9277 .collect::<Vec<_>>();
9278 assert!(matches!(
9279 controls.as_slice(),
9280 [EffectiveChange::ControlUpdated { path, previous: Some(_), current: None }]
9281 if path == Path::new("docs/.gitignore")
9282 ));
9283 }
9284
9285 #[test]
9289 fn an_over_budget_control_is_refused_while_its_batch_commits() {
9290 let mut index = Index::new_opened_with_scope_types_and_journal_capacity_bytes(
9291 "/root",
9292 crate::test_support::observing_controls(),
9293 crate::classify::TypeRegistry::compiled_shared(),
9294 DEFAULT_JOURNAL_CAPACITY_BYTES,
9295 );
9296 index.apply_ok(&Observation::new(vec![
9297 Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
9298 upsert("debug.log", EntryKind::File, file_attrs(10, 1)),
9299 ]));
9300 assert_eq!(index.is_ignored(Path::new("debug.log")).expect("observed"), Some(true));
9301 let mut oversized = crate::control::source_at_test_limit();
9302 oversized.push(b'a');
9303
9304 let outcome = index.apply_ok(&Observation::new(vec![
9305 upsert("ordinary.txt", EntryKind::File, file_attrs(1, 2)),
9306 Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: oversized },
9307 ]));
9308
9309 assert!(index.lookup(Path::new("ordinary.txt")).is_some(), "ordinary work commits");
9310 assert_eq!(index.total().files, 2);
9311 assert_eq!(
9312 index.is_ignored(Path::new("debug.log")).expect("observed"),
9313 Some(false),
9314 "the rules the refused source replaced no longer apply"
9315 );
9316 let changes = &outcome.commit.as_ref().expect("one commit").changes;
9317 let refused = Some(crate::control::ControlRefusalReason::Budget);
9318 assert!(changes.iter().any(|change| matches!(
9319 change,
9320 EffectiveChange::ControlUpdated { previous: Some(_), current: None, .. }
9321 )));
9322 assert!(changes.iter().any(|change| matches!(
9323 change,
9324 EffectiveChange::ControlRefusalUpdated { previous: None, current, .. }
9325 if *current == refused
9326 )));
9327 let crate::control::ControlCoverage::Observed(coverage) = index.control_coverage() else {
9328 panic!("an observing index reports observed coverage");
9329 };
9330 assert_eq!((coverage.applied, coverage.refused), (0, 1));
9331 assert_eq!(coverage.refusals[0].path, Path::new(".gitignore"));
9332
9333 let lifted = index.apply_ok(&Observation::new(vec![Op::ControlRemove {
9335 path: PathBuf::from(".gitignore"),
9336 }]));
9337 assert!(matches!(
9338 lifted.commit.as_ref().expect("lifting a refusal commits").changes.as_slice(),
9339 [EffectiveChange::ControlRefusalUpdated { previous, current: None, .. }]
9340 if *previous == refused
9341 ));
9342 assert_eq!(
9343 index.control_coverage(),
9344 crate::control::ControlCoverage::Observed(crate::control::ControlObservation {
9345 limits: crate::control::ControlLimits::default(),
9346 applied: 0,
9347 refused: 0,
9348 refusals: Vec::new(),
9349 })
9350 );
9351 }
9352
9353 #[test]
9360 fn a_batch_that_cannot_change_the_control_table_does_not_copy_it() {
9361 let projection_clones = |index: &mut Index, ops: Vec<Op>| {
9362 CONTROL_PROJECTION_CLONES.with(|clones| clones.set(0));
9363 let outcome = index.apply_ok(&Observation::new(ops));
9364 (CONTROL_PROJECTION_CLONES.with(std::cell::Cell::get), outcome)
9365 };
9366 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9367
9368 let (clones, _) = projection_clones(
9371 &mut index,
9372 vec![
9373 upsert("cold", EntryKind::Dir, file_attrs(0, 1)),
9374 upsert("cold/file.txt", EntryKind::File, file_attrs(1, 1)),
9375 ],
9376 );
9377 assert_eq!(clones, 0, "an empty table has nothing a structural batch can change");
9378
9379 let mut over_budget = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
9380 over_budget.push(b'\n');
9381 index.apply_ok(&Observation::new(vec![
9382 upsert("keep", EntryKind::Dir, file_attrs(0, 1)),
9383 upsert("keep/file.txt", EntryKind::File, file_attrs(3, 1)),
9384 upsert("vendor", EntryKind::Dir, file_attrs(0, 1)),
9385 Op::ControlUpsert { path: PathBuf::from(".gitignore"), source: b"*.log\n".to_vec() },
9386 Op::ControlUpsert {
9387 path: PathBuf::from("vendor/.gitignore"),
9388 source: over_budget.clone(),
9389 },
9390 ]));
9391 let coverage = index.control_coverage();
9392
9393 let (clones, outcome) = projection_clones(
9396 &mut index,
9397 vec![
9398 upsert("keep/file.txt", EntryKind::File, file_attrs(4, 1)),
9399 Op::ControlUpsert {
9400 path: PathBuf::from(".gitignore"),
9401 source: b"*.log\n".to_vec(),
9402 },
9403 Op::ControlUpsert { path: PathBuf::from("vendor/.gitignore"), source: over_budget },
9404 ],
9405 );
9406 assert_eq!(clones, 0, "no control op changes anything");
9407 assert_eq!(index.control_coverage(), coverage);
9408 assert!(!outcome.commit.expect("the file's size changed").changes.iter().any(
9409 |change| matches!(
9410 change,
9411 EffectiveChange::ControlUpdated { .. }
9412 | EffectiveChange::ControlRefusalUpdated { .. }
9413 )
9414 ));
9415
9416 let (clones, _) = projection_clones(
9418 &mut index,
9419 vec![Op::ControlRemove { path: PathBuf::from("vendor/.gitignore") }],
9420 );
9421 assert_eq!(clones, 1);
9422 assert_eq!(index.controls().expect("observed").refused_len(), 0);
9423 }
9424
9425 #[test]
9427 fn removing_a_subtree_lifts_the_refusals_beneath_it() {
9428 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
9429 let mut line = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
9430 line.push(b'\n');
9431 index.apply_ok(&Observation::new(vec![
9432 upsert("vendor", EntryKind::Dir, file_attrs(0, 1)),
9433 upsert("vendor/.gitignore", EntryKind::File, file_attrs(16_386, 1)),
9434 Op::ControlUpsert { path: PathBuf::from("vendor/.gitignore"), source: line },
9435 ]));
9436 assert_eq!(index.controls().expect("observed").refused_len(), 1);
9437
9438 let outcome =
9439 index.apply_ok(&Observation::new(vec![Op::Remove { path: PathBuf::from("vendor") }]));
9440
9441 assert_eq!(index.controls().expect("observed").refused_len(), 0);
9442 assert!(outcome.commit.expect("commit").changes.iter().any(|change| matches!(
9443 change,
9444 EffectiveChange::ControlRefusalUpdated { current: None, .. }
9445 )));
9446 }
9447
9448 #[test]
9449 fn one_sweep_reports_one_as_of_time_for_everything_it_verified() {
9450 let mut index = Index::new("/root");
9458 index.set_applying_source(Source::Cached, 1_000);
9459 index.apply_ok(&Observation::new(vec![
9460 Op::Upsert { path: "a".into(), kind: EntryKind::Dir, attrs: file_attrs(0, 1) },
9461 Op::Upsert {
9462 path: "a/kept.txt".into(),
9463 kind: EntryKind::File,
9464 attrs: file_attrs(1, 1),
9465 },
9466 Op::Upsert {
9467 path: "a/changed.txt".into(),
9468 kind: EntryKind::File,
9469 attrs: file_attrs(2, 2),
9470 },
9471 ]));
9472
9473 index.set_applying_source(Source::Revalidated, 2_000);
9475 index.begin_reconcile(Path::new("")).expect("begin reconciliation");
9476 index.apply_ok(&Observation::new(vec![
9477 Op::Upsert {
9478 path: "a/kept.txt".into(),
9479 kind: EntryKind::File,
9480 attrs: file_attrs(1, 1),
9481 },
9482 Op::Upsert {
9483 path: "a/changed.txt".into(),
9484 kind: EntryKind::File,
9485 attrs: file_attrs(9, 2),
9486 },
9487 ]));
9488 index
9489 .finish_reconcile(
9490 Path::new(""),
9491 0,
9492 true,
9493 &[],
9494 &[],
9495 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9496 )
9497 .expect("finish reconciliation");
9498
9499 let kept = index.provenance(Path::new("a/kept.txt")).expect("present");
9500 let changed = index.provenance(Path::new("a/changed.txt")).expect("present");
9501 assert!(kept.is_verified() && changed.is_verified(), "the sweep covered both");
9502 assert_eq!(
9503 kept.observed_at_ns, changed.observed_at_ns,
9504 "one sweep, one as-of time: {kept:?} vs {changed:?}"
9505 );
9506 }
9507
9508 #[test]
9509 fn withdrawn_trust_beats_a_verification_interval() {
9510 let mut index = Index::new("/root");
9515 index.set_applying_source(Source::Cached, 1_000);
9517 index.apply_baseline_ok(&Observation::new(vec![
9518 Op::Upsert { path: PathBuf::from("a"), kind: EntryKind::Dir, attrs: Attrs::default() },
9519 Op::Upsert {
9520 path: PathBuf::from("a/file.txt"),
9521 kind: EntryKind::File,
9522 attrs: Attrs { size: 1, ..Attrs::default() },
9523 },
9524 ]));
9525 assert_eq!(
9526 index.provenance(Path::new("a/file.txt")).expect("present").source,
9527 Source::Cached,
9528 "nothing has checked it yet"
9529 );
9530 index
9532 .finish_reconcile(
9533 Path::new(""),
9534 0,
9535 true,
9536 &[],
9537 &[],
9538 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9539 )
9540 .expect("finish reconciliation");
9541 let path = Path::new("a/file.txt");
9542 assert_eq!(
9543 index.provenance(path).expect("present").source,
9544 Source::Revalidated,
9545 "a completed sweep covers this path"
9546 );
9547
9548 index.mark_unfresh(Path::new("a"), Freshness::Stale);
9550 let provenance = index.provenance(path).expect("present");
9551 assert!(
9552 !provenance.is_verified(),
9553 "an invalidated path must not read as verified: {provenance:?}"
9554 );
9555 assert_eq!(
9556 provenance.status,
9557 Status::Complete,
9558 "withdrawing trust changes how far to believe the value, not how much of \
9559 the subtree it covers: the cached total still accounts for every entry \
9560 beneath this path, and reporting it as Partial would tell a consumer the \
9561 number is still being built when it is merely unverified"
9562 );
9563 }
9564
9565 #[test]
9566 fn verification_intervals_stay_bounded() {
9567 let mut index = Index::new("/root");
9570 for which in 0..(MAX_VERIFIED_INTERVALS * 2) {
9571 index
9572 .finish_reconcile(
9573 &PathBuf::from(format!("dir-{which}")),
9574 0,
9575 true,
9576 &[],
9577 &[],
9578 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9579 )
9580 .expect("finish reconciliation");
9581 }
9582 assert!(
9583 index.verified.len() <= MAX_VERIFIED_INTERVALS,
9584 "interval list grew to {}",
9585 index.verified.len()
9586 );
9587 }
9588
9589 #[test]
9590 fn retained_walk_issues_are_the_same_first_paths_for_every_arrival_order() {
9591 let make = |order: Vec<usize>| {
9592 order
9593 .into_iter()
9594 .map(|number| {
9595 crate::Error::io(
9596 PathBuf::from(format!("/root/file-{number:02}")),
9597 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
9598 )
9599 })
9600 .collect::<Vec<_>>()
9601 };
9602 let mut reverse_order: Vec<_> = (0..66).rev().collect();
9603 reverse_order.push(65);
9604 let mut shuffled_order: Vec<_> = (0..66).step_by(2).collect();
9605 shuffled_order.extend((0..66).skip(1).step_by(2));
9606 shuffled_order.push(65);
9607
9608 let mut reverse = Index::new("/root");
9609 let mut reverse_errors = make(reverse_order);
9610 reverse.record_walk_errors(&mut reverse_errors);
9611 let mut shuffled = Index::new("/root");
9612 let mut shuffled_errors = make(shuffled_order);
9613 shuffled.record_walk_errors(&mut shuffled_errors);
9614
9615 let reverse_paths: Vec<_> =
9616 reverse.issues().iter().map(|issue| issue.path.clone().expect("path")).collect();
9617 let shuffled_paths: Vec<_> =
9618 shuffled.issues().iter().map(|issue| issue.path.clone().expect("path")).collect();
9619 assert_eq!(reverse_paths, shuffled_paths);
9620 assert_eq!(reverse_paths.len(), MAX_RETAINED_ISSUES);
9621 assert_eq!(reverse_paths.first().map(PathBuf::as_path), Some(Path::new("file-00")));
9622 assert_eq!(reverse_paths.last().map(PathBuf::as_path), Some(Path::new("file-63")));
9623 assert_eq!(reverse.state().issues.omitted, 2);
9624 assert_eq!(shuffled.state().issues.omitted, 2);
9625 assert_eq!(reverse.issue_epochs.len(), reverse.issues.len());
9626 assert_eq!(shuffled.issue_epochs.len(), shuffled.issues.len());
9627 }
9628
9629 #[test]
9630 fn repeated_partial_root_pass_replaces_bounded_issues_and_omitted_count() {
9631 let root = Path::new("/root");
9632 let mut index = Index::new(root);
9633 let run = |index: &mut Index, range: std::ops::Range<usize>| {
9634 let errors: Vec<_> = range
9635 .clone()
9636 .map(|number| {
9637 crate::Error::io(
9638 root.join(format!("file-{number:02}")),
9639 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
9640 )
9641 })
9642 .collect();
9643 let failed: Vec<_> =
9644 range.map(|number| PathBuf::from(format!("file-{number:02}"))).collect();
9645 let (started, _) = index.begin_reconcile(Path::new("")).expect("begin");
9646 index
9647 .finish_reconcile(
9648 Path::new(""),
9649 started,
9650 false,
9651 &[],
9652 &failed,
9653 ReconcileErrors { errors: &errors, terminal: None, disproves_old: true },
9654 )
9655 .expect("finish");
9656 };
9657
9658 run(&mut index, 0..66);
9659 assert_eq!(index.state().issues.omitted, 2);
9660 run(&mut index, 10..76);
9661
9662 assert_eq!(index.state().issues.omitted, 2, "a retry replaces the old omission count");
9663 assert_eq!(index.omitted_issue_epochs.len(), 1, "sequential failures use one bucket");
9664 assert_eq!(index.issues().len(), crate::MAX_RETAINED_ISSUES);
9665 assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("file-10")));
9666 assert_eq!(index.issues()[63].path.as_deref(), Some(Path::new("file-73")));
9667 }
9668
9669 #[test]
9670 fn partial_pass_preserves_an_issue_published_after_it_began() {
9671 let root = Path::new("/root");
9672 let mut index = Index::new(root);
9673 let (started, _) = index.begin_reconcile(Path::new("")).expect("begin");
9674 index.mark_unfresh(Path::new("concurrent"), Freshness::Stale);
9675 index.retain_issue(Issue::from_error_under(
9676 root,
9677 &crate::Error::io(
9678 root.join("concurrent"),
9679 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "concurrent"),
9680 ),
9681 ));
9682 let pass_error = crate::Error::io(
9683 root.join("pass"),
9684 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "pass"),
9685 );
9686
9687 index
9688 .finish_reconcile(
9689 Path::new(""),
9690 started,
9691 false,
9692 &[],
9693 &[PathBuf::from("pass")],
9694 ReconcileErrors { errors: &[pass_error], terminal: None, disproves_old: true },
9695 )
9696 .expect("finish");
9697
9698 let paths: Vec<_> =
9699 index.issues().iter().filter_map(|issue| issue.path.as_deref()).collect();
9700 assert_eq!(paths, [Path::new("concurrent"), Path::new("pass")]);
9701 }
9702
9703 #[test]
9704 fn older_root_closer_preserves_newer_pass_omissions_and_partial_coverage() {
9705 let root = Path::new("/root");
9706 let mut index = Index::new(root);
9707 for number in 0..66 {
9708 let path = PathBuf::from(format!("a-{number:02}"));
9709 index.retain_issue(Issue::observation_gap(
9710 &path,
9711 crate::InvalidateReason::WatchOverflow,
9712 ));
9713 }
9714 index.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
9715 assert_eq!(index.state.issues.omitted, 2);
9716
9717 let (older, _) = index.begin_reconcile(Path::new("")).expect("begin older pass");
9718 let (newer, _) = index.begin_reconcile(Path::new("")).expect("begin newer pass");
9719 let newer_errors = ["z-one", "z-two"].map(|path| {
9720 crate::Error::io(
9721 root.join(path),
9722 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
9723 )
9724 });
9725 index
9726 .finish_reconcile(
9727 Path::new(""),
9728 newer,
9729 false,
9730 &[],
9731 &[PathBuf::from("z-one"), PathBuf::from("z-two")],
9732 ReconcileErrors { errors: &newer_errors, terminal: None, disproves_old: true },
9733 )
9734 .expect("finish newer pass");
9735 assert_eq!(index.state.issues.omitted, 2);
9736
9737 index
9738 .finish_reconcile(
9739 Path::new(""),
9740 older,
9741 true,
9742 &[],
9743 &[],
9744 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9745 )
9746 .expect("finish older pass");
9747
9748 assert_eq!(index.state.issues.omitted, 2, "the older closer cannot erase newer omissions");
9749 assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
9750 }
9751
9752 #[test]
9753 fn an_unvisited_aborted_scope_does_not_disprove_its_old_issue() {
9754 let root = Path::new("/root");
9755 let mut index = Index::new(root);
9756 index.retain_issue(Issue::from_error_under(
9757 root,
9758 &crate::Error::io(
9759 root.join("later/blocked"),
9760 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "old failure"),
9761 ),
9762 ));
9763 let (started, _) = index.begin_reconcile(Path::new("later")).expect("begin later scope");
9764
9765 index
9766 .finish_reconcile(
9767 Path::new("later"),
9768 started,
9769 false,
9770 &[],
9771 &[],
9772 ReconcileErrors { errors: &[], terminal: None, disproves_old: false },
9773 )
9774 .expect("close skipped scope");
9775
9776 assert_eq!(index.issues().len(), 1);
9777 assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("later/blocked")));
9778 }
9779 #[test]
9780 fn older_pass_cannot_publish_errors_after_newer_clean_verification() {
9781 let root = Path::new("/root");
9782 let mut index = Index::new(root);
9783 let (older, _) = index.begin_reconcile(Path::new("")).expect("begin older pass");
9784 let (newer, _) = index.begin_reconcile(Path::new("")).expect("begin newer pass");
9785 index
9786 .finish_reconcile(
9787 Path::new(""),
9788 newer,
9789 true,
9790 &[],
9791 &[],
9792 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9793 )
9794 .expect("finish newer pass");
9795 let stale_error = crate::Error::io(
9796 root.join("stale"),
9797 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "older failure"),
9798 );
9799
9800 index
9801 .finish_reconcile(
9802 Path::new(""),
9803 older,
9804 false,
9805 &[],
9806 &[PathBuf::from("stale")],
9807 ReconcileErrors { errors: &[stale_error], terminal: None, disproves_old: true },
9808 )
9809 .expect("finish superseded older pass");
9810
9811 assert!(index.issues().is_empty());
9812 assert_eq!(index.state.coverage, Coverage::Complete);
9813 assert_eq!(index.state.freshness, Freshness::Fresh);
9814 }
9815
9816 #[test]
9817 fn newer_unchanged_verification_refuses_an_older_conditional_fact() {
9818 let mut index = Index::new("/root");
9819 index
9820 .apply(&Observation::new(vec![Op::Upsert {
9821 path: PathBuf::from("same"),
9822 kind: EntryKind::File,
9823 attrs: file_attrs(1, 1),
9824 }]))
9825 .expect("fixture");
9826 let handle = IndexHandle::new(index);
9827 let baseline = handle.expectation(Path::new("same")).expect("baseline");
9828 let (older, _) = handle.begin_reconcile(Path::new("")).expect("begin older pass");
9829 let (newer, _) = handle.begin_reconcile(Path::new("")).expect("begin newer pass");
9830 handle
9831 .finish_reconcile(
9832 Path::new(""),
9833 newer,
9834 true,
9835 &[],
9836 &[],
9837 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9838 )
9839 .expect("finish unchanged newer pass");
9840
9841 let stale = handle
9842 .apply_reconcile(
9843 older,
9844 &Observation::from_ops(vec![ObservationOp::if_state(
9845 Op::Remove { path: PathBuf::from("same") },
9846 baseline,
9847 )]),
9848 )
9849 .expect("arbitrate older fact");
9850
9851 assert_eq!(stale.stats.stale, 1);
9852 assert!(stale.commit.is_none());
9853 assert!(handle.attrs(Path::new("same")).expect("attrs").is_some());
9854 }
9855
9856 #[test]
9857 fn newer_disjoint_verification_does_not_refuse_an_older_fact() {
9858 let mut index = Index::new("/root");
9859 index
9860 .apply(&Observation::new(vec![
9861 Op::Upsert {
9862 path: PathBuf::from("a"),
9863 kind: EntryKind::File,
9864 attrs: file_attrs(1, 1),
9865 },
9866 Op::Upsert {
9867 path: PathBuf::from("b"),
9868 kind: EntryKind::File,
9869 attrs: file_attrs(1, 1),
9870 },
9871 ]))
9872 .expect("fixture");
9873 let handle = IndexHandle::new(index);
9874 let baseline = handle.expectation(Path::new("a")).expect("baseline");
9875 let (older, _) = handle.begin_reconcile(Path::new("a")).expect("begin a");
9876 let (newer, _) = handle.begin_reconcile(Path::new("b")).expect("begin b");
9877 handle
9878 .finish_reconcile(
9879 Path::new("b"),
9880 newer,
9881 true,
9882 &[],
9883 &[],
9884 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9885 )
9886 .expect("finish b");
9887
9888 let applied = handle
9889 .apply_reconcile(
9890 older,
9891 &Observation::from_ops(vec![ObservationOp::if_state(
9892 Op::Remove { path: PathBuf::from("a") },
9893 baseline,
9894 )]),
9895 )
9896 .expect("apply disjoint a fact");
9897
9898 assert_eq!(applied.stats.removed, 1);
9899 assert_eq!(applied.stats.stale, 0);
9900 assert!(handle.attrs(Path::new("a")).expect("attrs").is_none());
9901 }
9902 #[test]
9903 fn newer_child_verification_preserves_older_sibling_failure() {
9904 let root = Path::new("/root");
9905 let mut index = Index::new(root);
9906 index.apply_ok(&Observation::new(
9907 ["a", "a/old", "b", "b/blocked", "healthy"]
9908 .map(|path| Op::Upsert {
9909 path: PathBuf::from(path),
9910 kind: EntryKind::Dir,
9911 attrs: Attrs::default(),
9912 })
9913 .to_vec(),
9914 ));
9915 index.set_initial_scan_freshness(&[]);
9916 let (older, _) = index.begin_reconcile(Path::new("")).expect("older root");
9917 let (newer, _) = index.begin_reconcile(Path::new("a")).expect("newer child");
9918 index
9919 .finish_reconcile(
9920 Path::new("a"),
9921 newer,
9922 true,
9923 &[],
9924 &[],
9925 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
9926 )
9927 .expect("verify a");
9928 let errors = ["a/old", "b/blocked"].map(|path| {
9929 crate::Error::io(
9930 root.join(path),
9931 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed read"),
9932 )
9933 });
9934 index
9935 .finish_reconcile(
9936 Path::new(""),
9937 older,
9938 false,
9939 &[],
9940 &[PathBuf::from("a/old"), PathBuf::from("b/blocked")],
9941 ReconcileErrors { errors: &errors, terminal: None, disproves_old: true },
9942 )
9943 .expect("finish older root");
9944 for path in ["", "a", "a/old", "b", "healthy"] {
9945 assert_eq!(index.directory_complete(Path::new(path)), Some(true), "{path}");
9946 }
9947 assert_eq!(index.directory_complete(Path::new("b/blocked")), Some(false));
9948 assert_eq!(index.issues().len(), 1);
9949 assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("b/blocked")));
9950 assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
9951 assert_eq!(index.freshness_at(Path::new("a")), Freshness::Fresh);
9952 assert_eq!(index.freshness_at(Path::new("b/blocked")), Freshness::Partial);
9953 }
9954 #[test]
9955 fn failure_published_after_a_newer_pass_began_keeps_its_issue_with_its_mark() {
9956 let root = Path::new("/root");
9962 let mut index = Index::new(root);
9963 index.apply_ok(&Observation::new(
9964 ["x", "x/deep", "healthy"]
9965 .map(|path| Op::Upsert {
9966 path: PathBuf::from(path),
9967 kind: EntryKind::Dir,
9968 attrs: Attrs::default(),
9969 })
9970 .to_vec(),
9971 ));
9972 index.set_initial_scan_freshness(&[]);
9973 let (older_root, _) = index.begin_reconcile(Path::new("")).expect("older root");
9974 let (newer_child, _) = index.begin_reconcile(Path::new("x")).expect("newer child");
9975 let error = crate::Error::io(
9976 root.join("x"),
9977 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed read"),
9978 );
9979 index
9980 .finish_reconcile(
9981 Path::new(""),
9982 older_root,
9983 false,
9984 &[],
9985 &[PathBuf::from("x")],
9986 ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
9987 )
9988 .expect("older root fails on x");
9989 assert_eq!(index.freshness_at(Path::new("x")), Freshness::Partial);
9990 assert_eq!(index.issues().len(), 1);
9991
9992 index
9993 .finish_reconcile(
9994 Path::new("x"),
9995 newer_child,
9996 true,
9997 &[],
9998 &[],
9999 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10000 )
10001 .expect("newer child verifies clean");
10002
10003 let explained = !index.issues().is_empty();
10004 let partial = index.freshness_at(Path::new("x")) == Freshness::Partial;
10005 assert_eq!(
10006 partial,
10007 explained,
10008 "a surviving partial mark and its issue must be kept or dropped together: \
10009 partial={partial}, issues={:?}",
10010 index.issues()
10011 );
10012 assert!(partial, "the mark minted after the child pass began is the newer claim");
10013 assert_eq!(index.issues()[0].path.as_deref(), Some(Path::new("x")));
10014 assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10015 assert_eq!(index.state.freshness, Freshness::Partial);
10016 let request = crate::query::Request::new(
10017 crate::query::Basis::held_by(&index),
10018 crate::query::Query::default(),
10019 std::time::UNIX_EPOCH,
10020 );
10021 let status = crate::query::TreeStatus::of(&index, &request);
10022 assert!(!status.complete);
10023 assert_eq!(status.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10024 assert_eq!(status.errors.len(), 1, "an incomplete status names its cause");
10025 }
10026 #[test]
10027 fn cold_scan_failure_does_not_verify_unknown_descendants_or_unscoped_work() {
10028 let mut index = Index::new("/root");
10029 index.apply_ok(&Observation::new(
10030 ["blocked", "blocked/nested", "healthy"]
10031 .map(|path| Op::Upsert {
10032 path: PathBuf::from(path),
10033 kind: EntryKind::Dir,
10034 attrs: Attrs::default(),
10035 })
10036 .to_vec(),
10037 ));
10038 let error = crate::Error::io(
10039 PathBuf::from("/root/blocked"),
10040 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed listing"),
10041 );
10042 index.set_initial_scan_freshness(&[error]);
10043 assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
10044 assert_eq!(index.directory_complete(Path::new("")), Some(true));
10045 assert_eq!(index.freshness_at(Path::new("")), Freshness::Partial);
10046 for path in ["blocked", "blocked/nested"] {
10047 assert_eq!(index.directory_complete(Path::new(path)), Some(false), "{path}");
10048 assert_eq!(index.freshness_at(Path::new(path)), Freshness::Partial, "{path}");
10049 }
10050 index.set_initial_scan_freshness(&[crate::Error::Snapshot("unscoped failure".into())]);
10051 assert_eq!(index.directory_complete(Path::new("healthy")), Some(false));
10052 assert_eq!(index.freshness_at(Path::new("healthy")), Freshness::Partial);
10053 }
10054
10055 #[test]
10056 fn unscoped_failure_publishes_listing_withdrawal_when_root_state_is_unchanged() {
10057 let mut index = Index::new("/root");
10058 index.apply_ok(&Observation::new(vec![Op::Upsert {
10059 path: PathBuf::from("healthy"),
10060 kind: EntryKind::Dir,
10061 attrs: Attrs::default(),
10062 }]));
10063 index.set_initial_scan_freshness(&[]);
10064 index.mark_unfresh(Path::new("elsewhere"), Freshness::Partial);
10065 index.state.freshness = Freshness::Partial;
10066 index.state.coverage = Coverage::Partial(CoverageReason::Inaccessible);
10067 let error = crate::Error::Snapshot("unscoped failure".into());
10068 index.retain_issue(Issue::from_error_under(&index.root_path, &error));
10069 let progress = index.state.progress;
10070 let (epoch, _) = index.begin_reconcile(Path::new("")).expect("begin partial root");
10071 let before = index.state;
10072 let clock = index.clock;
10073 let finished = index
10074 .finish_reconcile(
10075 Path::new(""),
10076 epoch,
10077 false,
10078 &[],
10079 &[],
10080 ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
10081 )
10082 .expect("finish failure");
10083 assert_eq!(index.state, before, "aggregate root state remains identical");
10084 assert_eq!(index.state.progress, progress, "discovery progress is cumulative");
10085 assert_eq!(index.directory_complete(Path::new("healthy")), Some(false));
10086 let commit = finished.commit.expect("withdrawal must publish even without another effect");
10087 assert!(index.clock > clock);
10088 assert_eq!(commit.clock, index.clock);
10089 assert!(
10090 commit
10091 .state
10092 .iter()
10093 .all(|effect| matches!(effect, StateTransition::DirectoryIncomplete { .. }))
10094 );
10095 assert!(
10096 commit
10097 .state
10098 .contains(&StateTransition::DirectoryIncomplete { path: PathBuf::from("healthy") })
10099 );
10100 assert!(commit.impact.dirty_paths.contains(&PathBuf::from("healthy")));
10101 }
10102
10103 #[test]
10104 fn omitted_failed_entry_withdraws_parent_listing_without_tainting_siblings() {
10105 let mut index = Index::new("/root");
10106 index.apply_ok(&Observation::new(vec![Op::Upsert {
10107 path: PathBuf::from("healthy"),
10108 kind: EntryKind::Dir,
10109 attrs: Attrs::default(),
10110 }]));
10111 let error = crate::Error::io(
10112 PathBuf::from("/root/missing"),
10113 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "metadata failed"),
10114 );
10115 index.set_initial_scan_freshness(&[error]);
10116 assert_eq!(index.directory_complete(Path::new("")), Some(false));
10117 assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
10118 index.set_initial_scan_freshness(&[]);
10119 let (epoch, _) = index.begin_reconcile(Path::new("")).expect("begin root");
10120 let error = crate::Error::io(
10121 PathBuf::from("/root/missing"),
10122 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "metadata failed"),
10123 );
10124 index
10125 .finish_reconcile(
10126 Path::new(""),
10127 epoch,
10128 false,
10129 &[],
10130 &[PathBuf::from("missing")],
10131 ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
10132 )
10133 .expect("partial root");
10134 assert_eq!(index.directory_complete(Path::new("")), Some(false));
10135 assert_eq!(index.directory_complete(Path::new("healthy")), Some(true));
10136 }
10137
10138 #[test]
10139 fn complete_older_root_does_not_verify_a_newer_failed_child() {
10140 let root = Path::new("/root");
10141 let mut index = Index::new(root);
10142 let (older, _) = index.begin_reconcile(Path::new("")).expect("older root");
10143 let (newer, _) = index.begin_reconcile(Path::new("child")).expect("newer child");
10144 let error = crate::Error::io(
10145 root.join("child"),
10146 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "new failure"),
10147 );
10148 index
10149 .finish_reconcile(
10150 Path::new("child"),
10151 newer,
10152 false,
10153 &[],
10154 &[PathBuf::from("child")],
10155 ReconcileErrors { errors: &[error], terminal: None, disproves_old: true },
10156 )
10157 .expect("failed child");
10158 let finish = index
10159 .finish_reconcile(
10160 Path::new(""),
10161 older,
10162 true,
10163 &[],
10164 &[],
10165 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10166 )
10167 .expect("complete older walk");
10168 assert_eq!(index.issues().len(), 1);
10169 assert_eq!(index.freshness_at(Path::new("child")), Freshness::Partial);
10170 assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10171 assert!(!finish.commit.iter().flat_map(|commit| commit.state.iter()).any(|state| {
10172 matches!(state, StateTransition::Verified { path } if path.as_os_str().is_empty())
10173 }));
10174 }
10175
10176 #[test]
10177 fn reconciliation_scope_budget_preserves_issues_and_newer_facts() {
10178 let mut index = Index::new("/root");
10179 index.apply_ok(&Observation::new(vec![Op::Upsert {
10180 path: PathBuf::from("kept"),
10181 kind: EntryKind::File,
10182 attrs: file_attrs(1, 1),
10183 }]));
10184 index.retain_issue(Issue::provider_failure(
10185 Some(Path::new("unvisited")),
10186 "earlier failure".into(),
10187 ));
10188 let (older, _) = index.begin_reconcile(Path::new("")).expect("older pass");
10189 let budget = index.active_reconciles[&older].scope_budget;
10190 assert_eq!(budget, 2, "root and kept file define the evidence budget");
10191 for number in 0..100 {
10192 let path = PathBuf::from(format!("missing-{number}"));
10193 let (newer, _) = index.begin_reconcile(&path).expect("newer pass");
10194 index
10195 .finish_reconcile(
10196 &path,
10197 newer,
10198 true,
10199 &[],
10200 &[],
10201 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10202 )
10203 .expect("newer verification");
10204 if let ReconcileEvidence::Scopes(scopes) = &index.active_reconciles[&older].evidence {
10205 assert!(scopes.len() <= budget);
10206 }
10207 }
10208 assert!(matches!(index.active_reconciles[&older].evidence, ReconcileEvidence::Retry));
10209 index.apply_ok(&Observation::new(vec![Op::Upsert {
10210 path: PathBuf::from("kept"),
10211 kind: EntryKind::File,
10212 attrs: file_attrs(9, 2),
10213 }]));
10214 let finished = index
10215 .finish_reconcile(
10216 Path::new(""),
10217 older,
10218 true,
10219 &[],
10220 &[],
10221 ReconcileErrors { errors: &[], terminal: None, disproves_old: true },
10222 )
10223 .expect("close interrupted pass");
10224 assert!(finished.retry);
10225 assert!(finished.commit.is_some());
10226 assert_eq!(index.total_scalars().bytes, 9);
10227 assert!(
10228 index
10229 .issues()
10230 .iter()
10231 .any(|issue| issue.path.as_deref() == Some(Path::new("unvisited")))
10232 );
10233 assert_eq!(index.state.coverage, Coverage::Partial(CoverageReason::Inaccessible));
10234 assert!(index.active_reconciles.is_empty(), "closed passes retain no shadow history");
10235 }
10236}