fdu_core/engine_contract.rs
1//! The observation and commit contract shared by every producer and consumer.
2//!
3//! The walker, revalidator, and watch layer produce [`Observation`] batches. The index
4//! arbitrates their preconditions, removes no-ops, and creates a [`Commit`] only after
5//! every effective fact, reducer, and state change is known. The journal retains those
6//! commits as the only source of change truth.
7//!
8//! Three properties are load-bearing, and the rest of the crate depends on them:
9//!
10//! - **Observations carry truth, not hints.** A producer stats before it emits. Filesystem
11//! events on most platforms carry no metadata, so a raw event is never a delta.
12//! - **Conditional observations cannot overwrite newer state.** Revalidation attaches
13//! state plus generation and revision guards from the start of its check. If another
14//! producer commits a conflicting change first, arbitration rejects the delayed
15//! observation.
16//! - **Commits contain changes, not attempts.** No-ops and stale observations do not
17//! advance the public clock or consume journal space.
18
19use std::path::{Path, PathBuf};
20
21use crate::stored_state::EntryScope;
22
23/// A monotonic logical clock, in the spirit of Watchman's clockspec but process-local.
24///
25/// Every [`Commit`] is stamped, so a consumer can ask "what changed since C?"
26/// rather than having to hold a live subscription.
27#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Default, Hash)]
28pub struct Clock(pub u64);
29
30impl Clock {
31 /// The clock before anything has been applied.
32 pub const ZERO: Clock = Clock(0);
33
34 /// The next clock value, or `None` when the process-local clock is exhausted.
35 #[inline]
36 #[must_use]
37 pub const fn checked_next(self) -> Option<Clock> {
38 match self.0.checked_add(1) {
39 Some(value) => Some(Clock(value)),
40 None => None,
41 }
42 }
43}
44
45/// What kind of filesystem entry a record describes.
46///
47/// The numeric values reach the snapshot format, so they are pinned: never renumber a
48/// variant, only append.
49#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
50#[repr(u8)]
51pub enum EntryKind {
52 /// Regular file.
53 File = 0,
54 /// Directory.
55 Dir = 1,
56 /// Symbolic link retained without following it.
57 Symlink = 2,
58 /// Other filesystem object such as a socket or device.
59 Other = 3,
60}
61
62impl EntryKind {
63 /// Recover a kind from its pinned on-disk value.
64 pub const fn from_u8(raw: u8) -> Option<Self> {
65 match raw {
66 0 => Some(Self::File),
67 1 => Some(Self::Dir),
68 2 => Some(Self::Symlink),
69 3 => Some(Self::Other),
70 _ => None,
71 }
72 }
73
74 #[inline]
75 /// Whether this kind is a directory.
76 pub const fn is_dir(self) -> bool {
77 matches!(self, Self::Dir)
78 }
79}
80
81/// The stat fields an entry contributes to roll-ups, plus the ones that identify it.
82#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Hash)]
83pub struct Attrs {
84 /// Apparent size in bytes.
85 pub size: u64,
86 /// Allocated size in bytes (block count x 512 on Unix). Falls back to `size` on
87 /// platforms that do not report block counts.
88 pub allocated: u64,
89 /// Modification time, nanoseconds since the Unix epoch.
90 pub mtime_ns: i64,
91 /// Inode change time, nanoseconds since the Unix epoch. Zero where unavailable.
92 pub ctime_ns: i64,
93 /// Inode number. Zero where unavailable.
94 pub inode: u64,
95 /// Device number. Zero where unavailable.
96 pub dev: u64,
97}
98
99impl Attrs {
100 /// The change-detection fingerprint for these attributes.
101 #[inline]
102 pub const fn fingerprint(&self) -> Fingerprint {
103 Fingerprint {
104 size: self.size,
105 mtime_ns: self.mtime_ns,
106 ctime_ns: self.ctime_ns,
107 inode: self.inode,
108 dev: self.dev,
109 }
110 }
111}
112
113/// The fingerprint used to decide whether an entry really changed.
114///
115/// Size and mtime alone are not enough. mtime is user-settable, and some applications
116/// roll it back after modifying a file; ctime is kernel-controlled and cannot be set
117/// directly. Borg keys on ctime/size/inode and restic requires both mtime and ctime to
118/// match, and this engine follows them: an index that keys purely on mtime is trusting a
119/// value userspace can forge.
120#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Hash)]
121pub struct Fingerprint {
122 /// Apparent file size.
123 pub size: u64,
124 /// Modification time in nanoseconds since the Unix epoch.
125 pub mtime_ns: i64,
126 /// Inode-change time in nanoseconds since the Unix epoch.
127 pub ctime_ns: i64,
128 /// Platform inode or file identity component.
129 pub inode: u64,
130 /// Platform device or volume identity component.
131 pub dev: u64,
132}
133
134/// Semantic inputs that decide which entries and derived values belong in an index.
135///
136/// Operational settings such as producer batch size are intentionally absent. A
137/// snapshot may be reused only when this value matches exactly.
138#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
139pub struct ScanScope {
140 /// Maximum retained relative depth, or unlimited when absent.
141 pub max_depth: Option<usize>,
142 /// Whether directory symlinks are followed.
143 pub follow_symlinks: bool,
144 /// Whether traversal stays on the root filesystem.
145 pub one_filesystem: bool,
146 /// Identity of leading-dot component admission and its exact-name allowlist.
147 pub hidden_fingerprint: u64,
148 /// Whether filesystem objects outside files, directories, and symlinks are excluded.
149 pub exclude_special: bool,
150 /// Ignored population retained by this scan.
151 pub population: crate::query::IgnoredEntries,
152 /// Identity of the compiled ignore policy.
153 pub ignore_rules_fingerprint: u64,
154 /// Identity of the compiled type-classification policy.
155 pub type_rules_fingerprint: u64,
156 /// Identity of the enabled reducer set.
157 pub reducers_fingerprint: u64,
158}
159
160/// Answer-semantics identity derived from validated classification and reducer rules.
161#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
162pub struct SemanticIdentity {
163 /// Identity of the compiled ignore policy.
164 pub ignore_rules_fingerprint: u64,
165 /// Identity of the compiled type-classification policy.
166 pub type_rules_fingerprint: u64,
167 /// Identity of the enabled reducer set.
168 pub reducers_fingerprint: u64,
169}
170
171/// Opaque identity of one opened-root lifetime.
172///
173/// This process-local value prevents a cursor or expected version from one open from
174/// being accepted by another whose sequence happens to match. It is not a credential
175/// and is never persisted.
176#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
177pub struct SessionId(pub(crate) u64);
178
179impl SessionId {
180 /// Return the process-local opaque value for a language or wire adapter.
181 ///
182 /// This value is an identity, not a credential. Consumers should preserve it exactly
183 /// and must not infer ordering or lifetime from it.
184 pub const fn opaque(self) -> u64 {
185 self.0
186 }
187
188 /// Recover an opaque session identity previously returned by [`Self::opaque`].
189 ///
190 /// Zero is reserved and never identifies a live opened root.
191 pub const fn from_opaque(value: u64) -> Option<Self> {
192 if value == 0 { None } else { Some(Self(value)) }
193 }
194}
195
196/// Identity and exact sequence of one committed opened-root state.
197#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
198pub struct EngineVersion {
199 /// Live owner that minted this version.
200 pub session: SessionId,
201 /// Exact committed index sequence observed by the read.
202 pub sequence: Clock,
203 /// Filesystem-fact identity bound when the root was opened.
204 pub scope: EntryScope,
205 /// Classification and reducer identity bound when the root was opened.
206 pub semantics: SemanticIdentity,
207}
208
209impl ScanScope {
210 /// Whether an index of this scope observed `.gitignore` control state.
211 ///
212 /// False when the scan ran with [`ScanConfig::read_controls`](crate::ScanConfig) off:
213 /// no control file was read and no entry was classified. Such an index cannot say
214 /// whether an entry is ignored, so
215 /// [`Index::is_ignored`](crate::Index::is_ignored),
216 /// [`Index::controls`](crate::Index::controls), and the partition accessors
217 /// ([`Index::partition_total`](crate::Index::partition_total) and its per-directory
218 /// forms) refuse with [`Error::ControlStateNotObserved`] rather than answer "not
219 /// ignored" for everything, and a shared
220 /// [`ChildSnapshot`](crate::ChildSnapshot) carries no ignore bit or partitions.
221 ///
222 /// The ignore-rules fingerprint is derived from the scope's
223 /// [`ControlTierIdentity`](crate::ControlTierIdentity), which reserves zero for a tier
224 /// that observed nothing; a caller holding the identity asks it directly.
225 pub const fn observes_controls(self) -> bool {
226 self.ignore_rules_fingerprint != 0
227 }
228
229 /// The part of this validated scope that determines retained filesystem facts.
230 pub const fn entry_scope(self) -> EntryScope {
231 EntryScope {
232 max_depth: self.max_depth,
233 follow_symlinks: self.follow_symlinks,
234 one_filesystem: self.one_filesystem,
235 hidden_fingerprint: self.hidden_fingerprint,
236 exclude_special: self.exclude_special,
237 population: self.population,
238 control_fingerprint: if matches!(self.population, crate::query::IgnoredEntries::Include)
239 {
240 0
241 } else {
242 self.ignore_rules_fingerprint
243 },
244 }
245 }
246
247 /// The part of this validated scope that determines classifications and roll-ups.
248 pub const fn semantic_identity(self) -> SemanticIdentity {
249 SemanticIdentity {
250 ignore_rules_fingerprint: self.ignore_rules_fingerprint,
251 type_rules_fingerprint: self.type_rules_fingerprint,
252 reducers_fingerprint: self.reducers_fingerprint,
253 }
254 }
255}
256
257/// Where a value came from, so a consumer can trade speed for certainty knowingly.
258///
259/// Ordered weakest-last: comparing two sources yields the one to trust less, which is
260/// what a roll-up needs when combining a subtree.
261#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash, Default)]
262pub enum Source {
263 /// Observed from the filesystem by this process.
264 #[default]
265 Scanned,
266 /// Loaded from a snapshot and re-verified by a fresh stat this session.
267 Revalidated,
268 /// Loaded from a snapshot; a change journal reported nothing touching this subtree
269 /// since the cursor, and nothing has re-checked it.
270 ///
271 /// Named for what actually happened — a journal *scoped* the work — rather than for
272 /// what a reader might wish it meant. Nothing here was confirmed against the
273 /// filesystem: a scoped revalidation stats the paths the journal names and does not
274 /// stat the rest, so this value rests on the journal having been complete.
275 ///
276 /// It is deliberately weaker than [`Self::Revalidated`] because that assumption is
277 /// known to fail. macOS `FSEvents` will report `HistoryDone` after silently dropping
278 /// history, with no degradation flag, which means a journal answer can be wrong
279 /// without announcing it. Journal-assisted revalidation therefore bounds exposure
280 /// with a maximum age and a periodic full sweep; those are risk controls, not
281 /// proofs, and they do not make any individual answer here verified.
282 JournalScoped,
283 /// Loaded from a snapshot and not re-checked since.
284 Cached,
285}
286
287/// Whether a value covers everything beneath its path.
288///
289/// This is the **structural coverage** axis, and only that. How far to *trust* what is
290/// covered is [`Source`], and the two are deliberately independent: a cached value
291/// covers the whole subtree but may be out of date, while a half-built one covers less
292/// than the subtree but every byte in it was just observed.
293///
294/// An enum rather than a boolean because coverage will gain more ways to be incomplete
295/// — truncated by a cap, cancelled, failed — and ordered worst-last so roll-ups combine
296/// by taking the maximum. Those variants are not here yet; see the progressive-results
297/// plan for the lifecycle they belong to.
298#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash, Default)]
299#[non_exhaustive]
300pub enum Status {
301 /// The value accounts for everything beneath this path that is in scope.
302 #[default]
303 Complete,
304 /// The value does not account for everything beneath this path.
305 ///
306 /// **Not a promise of monotonicity.** A value being built by an additive walk only
307 /// grows, but one left incomplete by reconciliation errors can move either way once
308 /// the missing part is read. Monotonicity is a property of the *producer* that is
309 /// running, not of this status, and a consumer that needs it must know a walk is in
310 /// progress rather than infer it from here.
311 Partial,
312}
313
314/// Everything a consumer needs to decide how far to trust one value.
315///
316/// A *view* type, built on demand rather than stored: the index keeps one [`Source`]
317/// byte per entry and its observation timestamps once, because on a tree of millions
318/// of entries the timestamps are shared by nearly all of them and a per-entry struct
319/// would cost more memory than the information is worth.
320///
321/// The three facts are independent on purpose, because they answer different
322/// questions. [`Status`] asks how much of the subtree the number covers;
323/// [`Source`] asks how far to trust what it covers; `observed_at_ns` asks when.
324/// A [`Status::Complete`] but [`Source::Cached`] value is a point estimate that may
325/// move either way and reads as "about 3.2 GB, as of two minutes ago", while a
326/// [`Status::Partial`] value is missing part of its subtree and reads as "3.2 GB so
327/// far". Collapsing them would make a shrinking number look like a defect.
328///
329/// Note that "3.2 GB so far" is only a *lower bound that grows* while an additive walk
330/// is running. See [`Status::Partial`]: the status records coverage, not direction.
331#[derive(Clone, Copy, PartialEq, Eq, Debug)]
332pub struct Provenance {
333 /// Where the value came from.
334 pub source: Source,
335 /// When the underlying filesystem observation was made, in nanoseconds since the
336 /// Unix epoch. For [`Source::Cached`] this is when the snapshot captured it — the
337 /// "as of" a consumer displays. Zero when unknown.
338 pub observed_at_ns: i64,
339 /// How settled the value is.
340 pub status: Status,
341}
342
343impl Source {
344 /// Whether a value from this source was checked against the filesystem during
345 /// this session.
346 pub const fn is_verified(self) -> bool {
347 matches!(self, Self::Scanned | Self::Revalidated)
348 }
349}
350
351impl Provenance {
352 /// Freshly observed by this process, complete.
353 pub const fn scanned(observed_at_ns: i64) -> Self {
354 Self { source: Source::Scanned, observed_at_ns, status: Status::Complete }
355 }
356
357 /// Combine with another value's provenance, taking the less trustworthy of each
358 /// fact.
359 ///
360 /// This is what makes a directory only as trustworthy as its least trustworthy
361 /// descendant: the weakest source, the oldest observation, and the worst status.
362 ///
363 /// Every fact fails closed, including time: an unknown `observed_at_ns` is
364 /// absorbing rather than skipped, so a subtree with one contributor of unknown age
365 /// reports an unknown age instead of a precise time it cannot prove.
366 ///
367 /// There is deliberately **no identity element**. Because unknown is absorbing, it
368 /// cannot double as the seed of a fold, and a caller aggregating a possibly-empty
369 /// set must represent emptiness separately (`Option<Provenance>`) rather than
370 /// seeding with a zero timestamp — otherwise every roll-up would come out unknown.
371 #[must_use]
372 pub fn combine(self, other: Self) -> Self {
373 Self {
374 source: self.source.max(other.source),
375 observed_at_ns: match (self.observed_at_ns, other.observed_at_ns) {
376 // Unknown is contagious, not skipped. Zero means "we cannot say when",
377 // and the honest combination of a known time with an unknown one is
378 // still unknown: a parent that drops the unknown contributor would
379 // advertise a precise "as of" it cannot prove for the whole subtree.
380 // Unknown is not the identity for "oldest observation" — it is the
381 // absorbing element.
382 (0, _) | (_, 0) => 0,
383 (mine, other) => mine.min(other),
384 },
385 status: self.status.max(other.status),
386 }
387 }
388
389 /// Whether this value was checked against the filesystem during this session.
390 pub const fn is_verified(self) -> bool {
391 self.source.is_verified()
392 }
393}
394
395/// Trust state for an index or queried subtree.
396#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
397pub enum Freshness {
398 /// Every path in scope has been reconciled successfully.
399 Fresh,
400 /// A reconciliation pass is currently checking this scope.
401 Reconciling,
402 /// A producer reported lost precision and reconciliation has not completed.
403 Stale,
404 /// Reconciliation encountered errors, so some state is unknown.
405 Partial,
406}
407
408/// Current activity of one opened root.
409///
410/// Phase is deliberately independent of coverage and freshness. A stopped root may
411/// still serve a useful partial image, and a watching root may temporarily be stale.
412#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
413pub enum LifecyclePhase {
414 /// A cold walk is adding verified entries.
415 Discovering,
416 /// Explicit or gap-closing verification is in progress.
417 Reconciling,
418 /// The current retained image is available without a live observer.
419 Ready,
420 /// Native or polling observation is active.
421 Watching,
422 /// The owner will perform no more expanding work.
423 Stopped,
424 /// A terminal provider failure ended useful work.
425 Failed,
426}
427
428/// Why an opened root cannot claim complete structural coverage.
429#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
430pub enum CoverageReason {
431 /// Initial discovery has not yet finished.
432 Building,
433 /// A configured resource budget refused additional admissible work.
434 Budget,
435 /// The owner was cancelled before the operation completed.
436 Cancelled,
437 /// Part of the configured scope could not be read.
438 Inaccessible,
439 /// A terminal provider failure prevented completion.
440 Failed,
441}
442
443/// Structural coverage of one opened root.
444#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
445pub enum Coverage {
446 /// Every directory in scope has a complete child listing.
447 Complete,
448 /// Some in-scope absence remains unknowable for the stated reason.
449 Partial(CoverageReason),
450}
451
452/// Maximum issue details retained by one index image.
453pub const MAX_RETAINED_ISSUES: usize = 64;
454/// Maximum UTF-8 bytes retained in one rendered issue message.
455pub const MAX_ISSUE_MESSAGE_BYTES: usize = 512;
456/// Maximum native encoded bytes retained for one issue path.
457pub const MAX_ISSUE_PATH_BYTES: usize = 4_096;
458
459/// Stable category for one non-fatal condition or terminal provider failure.
460#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
461pub enum IssueKind {
462 /// The operating system refused access to a path.
463 Permission,
464 /// A path disappeared during verification.
465 Disappeared,
466 /// Filesystem metadata could not be interpreted.
467 InvalidMetadata,
468 /// A configured discovery resource bound refused work.
469 ResourceBudget,
470 /// The filesystem observer lost precision and required verified recovery.
471 ObservationGap,
472 /// The provider failed for another reason.
473 ProviderFailure,
474}
475
476/// Bounded diagnostic evidence retained with an index state.
477#[derive(Clone, PartialEq, Eq, Debug, Hash)]
478pub struct Issue {
479 /// Machine-readable category.
480 pub kind: IssueKind,
481 /// Affected path when it fits the detail bound. Issues an opened root retains or
482 /// returns name it relative to the root, the form its reads use; a message may still
483 /// name the absolute path the operating system refused.
484 pub path: Option<PathBuf>,
485 /// Human-readable detail, truncated at a UTF-8 boundary when necessary.
486 pub message: String,
487 /// Operating-system error number when one was supplied.
488 pub os_error: Option<i32>,
489}
490
491impl Issue {
492 /// Convert one engine error without retaining unbounded rendered detail.
493 pub fn from_error(error: &Error) -> Self {
494 match error {
495 Error::Io { path, source } => Self::from_io(path, source),
496 other => Self {
497 kind: IssueKind::ProviderFailure,
498 path: None,
499 message: bounded_issue_message(other.to_string()),
500 os_error: None,
501 },
502 }
503 }
504
505 pub(crate) fn from_io(path: &Path, source: &std::io::Error) -> Self {
506 Self {
507 kind: match source.kind() {
508 std::io::ErrorKind::PermissionDenied => IssueKind::Permission,
509 std::io::ErrorKind::NotFound => IssueKind::Disappeared,
510 std::io::ErrorKind::InvalidData | std::io::ErrorKind::InvalidInput => {
511 IssueKind::InvalidMetadata
512 }
513 _ => IssueKind::ProviderFailure,
514 },
515 path: bounded_issue_path(path),
516 message: bounded_issue_message(format!("I/O error at {}: {source}", path.display())),
517 os_error: source.raw_os_error(),
518 }
519 }
520
521 /// Convert one error met while reading an opened root, naming its path relative to it.
522 ///
523 /// Scan errors carry the absolute path the operating system refused, and the message
524 /// keeps it for whoever has to fix the permission. The `path` field is the root-relative
525 /// form every other opened-root issue uses, so two issues about one directory agree and
526 /// a consumer can match an issue to the path it reads.
527 pub(crate) fn from_error_under(root: &Path, error: &Error) -> Self {
528 let mut issue = Self::from_error(error);
529 if let Error::Io { path, .. } = error {
530 issue.relativize(root, path);
531 }
532 issue
533 }
534
535 /// [`Self::from_io`] for a path under an opened root, naming it relative to the root.
536 pub(crate) fn from_io_under(root: &Path, path: &Path, source: &std::io::Error) -> Self {
537 let mut issue = Self::from_io(path, source);
538 issue.relativize(root, path);
539 issue
540 }
541
542 fn relativize(&mut self, root: &Path, path: &Path) {
543 if let Ok(relative) = path.strip_prefix(root) {
544 self.path = bounded_issue_path(relative);
545 }
546 }
547
548 /// Describe the first file refused by an opened-root resource budget.
549 pub(crate) fn resource_budget(max_files: u64) -> Self {
550 Self {
551 kind: IssueKind::ResourceBudget,
552 path: None,
553 message: format!(
554 "verified work refused an admissible file after retaining {max_files}"
555 ),
556 os_error: None,
557 }
558 }
559
560 /// Describe observer loss without confusing it with consumer journal loss.
561 pub(crate) fn observation_gap(path: &Path, reason: InvalidateReason) -> Self {
562 Self {
563 kind: IssueKind::ObservationGap,
564 path: bounded_issue_path(path),
565 message: bounded_issue_message(format!(
566 "filesystem observation lost precision at {}: {reason:?}",
567 path.display()
568 )),
569 os_error: None,
570 }
571 }
572
573 /// Describe an operational provider failure when no structured OS error is available.
574 pub(crate) fn provider_failure(path: Option<&Path>, message: String) -> Self {
575 Self {
576 kind: IssueKind::ProviderFailure,
577 path: path.and_then(bounded_issue_path),
578 message: bounded_issue_message(message),
579 os_error: None,
580 }
581 }
582}
583
584fn bounded_issue_path(path: &Path) -> Option<PathBuf> {
585 (path.as_os_str().as_encoded_bytes().len() <= MAX_ISSUE_PATH_BYTES).then(|| path.to_path_buf())
586}
587
588fn bounded_issue_message(mut message: String) -> String {
589 if message.len() <= MAX_ISSUE_MESSAGE_BYTES {
590 return message;
591 }
592 let mut end = MAX_ISSUE_MESSAGE_BYTES;
593 while !message.is_char_boundary(end) {
594 end -= 1;
595 }
596 message.truncate(end);
597 message
598}
599
600/// Counts for the bounded issue details captured with a state.
601#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Hash)]
602pub struct IssueSummary {
603 /// Details retained and available to a coherent read.
604 pub retained: u64,
605 /// Additional details omitted after the bound was reached.
606 pub omitted: u64,
607}
608
609/// Committed, bounded discovery counters.
610#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Hash)]
611pub struct DiscoveryProgress {
612 /// Regular files retained by cold discovery.
613 pub files_retained: u64,
614 /// Directories whose complete in-scope child listing was committed: by discovery, or
615 /// by a later complete reconciliation that listed one the index did not yet hold as
616 /// complete, such as a directory created afterwards or one discovery could not read.
617 /// Each is one [`StateTransition::DirectoryComplete`].
618 pub directories_complete: u64,
619}
620
621/// Coherent public state captured at an index commit boundary.
622#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
623pub struct IndexState {
624 /// Current activity of the opened root.
625 pub phase: LifecyclePhase,
626 /// Whether the retained tree covers all configured scope.
627 pub coverage: Coverage,
628 /// How current the retained facts are believed to be.
629 pub freshness: Freshness,
630 /// Weakest source represented by this first implementation.
631 pub source: Source,
632 /// Stable counters advanced only by committed discovery and reconciliation work.
633 pub progress: DiscoveryProgress,
634 /// Bounded diagnostic evidence counts at this version.
635 pub issues: IssueSummary,
636}
637
638/// Maximum native projections accepted by one coherent read.
639pub const MAX_READ_PROJECTIONS: usize = 16;
640/// Maximum rows returned by one page projection.
641pub const MAX_PAGE_ROWS: usize = 4_096;
642/// Maximum deterministic work allowance accepted by one page projection.
643pub const MAX_PAGE_WORK: u64 = 1_000_000;
644/// Maximum report sections and reported omissions accepted in one opened read.
645pub const MAX_REPORT_VIEWS: usize = 16;
646/// Default cap for a selection count not backed by an exact maintained aggregate.
647pub const DEFAULT_COUNT_CAP: u64 = 10_000;
648/// Maximum caller-selected cap for an on-demand aggregate.
649pub const MAX_COUNT_CAP: u64 = 1_000_000;
650/// Maximum retained payload for one handle-local continuation record.
651///
652/// Together with the 128-record table bound, this caps retained continuation payload at
653/// eight MiB per opened root, excluding fixed map nodes and allocator bookkeeping.
654pub const MAX_CONTINUATION_RECORD_BYTES: usize = 64 * 1_024;
655
656/// Opaque identifier for resumable work retained by one opened root.
657#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
658pub struct ContinuationId {
659 pub(crate) session: SessionId,
660 pub(crate) ordinal: u64,
661}
662
663impl ContinuationId {
664 /// Return the opaque parts a language or wire adapter must round-trip.
665 pub const fn opaque_parts(self) -> (u64, u64) {
666 (self.session.opaque(), self.ordinal)
667 }
668
669 /// Recover an opaque continuation previously returned by [`Self::opaque_parts`].
670 ///
671 /// Both zero values are reserved and never identify live continuation state.
672 pub const fn from_opaque_parts(session: u64, ordinal: u64) -> Option<Self> {
673 match SessionId::from_opaque(session) {
674 Some(session) if ordinal != 0 => Some(Self { session, ordinal }),
675 _ => None,
676 }
677 }
678}
679
680/// Output and work bounds for one resumable page.
681#[derive(Clone, Copy, PartialEq, Eq, Debug)]
682pub struct PageRequest {
683 /// Maximum variable rows to return, excluding the projection's fixed envelope.
684 pub limit: usize,
685 /// Maximum retained-index rows to inspect.
686 pub max_work: u64,
687}
688
689/// A retained entry's canonical POSIX-relative name, in the form ordered pages use.
690///
691/// **This is not the filename.** It is a derived name, and it differs from the native one
692/// whenever a component holds bytes that are not valid UTF-8, or holds a literal `%`.
693/// Never open, stat, or compare a filesystem path against one of these:
694/// [`EntryValue::path`] is the identity, and this is the wire form the ordered
695/// projections are keyed and ordered by. The newtype exists so that mistake is a compile
696/// error rather than a convention, because a bare `String` here reads exactly like a path
697/// and every test tree an author thinks to write is pure UTF-8, so the substitution passes
698/// locally and fails on a real disk.
699///
700/// Every entry has one. A native path is not obliged to be UTF-8 — Unix filenames are
701/// arbitrary non-NUL bytes, Windows filenames may hold unpaired surrogates — so the two
702/// kinds of byte that cannot be carried are percent-escaped: those that do not decode,
703/// and `%` itself. Escaping `%` is what makes the mapping injective. A file named
704/// `caf%FF.txt` is valid UTF-8 and a file named `caf<0xFF>.txt` is not; escaping only the
705/// undecodable byte would give both the same wire name, which is the aliasing bug of
706/// lossy conversion in better clothes.
707///
708/// Nothing else is touched. This produces a JSON string, not a URL, so spaces, `#`, `?`
709/// and every non-ASCII scalar pass through: `café/naïve.txt` is unchanged.
710///
711/// Totality is why ordered pages and native roll-ups answer over one population, why a
712/// directory whose name has a stray byte still lists its children, and why a complete
713/// directory that does not hold a name can answer `absent` rather than `unknown`. The
714/// partial version that preceded it needed an omission count, bounded escaped examples,
715/// and a second completeness flag on [`TreePage`] to describe what it could not name; all
716/// three are gone.
717///
718/// Distinct from the unrelated structural question of whether a path is relative and
719/// never ascends. A path can satisfy either condition and fail the other.
720#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
721pub struct PortablePath(String);
722
723impl PortablePath {
724 /// Wrap an already-canonical portable string.
725 pub(crate) fn new(path: String) -> Self {
726 Self(path)
727 }
728
729 /// The canonical POSIX-relative form, for transport and ordering only.
730 pub fn as_str(&self) -> &str {
731 &self.0
732 }
733
734 /// Consume the wrapper, yielding the canonical POSIX-relative form.
735 pub fn into_string(self) -> String {
736 self.0
737 }
738
739 /// Heap payload retained when a continuation record owns this path.
740 ///
741 /// Named to match `Selection::retained_heap_bytes` and
742 /// `EntrySelection::retained_heap_bytes`, because all three feed one cap and a
743 /// component that accounts for itself differently is how a bound silently stops
744 /// holding.
745 pub(crate) fn retained_heap_bytes(&self) -> usize {
746 self.0.capacity()
747 }
748}
749
750impl std::borrow::Borrow<str> for PortablePath {
751 fn borrow(&self) -> &str {
752 &self.0
753 }
754}
755
756impl std::fmt::Display for PortablePath {
757 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
758 formatter.write_str(&self.0)
759 }
760}
761
762// Transparent on purpose. These values appear in session goldens, where the reader is
763// checking which path a page returned; a wrapper name repeated on every row would be
764// noise in the one place the value has to stay legible.
765impl std::fmt::Debug for PortablePath {
766 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
767 std::fmt::Debug::fmt(&self.0, formatter)
768 }
769}
770
771/// One immutable retained entry returned by an opened-root read.
772#[derive(Clone, PartialEq, Eq, Debug)]
773pub struct EntryValue {
774 /// Native path relative to the opened root.
775 pub path: PathBuf,
776 /// Canonical POSIX-relative form. Every entry has one. See [`PortablePath`].
777 pub portable_path: PortablePath,
778 /// Retained filesystem kind.
779 pub kind: EntryKind,
780 /// Retained filesystem attributes.
781 pub attrs: Attrs,
782 /// Effective fixed-control classification.
783 pub ignored: bool,
784 /// Name- and registry-derived identity for a regular file.
785 ///
786 /// Projected at read time so detached indexes and the standalone CLI retain no
787 /// duplicate strings or interactive-only classification payload.
788 pub classification: Option<crate::classify::NameClassification>,
789 /// Both constant-size maintained aggregate partitions for a directory.
790 pub rollup: Option<crate::index::PartitionRollUpSummary>,
791 /// Whether a directory's complete in-scope child set is known.
792 pub children_complete: Option<bool>,
793}
794
795/// Three-valued knowledge for a path lookup.
796#[derive(Clone, PartialEq, Eq, Debug)]
797pub enum Knowledge<T> {
798 /// The requested value is retained.
799 Present(T),
800 /// Complete relevant coverage proves the path is absent.
801 Absent,
802 /// Current coverage cannot prove presence or absence.
803 Unknown {
804 /// Why the relevant scope is incomplete.
805 reason: CoverageReason,
806 },
807}
808
809/// One fdu-native projection requested under a coherent read boundary.
810#[derive(Clone, Debug)]
811pub enum ReadProjection {
812 /// Look up one relative path with three-valued, portable-safe absence.
813 Lookup {
814 /// Native path relative to the opened root.
815 path: PathBuf,
816 },
817 /// Return both maintained aggregate partitions for one directory.
818 RollUp {
819 /// Directory relative to the opened root.
820 path: PathBuf,
821 },
822 /// Return one page of portable descendants, level by level to `depth`.
823 Tree {
824 /// Directory relative to the opened root.
825 path: PathBuf,
826 /// How many levels below `path` the page descends.
827 ///
828 /// `Limit(1)` is this directory's own children. Rows arrive in breadth-first
829 /// level order, so a page cut short by `page` withholds depth rather than
830 /// breadth: the caller can always tell how wide each level it received was.
831 depth: crate::query::Bound,
832 /// Whether entries in the fixed ignored partition are traversed.
833 ///
834 /// `false` prunes the excluded directory's whole subtree, contributing neither
835 /// it nor any descendant, rather than filtering its row and descending anyway.
836 include_ignored: bool,
837 /// Page output and work bounds.
838 page: PageRequest,
839 },
840 /// Return one portable-path-ordered page under an fdu-native selection.
841 Flat {
842 /// Additive portable-entry selection composing the existing query predicates.
843 selection: crate::query::EntrySelection,
844 /// Compact or full retained row shape.
845 shape: RowShape,
846 /// Page output and work bounds.
847 page: PageRequest,
848 },
849 /// Count portable entries under one selection, exactly or to an explicit cap.
850 Aggregate {
851 /// Additive portable-entry selection composing the existing query predicates.
852 selection: crate::query::EntrySelection,
853 /// Maximum matches counted before returning a lower bound.
854 count_cap: u64,
855 /// Maximum portable index rows inspected.
856 max_work: u64,
857 },
858 /// Evaluate the existing pure fdu query/report machinery at this read boundary.
859 Report(ReportRequest),
860 /// Resume a tree or flat page from handle-local retained traversal state.
861 Continue {
862 /// Opaque continuation returned by a prior page.
863 continuation: ContinuationId,
864 /// Bounds for this page; query identity remains in the handle.
865 page: PageRequest,
866 },
867 /// Return fixed-size owner, scope, and retained-issue diagnostics.
868 Diagnostics,
869}
870
871/// Fixed-size diagnostics captured with a coherent read.
872#[derive(Clone, PartialEq, Eq, Debug)]
873pub struct ReadDiagnostics {
874 /// Absolute filesystem root owned by this handle.
875 pub root: PathBuf,
876 /// Validated filesystem and semantic scope.
877 pub scope: ScanScope,
878 /// Live retained entries, including the root.
879 pub entries: u64,
880 /// Bounded typed issue details at this version.
881 pub issues: Vec<Issue>,
882 /// Which `.gitignore` files apply and which were refused, at this version. An opened
883 /// root always observes control state.
884 pub controls: crate::control::ControlObservation,
885}
886
887/// One structural page of a directory's descendants, to the requested depth.
888#[derive(Clone, PartialEq, Eq, Debug)]
889pub struct TreePage {
890 /// Directory whose descendants are listed.
891 pub directory: EntryValue,
892 /// Portable rows in breadth-first level order; each parent's children are
893 /// directories-first in canonical byte order.
894 pub rows: Vec<EntryValue>,
895 /// Opaque continuation when another page exists at this version.
896 pub next: Option<ContinuationId>,
897 /// Whether this directory's complete in-scope child set is known.
898 pub complete: bool,
899}
900
901/// Retained fields copied into portable page rows.
902#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Hash)]
903pub enum RowShape {
904 /// Path, kind, attributes, and classification without directory roll-ups.
905 #[default]
906 Compact,
907 /// Compact fields plus maintained directory roll-ups and completeness.
908 Full,
909}
910
911/// One portable flat-entry page.
912#[derive(Clone, PartialEq, Eq, Debug)]
913pub struct FlatPage {
914 /// Rows in complete canonical POSIX-relative UTF-8 byte order.
915 pub rows: Vec<EntryValue>,
916 /// Opaque continuation when another matching row exists at this version.
917 pub next: Option<ContinuationId>,
918}
919
920/// Product count whose exactness is explicit.
921#[derive(Clone, Copy, PartialEq, Eq, Debug)]
922pub enum CountResult {
923 /// Every matching row was counted.
924 Exact(u64),
925 /// Additional matches exist beyond this proven lower bound.
926 AtLeast(u64),
927}
928
929/// The read half of a request at an opened root, plus one read's work bound.
930///
931/// The basis is the opened root's own and is never supplied here: a caller names what each
932/// read asks -- the query and the instant it is asked at -- and [`crate::query::Request`]
933/// composes the two for validation.
934#[derive(Clone, Debug)]
935pub struct ReportRequest {
936 /// Existing selection and view vocabulary shared by one-shot surfaces.
937 pub query: crate::query::Query,
938 /// The instant this read resolves against, which is also the report's `generated_at`.
939 ///
940 /// Caller-supplied, keeping the projection deterministic.
941 pub now: std::time::SystemTime,
942 /// Maximum retained-index and maintained-index rows read by the report.
943 pub max_work: u64,
944}
945
946/// Projection whose deterministic work allowance was exhausted.
947#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
948pub enum LimitedProjection {
949 /// Structural tree page.
950 Tree,
951 /// Portable flat page.
952 Flat,
953 /// Existing query/report projection.
954 Report,
955 /// Selection count outside the maintained aggregate set.
956 Aggregate,
957}
958
959/// Typed bounded-query result; no partial calculation is presented as exact.
960#[derive(Clone, Copy, PartialEq, Eq, Debug)]
961pub struct QueryLimit {
962 /// Projection that exhausted its allowance.
963 pub projection: LimitedProjection,
964 /// Work allowance supplied by the caller.
965 pub max_work: u64,
966 /// Rows examined before stopping.
967 pub rows_visited: u64,
968}
969
970/// Input to one coherent opened-root read.
971#[derive(Clone, Debug, Default)]
972pub struct ReadRequest {
973 /// Projections to return, in this exact order.
974 pub projections: Vec<ReadProjection>,
975 /// Exact version required by a caller assembling a multi-read result.
976 pub expected: Option<EngineVersion>,
977}
978
979/// Why one projection of a read refused, while every other projection still answered.
980///
981/// A read fails as a whole only when no projection in it can be trusted: the request's
982/// shape is invalid, the root is closed, or the version it pinned is not the one the index
983/// holds. A refusal is narrower. It depends on what one projection found at that version,
984/// so the lookup of a path beside a tree page that refused the same path still answers,
985/// and a caller never has to prove a path is a directory before it may batch the question.
986#[derive(Clone, PartialEq, Eq, Debug)]
987pub enum ProjectionRefusal {
988 /// A tree page or roll-up named a retained path that is not a directory.
989 ///
990 /// A lookup of the same path answers `Present`, so neither three-valued answer fits:
991 /// `Absent` claims coverage proves the path missing, and `Unknown` claims coverage
992 /// cannot tell, which for a retained path never resolves. It depends on index state:
993 /// the same projection answers while the path is a directory and refuses once it has
994 /// become a file, so a path taken from an earlier page can start refusing between
995 /// reads.
996 NotADirectory {
997 /// The normalized path the projection named.
998 path: PathBuf,
999 },
1000 /// A page stopped with rows left, and the record that would resume it exceeds
1001 /// [`MAX_CONTINUATION_RECORD_BYTES`].
1002 ///
1003 /// The page is refused rather than returned without a way to continue, which would
1004 /// present a truncated page as a finished one. Only very long paths or a very large
1005 /// selection reach it. A refused continued page keeps its continuation, so the caller
1006 /// may retry it with a different page bound.
1007 ContinuationRecordLimit {
1008 /// Structural payload bytes the record would retain.
1009 attempted: usize,
1010 /// Maximum structural payload retained by one record.
1011 limit: usize,
1012 },
1013 /// A `Continue` named a continuation this root issued but no longer retains.
1014 ///
1015 /// An earlier page consumed it, or the root's bound on retained continuations evicted
1016 /// it to make room for newer pages. Both depend on what other pages did, not on the
1017 /// request, so a token that worked a moment ago costs only its own projection. Start
1018 /// the page again from its first request. A token from another opened root, or one
1019 /// this root never issued, still fails the whole read with
1020 /// [`Error::ContinuationUnavailable`].
1021 ContinuationUnavailable,
1022}
1023
1024/// One projection result, in the same position as its request.
1025#[derive(Clone, Debug)]
1026pub enum ProjectionResult {
1027 /// Three-valued retained-entry lookup.
1028 Lookup(Knowledge<EntryValue>),
1029 /// Three-valued directory roll-up lookup.
1030 RollUp(Knowledge<crate::index::PartitionRollUpSummary>),
1031 /// Three-valued structural tree page.
1032 Tree(Knowledge<TreePage>),
1033 /// Portable flat entry page.
1034 Flat(FlatPage),
1035 /// Exact maintained or explicitly capped portable selection count.
1036 Aggregate(CountResult),
1037 /// Existing fdu query report.
1038 Report(crate::query::Report),
1039 /// Fixed-size provider and lifecycle diagnostics.
1040 Diagnostics(ReadDiagnostics),
1041 /// A bounded projection stopped without returning a misleading partial answer.
1042 Limit(QueryLimit),
1043 /// This projection refused; the rest of the read answered.
1044 Refused(ProjectionRefusal),
1045}
1046
1047/// One coherent opened-root response.
1048#[derive(Clone, Debug)]
1049pub struct ReadResponse {
1050 /// Exact live-session version observed by every returned field.
1051 pub version: EngineVersion,
1052 /// State captured at the same commit boundary as every projection.
1053 pub state: IndexState,
1054 /// Projection results in request order.
1055 pub results: Vec<ProjectionResult>,
1056 /// Deterministic work charged while assembling the response.
1057 pub work: Work,
1058 /// Cursor from which a consumer can later resume exact changes.
1059 pub change_cursor: EngineVersion,
1060}
1061
1062/// Input to one blocking opened-root journal poll.
1063#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1064pub struct ChangeRequest {
1065 /// Exact live version after which commits are requested.
1066 pub after: EngineVersion,
1067 /// Maximum time to wait when the journal has not advanced.
1068 pub timeout: std::time::Duration,
1069}
1070
1071/// Journal outcome at one coherent terminal version and state.
1072#[derive(Clone, PartialEq, Eq, Debug)]
1073pub enum ChangeOutcome {
1074 /// Every retained exact commit after the requested version, oldest first.
1075 Changes {
1076 /// Exact engine commits; consumers invalidate and coherently reread projections.
1077 commits: Vec<Commit>,
1078 /// Bounded union of the commits' answer invalidations.
1079 impact: Impact,
1080 },
1081 /// No newer commit arrived before the timeout.
1082 Idle,
1083 /// The requested version predates retained history and the consumer must reread.
1084 Reset {
1085 /// Complete invalidation guidance; lost paths are never presented as enumerable.
1086 impact: Impact,
1087 },
1088}
1089
1090/// Result of one opened-root journal poll.
1091#[derive(Clone, PartialEq, Eq, Debug)]
1092pub struct ChangePoll {
1093 /// Cursor to use for the next poll; unchanged for [`ChangeOutcome::Idle`].
1094 pub cursor: EngineVersion,
1095 /// Exact terminal version captured with the outcome and state.
1096 pub version: EngineVersion,
1097 /// Complete public state at `version`.
1098 pub state: IndexState,
1099 /// Changes, timeout, or consumer-history recovery.
1100 pub outcome: ChangeOutcome,
1101 /// Deterministic journal work performed while assembling the result.
1102 pub work: Work,
1103}
1104
1105/// Why one requested refresh path was not verified.
1106///
1107/// Rejections are values rather than dropped inputs so a hint producer can distinguish
1108/// "verified and unchanged" from "not examined" without parsing an error message.
1109#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
1110#[non_exhaustive]
1111pub enum RefreshRejection {
1112 /// The path was absolute or traversed above the opened root.
1113 OutsideRoot,
1114 /// The path is deeper than the opened root's semantic depth boundary.
1115 BeyondDepth,
1116 /// A fixed admission rule excludes the path from retained truth.
1117 NotAdmitted,
1118 /// A symlink or filesystem boundary makes the requested ancestry unsafe to walk.
1119 UnsafeAncestry,
1120 /// Verifying the path could expand a resource-stopped retained set.
1121 ResourceBudget,
1122}
1123
1124impl RefreshRejection {
1125 /// Stable label for wire adapters.
1126 pub const fn as_str(self) -> &'static str {
1127 match self {
1128 Self::OutsideRoot => "outside_root",
1129 Self::BeyondDepth => "beyond_depth",
1130 Self::NotAdmitted => "not_admitted",
1131 Self::UnsafeAncestry => "unsafe_ancestry",
1132 Self::ResourceBudget => "resource_budget",
1133 }
1134 }
1135}
1136
1137/// One refresh path that the engine declined, with its typed reason.
1138#[derive(Clone, PartialEq, Eq, Debug, Hash)]
1139pub struct RejectedRefreshPath {
1140 /// Path exactly as supplied by the caller.
1141 pub path: PathBuf,
1142 /// Rule that prevented verification.
1143 pub reason: RefreshRejection,
1144}
1145
1146/// Result of one bounded, multi-path refresh.
1147///
1148/// Every commit created by this operation lies in the half-open journal interval
1149/// `(after, version]`. Concurrent producers may also commit within that interval, so
1150/// `impact` covers the complete interval rather than trying to identify a producer.
1151#[derive(Clone, PartialEq, Eq, Debug)]
1152pub struct RefreshResult {
1153 /// Coherent engine version immediately before refresh state was announced.
1154 pub after: EngineVersion,
1155 /// Coherent terminal engine version after every accepted scope was closed.
1156 pub version: EngineVersion,
1157 /// Complete public state at `version`.
1158 pub state: IndexState,
1159 /// Unique canonical paths accepted for verification, in stable path order.
1160 pub accepted: Vec<PathBuf>,
1161 /// Rejected request entries in caller order.
1162 pub rejected: Vec<RejectedRefreshPath>,
1163 /// Bounded union of every invalidation in `(after, version]`, or `all_dirty` when
1164 /// the journal floor advanced before the receipt was assembled.
1165 pub impact: Impact,
1166 /// Deterministic filesystem and commit work performed by this refresh.
1167 pub work: Work,
1168 /// Bounded operational issues encountered while verification continued.
1169 pub issues: Vec<Issue>,
1170 /// Additional issues omitted after the public detail bound.
1171 pub omitted_issues: u64,
1172}
1173
1174impl Default for IndexState {
1175 fn default() -> Self {
1176 Self {
1177 phase: LifecyclePhase::Ready,
1178 coverage: Coverage::Complete,
1179 freshness: Freshness::Fresh,
1180 source: Source::Scanned,
1181 progress: DiscoveryProgress::default(),
1182 issues: IssueSummary::default(),
1183 }
1184 }
1185}
1186
1187impl Freshness {
1188 pub(crate) const fn rank(self) -> u8 {
1189 match self {
1190 Self::Fresh => 0,
1191 Self::Reconciling => 1,
1192 Self::Stale => 2,
1193 Self::Partial => 3,
1194 }
1195 }
1196}
1197
1198/// Why a producer had to escalate to [`Op::InvalidateSubtree`] instead of describing a
1199/// change precisely.
1200///
1201/// Each variant corresponds to a real, documented platform limitation rather than a
1202/// defensive catch-all, and the scan layer resolves every one of them the same way.
1203#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
1204pub enum InvalidateReason {
1205 /// The kernel dropped events: inotify `Q_OVERFLOW`, `FSEvents` `MustScanSubDirs`, or a
1206 /// Windows `ReadDirectoryChangesW` buffer overrun. All three surface through notify
1207 /// as `Flag::Rescan`, and swallowing that flag silently corrupts any index built on
1208 /// events.
1209 WatchOverflow,
1210 /// A rename left entries no event describes. Renames arrive one side at a time
1211 /// (`FSEvents` reports each path with no mechanism to associate old and new), and
1212 /// each side is verified as its own path, so this is raised only where a side's own
1213 /// path does not settle it: a directory that arrived by rename is relisted, because
1214 /// no backend reports a moved tree's contents; a renamed name its parent does not
1215 /// list exactly (a case-only rename on an insensitive filesystem) reconciles the
1216 /// parent; and a rename of the root itself, or from a backend that may never name
1217 /// the new side (kqueue), reconciles the whole root.
1218 UnpairedRename,
1219 /// A directory was created and its watch registered a moment later; anything created
1220 /// inside that window produced no event at all.
1221 WatchSetupRace,
1222 /// A periodic reconciliation sweep, for backends that cannot signal drops at all
1223 /// (kqueue).
1224 PeriodicSweep,
1225 /// Stat verification failed without proving the path is gone. The known entry must
1226 /// remain until reconciliation can retry and report the underlying I/O error.
1227 VerificationFailed,
1228 /// A verified child arrived below ancestry the index has not verified. The child is
1229 /// withheld while reconciliation starts from the nearest known directory.
1230 UnknownAncestry,
1231 /// Repeated concurrent commits prevented a watch sample from reaching a stable
1232 /// arbitration boundary. The root is reconciled instead of doing filesystem I/O
1233 /// under the index lock or allowing an old sample to win.
1234 WatchContention,
1235 /// A control edit may admit or remove entries under a narrowed ignored population,
1236 /// including entries absent from the current index. Revisit its parent directory.
1237 ControlPopulationChanged,
1238 /// Requested by the caller.
1239 Requested,
1240}
1241
1242/// A single change to one path.
1243#[derive(Clone, PartialEq, Eq, Debug)]
1244pub enum Op {
1245 /// The entry appeared or changed. Always carries a fresh stat, never a bare event.
1246 Upsert {
1247 /// Path relative to the index root.
1248 path: PathBuf,
1249 /// Newly observed entry kind.
1250 kind: EntryKind,
1251 /// Newly observed metadata.
1252 attrs: Attrs,
1253 },
1254 /// The entry is gone. Implies removal of every descendant.
1255 Remove {
1256 /// Path relative to the index root.
1257 path: PathBuf,
1258 },
1259 /// Exact bytes read from a verified `.gitignore` control file.
1260 ///
1261 /// This is separate from the file entry upsert so admission may retain the signal
1262 /// without creating a visible row. Producers normally emit both while control files
1263 /// are visible and only this operation when later admission excludes the row.
1264 ControlUpsert {
1265 /// Control-file path relative to the index root.
1266 path: PathBuf,
1267 /// Complete source bytes, bounded by the receiving control table.
1268 source: Vec<u8>,
1269 },
1270 /// A previously observed `.gitignore` control file is absent.
1271 ControlRemove {
1272 /// Control-file path relative to the index root.
1273 path: PathBuf,
1274 },
1275 /// The producer could not describe the change precisely; the consumer must re-scan
1276 /// this subtree. The scan layer turns this back into precise ops, so escalation is
1277 /// closed-loop rather than a dead end.
1278 InvalidateSubtree {
1279 /// Relative root of the scope that must be reconciled.
1280 path: PathBuf,
1281 /// Why the producer could not report precise changes.
1282 reason: InvalidateReason,
1283 },
1284}
1285
1286impl Op {
1287 /// The path this op applies to.
1288 pub fn path(&self) -> &Path {
1289 match self {
1290 Self::Upsert { path, .. }
1291 | Self::Remove { path }
1292 | Self::ControlUpsert { path, .. }
1293 | Self::ControlRemove { path }
1294 | Self::InvalidateSubtree { path, .. } => path,
1295 }
1296 }
1297}
1298
1299/// The complete indexed state of one path at an observation boundary.
1300#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
1301pub enum PathState {
1302 /// The path was not indexed.
1303 Absent,
1304 /// The path was indexed with these observed fields.
1305 Present {
1306 /// Indexed entry kind.
1307 kind: EntryKind,
1308 /// Indexed metadata.
1309 attrs: Attrs,
1310 },
1311}
1312
1313/// Generation- and revision-safe identity for one indexed entry.
1314///
1315/// The fields are intentionally opaque. Producers obtain identities through
1316/// [`crate::Index::expectation`] rather than manufacturing handles that could
1317/// accidentally alias a recycled arena slot or bypass ABA detection.
1318#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
1319pub(crate) struct EntryIdentity {
1320 slot: u32,
1321 generation: u64,
1322 revision: u64,
1323 children_revision: u64,
1324 directory: bool,
1325}
1326
1327impl EntryIdentity {
1328 pub(crate) const fn new(
1329 slot: u32,
1330 generation: u64,
1331 revision: u64,
1332 children_revision: u64,
1333 directory: bool,
1334 ) -> Self {
1335 Self { slot, generation, revision, children_revision, directory }
1336 }
1337
1338 pub(crate) const fn same_target(self, other: Self, require_structure: bool) -> bool {
1339 self.slot == other.slot
1340 && self.generation == other.generation
1341 && self.revision == other.revision
1342 && (!require_structure || self.children_revision == other.children_revision)
1343 }
1344
1345 pub(crate) const fn same_absence_guard(self, other: Self) -> bool {
1346 self.slot == other.slot
1347 && self.generation == other.generation
1348 && self.children_revision == other.children_revision
1349 && (other.directory || self.revision == other.revision)
1350 }
1351}
1352
1353/// State and entry revisions captured at one observation boundary.
1354///
1355/// Present paths carry a generation-safe target identity and direct revision so a
1356/// change-away-and-back cannot masquerade as the original state. Absent paths carry the
1357/// nearest existing ancestor's structural revision, closing create/remove and parent
1358/// replacement races without making unrelated subtrees conflict.
1359#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
1360pub struct PathExpectation {
1361 /// Visible state captured at the observation boundary.
1362 pub state: PathState,
1363 entry: Option<EntryIdentity>,
1364 absence_guard: Option<EntryIdentity>,
1365}
1366
1367impl PathExpectation {
1368 pub(crate) const fn new(
1369 state: PathState,
1370 entry: Option<EntryIdentity>,
1371 absence_guard: Option<EntryIdentity>,
1372 ) -> Self {
1373 Self { state, entry, absence_guard }
1374 }
1375
1376 pub(crate) const fn entry(self) -> Option<EntryIdentity> {
1377 self.entry
1378 }
1379
1380 pub(crate) const fn absence_guard(self) -> Option<EntryIdentity> {
1381 self.absence_guard
1382 }
1383}
1384
1385/// The condition under which an observation may be committed.
1386#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
1387pub enum Expectation {
1388 /// Commit according to arrival order. Used by freshly verified watch observations
1389 /// and cold-scan bootstrap data.
1390 Any,
1391 /// Commit only if the target state and relevant structural revisions still match the
1392 /// producer baseline.
1393 State(PathExpectation),
1394}
1395
1396/// One observed operation together with its arbitration precondition.
1397#[derive(Clone, PartialEq, Eq, Debug)]
1398pub struct ObservationOp {
1399 /// Proposed path mutation.
1400 pub op: Op,
1401 /// Arbitration precondition for that mutation.
1402 pub expectation: Expectation,
1403}
1404
1405impl ObservationOp {
1406 /// An operation whose fresh verification makes arrival order authoritative.
1407 pub const fn unconditional(op: Op) -> Self {
1408 Self { op, expectation: Expectation::Any }
1409 }
1410
1411 /// An operation valid only while `expected` still matches the index.
1412 pub const fn if_state(op: Op, expected: PathExpectation) -> Self {
1413 Self { op, expectation: Expectation::State(expected) }
1414 }
1415}
1416
1417/// A producer batch awaiting arbitration by the index.
1418///
1419/// Batching is not just an efficiency detail: producers coalesce per path within a batch
1420/// and stat once per batch rather than once per event.
1421#[derive(Clone, PartialEq, Eq, Debug, Default)]
1422pub struct Observation {
1423 /// Ordered operations in this producer batch.
1424 pub ops: Vec<ObservationOp>,
1425}
1426
1427impl Observation {
1428 /// Build an unconditional batch from freshly verified operations.
1429 pub fn new(ops: Vec<Op>) -> Self {
1430 Self { ops: ops.into_iter().map(ObservationOp::unconditional).collect() }
1431 }
1432
1433 /// Build a batch whose operations already carry explicit expectations.
1434 pub const fn from_ops(ops: Vec<ObservationOp>) -> Self {
1435 Self { ops }
1436 }
1437
1438 #[inline]
1439 /// Whether the batch contains no operations.
1440 pub fn is_empty(&self) -> bool {
1441 self.ops.is_empty()
1442 }
1443
1444 #[inline]
1445 /// Number of operations in the batch.
1446 pub fn len(&self) -> usize {
1447 self.ops.len()
1448 }
1449}
1450
1451/// One exact fact mutation performed by the index.
1452///
1453/// These are deliberately more specific than [`Op`]. An observation says what a
1454/// producer requested; an effective change says what the index actually did. One
1455/// upsert may, for example, replace a subtree and insert a differently typed entry.
1456#[derive(Clone, PartialEq, Eq, Debug)]
1457pub enum EffectiveChange {
1458 /// A previously absent entry was inserted.
1459 Inserted {
1460 /// Relative path of the inserted entry.
1461 path: PathBuf,
1462 /// Filesystem kind retained for the entry.
1463 kind: EntryKind,
1464 /// Metadata retained for the entry.
1465 attrs: Attrs,
1466 },
1467 /// An existing entry retained its kind and changed metadata.
1468 Updated {
1469 /// Relative path of the updated entry.
1470 path: PathBuf,
1471 /// Filesystem kind retained for the entry.
1472 kind: EntryKind,
1473 /// Metadata before the commit.
1474 previous: Attrs,
1475 /// Metadata after the commit.
1476 current: Attrs,
1477 },
1478 /// One entry was removed. Removing a subtree records one change per entry.
1479 Removed {
1480 /// Relative path of the removed entry.
1481 path: PathBuf,
1482 /// Filesystem kind the entry had before removal.
1483 kind: EntryKind,
1484 /// Metadata the entry had before removal.
1485 attrs: Attrs,
1486 },
1487 /// Exact control source identity changed.
1488 ControlUpdated {
1489 /// Relative `.gitignore` path.
1490 path: PathBuf,
1491 /// Previous source identity, or absence.
1492 previous: Option<crate::control::ControlIdentity>,
1493 /// Current source identity, or absence.
1494 current: Option<crate::control::ControlIdentity>,
1495 },
1496 /// A control file was refused, or its refusal lifted, so ignore classification's
1497 /// coverage changed.
1498 ///
1499 /// A refusal replacing a retained source arrives with the [`Self::ControlUpdated`]
1500 /// that drops the source; a refusal of a file no rule came from arrives alone.
1501 ControlRefusalUpdated {
1502 /// Relative `.gitignore` path.
1503 path: PathBuf,
1504 /// Why the file was refused before the commit, or absence.
1505 previous: Option<crate::control::ControlRefusalReason>,
1506 /// Why the file is refused after the commit, or absence.
1507 current: Option<crate::control::ControlRefusalReason>,
1508 },
1509 /// One retained entry moved between the fixed ignored and unignored partitions.
1510 Reclassified {
1511 /// Relative retained-entry path.
1512 path: PathBuf,
1513 /// Effective ignore classification before the commit.
1514 previous_ignored: bool,
1515 /// Effective ignore classification after the commit.
1516 current_ignored: bool,
1517 },
1518 /// A producer reported uncertainty that requires verified reconciliation.
1519 Invalidated {
1520 /// Relative root of the invalidated subtree.
1521 path: PathBuf,
1522 /// Why precise facts were unavailable.
1523 reason: InvalidateReason,
1524 },
1525}
1526
1527impl EffectiveChange {
1528 /// Relative path affected by this change.
1529 pub fn path(&self) -> &Path {
1530 match self {
1531 Self::Inserted { path, .. }
1532 | Self::Updated { path, .. }
1533 | Self::Removed { path, .. }
1534 | Self::ControlUpdated { path, .. }
1535 | Self::ControlRefusalUpdated { path, .. }
1536 | Self::Reclassified { path, .. }
1537 | Self::Invalidated { path, .. } => path,
1538 }
1539 }
1540}
1541
1542/// A stable fdu-native answer domain that one commit may have made stale.
1543#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
1544pub enum ImpactDomain {
1545 /// Entry presence, kind, parentage, or child membership.
1546 Topology,
1547 /// Filesystem metadata on retained entries.
1548 Metadata,
1549 /// Type or ignore classification.
1550 Classification,
1551 /// Maintained directory and whole-tree aggregates.
1552 Aggregates,
1553 /// Derived content records or aggregates.
1554 Content,
1555 /// Trust, coverage, or lifecycle state.
1556 State,
1557}
1558
1559/// Maximum number of individual dirty paths retained in one commit.
1560///
1561/// When a commit touches more, [`Impact::all_dirty`] is set and the partial list is
1562/// discarded. A truncated list would look complete and allow a stale answer to survive.
1563pub const MAX_DIRTY_PATHS: usize = 256;
1564
1565/// Bounded invalidation guidance derived from exact effective changes.
1566#[derive(Clone, PartialEq, Eq, Debug, Default)]
1567pub struct Impact {
1568 /// Answer domains that may have changed, in stable enum order without duplicates.
1569 pub domains: Vec<ImpactDomain>,
1570 /// Exact affected paths and ancestors, unless [`Self::all_dirty`] is set.
1571 pub dirty_paths: Vec<PathBuf>,
1572 /// Whether the affected path set exceeded the engine's retained-path limit.
1573 pub all_dirty: bool,
1574}
1575
1576/// One observable transition that did not change a retained filesystem entry.
1577#[derive(Clone, PartialEq, Eq, Debug)]
1578pub enum StateTransition {
1579 /// The trust state visible for a subtree changed.
1580 Freshness {
1581 /// Relative root of the affected subtree.
1582 path: PathBuf,
1583 /// State before the commit.
1584 previous: Freshness,
1585 /// State after the commit.
1586 current: Freshness,
1587 },
1588 /// A completed reconciliation verified every retained path beneath this root.
1589 Verified {
1590 /// Relative root covered by the reconciliation.
1591 path: PathBuf,
1592 },
1593 /// One directory's complete in-scope child listing became known.
1594 DirectoryComplete {
1595 /// Relative directory whose child set is now authoritative.
1596 path: PathBuf,
1597 },
1598 /// Previously known child-listing completeness was withdrawn after failed verification.
1599 DirectoryIncomplete {
1600 /// Relative directory whose child set is no longer authoritative.
1601 path: PathBuf,
1602 },
1603 /// The coherent opened-root state changed.
1604 IndexState {
1605 /// State before this commit.
1606 previous: IndexState,
1607 /// State after this commit.
1608 current: IndexState,
1609 },
1610}
1611
1612impl StateTransition {
1613 /// Relative path affected by this transition.
1614 pub fn path(&self) -> &Path {
1615 match self {
1616 Self::Freshness { path, .. }
1617 | Self::Verified { path }
1618 | Self::DirectoryComplete { path }
1619 | Self::DirectoryIncomplete { path } => path,
1620 Self::IndexState { .. } => Path::new(""),
1621 }
1622 }
1623}
1624
1625/// Bounded work performed while committing producer input or serving engine reads.
1626#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
1627pub struct Work {
1628 /// Producer operations considered.
1629 pub observations: u64,
1630 /// Accepted operations whose complete observed state already matched.
1631 pub unchanged: u64,
1632 /// Conditional observations rejected at the commit boundary.
1633 pub stale: u64,
1634 /// File observations refused at the commit boundary by a resource budget.
1635 pub resource_refused: u64,
1636 /// Retained index rows examined by a read projection.
1637 pub rows_visited: u64,
1638 /// Rows copied into read projection results.
1639 pub rows_returned: u64,
1640 /// Lookups against commit-maintained projection indexes.
1641 pub maintained_index_work: u64,
1642 /// Retained journal commits examined by a change poll.
1643 pub commits_visited: u64,
1644 /// Exact journal commits copied into a change result.
1645 pub commits_returned: u64,
1646 /// Filesystem directories successfully enumerated by verified work.
1647 pub directories_read: u64,
1648 /// Filesystem entries whose metadata was examined by verified work.
1649 pub entries_visited: u64,
1650 /// Regular files whose metadata was examined by verified work.
1651 pub files_visited: u64,
1652 /// Apparent bytes represented by regular files examined by verified work.
1653 pub bytes_visited: u64,
1654}
1655
1656/// Bytes [`Commit::retained_cost`] charges for a commit's own frame in the journal.
1657const RETAINED_COMMIT_BYTES: usize = 256;
1658/// Bytes [`Commit::retained_cost`] charges for each retained change, transition, or dirty
1659/// path, before the bytes of the path it names.
1660const RETAINED_ITEM_BYTES: usize = 128;
1661/// Smallest journal budget, in bytes, an opened root accepts.
1662///
1663/// The floor refuses a count passed where bytes are expected. Until the budget was stated
1664/// in bytes it counted retained items, and its default was this same number, so a caller
1665/// still thinking in counts passes at most that: every smaller count is refused, and the
1666/// old default itself, read as bytes, is a working budget.
1667///
1668/// It also guarantees history worth polling, as [`Commit::retained_cost`] charges it. The
1669/// cheapest commit a tree produces, one change to a one-byte name at the root, costs the
1670/// frame, three items (the change, the root it dirties, and its own path), and two path
1671/// bytes: 642 bytes. The floor holds about a hundred of those, or one commit of some two
1672/// hundred changes to short names in one directory. A budget that held only a few would
1673/// answer [`ChangeOutcome::Reset`] to a consumer barely behind, for a cause it cannot see.
1674pub const MIN_JOURNAL_CAPACITY_BYTES: usize = 64 * 1024;
1675
1676/// One atomic, exact index transition.
1677///
1678/// Detached indexes use the process-local [`Clock`] as their version sequence. The
1679/// opened-root layer later binds that sequence to its own lifetime identity without
1680/// putting live-session identity into clonable detached state.
1681#[derive(Clone, PartialEq, Eq, Debug)]
1682pub struct Commit {
1683 /// Logical commit clock minted for the complete transition.
1684 pub clock: Clock,
1685 /// Exact retained fact mutations in application order.
1686 pub changes: Vec<EffectiveChange>,
1687 /// Bounded answer invalidation derived from `changes` and `state`.
1688 pub impact: Impact,
1689 /// Observable non-entry transitions committed at the same boundary.
1690 pub state: Vec<StateTransition>,
1691 /// Work performed to reach this commit.
1692 pub work: Work,
1693}
1694
1695impl Commit {
1696 /// Whether this value carries no effective fact or state transition.
1697 pub fn is_empty(&self) -> bool {
1698 self.changes.is_empty() && self.state.is_empty()
1699 }
1700
1701 /// Approximate bytes this commit retains in the bounded journal.
1702 ///
1703 /// The estimate is a fixed allowance for the commit's own frame plus, for every
1704 /// change, transition, and dirty path, a fixed allowance for the item and the bytes of
1705 /// the path it names. Paths are the part that varies, so charging their bytes makes
1706 /// [`crate::DEFAULT_JOURNAL_CAPACITY_BYTES`] mean what it says whatever the tree's paths
1707 /// look like; a charge per item would let long paths hold many times the budget, and
1708 /// every change poll clones what the journal holds. The allowances are fixed rather
1709 /// than measured with `size_of` so the budget means the same on every target: the
1710 /// retained types differ in size by platform, and a recorded journal work count would
1711 /// otherwise differ with them.
1712 pub fn retained_cost(&self) -> usize {
1713 let paths = self
1714 .changes
1715 .iter()
1716 .map(|change| change.path().as_os_str().len())
1717 .chain(self.state.iter().map(|transition| transition.path().as_os_str().len()))
1718 .chain(self.impact.dirty_paths.iter().map(|path| path.as_os_str().len()))
1719 .sum::<usize>();
1720 let items = self.changes.len() + self.state.len() + self.impact.dirty_paths.len();
1721 RETAINED_COMMIT_BYTES + items * RETAINED_ITEM_BYTES + paths
1722 }
1723}
1724
1725/// Errors the engine can report.
1726#[derive(Debug, thiserror::Error)]
1727pub enum Error {
1728 /// A filesystem operation failed at a specific path.
1729 #[error("I/O error at {path}: {source}")]
1730 Io {
1731 /// Path whose operation failed.
1732 path: PathBuf,
1733 #[source]
1734 /// Underlying operating-system error.
1735 source: std::io::Error,
1736 },
1737
1738 /// An observation or subtree path was not relative to the index root.
1739 #[error("path escapes the index root: {0}")]
1740 PathEscapesRoot(PathBuf),
1741
1742 /// A live upsert named a child below ancestry the index has not verified.
1743 #[error("upsert {path:?} has unknown ancestry; reconcile from {reconcile_from:?}")]
1744 UnknownAncestry {
1745 /// Child whose parent chain is not known.
1746 path: PathBuf,
1747 /// Nearest known directory from which a producer can reconcile safely.
1748 reconcile_from: PathBuf,
1749 },
1750
1751 /// A control observation did not name the fixed control filename.
1752 #[error("invalid control-file path: {0:?}")]
1753 InvalidControlPath(PathBuf),
1754
1755 /// Snapshot persistence failed after a usable snapshot had been selected.
1756 #[error("snapshot is not usable: {0}")]
1757 Snapshot(String),
1758
1759 /// A scan or watch setting has no supported safe semantics.
1760 ///
1761 /// Raised only where no request was built: a bound root, a raw scan, a watch
1762 /// configuration, a scanner batch. A scope axis a request names is refused by
1763 /// [`Request::validate`](crate::query::Request::validate) as
1764 /// [`RequestError::ScopeUnsupported`](crate::query::RequestError::ScopeUnsupported)
1765 /// before any stored state is read, so a surface renders it in its own words and
1766 /// classifies it as the refused request it is. Both print the same sentence, because
1767 /// both ask [`ScopeAxis::reason`](crate::query::ScopeAxis::reason) for it.
1768 #[error("unsupported scan configuration: {0}")]
1769 UnsupportedScanConfig(&'static str),
1770
1771 /// An index or report built without observing `.gitignore` control state was asked
1772 /// about it, was asked to select entries by it, or was handed control input.
1773 ///
1774 /// Such a scan read no control file and classified no entry, so answering "not
1775 /// ignored" for every entry, handing back an empty control table, or selecting every
1776 /// entry or none by ignored state would state a fact nobody observed. Nor does an index
1777 /// accept a `ControlUpsert` or `ControlRemove` ([`Op`]): its scope says no rule was
1778 /// read, and a table installed anyway would contradict it, in the index and in every
1779 /// snapshot saved from it. Scanning with [`ScanConfig::read_controls`](crate::ScanConfig)
1780 /// on, as it is by default, makes the answers exact.
1781 #[error(
1782 "this scan did not observe .gitignore control state, so it neither says nor selects \
1783 what is ignored and accepts no control input; scan with read_controls to observe it"
1784 )]
1785 ControlStateNotObserved,
1786
1787 /// An index's control table enforces other limits than the ones its scan scope was
1788 /// taken under.
1789 ///
1790 /// The scope's ignore-rules identity names the limits that decide which `.gitignore`
1791 /// rules apply, so a table refusing under other limits would contradict it, in the index
1792 /// and in every snapshot saved from it. [`Index::new_with_scope`](crate::Index) applies
1793 /// the default limits whatever its scope claims;
1794 /// [`Index::new_with_config`](crate::Index::new_with_config) builds a table and a scope
1795 /// from one configuration, so they agree.
1796 #[error(
1797 "this index's .gitignore limits ({limits}) are not the ones its scan scope was taken \
1798 under; build it with Index::new_with_config from the ScanConfig that made its scope"
1799 )]
1800 ControlLimitsOutsideScope {
1801 /// The limits the index's control table enforces.
1802 limits: crate::control::ControlLimits,
1803 },
1804
1805 /// An opened root's journal budget is below [`MIN_JOURNAL_CAPACITY_BYTES`].
1806 #[error(
1807 "journal_capacity_bytes is {requested} bytes, below the {minimum}-byte minimum; it is \
1808 a size in bytes, not a count, so set it to at least {minimum} bytes, or leave it \
1809 unset for the default"
1810 )]
1811 JournalCapacityTooSmall {
1812 /// The budget requested, in bytes.
1813 requested: usize,
1814 /// [`MIN_JOURNAL_CAPACITY_BYTES`].
1815 minimum: usize,
1816 },
1817
1818 /// Requested scan semantics differ from the index's immutable scope.
1819 #[error("scan scope mismatch: index has {indexed:?}, requested {requested:?}")]
1820 ScanScopeMismatch {
1821 /// Scope represented by the index.
1822 indexed: ScanScope,
1823 /// Scope requested by the operation.
1824 requested: ScanScope,
1825 },
1826
1827 /// A requested relative subtree lies beyond the configured scan boundary.
1828 #[error("subtree {path:?} lies outside scan scope {scope:?}")]
1829 SubtreeOutsideScanScope {
1830 /// Rejected relative path.
1831 path: PathBuf,
1832 /// Scope that excludes the path.
1833 scope: ScanScope,
1834 },
1835
1836 /// A writer panicked while owning the shared index lock.
1837 #[error("index lock was poisoned by a panicking writer")]
1838 IndexLockPoisoned,
1839
1840 /// No further logical commit clock can be represented.
1841 #[error("the process-local index clock is exhausted")]
1842 ClockExhausted,
1843
1844 /// A tree, or a batch, would leave a whole-tree total no `u64` can hold.
1845 ///
1846 /// Roll-ups are `u64` counts and byte totals, and every route that counts a tree
1847 /// refuses one whose total leaves that range, with this error, rather than wrapping
1848 /// or panicking. A filesystem can produce one: tmpfs, XFS, and btrfs let a sparse file
1849 /// claim up to 8 EiB apparent without allocating anything, so three such files sum
1850 /// past `u64::MAX`.
1851 ///
1852 /// - A one-shot report, whether it folds a summary or builds a full or folded index,
1853 /// fails at the first file the root's total cannot hold, and reports nothing.
1854 /// - A batch committed to an index ([`Index::apply`](crate::Index::apply), and every
1855 /// scan, refresh, reconciliation, and watch commit) is applied in order, so the index
1856 /// must be representable after each of its operations: a batch that removes before
1857 /// it adds is accepted where the reverse order is not. The batch is refused before
1858 /// any of it is applied, and the index, its clock, and its journal are as they were.
1859 #[error(
1860 "counting {path:?} would carry the tree's {counter} total past what a u64 can hold, \
1861 so nothing that includes it was applied or reported"
1862 )]
1863 UnrepresentableTotal {
1864 /// The entry, or the batch operation, at which the total would leave the
1865 /// representable range.
1866 path: PathBuf,
1867 /// Which total: `files`, `directories`, `bytes`, or `allocated bytes`.
1868 counter: &'static str,
1869 },
1870
1871 /// No further live-session identity can be represented in this process.
1872 #[error("the process-local opened-index identity space is exhausted")]
1873 OpenedIdentityExhausted,
1874
1875 /// An operation was attempted after shared shutdown began.
1876 #[error("the opened index is closed")]
1877 OpenedIndexClosed,
1878
1879 /// An expanding operation was attempted after a resource stop.
1880 #[error("the opened index is stopped and cannot expand its retained set")]
1881 OpenedIndexStopped,
1882
1883 /// A priority request exceeded the public per-call bound.
1884 #[error("priority request contains {attempted} paths; limit is {limit}")]
1885 PriorityPathLimit {
1886 /// Paths supplied by the caller.
1887 attempted: usize,
1888 /// Maximum paths accepted by one request.
1889 limit: usize,
1890 },
1891
1892 /// A refresh request exceeded the public per-call input bound.
1893 #[error("refresh request contains {attempted} paths; limit is {limit}")]
1894 RefreshPathLimit {
1895 /// Paths supplied by the caller.
1896 attempted: usize,
1897 /// Maximum paths accepted by one request.
1898 limit: usize,
1899 },
1900
1901 /// A coherent read requested more projections than one bounded call accepts.
1902 #[error("read request contains {attempted} projections; limit is {limit}")]
1903 ReadProjectionLimit {
1904 /// Projections supplied by the caller.
1905 attempted: usize,
1906 /// Maximum projections accepted by one request.
1907 limit: usize,
1908 },
1909
1910 /// A pinned coherent read named a foreign or no-longer-current version.
1911 #[error("requested opened-index version {requested:?} is unavailable; current is {current:?}")]
1912 VersionUnavailable {
1913 /// Version required by the caller.
1914 requested: Box<EngineVersion>,
1915 /// Only version the live image can currently answer.
1916 current: Box<EngineVersion>,
1917 },
1918
1919 /// A page output bound was zero or exceeded the public maximum.
1920 #[error("page row limit {attempted} is outside 1..={limit}")]
1921 PageRowLimit {
1922 /// Rejected row limit.
1923 attempted: usize,
1924 /// Maximum accepted row limit.
1925 limit: usize,
1926 },
1927
1928 /// A page work bound was zero or exceeded the public maximum.
1929 #[error("page work limit {attempted} is outside 1..={limit}")]
1930 PageWorkLimit {
1931 /// Rejected work limit.
1932 attempted: u64,
1933 /// Maximum accepted work limit.
1934 limit: u64,
1935 },
1936
1937 /// A tree page asked for zero levels.
1938 ///
1939 /// Distinct from the page bounds above, which are about how much of a level fits.
1940 /// Zero levels is not a small page, it is a request with no rows to return, and
1941 /// reporting it as a row-limit failure sends a caller to inspect the wrong argument.
1942 #[error("tree page depth must be at least one level")]
1943 TreeDepthZero,
1944
1945 /// A flat page attempted to use presentation axes whose ordering is not resumable.
1946 #[error(
1947 "flat opened-index pages use fixed portable path order; selection cannot set depth, limit, sort, or reverse"
1948 )]
1949 UnsupportedFlatSelection,
1950
1951 /// An aggregate cap was zero or exceeded the public maximum.
1952 #[error("aggregate count cap {attempted} is outside 1..={limit}")]
1953 CountCapLimit {
1954 /// Rejected count cap.
1955 attempted: u64,
1956 /// Maximum accepted count cap.
1957 limit: u64,
1958 },
1959
1960 /// A request no holder of its own basis could answer, or that this holder cannot.
1961 ///
1962 /// The refusal is a value, rendered here in the library's field names; a surface that
1963 /// built the request renders the same value in its own words through
1964 /// [`RequestError::message`](crate::query::RequestError::message), which is why every
1965 /// door refuses one request with one rule.
1966 #[error(transparent)]
1967 InvalidRequest(crate::query::RequestError),
1968
1969 /// A report request exceeded the bounded section vocabulary for one read.
1970 #[error("report request contains {attempted} views or omissions; limit is {limit}")]
1971 ReportViewLimit {
1972 /// Views and omitted-view records supplied by the caller.
1973 attempted: usize,
1974 /// Maximum accepted combined records.
1975 limit: usize,
1976 },
1977
1978 /// A continuation belongs to another opened root, or names an ordinal this root never
1979 /// issued.
1980 ///
1981 /// Either is a malformed request, so the whole read fails. A token this root issued and
1982 /// no longer retains -- consumed or evicted -- refuses only its own projection with
1983 /// [`ProjectionRefusal::ContinuationUnavailable`].
1984 #[error(
1985 "the page continuation was not issued by this opened index; continue from a token a \
1986 page of this root returned"
1987 )]
1988 ContinuationUnavailable,
1989
1990 /// No further handle-local continuation identifier can be represented.
1991 #[error("the opened index continuation identity space is exhausted")]
1992 ContinuationIdentityExhausted,
1993
1994 /// A continuation was pinned to an older committed image.
1995 #[error("the page continuation version {requested:?} is stale; current is {current:?}")]
1996 ContinuationStale {
1997 /// Version captured by the continuation.
1998 requested: Box<EngineVersion>,
1999 /// Current live version.
2000 current: Box<EngineVersion>,
2001 },
2002
2003 /// A change cursor belongs to another handle, has incompatible identities, or is in
2004 /// the future.
2005 #[error("change cursor {requested:?} is unavailable; current is {current:?}")]
2006 ChangeCursorUnavailable {
2007 /// Cursor supplied by the consumer.
2008 requested: Box<EngineVersion>,
2009 /// Current live version against which it was validated.
2010 current: Box<EngineVersion>,
2011 },
2012
2013 /// A panic poisoned opened-root journal wait coordination.
2014 #[error("opened-index journal wait state was poisoned by a panic")]
2015 OpenedJournalPoisoned,
2016
2017 /// A producer tried to complete a path that was not a retained directory.
2018 #[error("directory completion named an unknown or non-directory path: {0:?}")]
2019 InvalidDirectoryCompletion(PathBuf),
2020
2021 /// A panic poisoned the opened index's lifecycle coordination state.
2022 #[error("opened-index lifecycle state was poisoned by a panic")]
2023 OpenedLifecyclePoisoned,
2024
2025 /// An owned opened-index worker panicked.
2026 ///
2027 /// Joined shutdown reports it, and so does a change poll that would otherwise wait for
2028 /// commits the root can no longer make.
2029 #[error("opened-index worker {worker} panicked")]
2030 OpenedWorkerPanicked {
2031 /// Stable role of the failed worker.
2032 worker: &'static str,
2033 },
2034
2035 /// An owned opened-index worker returned a terminal error during joined shutdown.
2036 #[error("opened-index worker {worker} failed: {source}")]
2037 OpenedWorkerFailed {
2038 /// Stable role of the failed worker.
2039 worker: &'static str,
2040 /// Original typed engine error, shared so repeated close returns the same cause.
2041 #[source]
2042 source: std::sync::Arc<Error>,
2043 },
2044
2045 /// An operating-system thread could not be created for an opened-index worker.
2046 #[error("could not start opened-index worker {worker}: {source}")]
2047 OpenedWorkerSpawn {
2048 /// Stable role of the worker that could not start.
2049 worker: &'static str,
2050 #[source]
2051 /// Underlying operating-system error.
2052 source: std::io::Error,
2053 },
2054
2055 /// The watch worker ended permanently without panicking.
2056 #[error("watch worker stopped before another observation was available")]
2057 WatchStopped,
2058
2059 /// The watch worker panicked; its bounded channel is no longer live.
2060 #[error("watch worker panicked and stopped")]
2061 WatchWorkerPanicked,
2062
2063 /// A scripted watch backend's event file could not be used.
2064 #[cfg(all(feature = "watch", test))]
2065 #[error("invalid watch script: {0}")]
2066 WatchScript(String),
2067
2068 /// Capture began, but verification could not establish a complete live baseline.
2069 #[error("filesystem observation handoff could not establish a complete verified baseline")]
2070 ObservationHandoffIncomplete,
2071
2072 #[cfg(test)]
2073 /// A test-only reducer preflight rejected a prepared transition.
2074 #[error("prepared commit rejected by {0}")]
2075 CommitRejected(&'static str),
2076
2077 /// An argument value did not match its documented grammar.
2078 ///
2079 /// Carries a suggestion rather than only a rejection, because these values are typed
2080 /// by humans and agents at a prompt: the whole point of a closed grammar is that a
2081 /// near miss can say what the near-hit spelling would be.
2082 #[error("invalid {kind} {value:?}: {hint}")]
2083 InvalidValue {
2084 /// Which grammar was expected, for the message: `time` or `size`.
2085 kind: &'static str,
2086 /// The rejected input, as written.
2087 value: String,
2088 /// What to write instead.
2089 hint: String,
2090 },
2091
2092 /// A watcher was paired with an index rooted at a different directory.
2093 #[error("watch root {watched:?} does not match index root {indexed:?}")]
2094 WatchRootMismatch {
2095 /// Canonical root owned by the watcher.
2096 watched: PathBuf,
2097 /// Canonical root owned by the index.
2098 indexed: PathBuf,
2099 },
2100}
2101
2102impl Error {
2103 pub(crate) fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
2104 Self::Io { path: path.into(), source }
2105 }
2106}
2107
2108/// Result alias for engine operations.
2109pub type Result<T> = std::result::Result<T, Error>;
2110
2111#[cfg(test)]
2112mod tests {
2113 use super::*;
2114
2115 #[test]
2116 fn clock_advances_monotonically() {
2117 let c = Clock::ZERO;
2118 assert_eq!(c.checked_next(), Some(Clock(1)));
2119 assert!(c.checked_next().is_some_and(|next| next > c));
2120 assert_eq!(Clock(u64::MAX).checked_next(), None);
2121 }
2122
2123 #[test]
2124 fn entry_kind_roundtrips_through_its_pinned_value() {
2125 for kind in [EntryKind::File, EntryKind::Dir, EntryKind::Symlink, EntryKind::Other] {
2126 assert_eq!(EntryKind::from_u8(kind as u8), Some(kind));
2127 }
2128 assert_eq!(EntryKind::from_u8(99), None);
2129 }
2130
2131 #[test]
2132 fn fingerprint_ignores_allocated_size_but_tracks_ctime() {
2133 let base = Attrs { size: 10, allocated: 4096, mtime_ns: 5, ctime_ns: 7, inode: 42, dev: 1 };
2134 // Allocation changes alone are not a content change.
2135 let repacked = Attrs { allocated: 8192, ..base };
2136 assert_eq!(base.fingerprint(), repacked.fingerprint());
2137
2138 // A ctime bump is, even when mtime was rolled back to look unchanged.
2139 let touched = Attrs { ctime_ns: 9, ..base };
2140 assert_ne!(base.fingerprint(), touched.fingerprint());
2141 }
2142
2143 #[test]
2144 fn scan_scope_separates_admission_from_answer_semantics() {
2145 let base = ScanScope::default();
2146 let changed_admission = ScanScope { exclude_special: !base.exclude_special, ..base };
2147 let changed_semantics =
2148 ScanScope { reducers_fingerprint: base.reducers_fingerprint.wrapping_add(1), ..base };
2149
2150 assert_ne!(base.entry_scope(), changed_admission.entry_scope());
2151 assert_eq!(base.semantic_identity(), changed_admission.semantic_identity());
2152 assert_eq!(base.entry_scope(), changed_semantics.entry_scope());
2153 assert_ne!(base.semantic_identity(), changed_semantics.semantic_identity());
2154 }
2155}
2156
2157#[cfg(test)]
2158mod provenance_tests {
2159 use super::*;
2160
2161 #[test]
2162 fn sources_order_from_most_to_least_trustworthy() {
2163 assert!(Source::Scanned < Source::Revalidated);
2164 assert!(Source::Revalidated < Source::JournalScoped);
2165 assert!(Source::JournalScoped < Source::Cached);
2166 }
2167
2168 #[test]
2169 fn combining_takes_the_least_trustworthy_of_each_fact() {
2170 let verified =
2171 Provenance { source: Source::Scanned, observed_at_ns: 900, status: Status::Complete };
2172 let stale =
2173 Provenance { source: Source::Cached, observed_at_ns: 100, status: Status::Partial };
2174 let combined = verified.combine(stale);
2175 assert_eq!(combined.source, Source::Cached, "weakest source wins");
2176 assert_eq!(combined.observed_at_ns, 100, "oldest observation wins");
2177 assert_eq!(combined.status, Status::Partial, "worst status wins");
2178 assert_eq!(combined, stale.combine(verified), "combination is commutative");
2179 }
2180
2181 #[test]
2182 fn an_unknown_timestamp_makes_the_combination_unknown() {
2183 // Fail closed. Zero means "we cannot say when", so a subtree containing one
2184 // contributor of unknown age has an unknown age too. Returning the known
2185 // timestamp instead would let a directory advertise a precise "as of" that is
2186 // wrong for part of what it summarises — the silent lie the provenance model
2187 // exists to prevent.
2188 let known = Provenance::scanned(500);
2189 let unknown = Provenance { observed_at_ns: 0, ..Provenance::scanned(0) };
2190 assert_eq!(known.combine(unknown).observed_at_ns, 0);
2191 assert_eq!(unknown.combine(known).observed_at_ns, 0);
2192 assert_eq!(known.combine(unknown), unknown.combine(known), "combination stays commutative");
2193 }
2194
2195 #[test]
2196 fn only_this_session_counts_as_verified() {
2197 assert!(Provenance::scanned(1).is_verified());
2198 assert!(Provenance { source: Source::Revalidated, ..Provenance::scanned(1) }.is_verified());
2199 // The journal can omit history without saying so, so its word is not a check.
2200 assert!(
2201 !Provenance { source: Source::JournalScoped, ..Provenance::scanned(1) }.is_verified()
2202 );
2203 assert!(!Provenance { source: Source::Cached, ..Provenance::scanned(1) }.is_verified());
2204 }
2205}