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