1use std::time::SystemTime;
12
13use crate::query::{
14 Delivery, Report, ReportProvenance, ReportSource, Request, SummaryRow, TreeStatus, ViewSpec,
15 report, report_summary,
16};
17use crate::{CachePolicy, EntryKind, Error, OpenPath, PendingSave, Progress, Result, execute};
18
19#[derive(Clone, Copy, PartialEq, Eq, Debug)]
25pub(crate) enum RetainedState {
26 Summary,
32 FullIndex,
34}
35
36#[derive(Clone, Copy, PartialEq, Eq, Debug)]
38pub enum Route {
39 OneShot,
41 Retained,
43 Refresh,
45 Watch,
47 Opened,
49}
50
51#[derive(Clone, Copy, PartialEq, Eq, Debug)]
53pub enum Load {
54 None,
56 Snapshot,
58}
59
60#[derive(Clone, Copy, PartialEq, Eq, Debug)]
62pub enum Verify {
63 None,
65 Filesystem,
67}
68
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub enum OutcomeClass {
72 Success,
74 Partial,
76}
77
78#[derive(Clone, Debug)]
80pub struct Plan {
81 pub(crate) basis: crate::query::Basis,
82 pub(crate) route: Route,
83 pub(crate) retained: RetainedState,
84 pub(crate) load: Load,
85 pub(crate) verify: Verify,
86 pub(crate) persist: bool,
87 pub(crate) delivery: Delivery,
88}
89
90impl Plan {
91 pub fn outcome(&self, status: &TreeStatus) -> OutcomeClass {
93 if status.complete || self.delivery.accept_partial {
94 OutcomeClass::Success
95 } else {
96 OutcomeClass::Partial
97 }
98 }
99 pub fn basis(&self) -> &crate::query::Basis {
101 &self.basis
102 }
103 pub const fn route(&self) -> Route {
105 self.route
106 }
107 pub const fn load(&self) -> Load {
109 self.load
110 }
111 pub const fn verify(&self) -> Verify {
113 self.verify
114 }
115 pub const fn persists(&self) -> bool {
122 self.persist
123 }
124 pub fn delivery(&self) -> &Delivery {
126 &self.delivery
127 }
128}
129
130pub(crate) struct StoreHeader<'a> {
132 pub(crate) root: &'a std::path::Path,
133 pub(crate) snapshot: crate::SnapshotIdentity,
134 pub(crate) content: Option<&'a crate::ContentTierIdentity>,
135 pub(crate) content_complete: bool,
136}
137
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub(crate) enum Admission {
141 Serve(crate::Serves),
142 NoLocation,
143 Missing,
144 WrongRoot,
145 WrongScope,
146 IncompleteContent,
147}
148
149impl Plan {
150 pub(crate) fn admit(
157 &self,
158 stored: Option<&StoreHeader<'_>>,
159 basis: &crate::query::Basis,
160 ) -> Admission {
161 if self.delivery.cache_path.is_none() {
162 return Admission::NoLocation;
163 }
164 let Some(stored) = stored else {
165 return Admission::Missing;
166 };
167 if stored.root != basis.root {
168 return Admission::WrongRoot;
169 }
170 let relation = crate::serves_snapshot(stored.snapshot, basis.scope.snapshot_identity());
171 if relation == crate::Serves::Refuse {
172 return Admission::WrongScope;
173 }
174 if self.verify == Verify::None && basis.content.is_enabled() {
175 let wanted = crate::ContentTierIdentity::for_request(
176 basis.scope.snapshot_identity().entries,
177 basis.content,
178 );
179 if !stored.content_complete
180 || stored.content.and_then(|identity| wanted.admit(identity)).is_none()
181 {
182 return Admission::IncompleteContent;
183 }
184 }
185 Admission::Serve(relation)
186 }
187}
188
189#[derive(Clone, Copy, Debug)]
191#[allow(clippy::struct_excessive_bools)]
192pub(crate) struct RunFacts {
193 pub(crate) entries_verified: bool,
194 pub(crate) entries_changed: bool,
195 pub(crate) content_changed: bool,
196 pub(crate) content_requested: bool,
197 pub(crate) projected: bool,
198 pub(crate) paired_entries: bool,
199}
200
201#[derive(Clone, Copy, Debug, PartialEq, Eq)]
203pub(crate) struct SaveTargets {
204 pub(crate) metadata: bool,
205 pub(crate) content: bool,
206}
207
208impl SaveTargets {
209 pub(crate) const fn none(self) -> bool {
210 !self.metadata && !self.content
211 }
212}
213
214impl Plan {
215 pub(crate) fn writes(&self, run: RunFacts) -> SaveTargets {
216 let allowed = self.persist && self.delivery.cache_path.is_some();
217 SaveTargets {
218 metadata: allowed && run.entries_verified && run.entries_changed && !run.projected,
219 content: allowed
220 && run.content_requested
221 && run.content_changed
222 && (run.entries_verified || run.paired_entries),
223 }
224 }
225}
226
227#[derive(Clone, Copy, PartialEq, Eq, Debug)]
232pub struct PerformanceSummary {
233 pub walked_files: u64,
235 pub walked_bytes: u64,
237 pub walked_allocated: u64,
240 pub fresh_files: u64,
242 pub bytes_read: u64,
244 pub analysis_ns: u64,
246 pub cached_files: u64,
248 pub cached_bytes: u64,
250 pub source: ReportSource,
252}
253
254impl Default for PerformanceSummary {
255 fn default() -> Self {
256 Self {
257 walked_files: 0,
258 walked_bytes: 0,
259 walked_allocated: 0,
260 fresh_files: 0,
261 bytes_read: 0,
262 analysis_ns: 0,
263 cached_files: 0,
264 cached_bytes: 0,
265 source: ReportSource::ColdScan,
266 }
267 }
268}
269
270impl PerformanceSummary {
271 pub fn total_throughput(
274 self,
275 elapsed: std::time::Duration,
276 size: crate::query::SizeMetric,
277 ) -> String {
278 let bytes = match size {
279 crate::query::SizeMetric::Apparent => self.walked_bytes,
280 crate::query::SizeMetric::Allocated => self.walked_allocated,
281 };
282 throughput_rates(self.walked_files, bytes, elapsed).map_or_else(
283 || "throughput unavailable".to_owned(),
284 |(files, gib)| format!("{files} files/s ({gib} GiB/s)"),
285 )
286 }
287
288 fn from_open_report(report: &crate::OpenReport) -> Self {
289 let analysis = report.analysis.unwrap_or_default();
290 Self {
291 walked_files: report.scan.files_walked,
292 walked_bytes: report.scan.bytes_walked,
293 walked_allocated: report.scan.allocated_walked,
294 fresh_files: analysis.candidates,
295 bytes_read: analysis.bytes_read,
296 analysis_ns: analysis.elapsed_ns,
297 cached_files: report.content_cache.hits,
298 cached_bytes: report.content_cache.bytes,
299 source: match report.path_taken {
300 OpenPath::ColdScan => ReportSource::ColdScan,
301 OpenPath::WarmRevalidate => ReportSource::WarmRevalidate,
302 OpenPath::CacheOnly => ReportSource::CacheOnly,
303 },
304 }
305 }
306}
307
308pub fn throughput_rates(
313 files: u64,
314 bytes: u64,
315 elapsed: std::time::Duration,
316) -> Option<(String, String)> {
317 let ns = elapsed.as_nanos();
318 if ns == 0 {
319 return None;
320 }
321 let files_per_second = u128::from(files) * 1_000_000_000 / ns;
322 let gib_denominator = ns * (1_u128 << 30);
323 let gib_thousandths =
324 (u128::from(bytes) * 1_000_000_000 * 1_000 + gib_denominator / 2) / gib_denominator;
325 Some((
326 crate::report_format::human_count_u128(files_per_second),
327 format!("{}.{:03}", gib_thousandths / 1_000, gib_thousandths % 1_000),
328 ))
329}
330
331pub fn plan(
373 request: &Request,
374 delivery: &Delivery,
375 route: Route,
376) -> std::result::Result<Plan, crate::query::RequestError> {
377 request.validate()?;
378 let mut normalized = delivery.clone();
379 if route == Route::Watch {
380 normalized.watch.get_or_insert_with(crate::query::WatchDelivery::default);
381 }
382 let delivery = &normalized;
383 request.validate_delivery(delivery)?;
384 if route == Route::Opened {
385 if delivery.cache != CachePolicy::Off
386 || delivery.watch.is_some()
387 || request.basis.content.is_enabled()
388 {
389 return Err(crate::query::RequestError::DeliveryUnsupported {
390 route: "opened",
391 reason: "progressive discovery requires cache off, no content analyzers, and observation configured through OpenOptions",
392 });
393 }
394 if delivery.workers.scan.is_some()
398 || delivery.order != crate::ScanOrder::default()
399 || delivery.accept_partial
400 {
401 return Err(crate::query::RequestError::DeliveryUnsupported {
402 route: "opened",
403 reason: "progressive discovery schedules one breadth-first producer and reports coverage as state, so it takes no scan worker count, traversal order, or partial-answer acceptance",
404 });
405 }
406 }
407 if route == Route::Refresh && delivery.stale_ok {
408 return Err(crate::query::RequestError::DeliveryUnsupported {
409 route: "refresh",
410 reason: "a stale answer cannot verify filesystem state",
411 });
412 }
413 let analysis_requested = request.basis.content.is_enabled();
414 let summary_is_sufficient = request.query.views.as_slice() == [ViewSpec::Summary]
415 && request.query.selection.is_unfiltered()
416 && request.basis.scope.population == crate::query::IgnoredEntries::Include;
417 let policy_requires_index =
418 delivery.stale_ok || (delivery.cache == CachePolicy::On && delivery.cache_path.is_some());
419 let stored_state_pays = route != Route::OneShot || analysis_requested;
425 let read_snapshot = delivery.stale_ok
426 || match delivery.cache {
427 CachePolicy::Off => false,
428 CachePolicy::Auto | CachePolicy::On => stored_state_pays,
429 };
430 let persist = !delivery.stale_ok
431 && match delivery.cache {
432 CachePolicy::Off => false,
433 CachePolicy::Auto => stored_state_pays,
434 CachePolicy::On => true,
435 };
436 Ok(Plan {
437 basis: request.basis.clone(),
438 route,
439 retained: if route == Route::OneShot
440 && !policy_requires_index
441 && !analysis_requested
442 && summary_is_sufficient
443 {
444 RetainedState::Summary
445 } else {
446 RetainedState::FullIndex
447 },
448 load: if read_snapshot { Load::Snapshot } else { Load::None },
449 verify: if delivery.stale_ok { Verify::None } else { Verify::Filesystem },
450 persist,
451 delivery: delivery.clone(),
452 })
453}
454
455pub fn prepare_report(
483 request: &Request,
484 delivery: &Delivery,
485) -> Result<(Report, PendingSave, PerformanceSummary)> {
486 prepare_report_internal(request, delivery, false, None)
487 .map(|(report, pending, performance, _diagnostics)| (report, pending, performance))
488}
489
490pub fn prepare_report_with_progress(
507 request: &Request,
508 delivery: &Delivery,
509 progress: &Progress,
510) -> Result<(Report, PendingSave, PerformanceSummary)> {
511 prepare_report_internal(request, delivery, false, Some(progress))
512 .map(|(report, pending, performance, _diagnostics)| (report, pending, performance))
513}
514
515pub fn prepare_report_with_scan_diagnostics(
524 request: &Request,
525 delivery: &Delivery,
526) -> Result<(Report, PendingSave, PerformanceSummary, Option<crate::scan::ScanDiagnostics>)> {
527 prepare_report_internal(request, delivery, true, None)
528}
529
530fn prepare_report_internal(
531 request: &Request,
532 delivery: &Delivery,
533 collect_scan_diagnostics: bool,
534 progress: Option<&Progress>,
535) -> Result<(Report, PendingSave, PerformanceSummary, Option<crate::scan::ScanDiagnostics>)> {
536 request.validate().map_err(Error::InvalidRequest)?;
543 let scan_config = crate::ScanConfig {
544 progress: progress.cloned(),
545 ..request.basis.scope.scan_config(delivery)
546 };
547 let root = request.basis.root.as_path();
548 let scan_started_at = SystemTime::now();
549 let plan = plan(request, delivery, Route::OneShot).map_err(Error::InvalidRequest)?;
550 match plan.retained {
551 RetainedState::Summary => {
552 let root = root.canonicalize().map_err(|error| Error::io(root, error))?;
553 let mut fold = SummaryFold::new(&scan_config);
554 let mut reduce = |observed: &crate::ObservationOp| fold.observe(observed);
555 let (mut scan, scan_diagnostics) = if collect_scan_diagnostics {
556 let (scan, diagnostics) = crate::scan::scan_summary_fold_with_diagnostics(
557 &root,
558 &scan_config,
559 &mut reduce,
560 )?;
561 (scan, Some(diagnostics))
562 } else {
563 (crate::scan::scan_summary_fold(&root, &scan_config, &mut reduce)?, None)
564 };
565 let complete = scan.is_complete();
566 let generated_at = SystemTime::now();
567 let (summary, ignore_rules, ignored_unverified) = fold.finish(&root, &scan.errors)?;
568 let report = report_summary(
569 &root,
570 scan_config.scope(),
571 request,
572 summary,
573 ignore_rules,
574 ignored_unverified,
575 TreeStatus::of_walk(&root, &mut scan),
576 ReportProvenance::of_walk(scan_started_at, generated_at, complete),
577 );
578 let performance = PerformanceSummary {
579 walked_files: scan.files_walked,
580 walked_bytes: scan.bytes_walked,
581 walked_allocated: scan.allocated_walked,
582 source: ReportSource::ColdScan,
583 ..PerformanceSummary::default()
584 };
585 Ok((report, PendingSave::none(), performance, scan_diagnostics))
586 }
587 RetainedState::FullIndex => {
588 let (index, open_report, pending_save, scan_diagnostics) =
589 execute(&plan, &request.basis, collect_scan_diagnostics, progress)?;
590 let performance = PerformanceSummary::from_open_report(&open_report);
591 if let Some(progress) = progress {
592 progress.enter(crate::ProgressPhase::Summarizing);
593 }
594 let answer = report(&index, request, SystemTime::now())?;
595 debug_assert_eq!(answer.scope, scan_config.scope());
596 crate::release_index(index);
599 Ok((answer, pending_save, performance, scan_diagnostics))
600 }
601 }
602}
603
604struct SummaryFold {
642 all: SummaryRow,
644 controls: Option<SummaryControls>,
646}
647
648struct SummaryControls {
651 table: crate::control::ControlTable,
652 ignored_heads: std::collections::HashSet<std::path::PathBuf>,
660 parent: Option<(std::path::PathBuf, bool)>,
663 chain: Option<(std::path::PathBuf, crate::control::ControlChain)>,
666 unignored: crate::index::RollUpScalars,
668 rejected: Option<Error>,
671}
672
673impl SummaryFold {
674 fn new(config: &crate::ScanConfig) -> Self {
675 Self {
676 all: SummaryRow::default(),
677 controls: config.read_controls.then(|| SummaryControls {
678 table: crate::control::ControlTable::with_limits(config.control_limits),
679 ignored_heads: std::collections::HashSet::new(),
680 parent: None,
681 chain: None,
682 unignored: crate::index::RollUpScalars::default(),
683 rejected: None,
684 }),
685 }
686 }
687
688 fn observe(&mut self, observed: &crate::ObservationOp) {
689 match &observed.op {
690 crate::Op::Upsert { path, kind, attrs } => {
691 match kind {
692 EntryKind::File => {
693 self.all.files += 1;
694 self.all.bytes += attrs.size;
695 self.all.allocated += attrs.allocated;
696 self.all.newest_mtime_ns = Some(
697 self.all
698 .newest_mtime_ns
699 .map_or(attrs.mtime_ns, |current| current.max(attrs.mtime_ns)),
700 );
701 }
702 EntryKind::Dir => self.all.dirs += 1,
703 EntryKind::Symlink | EntryKind::Other => {}
704 }
705 let Some(controls) = &mut self.controls else { return };
706 if controls.classify(path, *kind) {
707 return;
708 }
709 match kind {
712 EntryKind::File => {
713 controls.unignored.files += 1;
714 controls.unignored.bytes += attrs.size;
715 controls.unignored.allocated += attrs.allocated;
716 }
717 EntryKind::Dir => controls.unignored.dirs += 1,
718 EntryKind::Symlink | EntryKind::Other => {}
719 }
720 }
721 crate::Op::ControlUpsert { path, source } => {
722 if let Some(controls) = &mut self.controls {
723 controls.chain = None;
724 let admitted = controls.table.upsert(path, source.clone()).map(drop);
725 controls.record(admitted);
726 }
727 }
728 crate::Op::ControlRemove { path } => {
729 if let Some(controls) = &mut self.controls {
730 controls.chain = None;
731 let removed = controls.table.remove(path).map(drop);
732 controls.record(removed);
733 }
734 }
735 crate::Op::Remove { .. } | crate::Op::InvalidateSubtree { .. } => {}
737 }
738 }
739
740 fn finish(
745 self,
746 root: &std::path::Path,
747 errors: &[Error],
748 ) -> Result<(SummaryRow, crate::control::ControlCoverage, bool)> {
749 let Some(controls) = self.controls else {
750 return Ok((
751 SummaryRow { ignored: None, ..self.all },
752 crate::control::ControlCoverage::NotObserved,
753 false,
754 ));
755 };
756 if let Some(error) = controls.rejected {
757 return Err(error);
758 }
759 let unreadable =
760 errors.iter().any(|error| crate::control::unreadable_control(root, error).is_some());
761 let verified = controls.table.refused_len() == 0 && !unreadable;
762 let all = crate::index::RollUpScalars {
763 files: self.all.files,
764 dirs: self.all.dirs,
765 bytes: self.all.bytes,
766 allocated: self.all.allocated,
767 newest_mtime_ns: self.all.newest_mtime_ns.unwrap_or_default(),
768 };
769 let summary = SummaryRow {
770 ignored: verified.then(|| crate::query::IgnoredTally::between(all, controls.unignored)),
771 ..self.all
772 };
773 Ok((
774 summary,
775 crate::control::ControlCoverage::Observed(controls.table.observation()),
776 !verified,
777 ))
778 }
779}
780
781impl SummaryControls {
782 fn classify(&mut self, path: &std::path::Path, kind: EntryKind) -> bool {
787 if self.ignored_heads.is_empty() && self.table.is_empty() {
790 return false;
791 }
792 let parent = path.parent().unwrap_or_else(|| std::path::Path::new(""));
793 let parent_ignored = self.parent_ignored(parent);
794 let ignored = if parent_ignored || self.table.is_empty() {
795 parent_ignored
796 } else if let Some(name) = path.file_name() {
797 if !matches!(&self.chain, Some((cached, _)) if cached == parent) {
798 self.chain = Some((parent.to_path_buf(), self.table.chain_for(parent)));
799 }
800 let (_, chain) = self.chain.as_ref().expect("the chain was just resolved");
801 chain.is_ignored(parent, name.as_encoded_bytes(), kind.is_dir())
802 } else {
803 self.table.matcher_for(path).is_ignored(kind.is_dir())
804 };
805 if ignored && !parent_ignored && kind.is_dir() {
806 self.ignored_heads.insert(path.to_path_buf());
807 }
808 ignored
809 }
810
811 fn parent_ignored(&mut self, parent: &std::path::Path) -> bool {
812 if self.ignored_heads.is_empty() {
813 return false;
814 }
815 if let Some((cached, ignored)) = &self.parent {
816 if cached == parent {
817 return *ignored;
818 }
819 }
820 let ignored = parent.ancestors().any(|ancestor| self.ignored_heads.contains(ancestor));
821 self.parent = Some((parent.to_path_buf(), ignored));
822 ignored
823 }
824
825 fn record(&mut self, applied: Result<()>) {
826 if let Err(error) = applied {
827 self.rejected.get_or_insert(error);
828 }
829 }
830}
831
832#[cfg(test)]
833mod tests {
834 use std::fs;
835 use std::path::{Path, PathBuf};
836
837 use super::*;
838 use crate::query::{IgnoredEntries, Pattern, Query, Section};
839 use crate::{OpenFixture, ScanConfig};
840
841 #[test]
842 fn total_throughput_uses_one_elapsed_sample_and_selected_size() {
843 use crate::query::SizeMetric;
844 let work = PerformanceSummary {
845 walked_files: 200,
846 walked_bytes: 4_000_000_000,
847 walked_allocated: 1_000_000_000,
848 ..PerformanceSummary::default()
849 };
850 assert_eq!(
851 work.total_throughput(std::time::Duration::from_secs(2), SizeMetric::Apparent),
852 "100 files/s (1.863 GiB/s)"
853 );
854 assert_eq!(
855 work.total_throughput(std::time::Duration::from_secs(2), SizeMetric::Allocated),
856 "100 files/s (0.466 GiB/s)"
857 );
858 assert_eq!(
859 work.total_throughput(std::time::Duration::ZERO, SizeMetric::Apparent),
860 "throughput unavailable"
861 );
862 assert_eq!(
863 PerformanceSummary::default()
864 .total_throughput(std::time::Duration::from_secs(1), SizeMetric::Apparent),
865 "0 files/s (0.000 GiB/s)"
866 );
867 assert_eq!(
868 throughput_rates(12_345, 3 * (1_u64 << 30), std::time::Duration::from_secs(2)),
869 Some(("6,172".to_owned(), "1.500".to_owned()))
870 );
871 }
872
873 #[test]
874 #[cfg(unix)]
875 fn an_unreadable_stored_header_never_authorizes_live_replacement() {
876 use std::os::unix::fs::PermissionsExt;
877 if !crate::test_support::require_permission_bits() {
878 return;
879 }
880 let root = tempfile::tempdir().expect("root");
881 let cache = tempfile::tempdir().expect("cache");
882 let snapshot = cache.path().join("snapshot.fdu");
883 fs::write(root.path().join(".gitignore"), b"ignored\n").expect("control");
884 let observed = crate::query::Basis {
885 root: root.path().into(),
886 scope: crate::query::Scope::default(),
887 content: crate::content::AnalysisSet::NONE,
888 };
889 let delivery = Delivery::new(CachePolicy::Auto, Some(snapshot.clone()));
890 crate::open(&observed, &delivery).expect("stronger snapshot");
891 let original = fs::read(&snapshot).expect("original image");
892 let basis = crate::query::Basis {
893 scope: crate::query::Scope { read_controls: false, ..Default::default() },
894 ..observed
895 };
896 let (mut index, _) =
897 crate::open(&basis, &Delivery::new(CachePolicy::Off, None)).expect("fresh blind index");
898 let request = Request::new(basis, Query::default(), SystemTime::now());
899 let plan = plan(&request, &delivery, Route::Refresh).expect("plan");
900 fs::set_permissions(&snapshot, fs::Permissions::from_mode(0o000))
901 .expect("deny header read");
902 let nonwriting = Delivery { cache: CachePolicy::Off, ..delivery.clone() };
903 let nonwriting_plan =
904 super::plan(&request, &nonwriting, Route::Refresh).expect("nonwriting plan");
905 assert!(
906 !crate::persist_index_changes(&index, &nonwriting_plan, true, true)
907 .expect("nonwriting policy never reads the header")
908 );
909 fs::write(root.path().join("fresh.txt"), b"fresh").expect("mutation");
910 crate::refresh(
911 &mut index,
912 &request.basis,
913 &Delivery { cache: CachePolicy::Off, ..delivery.clone() },
914 )
915 .expect("off refresh does not inspect cache state");
916 let result = crate::persist_index_changes(&index, &plan, true, true);
917 fs::set_permissions(&snapshot, fs::Permissions::from_mode(0o600)).expect("restore");
918 assert!(
919 result.is_err(),
920 "a writable parent must not let unknown identity authorize replacement"
921 );
922 assert_eq!(fs::read(&snapshot).expect("retained image"), original);
923 }
924
925 #[test]
926 fn refresh_rejects_another_root_before_mutating_or_persisting() {
927 let a = tempfile::tempdir().expect("root a");
928 let b = tempfile::tempdir().expect("root b");
929 let cache = tempfile::tempdir().expect("cache");
930 let basis = crate::query::Basis {
931 root: a.path().into(),
932 scope: crate::query::Scope::default(),
933 content: crate::content::AnalysisSet::NONE,
934 };
935 let (mut index, _) =
936 crate::open(&basis, &Delivery::new(CachePolicy::Off, None)).expect("open a");
937 fs::write(a.path().join("new"), b"new facts").expect("mutation a");
938 let before = index.clock();
939 let snapshot = cache.path().join("snapshot.fdu");
940 let wrong = crate::query::Basis { root: b.path().into(), ..basis.clone() };
941 let delivery = Delivery::new(CachePolicy::Auto, Some(snapshot.clone()));
942 let error = crate::refresh(&mut index, &wrong, &delivery).expect_err("different root");
943 assert!(matches!(
944 error,
945 Error::InvalidRequest(crate::query::RequestError::RootMismatch { .. })
946 ));
947 assert_eq!(index.clock(), before);
948 assert!(!snapshot.exists());
949 let alias = crate::query::Basis { root: a.path().join("."), ..basis };
950 crate::refresh(&mut index, &alias, &delivery).expect("same root spelling");
951 assert_eq!(index.total().files, 1);
952 #[cfg(unix)]
953 {
954 let link = cache.path().join("root-alias");
955 std::os::unix::fs::symlink(a.path(), &link).expect("root alias");
956 let symlink_basis = crate::query::Basis { root: link, ..alias };
957 crate::refresh(&mut index, &symlink_basis, &delivery)
958 .expect("same canonical root through symlink");
959 }
960 }
961
962 #[test]
963 fn unchanged_refresh_replaces_an_incompatible_stored_baseline() {
964 let root = tempfile::tempdir().expect("root");
965 let other = tempfile::tempdir().expect("other root");
966 let cache = tempfile::tempdir().expect("cache");
967 fs::write(root.path().join("file"), b"retained").expect("file");
968 let basis = crate::query::Basis {
969 root: root.path().into(),
970 scope: crate::query::Scope::default(),
971 content: crate::content::AnalysisSet::NONE,
972 };
973 let delivery = Delivery::new(CachePolicy::Auto, Some(cache.path().join("snapshot.fdu")));
974 for wrong_root in [false, true] {
975 let wrong = if wrong_root {
976 crate::query::Basis { root: other.path().into(), ..basis.clone() }
977 } else {
978 crate::query::Basis {
979 scope: crate::query::Scope { max_depth: Some(0), ..basis.scope.clone() },
980 ..basis.clone()
981 }
982 };
983 crate::open(&wrong, &Delivery { cache: CachePolicy::On, ..delivery.clone() })
984 .expect("incompatible snapshot");
985 let (mut index, _) = crate::open(&basis, &Delivery::new(CachePolicy::Off, None))
986 .expect("retained index");
987 let refreshed =
988 crate::refresh(&mut index, &basis, &delivery).expect("refresh reseeds cache");
989 assert!(!refreshed.apply.mutated(), "the existing index was already current");
990 let (cached, _) = crate::open(&basis, &Delivery { stale_ok: true, ..delivery.clone() })
991 .expect("cache-only can now answer");
992 assert_eq!(cached.total().bytes, 8);
993 }
994 }
995
996 #[test]
997 fn refreshed_metadata_and_content_are_visible_to_a_later_cache_only_open() {
998 let root = tempfile::tempdir().expect("root");
999 let cache = tempfile::tempdir().expect("cache");
1000 let path = root.path().join("note.txt");
1001 fs::write(&path, b"old\n").expect("old file");
1002 let basis = crate::query::Basis {
1003 root: root.path().into(),
1004 scope: crate::query::Scope::default(),
1005 content: crate::content::AnalysisSet::NONE.with_lines(),
1006 };
1007 let delivery = Delivery::new(CachePolicy::Auto, Some(cache.path().join("snapshot.fdu")));
1008 let (mut index, _) = crate::open(&basis, &delivery).expect("initial open");
1009 fs::write(&path, b"new longer text\nsecond line\n").expect("mutation");
1010 let refreshed = crate::refresh(&mut index, &basis, &delivery).expect("refresh");
1011 assert!(refreshed.is_complete());
1012 let (cached, report) = crate::open(&basis, &Delivery { stale_ok: true, ..delivery })
1013 .expect("cache-only sees refreshed tiers");
1014 assert_eq!(report.path_taken, OpenPath::CacheOnly);
1015 assert_eq!(cached.total(), index.total());
1016 assert_eq!(
1017 cached.total().bytes,
1018 u64::try_from(b"new longer text\nsecond line\n".len()).expect("length")
1019 );
1020 assert_eq!(report.content_cache.hits, 1);
1021 let original = index
1022 .content()
1023 .expect("fresh content")
1024 .file(Path::new("note.txt"))
1025 .expect("fresh file");
1026 let restored = cached
1027 .content()
1028 .expect("restored content")
1029 .file(Path::new("note.txt"))
1030 .expect("restored file");
1031 assert_eq!(restored, original);
1032 }
1033
1034 #[cfg(unix)]
1037 fn owed_persistence_fixture()
1038 -> (tempfile::TempDir, tempfile::TempDir, crate::query::Basis, Delivery) {
1039 let root = tempfile::tempdir().expect("root");
1040 let cache = tempfile::tempdir().expect("cache");
1041 fs::create_dir(root.path().join("locked")).expect("locked dir");
1042 fs::write(root.path().join("first"), b"first").expect("first file");
1043 let basis = crate::query::Basis {
1044 root: root.path().into(),
1045 scope: crate::query::Scope::default(),
1046 content: crate::content::AnalysisSet::NONE,
1047 };
1048 let delivery = Delivery::new(CachePolicy::Auto, Some(cache.path().join("snapshot.fdu")));
1049 (root, cache, basis, delivery)
1050 }
1051
1052 #[cfg(unix)]
1054 fn cached_files(basis: &crate::query::Basis, delivery: &Delivery) -> u64 {
1055 let cache_only = Delivery { stale_ok: true, ..delivery.clone() };
1056 crate::open(basis, &cache_only).expect("cache-only open").0.total().files
1057 }
1058
1059 #[test]
1066 #[cfg(unix)]
1067 fn an_unchanged_refresh_repeats_the_metadata_write_a_failed_refresh_owed() {
1068 use std::os::unix::fs::PermissionsExt;
1069 if !crate::test_support::require_permission_bits() {
1070 return;
1071 }
1072 let (root, cache, basis, delivery) = owed_persistence_fixture();
1073 let (mut index, _) = crate::open(&basis, &delivery).expect("initial open");
1074 assert_eq!(cached_files(&basis, &delivery), 1);
1075
1076 fs::write(root.path().join("second"), b"second").expect("second file");
1077 fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o555)).expect("deny write");
1078 let failed = crate::refresh(&mut index, &basis, &delivery);
1079 fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o755)).expect("restore");
1080 assert!(failed.is_err(), "a read-only cache directory fails the write");
1081 assert_eq!(index.total().files, 2, "the index advanced before the write");
1082 assert_eq!(cached_files(&basis, &delivery), 1, "the failed write left the old image");
1083
1084 let unchanged = crate::refresh(&mut index, &basis, &delivery).expect("unchanged refresh");
1085 assert!(unchanged.is_complete());
1086 assert!(!unchanged.apply.mutated(), "nothing changed between the passes");
1087 assert_eq!(cached_files(&basis, &delivery), 2, "the owed write ran");
1088
1089 let snapshot = delivery.cache_path.as_deref().expect("path");
1091 let written = fs::metadata(snapshot).expect("snapshot").modified().expect("mtime");
1092 crate::refresh(&mut index, &basis, &delivery).expect("settled refresh");
1093 assert_eq!(fs::metadata(snapshot).expect("snapshot").modified().expect("mtime"), written);
1094 }
1095
1096 #[test]
1099 #[cfg(unix)]
1100 fn an_unchanged_refresh_repeats_the_metadata_write_a_failed_open_owed() {
1101 use std::os::unix::fs::PermissionsExt;
1102 if !crate::test_support::require_permission_bits() {
1103 return;
1104 }
1105 let (root, cache, basis, delivery) = owed_persistence_fixture();
1106 crate::open(&basis, &delivery).expect("complete open writes the snapshot");
1107 fs::write(root.path().join("second"), b"second").expect("second file");
1108 fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o555)).expect("deny write");
1109 let opened = crate::open_with_pending_save(&basis, &delivery);
1110 let outcome = opened.map(|(index, report, pending)| (index, report, pending.join()));
1112 fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o755)).expect("restore");
1113 let (index, report, joined) = outcome.expect("the open itself succeeds");
1114 assert_eq!(report.path_taken, OpenPath::WarmRevalidate);
1115 assert!(joined.is_err(), "the startup write failed");
1116 let mut index = std::sync::Arc::into_inner(index).expect("the writer released the index");
1117 assert_eq!(cached_files(&basis, &delivery), 1);
1118
1119 let unchanged = crate::refresh(&mut index, &basis, &delivery).expect("unchanged refresh");
1120 assert!(!unchanged.apply.mutated(), "nothing changed between the passes");
1121 assert_eq!(cached_files(&basis, &delivery), 2, "the owed write ran");
1122 }
1123
1124 #[test]
1132 #[cfg(unix)]
1133 fn a_complete_refresh_persists_the_facts_a_partial_refresh_could_not() {
1134 use std::os::unix::fs::PermissionsExt;
1135 if !crate::test_support::require_permission_bits() {
1136 return;
1137 }
1138 let (root, _cache, basis, delivery) = owed_persistence_fixture();
1139 let locked = root.path().join("locked");
1140 let (mut index, _) = crate::open(&basis, &delivery).expect("initial open");
1141 assert_eq!(index.total().files, 1);
1142
1143 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("deny read");
1144 fs::write(root.path().join("second"), b"second").expect("second file");
1145 let partial = crate::refresh(&mut index, &basis, &delivery);
1146 fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).expect("restore");
1147 let partial = partial.expect("partial refresh");
1148 assert!(!partial.is_complete(), "the locked directory made the pass partial");
1149 assert!(partial.apply.mutated(), "the second file was inserted");
1150 assert_eq!(index.total().files, 2);
1151 assert_eq!(cached_files(&basis, &delivery), 1, "a partial pass never writes entries");
1152
1153 let complete = crate::refresh(&mut index, &basis, &delivery).expect("complete refresh");
1154 assert!(complete.is_complete(), "{:?}", complete.scan.errors);
1155 assert_eq!(cached_files(&basis, &delivery), 2, "the complete pass wrote the facts");
1156 }
1157
1158 #[test]
1159 fn cache_only_refusals_name_location_root_and_absence_separately() {
1160 let root = tempfile::tempdir().expect("root");
1161 let other = tempfile::tempdir().expect("other root");
1162 let cache = tempfile::tempdir().expect("cache");
1163 let basis = crate::query::Basis {
1164 root: root.path().into(),
1165 scope: crate::query::Scope::default(),
1166 content: crate::content::AnalysisSet::NONE,
1167 };
1168 let snapshot = cache.path().join("snapshot.fdu");
1169 let message = |delivery: &Delivery| {
1170 crate::open(&basis, delivery).expect_err("cache-only refusal").to_string()
1171 };
1172 let no_location = message(&Delivery::stale_ok(None));
1173 assert!(no_location.contains("no cache location"), "{no_location}");
1174 assert!(!no_location.contains("`on`"), "no write can succeed without a location");
1175 let missing = message(&Delivery::stale_ok(Some(snapshot.clone())));
1176 assert!(missing.contains("no usable snapshot"), "{missing}");
1177 assert!(missing.contains("with the `on` cache policy"), "{missing}");
1178 let other_basis = crate::query::Basis { root: other.path().into(), ..basis.clone() };
1179 crate::open(&other_basis, &Delivery::new(CachePolicy::Auto, Some(snapshot.clone())))
1180 .expect("other snapshot");
1181 let wrong_root = message(&Delivery::stale_ok(Some(snapshot)));
1182 assert!(wrong_root.contains("different root"), "{wrong_root}");
1183 }
1184
1185 #[test]
1186 fn route_delivery_matrix_rejects_contracts_the_route_cannot_execute() {
1187 let basis = crate::query::Basis {
1188 root: ".".into(),
1189 scope: crate::query::Scope::default(),
1190 content: crate::content::AnalysisSet::NONE,
1191 };
1192 let request = Request::new(basis, Query::default(), SystemTime::now());
1193 for delivery in Delivery::enumerate() {
1194 for route in
1195 [Route::OneShot, Route::Retained, Route::Refresh, Route::Watch, Route::Opened]
1196 {
1197 let result = plan(&request, &delivery, route);
1198 let forbidden = delivery.stale_ok
1199 && (delivery.watch.is_some()
1200 || matches!(route, Route::Watch | Route::Refresh)
1201 || delivery.cache == CachePolicy::Off)
1202 || route == Route::Opened
1203 && (delivery.cache != CachePolicy::Off
1204 || delivery.watch.is_some()
1205 || delivery.accept_partial);
1206 assert_eq!(result.is_err(), forbidden, "{route:?} {delivery:?}");
1207 if let Ok(plan) = result {
1208 if route == Route::Opened {
1209 assert_eq!(plan.load(), Load::None);
1210 assert_eq!(plan.verify(), Verify::Filesystem);
1211 }
1212 }
1213 }
1214 }
1215 }
1216
1217 #[test]
1218 fn an_opened_root_refuses_the_scheduling_it_would_otherwise_drop() {
1219 let basis = crate::query::Basis {
1224 root: ".".into(),
1225 scope: crate::query::Scope::default(),
1226 content: crate::content::AnalysisSet::NONE,
1227 };
1228 let request = Request::new(basis, Query::default(), SystemTime::now());
1229 let default = Delivery::new(CachePolicy::Off, None);
1230 let plan_opened = plan(&request, &default, Route::Opened).expect("defaults plan");
1231 assert_eq!(plan_opened.delivery().batch_size, default.batch_size);
1232 let unhonored = [
1233 (
1234 "scan workers",
1235 Delivery {
1236 workers: crate::query::Workers { scan: Some(4), ..default.workers },
1237 ..default.clone()
1238 },
1239 ),
1240 (
1241 "depth-first order",
1242 Delivery { order: crate::ScanOrder::DepthFirst, ..default.clone() },
1243 ),
1244 ("accept partial", Delivery { accept_partial: true, ..default.clone() }),
1245 ];
1246 for (case, delivery) in unhonored {
1247 let refused = plan(&request, &delivery, Route::Opened).expect_err(case);
1248 assert!(
1249 matches!(
1250 refused,
1251 crate::query::RequestError::DeliveryUnsupported { route: "opened", .. }
1252 ),
1253 "{case}: {refused}"
1254 );
1255 plan(&request, &delivery, Route::Retained)
1256 .unwrap_or_else(|error| panic!("{case} executes on a retained route: {error}"));
1257 }
1258 let batched = Delivery { batch_size: default.batch_size * 2, ..default };
1260 let plan_batched = plan(&request, &batched, Route::Opened).expect("batch size plans");
1261 assert_eq!(plan_batched.delivery().batch_size, batched.batch_size);
1262 }
1263
1264 #[test]
1265 fn tier_writes_depend_only_on_authorization_and_observed_facts() {
1266 let routes = [Route::OneShot, Route::Retained, Route::Refresh, Route::Watch, Route::Opened];
1270 for (delivery, persist) in
1271 Delivery::enumerate().flat_map(|delivery| [(delivery.clone(), false), (delivery, true)])
1272 {
1273 for bits in 0_u8..64 {
1274 let facts = RunFacts {
1275 entries_verified: bits & 1 != 0,
1276 entries_changed: bits & 2 != 0,
1277 content_changed: bits & 4 != 0,
1278 content_requested: bits & 8 != 0,
1279 projected: bits & 16 != 0,
1280 paired_entries: bits & 32 != 0,
1281 };
1282 let allowed = persist;
1283 let expected = SaveTargets {
1284 metadata: allowed
1285 && facts.entries_verified
1286 && facts.entries_changed
1287 && !facts.projected,
1288 content: allowed
1289 && facts.content_requested
1290 && facts.content_changed
1291 && (facts.entries_verified || facts.paired_entries),
1292 };
1293 for route in routes {
1294 let plan = Plan {
1295 basis: crate::query::Basis {
1296 root: ".".into(),
1297 scope: crate::query::Scope::default(),
1298 content: crate::content::AnalysisSet::NONE,
1299 },
1300 route,
1301 retained: RetainedState::FullIndex,
1302 load: Load::Snapshot,
1303 verify: Verify::Filesystem,
1304 persist,
1305 delivery: delivery.clone(),
1306 };
1307 assert_eq!(plan.writes(facts), expected, "{route:?} {delivery:?} {facts:?}");
1308 let unavailable = Plan {
1309 delivery: Delivery { cache_path: None, ..delivery.clone() },
1310 ..plan
1311 };
1312 assert!(unavailable.writes(facts).none());
1313 }
1314 }
1315 }
1316 }
1317
1318 fn planned(config: &OpenFixture, query: &Query) -> Plan {
1319 let (request, delivery) = split(Path::new("."), config, query);
1320 plan(&request, &delivery, Route::OneShot).expect("valid plan")
1321 }
1322
1323 fn summary_query() -> Query {
1324 Query { views: vec![ViewSpec::Summary], ..Query::default() }
1325 }
1326
1327 fn split(root: &Path, config: &OpenFixture, query: &Query) -> (Request, Delivery) {
1330 let (basis, delivery) = config.split(root);
1331 (Request::new(basis, query.clone(), std::time::UNIX_EPOCH), delivery)
1332 }
1333
1334 fn prepared(
1336 root: &Path,
1337 config: &OpenFixture,
1338 query: &Query,
1339 ) -> Result<(Report, PendingSave, PerformanceSummary)> {
1340 let (request, delivery) = split(root, config, query);
1341 prepare_report(&request, &delivery)
1342 }
1343
1344 fn prepared_with_diagnostics(
1346 root: &Path,
1347 config: &OpenFixture,
1348 query: &Query,
1349 ) -> Result<(Report, PendingSave, PerformanceSummary, Option<crate::scan::ScanDiagnostics>)>
1350 {
1351 let (request, delivery) = split(root, config, query);
1352 prepare_report_with_scan_diagnostics(&request, &delivery)
1353 }
1354
1355 fn config(policy: CachePolicy, cache_path: Option<PathBuf>) -> OpenFixture {
1356 OpenFixture { scan: ScanConfig::default(), cache_path, policy, ..OpenFixture::default() }
1357 }
1358
1359 fn blind(policy: CachePolicy, cache_path: Option<PathBuf>) -> OpenFixture {
1361 OpenFixture {
1362 scan: ScanConfig { read_controls: false, ..ScanConfig::default() },
1363 ..config(policy, cache_path)
1364 }
1365 }
1366
1367 fn controls_config(
1368 policy: CachePolicy,
1369 cache_path: PathBuf,
1370 read_controls: bool,
1371 ) -> OpenFixture {
1372 OpenFixture {
1373 scan: ScanConfig { read_controls, ..ScanConfig::default() },
1374 cache_path: Some(cache_path),
1375 policy,
1376 ..OpenFixture::default()
1377 }
1378 }
1379
1380 fn stale(fixture: OpenFixture) -> OpenFixture {
1382 OpenFixture { stale_ok: true, ..fixture }
1383 }
1384
1385 fn seed_controls_snapshot(root: &Path, cache_path: PathBuf) {
1386 fs::write(root.join(".gitignore"), b"ignored.log\n").expect("control file");
1387 fs::write(root.join("ignored.log"), b"ignored").expect("ignored file");
1388 crate::open_fixture(root, &controls_config(CachePolicy::Auto, cache_path, true))
1389 .expect("seed controls-on snapshot");
1390 }
1391
1392 #[test]
1393 fn planner_uses_compact_state_only_when_the_request_proves_it_is_sufficient() {
1394 let off = blind(CachePolicy::Off, Some(PathBuf::from("unused.fdu")));
1395 assert_eq!(planned(&off, &summary_query()).retained, RetainedState::Summary);
1396
1397 for policy in [CachePolicy::Auto, CachePolicy::On] {
1398 let unavailable = blind(policy, None);
1399 assert_eq!(planned(&unavailable, &summary_query()).retained, RetainedState::Summary);
1400 }
1401
1402 let mut several_views = summary_query();
1403 several_views.views.push(ViewSpec::Types);
1404 assert_eq!(planned(&off, &several_views).retained, RetainedState::FullIndex);
1405
1406 let mut filtered = summary_query();
1407 filtered.selection.include.push(Pattern::parse("*.rs").expect("pattern"));
1408 assert_eq!(planned(&off, &filtered).retained, RetainedState::FullIndex);
1409
1410 let observing = config(CachePolicy::Off, None);
1416 assert!(observing.scan.read_controls, "observation is the default");
1417 assert_eq!(planned(&observing, &summary_query()).retained, RetainedState::Summary);
1418 let mut by_ignored = summary_query();
1421 by_ignored.selection.ignored = IgnoredEntries::Exclude;
1422 assert_eq!(planned(&observing, &by_ignored).retained, RetainedState::FullIndex);
1423 for population in [IgnoredEntries::Exclude, IgnoredEntries::Only] {
1424 let narrowed = OpenFixture {
1425 scan: ScanConfig { population, ..ScanConfig::default() },
1426 ..config(CachePolicy::Off, None)
1427 };
1428 let mut query = summary_query();
1429 query.selection.ignored = population;
1430 assert_eq!(planned(&narrowed, &query).retained, RetainedState::FullIndex);
1431 }
1432 }
1433
1434 #[test]
1435 fn an_available_snapshot_does_not_force_the_index_for_a_metadata_summary() {
1436 let cached = blind(CachePolicy::Auto, Some(PathBuf::from("cache.fdu")));
1442 assert_eq!(
1443 planned(&cached, &summary_query()).retained,
1444 RetainedState::Summary,
1445 "a present snapshot must not force the index"
1446 );
1447 }
1448
1449 #[test]
1450 fn deliveries_whose_intent_is_the_snapshot_itself_still_retain_the_index() {
1451 let cached = || blind(CachePolicy::Auto, Some(PathBuf::from("cache.fdu")));
1455 for (name, delivery) in [
1456 ("stale", stale(cached())),
1457 ("on", OpenFixture { policy: CachePolicy::On, ..cached() }),
1458 ] {
1459 assert_eq!(
1460 planned(&delivery, &summary_query()).retained,
1461 RetainedState::FullIndex,
1462 "{name} needs the index to honour its contract"
1463 );
1464 }
1465 }
1466
1467 #[test]
1468 fn a_one_shot_metadata_query_does_not_read_the_snapshot_it_cannot_use() {
1469 let mut tree_query = summary_query();
1475 tree_query.views = vec![ViewSpec::Tree];
1476 for policy in [CachePolicy::Auto, CachePolicy::On] {
1477 let cached = config(policy, Some(PathBuf::from("cache.fdu")));
1478 assert!(
1479 planned(&cached, &tree_query).load != Load::Snapshot,
1480 "{policy:?} must not pay for a read that saves no work"
1481 );
1482 }
1483 }
1484
1485 #[test]
1486 fn the_snapshot_is_read_where_reading_pays_or_is_the_contract() {
1487 let only = stale(config(CachePolicy::Auto, Some(PathBuf::from("cache.fdu"))));
1489 assert_eq!(planned(&only, &summary_query()).load, Load::Snapshot);
1490
1491 let analyzed = OpenFixture {
1494 analysis: crate::content::AnalysisRequest {
1495 profile: crate::content::AnalysisSet::NONE.with_code(),
1496 ..Default::default()
1497 },
1498 ..config(CachePolicy::Auto, Some(PathBuf::from("cache.fdu")))
1499 };
1500 assert_eq!(planned(&analyzed, &summary_query()).load, Load::Snapshot);
1501
1502 let never = config(CachePolicy::Off, Some(PathBuf::from("cache.fdu")));
1504 assert_eq!(planned(&never, &summary_query()).load, Load::None);
1505 }
1506
1507 #[test]
1508 fn auto_persists_where_a_later_request_reads_what_it_stores() {
1509 let cache = Some(PathBuf::from("cache.fdu"));
1515 let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
1516 let persists = |fixture: &OpenFixture, query: &Query, route: Route| {
1517 let (request, delivery) = split(Path::new("."), fixture, query);
1518 plan(&request, &delivery, route).expect("valid plan").persists()
1519 };
1520 for (policy, one_shot, analysis, retained) in [
1521 (CachePolicy::Auto, false, true, true),
1522 (CachePolicy::On, true, true, true),
1523 (CachePolicy::Off, false, false, false),
1524 ] {
1525 let metadata = config(policy, cache.clone());
1526 assert_eq!(persists(&metadata, &tree, Route::OneShot), one_shot, "{policy:?}");
1527 assert_eq!(
1528 persists(&analyzing(metadata.clone()), &tree, Route::OneShot),
1529 analysis,
1530 "{policy:?} with analysis"
1531 );
1532 for route in [Route::Retained, Route::Refresh, Route::Watch] {
1533 assert_eq!(persists(&metadata, &tree, route), retained, "{policy:?} {route:?}");
1534 }
1535 }
1536 let stale_answer = stale(config(CachePolicy::On, cache));
1537 assert!(!persists(&stale_answer, &tree, Route::OneShot), "a stale answer writes nothing");
1538 assert!(!persists(&stale_answer, &tree, Route::Retained), "a stale answer writes nothing");
1539 }
1540
1541 #[test]
1542 fn an_analysis_request_never_selects_the_compact_summary_tier() {
1543 let off = blind(CachePolicy::Off, None);
1547 assert_eq!(planned(&off, &summary_query()).retained, RetainedState::Summary);
1548
1549 for profile in [
1550 crate::content::AnalysisSet::NONE.with_lines(),
1551 crate::content::AnalysisSet::NONE.with_code(),
1552 crate::content::AnalysisSet::NONE.with_words(),
1553 crate::content::AnalysisSet::ALL,
1554 ] {
1555 let analyzed = OpenFixture {
1556 analysis: crate::content::AnalysisRequest { profile, ..Default::default() },
1557 ..blind(CachePolicy::Off, None)
1558 };
1559 assert_eq!(
1560 planned(&analyzed, &summary_query()).retained,
1561 RetainedState::FullIndex,
1562 "{profile:?} must retain the index"
1563 );
1564 }
1565 }
1566
1567 #[test]
1568 fn a_repeated_one_shot_report_scans_cold_while_open_still_revalidates() {
1569 let root = tempfile::tempdir().expect("tempdir");
1574 fs::write(root.path().join("file.txt"), b"contents").expect("file");
1575 let cache = tempfile::tempdir().expect("cache dir");
1576 let auto = config(CachePolicy::Auto, Some(cache.path().join("cache.fdu")));
1577 let on = OpenFixture { policy: CachePolicy::On, ..auto.clone() };
1578 let mut tree_query = summary_query();
1579 tree_query.views = vec![ViewSpec::Tree];
1580
1581 let (first, pending, _) = prepared(root.path(), &on, &tree_query).expect("first report");
1582 pending.join().expect("first save");
1583 assert_eq!(first.provenance.source, ReportSource::ColdScan);
1584 assert!(on.cache_path.as_deref().expect("path").exists(), "`on` persists");
1585
1586 let (second, pending, performance) =
1587 prepared(root.path(), &auto, &tree_query).expect("second report");
1588 pending.join().expect("second save");
1589 assert_eq!(
1590 second.provenance.source,
1591 ReportSource::ColdScan,
1592 "a repeated one-shot must not pay for a read that saves no work"
1593 );
1594 assert_eq!(performance.walked_files, 1, "the walk still happened");
1595
1596 let (_, open_report) = crate::open_fixture(root.path(), &auto).expect("library open");
1599 assert_eq!(
1600 open_report.path_taken,
1601 OpenPath::WarmRevalidate,
1602 "a caller holding the index still amortises the load"
1603 );
1604 }
1605
1606 #[test]
1607 fn a_stale_answer_reads_what_an_on_report_leaves_and_auto_leaves_nothing() {
1608 let root = tempfile::tempdir().expect("tempdir");
1612 fs::write(root.path().join("file.txt"), b"contents").expect("file");
1613 let cache = tempfile::tempdir().expect("cache dir");
1614 let auto = config(CachePolicy::Auto, Some(cache.path().join("cache.fdu")));
1615 let mut tree_query = summary_query();
1616 tree_query.views = vec![ViewSpec::Tree];
1617
1618 let (_, pending, _) = prepared(root.path(), &auto, &tree_query).expect("report");
1619 assert!(!pending.writes_metadata(), "`auto` starts no metadata write");
1620 pending.join().expect("nothing to save");
1621 assert!(!cache.path().join("cache.fdu").exists(), "`auto` leaves nothing");
1622 let only = stale(config(CachePolicy::Auto, Some(cache.path().join("cache.fdu"))));
1623 let missing = prepared(root.path(), &only, &tree_query).expect_err("nothing to read");
1624 assert!(missing.to_string().contains("with the `on` cache policy"), "{missing}");
1625
1626 let on = OpenFixture { policy: CachePolicy::On, ..auto };
1627 let (_, pending, _) = prepared(root.path(), &on, &tree_query).expect("report");
1628 pending.join().expect("save");
1629
1630 let (from_cache, pending, performance, diagnostics) =
1631 prepared_with_diagnostics(root.path(), &only, &tree_query).expect("cache-only report");
1632 pending.join().expect("no save");
1633 assert_eq!(from_cache.provenance.source, ReportSource::CacheOnly);
1634 assert_eq!(performance.walked_files, 0, "cache-only never touches the tree");
1635 assert!(diagnostics.is_none(), "a cache-only open has no scan trace");
1636 }
1637
1638 #[test]
1639 fn controls_on_snapshot_projects_to_an_equivalent_controls_off_cache_only_report() {
1640 let root = tempfile::tempdir().expect("tempdir");
1641 let cache = tempfile::tempdir().expect("cache dir");
1642 let cache_path = cache.path().join("cache.fdu");
1643 fs::create_dir(root.path().join("src")).expect("source dir");
1644 fs::write(root.path().join("src/lib.rs"), b"library").expect("source file");
1645 seed_controls_snapshot(root.path(), cache_path.clone());
1646
1647 let controls_off = stale(controls_config(CachePolicy::Auto, cache_path, false));
1648 let query = Query {
1649 views: vec![
1650 ViewSpec::Summary,
1651 ViewSpec::Tree,
1652 ViewSpec::Families,
1653 ViewSpec::Types,
1654 ViewSpec::Extensions,
1655 ViewSpec::Languages,
1656 ViewSpec::Largest,
1657 ViewSpec::Recent,
1658 ViewSpec::Files,
1659 ],
1660 ..Query::default()
1661 };
1662 let (projected, pending, performance) =
1663 prepared(root.path(), &controls_off, &query).expect("projected report");
1664 pending.join().expect("no cache-only save");
1665
1666 let cold = OpenFixture {
1667 policy: CachePolicy::Off,
1668 stale_ok: false,
1669 cache_path: None,
1670 ..controls_off
1671 };
1672 let (mut expected, pending, _) =
1673 prepared(root.path(), &cold, &query).expect("controls-off cold report");
1674 pending.join().expect("no cold save");
1675 expected.provenance = projected.provenance.clone();
1676
1677 assert_eq!(performance.source, ReportSource::CacheOnly);
1678 assert_eq!(projected.scope, cold.scan.scope());
1679 assert_eq!(projected.ignore_rules, crate::control::ControlCoverage::NotObserved);
1680 assert_eq!(
1681 crate::report_format::render(&projected, crate::report_format::Format::Json, false,)
1682 .expect("compatible report format"),
1683 crate::report_format::render(&expected, crate::report_format::Format::Json, false,)
1684 .expect("compatible report format"),
1685 );
1686 }
1687
1688 #[test]
1689 fn controls_on_snapshot_projects_to_controls_off_auto_report() {
1690 let root = tempfile::tempdir().expect("tempdir");
1691 let cache = tempfile::tempdir().expect("cache dir");
1692 let cache_path = cache.path().join("cache.fdu");
1693 seed_controls_snapshot(root.path(), cache_path.clone());
1694
1695 let controls_off = OpenFixture {
1696 analysis: crate::content::AnalysisRequest {
1697 profile: crate::content::AnalysisSet::NONE.with_lines(),
1698 ..Default::default()
1699 },
1700 ..controls_config(CachePolicy::Auto, cache_path, false)
1701 };
1702 let (report, pending, performance) = prepared(root.path(), &controls_off, &summary_query())
1703 .expect("controls-off warm projection");
1704 pending.join().expect("save content only");
1705
1706 assert_eq!(report.provenance.source, ReportSource::WarmRevalidate);
1707 assert_eq!(report.scope, controls_off.scan.scope());
1708 assert_eq!(performance.source, ReportSource::WarmRevalidate);
1709 }
1710
1711 fn write_unobservable_controls(root: &Path) {
1717 let mut rule = vec![b'a'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
1718 rule.push(b'\n');
1719 fs::write(root.join(".gitignore"), rule).expect("oversized rule");
1720 fs::create_dir(root.join("vendored")).expect("nested directory");
1721 fs::write(
1722 root.join("vendored/.gitignore"),
1723 b"x\n".repeat(crate::control::DEFAULT_CONTROL_BUDGET / 2),
1724 )
1725 .expect("oversized source");
1726 }
1727
1728 #[test]
1729 fn a_one_shot_report_observes_control_state_as_its_caller_configures() {
1730 let root = tempfile::tempdir().expect("tempdir");
1735 fs::write(root.path().join("file.txt"), b"contents").expect("file");
1736 write_unobservable_controls(root.path());
1737 let mut tree_query = summary_query();
1738 tree_query.views = vec![ViewSpec::Tree];
1739
1740 for read_controls in [true, false] {
1741 let cache = tempfile::tempdir().expect("cache dir");
1742 let cache_path = cache.path().join("cache.fdu");
1743 let caller = controls_config(CachePolicy::On, cache_path.clone(), read_controls);
1744 for query in [summary_query(), tree_query.clone()] {
1745 let (report, pending, _) = prepared(root.path(), &caller, &query)
1746 .expect("a refused control file ends nothing");
1747 pending.join().expect("save");
1748 assert!(
1749 report.status.complete,
1750 "a refusal is not a partial: {:?}",
1751 report.status.errors
1752 );
1753 assert_eq!(report.scope, caller.scan.scope());
1754 match &report.ignore_rules {
1755 crate::control::ControlCoverage::Observed(coverage) => {
1756 assert!(read_controls, "observed only when asked");
1757 assert_eq!(coverage.refused, 2, "{coverage:?}");
1758 assert_eq!(report.notes.len(), 2, "{:?}", report.notes);
1759 assert!(
1760 report.notes[0].contains("2 ignore files not applied"),
1761 "{:?}",
1762 report.notes
1763 );
1764 assert_eq!(
1765 report.notes[1],
1766 "note: gitignored subtotals are unavailable where governing rules could not be verified"
1767 );
1768 }
1769 crate::control::ControlCoverage::NotObserved => {
1770 assert!(!read_controls, "unobserved only when turned off");
1771 assert!(report.notes.is_empty(), "{:?}", report.notes);
1772 }
1773 }
1774 }
1775
1776 let saved = crate::snapshot::load(&cache_path)
1777 .expect("load the snapshot")
1778 .expect("the index tier persisted");
1779 assert_eq!(saved.scope(), caller.scan.scope());
1780 assert_eq!(saved.controls().is_ok(), read_controls);
1781 }
1782 }
1783
1784 #[test]
1798 fn a_scope_this_build_cannot_honour_is_refused_before_any_snapshot_is_read() {
1799 let root = tempfile::tempdir().expect("tempdir");
1800 fs::write(root.path().join("file.txt"), b"contents").expect("file");
1801 let cache = tempfile::tempdir().expect("cache dir");
1802 let cache_path = cache.path().join("cache.fdu");
1803
1804 let warm = config(CachePolicy::On, Some(cache_path.clone()));
1807 let (_, pending, _) = prepared(root.path(), &warm, &summary_query()).expect("warm");
1808 pending.join().expect("save");
1809 let (_, pending, _) = prepared(
1810 root.path(),
1811 &stale(config(CachePolicy::Auto, Some(cache_path.clone()))),
1812 &summary_query(),
1813 )
1814 .expect("the snapshot answers a supported scope");
1815 pending.join().expect("no save");
1816
1817 let unsupported = ScanConfig { follow_symlinks: true, ..ScanConfig::default() };
1818 for (policy, stale_ok) in [
1819 (CachePolicy::Auto, true),
1820 (CachePolicy::Off, false),
1821 (CachePolicy::Auto, false),
1822 (CachePolicy::On, false),
1823 ] {
1824 let asked = OpenFixture {
1825 scan: unsupported.clone(),
1826 stale_ok,
1827 ..config(policy, Some(cache_path.clone()))
1828 };
1829 let refused = prepared(root.path(), &asked, &summary_query())
1830 .expect_err("a scope this build cannot honour has no answer at any policy");
1831 assert!(
1832 matches!(
1833 refused,
1834 Error::InvalidRequest(crate::query::RequestError::ScopeUnsupported {
1835 axis: crate::query::ScopeAxis::FollowSymlinks,
1836 ..
1837 })
1838 ),
1839 "{policy:?} must refuse the request rather than fail the operation: {refused}"
1840 );
1841 assert_eq!(
1842 refused.to_string(),
1843 "unsupported scan configuration: follow_symlinks requires cycle, root-boundary, \
1844 and filesystem-boundary semantics",
1845 "{policy:?} must name the scope it cannot honour"
1846 );
1847 }
1848 }
1849
1850 #[test]
1851 fn a_report_that_reads_no_gitignore_refuses_to_select_by_ignored_state() {
1852 let root = tempfile::tempdir().expect("tempdir");
1855 fs::write(root.path().join(".gitignore"), b"*.log\n").expect("control file");
1856 fs::write(root.path().join("debug.log"), b"ignored").expect("ignored file");
1857 let mut only = summary_query();
1858 only.selection.ignored = IgnoredEntries::Only;
1859
1860 assert!(matches!(
1864 prepared(root.path(), &blind(CachePolicy::Off, None), &only),
1865 Err(Error::InvalidRequest(crate::query::RequestError::IgnoredWithoutObservation(
1866 IgnoredEntries::Only
1867 )))
1868 ));
1869
1870 let (report, pending, _) =
1871 prepared(root.path(), &config(CachePolicy::Off, None), &only).expect("observed");
1872 pending.join().expect("no save");
1873 let Section::Summary(row) = report.sections[0] else { panic!("a summary") };
1874 assert_eq!((row.files, row.bytes), (1, 7), "only the ignored file is selected");
1875 }
1876
1877 #[test]
1878 fn an_on_reports_snapshot_serves_either_cache_only_report_but_not_the_reverse() {
1879 let root = tempfile::tempdir().expect("tempdir");
1885 fs::write(root.path().join("file.txt"), b"contents").expect("file");
1886 let mut tree_query = summary_query();
1887 tree_query.views = vec![ViewSpec::Tree];
1888
1889 for (writer, reader) in [(true, true), (true, false), (false, false), (false, true)] {
1890 let cache = tempfile::tempdir().expect("cache dir");
1891 let cache_path = cache.path().join("cache.fdu");
1892 let write = controls_config(CachePolicy::On, cache_path.clone(), writer);
1893 let (_, pending, _) =
1894 prepared(root.path(), &write, &tree_query).expect("writing report");
1895 pending.join().expect("save");
1896
1897 let read = stale(controls_config(CachePolicy::Auto, cache_path, reader));
1898 match prepared(root.path(), &read, &tree_query) {
1899 Ok((report, pending, _)) => {
1900 pending.join().expect("no save");
1901 assert!(writer || !reader, "writer {writer} served reader {reader}");
1902 assert_eq!(report.provenance.source, ReportSource::CacheOnly);
1903 assert_eq!(
1904 matches!(report.ignore_rules, crate::control::ControlCoverage::Observed(_)),
1905 reader,
1906 "a report describes the scope it asked for"
1907 );
1908 }
1909 Err(Error::Snapshot(message)) => {
1910 assert!(!writer && reader, "writer {writer}, reader {reader}: {message}");
1911 assert!(message.contains(".gitignore state"), "names the cause: {message}");
1912 assert!(message.contains("verified answer"), "names the remedy: {message}");
1913 }
1914 Err(other) => panic!("writer {writer}, reader {reader}: {other}"),
1915 }
1916 }
1917 }
1918
1919 #[test]
1920 fn a_cache_only_open_answers_from_an_on_reports_snapshot() {
1921 let root = tempfile::tempdir().expect("tempdir");
1925 fs::write(root.path().join(".gitignore"), b"*.log\n").expect("control file");
1926 fs::write(root.path().join("debug.log"), b"ignored").expect("ignored file");
1927 let cache = tempfile::tempdir().expect("cache dir");
1928 let cache_path = cache.path().join("cache.fdu");
1929 let mut tree_query = summary_query();
1930 tree_query.views = vec![ViewSpec::Tree];
1931
1932 let on = config(CachePolicy::On, Some(cache_path.clone()));
1933 let (_, pending, _) = prepared(root.path(), &on, &tree_query).expect("report");
1934 pending.join().expect("save");
1935
1936 let only = stale(config(CachePolicy::Auto, Some(cache_path)));
1937 let (index, report) = crate::open_fixture(root.path(), &only).expect("the shared snapshot");
1938 assert_eq!(report.path_taken, OpenPath::CacheOnly);
1939 assert_eq!(index.is_ignored(Path::new("debug.log")).ok(), Some(Some(true)));
1940 }
1941
1942 #[test]
1943 fn an_open_that_opts_out_of_control_state_shares_an_opted_out_reports_snapshot() {
1944 let root = tempfile::tempdir().expect("tempdir");
1949 fs::write(root.path().join("file.txt"), b"contents").expect("file");
1950 let cache = tempfile::tempdir().expect("cache dir");
1951 let cache_path = cache.path().join("cache.fdu");
1952 let mut tree_query = summary_query();
1953 tree_query.views = vec![ViewSpec::Tree];
1954
1955 let on = blind(CachePolicy::On, Some(cache_path.clone()));
1956 let (_, pending, _) = prepared(root.path(), &on, &tree_query).expect("report");
1957 pending.join().expect("save");
1958 assert!(cache_path.exists(), "the report left a snapshot");
1959
1960 let only = stale(blind(CachePolicy::Auto, Some(cache_path)));
1961 let (index, report) = crate::open_fixture(root.path(), &only).expect("the shared snapshot");
1962 assert_eq!(report.path_taken, OpenPath::CacheOnly);
1963 assert!(matches!(
1964 index.is_ignored(Path::new("file.txt")),
1965 Err(Error::ControlStateNotObserved)
1966 ));
1967 }
1968
1969 #[test]
1970 fn compact_summary_matches_the_indexed_summary_exactly() {
1971 let root = tempfile::tempdir().expect("tempdir");
1972 fs::create_dir(root.path().join("src")).expect("directory");
1973 fs::write(root.path().join("src/lib.rs"), b"library").expect("file");
1974 fs::write(root.path().join("README.md"), b"read me").expect("file");
1975 #[cfg(unix)]
1976 std::os::unix::fs::symlink("README.md", root.path().join("readme-link")).expect("symlink");
1977
1978 let query = summary_query();
1979 let off = OpenFixture {
1982 scan: ScanConfig { read_controls: false, threads: Some(2), ..ScanConfig::default() },
1983 ..blind(CachePolicy::Off, None)
1984 };
1985 let (compact, pending, performance) =
1986 prepared(root.path(), &off, &query).expect("compact report");
1987 pending.join().expect("no pending compact save");
1988 assert_eq!(performance.walked_files, 2);
1989 assert_eq!(performance.walked_bytes, 14);
1990
1991 let (index, _open_report) = crate::open_fixture(root.path(), &off).expect("indexed scan");
1994 let indexed = report(
1995 &index,
1996 &crate::test_support::read_of(&index, query.clone()),
1997 compact.provenance.generated_at,
1998 )
1999 .expect("report");
2000
2001 let Section::Summary(compact_row) = compact.sections[0] else {
2002 panic!("compact plan did not return a summary")
2003 };
2004 let Section::Summary(indexed_row) = indexed.sections[0] else {
2005 panic!("indexed plan did not return a summary")
2006 };
2007 assert_eq!(compact_row.files, indexed_row.files);
2008 assert_eq!(compact_row.dirs, indexed_row.dirs);
2009 assert_eq!(compact_row.bytes, indexed_row.bytes);
2010 assert_eq!(compact_row.allocated, indexed_row.allocated);
2011 assert_eq!(compact_row.newest_mtime_ns, indexed_row.newest_mtime_ns);
2012 assert_eq!(compact.root, indexed.root);
2013 assert_eq!(compact.scope, indexed.scope);
2014 assert_eq!(compact.status.complete, indexed.status.complete);
2015 assert_eq!(compact.provenance.freshness, indexed.provenance.freshness);
2016 }
2017
2018 struct ControlCase {
2022 name: &'static str,
2023 root: tempfile::TempDir,
2024 scan: ScanConfig,
2025 share: bool,
2028 refused: u64,
2030 order_dependent: bool,
2033 errors: bool,
2035 lookups: crate::test_support::CaseLookups,
2037 variant_governs: Option<bool>,
2040 #[cfg(unix)]
2043 denied: Option<PathBuf>,
2044 }
2045
2046 impl Drop for ControlCase {
2047 fn drop(&mut self) {
2048 #[cfg(unix)]
2049 if let Some(path) = &self.denied {
2050 use std::os::unix::fs::PermissionsExt;
2051 let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
2052 }
2053 }
2054 }
2055
2056 fn put(root: &Path, path: &str, contents: &[u8]) {
2057 let path = root.join(path);
2058 fs::create_dir_all(path.parent().expect("a parent")).expect("parent directories");
2059 fs::write(path, contents).expect("fixture file");
2060 }
2061
2062 fn rules_tree() -> tempfile::TempDir {
2066 let root = tempfile::tempdir().expect("tempdir");
2067 let root_path = root.path();
2068 put(
2069 root_path,
2070 ".gitignore",
2071 b"*.log\n!keep.log\n/anchored.txt\ncache/\nnode_modules/\nvendor/*\n!vendor/keep/\n",
2072 );
2073 put(root_path, "a.log", b"alog");
2074 put(root_path, "keep.log", b"keeplog");
2075 put(root_path, "anchored.txt", b"anchored");
2076 put(root_path, "cache", b"a file named like a directory rule");
2077 put(root_path, "README.md", b"readme!");
2078 put(root_path, "src/anchored.txt", b"not anchored here");
2079 put(root_path, "src/x.log", b"xlog-");
2080 put(root_path, "src/keep.log", b"kept everywhere");
2081 put(root_path, "src/main.rs", b"fn main() {}");
2082 put(root_path, "sub/.gitignore", b"!*.log\n*.tmp\n");
2083 put(root_path, "sub/y.log", b"re-included");
2084 put(root_path, "sub/z.tmp", b"tmp");
2085 put(root_path, "sub/cache/data.bin", b"cached bytes");
2086 put(root_path, "sub/deep/.gitignore", b"*\n!.gitignore\n");
2087 put(root_path, "sub/deep/f.txt", b"deep");
2088 put(root_path, "sub/deep/inner/g.txt", b"deeper");
2089 put(root_path, "node_modules/pkg/.gitignore", b"!*\n");
2090 put(root_path, "node_modules/pkg/index.js", b"module.exports = 1;");
2091 put(root_path, "node_modules/pkg/lib/a.js", b"a");
2092 put(root_path, "vendor/a.c", b"int a;");
2093 put(root_path, "vendor/keep/k.c", b"int k;");
2094 put(root_path, "vendor/drop/d.c", b"int d;");
2095 put(root_path, "selfish/.gitignore", b".gitignore\n*.bak\n");
2096 put(root_path, "selfish/x.bak", b"backup");
2097 put(root_path, "selfish/y.txt", b"kept");
2098 put(root_path, "many/.gitignore", b"*[02468].dat\n");
2099 for file in 0..40 {
2100 put(root_path, &format!("many/f{file:02}.dat"), &vec![b'.'; file + 1]);
2101 }
2102 root
2103 }
2104
2105 const CASE_VARIANT: &str = "a case-variant control name";
2107
2108 const CASE_BOTH: &str = "both spellings of the control name";
2110
2111 fn control_cases() -> Vec<ControlCase> {
2112 let case = |name, root, scan, share, refused| ControlCase {
2113 name,
2114 root,
2115 scan,
2116 share,
2117 refused,
2118 order_dependent: false,
2119 errors: false,
2120 lookups: crate::test_support::CaseLookups::Host,
2121 variant_governs: None,
2122 #[cfg(unix)]
2123 denied: None,
2124 };
2125 let mut cases = vec![
2126 case("rules", rules_tree(), ScanConfig::default(), true, 0),
2127 case(
2128 "rules with hidden entries pruned",
2129 rules_tree(),
2130 ScanConfig {
2131 hidden: Some(std::sync::Arc::new(crate::HiddenPolicy::prune_hidden(Vec::<
2132 std::ffi::OsString,
2133 >::new(
2134 )))),
2135 ..ScanConfig::default()
2136 },
2137 true,
2138 0,
2139 ),
2140 case(
2141 "rules under a depth bound",
2142 rules_tree(),
2143 ScanConfig { max_depth: Some(1), ..ScanConfig::default() },
2144 true,
2145 0,
2146 ),
2147 ];
2148
2149 let empty = tempfile::tempdir().expect("tempdir");
2150 put(empty.path(), "only.txt", b"no rules anywhere");
2151 cases.push(case("no control file", empty, ScanConfig::default(), true, 0));
2152
2153 let long = tempfile::tempdir().expect("tempdir");
2155 put(long.path(), ".gitignore", b"*.log\n");
2156 put(long.path(), "x.log", b"ignored");
2157 let mut line = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
2158 line.push(b'\n');
2159 put(long.path(), "long/.gitignore", &line);
2160 put(long.path(), "long/kept.txt", b"kept");
2161 put(long.path(), "long/y.log", b"unknown");
2162 cases.push(case("line limit", long, ScanConfig::default(), false, 1));
2163
2164 let over = tempfile::tempdir().expect("tempdir");
2167 put(over.path(), ".gitignore", b"*.log\n");
2168 let mut oversized = vec![b'x'; 200];
2169 oversized.push(b'\n');
2170 for directory in 0..70 {
2171 put(over.path(), &format!("d{directory:02}/.gitignore"), &oversized);
2172 put(over.path(), &format!("d{directory:02}/f.log"), b"log");
2173 }
2174 let limits = crate::control::ControlLimits { budget: Some(256), ..Default::default() };
2175 cases.push(case(
2176 "every nested file over the budget",
2177 over,
2178 ScanConfig { control_limits: limits, ..ScanConfig::default() },
2179 false,
2180 70,
2181 ));
2182
2183 let competing = tempfile::tempdir().expect("tempdir");
2185 for (position, directory) in ["a", "b", "c", "d"].into_iter().enumerate() {
2186 let mut rules = format!("r{position}").into_bytes();
2187 rules.extend(std::iter::repeat_n(b'y', 100));
2188 rules.push(b'\n');
2189 put(competing.path(), &format!("{directory}/.gitignore"), &rules);
2190 put(competing.path(), &format!("{directory}/file.txt"), b"file");
2191 }
2192 let limits = crate::control::ControlLimits { budget: Some(1000), ..Default::default() };
2193 let mut competing = case(
2194 "competing for the budget",
2195 competing,
2196 ScanConfig { control_limits: limits, ..ScanConfig::default() },
2197 false,
2198 2,
2199 );
2200 competing.order_dependent = true;
2201 cases.push(competing);
2202
2203 let probe = tempfile::tempdir().expect("tempdir");
2210 for (lookups, governs) in crate::test_support::CaseLookups::on_this_host(probe.path()) {
2211 for hidden in [None, Some(Vec::<std::ffi::OsString>::new())] {
2212 let variant = tempfile::tempdir().expect("tempdir");
2213 put(variant.path(), ".GITIGNORE", b"*.log\n");
2214 for file in 0..1_200 {
2215 put(variant.path(), &format!("f{file:04}.log"), b"log");
2216 }
2217 put(variant.path(), "kept.txt", b"kept");
2218 let scan = ScanConfig {
2219 hidden: hidden
2220 .map(|allow| std::sync::Arc::new(crate::HiddenPolicy::prune_hidden(allow))),
2221 ..ScanConfig::default()
2222 };
2223 let mut variant = case(CASE_VARIANT, variant, scan, true, 0);
2224 variant.lookups = lookups;
2225 variant.variant_governs = Some(governs);
2226 cases.push(variant);
2227 }
2228 }
2229
2230 for lookups in
2234 [crate::test_support::CaseLookups::Host, crate::test_support::CaseLookups::Folded]
2235 {
2236 let both = tempfile::tempdir().expect("tempdir");
2237 put(both.path(), ".gitignore", b"*.log\n");
2238 put(both.path(), ".GITIGNORE", b"*.tmp\n");
2239 if fs::read(both.path().join(".gitignore")).expect("read") != b"*.log\n" {
2240 eprintln!("skipped {CASE_BOTH:?}: the temporary directory is case-insensitive");
2241 break;
2242 }
2243 for file in 0..1_200 {
2244 put(both.path(), &format!("f{file:04}.tmp"), b"tmp");
2245 }
2246 put(both.path(), "x.log", b"log");
2247 let mut both = case(CASE_BOTH, both, ScanConfig::default(), true, 0);
2248 both.lookups = lookups;
2249 cases.push(both);
2250 }
2251
2252 #[cfg(unix)]
2253 {
2254 use std::os::unix::fs::PermissionsExt;
2255
2256 let shapes = tempfile::tempdir().expect("tempdir");
2258 put(shapes.path(), ".gitignore", b"*.tmp\n");
2259 put(shapes.path(), "weird/.gitignore/inner.tmp", b"inner");
2260 put(shapes.path(), "weird/kept.txt", b"kept");
2261 put(shapes.path(), "rules.txt", b"*.txt\n");
2262 fs::create_dir(shapes.path().join("linked")).expect("directory");
2263 std::os::unix::fs::symlink("../rules.txt", shapes.path().join("linked/.gitignore"))
2264 .expect("symlink");
2265 put(shapes.path(), "linked/still.txt", b"still counted");
2266 cases.push(case(
2267 "control files that are not files",
2268 shapes,
2269 ScanConfig::default(),
2270 true,
2271 0,
2272 ));
2273
2274 if crate::test_support::require_permission_bits() {
2275 let unreadable = tempfile::tempdir().expect("tempdir");
2276 put(unreadable.path(), ".gitignore", b"*.log\n");
2277 put(unreadable.path(), "x.log", b"ignored");
2278 put(unreadable.path(), "sub/.gitignore", b"!*.log\n");
2279 put(unreadable.path(), "sub/y.log", b"unknown");
2280 let control = unreadable.path().join("sub/.gitignore");
2281 let mut denied =
2282 case("unreadable control file", unreadable, ScanConfig::default(), false, 0);
2283 fs::set_permissions(&control, fs::Permissions::from_mode(0o000))
2284 .expect("deny the control file");
2285 denied.denied = Some(control);
2286 denied.errors = true;
2287 cases.push(denied);
2288 }
2289 }
2290 cases
2291 }
2292
2293 fn transient_and_indexed(root: &Path, scan: &ScanConfig, label: &str) -> (Report, Report) {
2297 let query = summary_query();
2298 let transient = OpenFixture { scan: scan.clone(), ..config(CachePolicy::Off, None) };
2299 assert_eq!(planned(&transient, &query).retained, RetainedState::Summary, "{label}");
2300 let (mut compact, pending, compact_performance) =
2301 prepared(root, &transient, &query).expect("transient report");
2302 pending.join().expect("the transient tier saves nothing");
2303
2304 let cache = tempfile::tempdir().expect("cache dir");
2307 let indexed = OpenFixture {
2308 scan: scan.clone(),
2309 ..config(CachePolicy::On, Some(cache.path().join("snapshot.fdu")))
2310 };
2311 assert_eq!(planned(&indexed, &query).retained, RetainedState::FullIndex, "{label}");
2312 let (indexed, pending, indexed_performance) =
2313 prepared(root, &indexed, &query).expect("indexed report");
2314 pending.join().expect("save");
2315
2316 assert_eq!(
2317 (
2318 compact_performance.walked_files,
2319 compact_performance.walked_bytes,
2320 compact_performance.walked_allocated,
2321 compact_performance.source,
2322 ),
2323 (
2324 indexed_performance.walked_files,
2325 indexed_performance.walked_bytes,
2326 indexed_performance.walked_allocated,
2327 indexed_performance.source,
2328 ),
2329 "{label}: walked totals"
2330 );
2331 assert_eq!(compact.provenance.source, indexed.provenance.source, "{label}");
2332 assert_eq!(compact.provenance.freshness, indexed.provenance.freshness, "{label}");
2333 assert_eq!(
2334 (compact.provenance.tiers.entries.source, compact.provenance.tiers.entries.freshness),
2335 (indexed.provenance.tiers.entries.source, indexed.provenance.tiers.entries.freshness),
2336 "{label}"
2337 );
2338 compact.provenance = indexed.provenance.clone();
2339 (compact, indexed)
2340 }
2341
2342 #[test]
2350 fn compact_summary_equals_the_indexed_summary_under_every_control_case() {
2351 use crate::report_format::{Format, render};
2352
2353 let default_batch = ScanConfig::default().batch_size;
2354 for case in control_cases() {
2355 let _lookups = case.lookups.install(case.root.path());
2356 let mut compared = 0;
2357 for threads in [Some(1), Some(2), Some(4), None] {
2358 if case.order_dependent && threads != Some(1) {
2359 continue;
2360 }
2361 for batch_size in [default_batch, 1, 3] {
2362 for order in [crate::ScanOrder::BreadthFirst, crate::ScanOrder::DepthFirst] {
2363 let scan = ScanConfig { batch_size, threads, order, ..case.scan.clone() };
2364 let label = format!(
2365 "{} ({:?} lookups, {threads:?} workers, batch {batch_size}, {order:?})",
2366 case.name, case.lookups
2367 );
2368 let (compact, indexed) =
2369 transient_and_indexed(case.root.path(), &scan, &label);
2370
2371 assert_eq!(format!("{compact:#?}"), format!("{indexed:#?}"), "{label}");
2372 for format in [Format::Text, Format::Json, Format::Yaml] {
2373 assert_eq!(
2374 render(&compact, format, false).expect("render"),
2375 render(&indexed, format, false).expect("render"),
2376 "{label}: {format:?}"
2377 );
2378 }
2379
2380 let Section::Summary(row) = indexed.sections[0] else {
2382 panic!("{label}: a summary")
2383 };
2384 let crate::control::ControlCoverage::Observed(coverage) =
2385 &indexed.ignore_rules
2386 else {
2387 panic!("{label}: the default scope observes .gitignore")
2388 };
2389 assert_eq!(coverage.refused, case.refused, "{label}");
2390 assert_eq!(row.ignored.is_some(), case.share, "{label}");
2391 assert_eq!(!indexed.status.errors.is_empty(), case.errors, "{label}");
2392 if case.share && case.name.starts_with("rules") {
2393 let ignored = row.ignored.expect("a share");
2394 assert!(
2395 ignored.files > 0 && ignored.files < row.files && ignored.dirs > 0,
2396 "{label}: {ignored:?} of {row:?}"
2397 );
2398 }
2399 if let Some(governs) = case.variant_governs {
2400 assert_eq!(coverage.applied, u64::from(governs), "{label}");
2401 assert_eq!(
2402 row.ignored.map(|ignored| ignored.files),
2403 Some(if governs { 1_200 } else { 0 }),
2404 "{label}"
2405 );
2406 }
2407 if case.name == CASE_BOTH {
2408 assert_eq!(coverage.applied, 1, "{label}: the exact name alone");
2409 assert_eq!(
2410 row.ignored.map(|ignored| ignored.files),
2411 Some(1),
2412 "{label}: only `x.log` is ignored"
2413 );
2414 }
2415 if !case.share {
2416 assert!(
2417 indexed
2418 .notes
2419 .iter()
2420 .any(|note| note.contains("could not be verified")),
2421 "{label}: {:?}",
2422 indexed.notes
2423 );
2424 }
2425 compared += 1;
2426 }
2427 }
2428 }
2429 assert!(compared > 0, "{} was compared", case.name);
2430 }
2431 }
2432
2433 #[test]
2434 fn compact_summary_never_creates_the_configured_snapshot() {
2435 let root = tempfile::tempdir().expect("tempdir");
2436 fs::write(root.path().join("payload"), b"payload").expect("file");
2437 let cache = root.path().join("must-not-exist.fdu");
2438
2439 let (report, pending, _) =
2440 prepared(root.path(), &blind(CachePolicy::Off, Some(cache.clone())), &summary_query())
2441 .expect("compact report");
2442 pending.join().expect("no pending compact save");
2443
2444 assert!(report.status.complete);
2445 assert!(!cache.exists());
2446 }
2447
2448 #[test]
2449 fn full_index_report_exposes_scan_diagnostics_when_requested() {
2450 let root = tempfile::tempdir().expect("tempdir");
2451 fs::create_dir(root.path().join("nested")).expect("directory");
2452 fs::write(root.path().join("nested/file.txt"), b"trace me").expect("file");
2453 let query = Query { views: vec![ViewSpec::Tree], ..Query::default() };
2454
2455 let (report, pending, performance, diagnostics) =
2456 prepared_with_diagnostics(root.path(), &config(CachePolicy::Off, None), &query)
2457 .expect("full-index report");
2458 pending.join().expect("no pending save");
2459
2460 assert!(report.status.complete);
2461 assert_eq!(performance.walked_files, 1);
2462 let diagnostics = diagnostics.expect("full-index scan diagnostics");
2463 assert_eq!(diagnostics.schema, crate::scan::SCAN_DIAGNOSTICS_SCHEMA);
2464 assert_eq!(diagnostics.worker_policy.ready_directories_at_finish, 0);
2465 assert_eq!(diagnostics.worker_policy.in_flight_directories_at_finish, 0);
2466 }
2467
2468 fn wide_tree(dirs: usize, files: usize) -> (tempfile::TempDir, u64, u64) {
2471 let root = tempfile::tempdir().expect("tempdir");
2472 let mut bytes = 0;
2473 for directory in 0..dirs {
2474 let path = root.path().join(format!("d{directory:03}"));
2475 fs::create_dir(&path).expect("directory");
2476 for file in 0..files {
2477 let size = directory * files + file + 1;
2478 fs::write(path.join(format!("f{file}.txt")), vec![b'.'; size]).expect("file");
2479 bytes += size as u64;
2480 }
2481 }
2482 (root, (dirs * files) as u64, bytes)
2483 }
2484
2485 fn prepared_with_progress(
2487 root: &Path,
2488 config: &OpenFixture,
2489 query: &Query,
2490 progress: &Progress,
2491 ) -> Result<(Report, PendingSave, PerformanceSummary)> {
2492 let (request, delivery) = split(root, config, query);
2493 prepare_report_with_progress(&request, &delivery, progress)
2494 }
2495
2496 fn analyzing(fixture: OpenFixture) -> OpenFixture {
2497 OpenFixture {
2498 analysis: crate::content::AnalysisRequest {
2499 profile: crate::content::AnalysisSet::LINES_ONLY,
2500 workers: 0,
2501 },
2502 ..fixture
2503 }
2504 }
2505
2506 #[test]
2514 fn progress_ends_at_the_walked_totals_of_every_one_shot_route() {
2515 use crate::ProgressPhase::{Scanning, Summarizing};
2516 let (root, files, bytes) = wide_tree(6, 4);
2517 let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
2518
2519 let progress = Progress::new();
2520 let (_, pending, performance) =
2521 prepared_with_progress(root.path(), &config(CachePolicy::Off, None), &tree, &progress)
2522 .expect("cold full-index report");
2523 pending.join().expect("no save");
2524 let snapshot = progress.snapshot();
2525 assert_eq!(performance.source, ReportSource::ColdScan);
2526 assert_eq!((performance.walked_files, performance.walked_bytes), (files, bytes));
2527 assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "cold full index");
2528 assert_eq!(snapshot.allocated, performance.walked_allocated);
2529 assert!(snapshot.allocated > 0, "files with content occupy blocks");
2530 assert_eq!(snapshot.directories, 7, "the root and its six children");
2531 assert_eq!(
2532 (snapshot.phase, snapshot.analysis),
2533 (Summarizing, None),
2534 "the walk ended, the index was assembled, then the answer was built"
2535 );
2536
2537 let progress = Progress::new();
2538 let (report, pending, performance) = prepared_with_progress(
2539 root.path(),
2540 &blind(CachePolicy::Off, None),
2541 &summary_query(),
2542 &progress,
2543 )
2544 .expect("compact summary report");
2545 pending.join().expect("no save");
2546 let Section::Summary(row) = report.sections[0] else { panic!("summary section") };
2547 let snapshot = progress.snapshot();
2548 assert_eq!((performance.walked_files, performance.walked_bytes), (files, bytes));
2549 assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "summary fold");
2550 assert_eq!(snapshot.allocated, performance.walked_allocated);
2551 assert_eq!(snapshot.allocated, row.allocated, "the progress figure is the answer's");
2552 assert_eq!(snapshot.directories, row.dirs + 1, "the row's directories and the root");
2553 assert_eq!((snapshot.phase, snapshot.analysis), (Scanning, None));
2554
2555 let cache_dir = tempfile::tempdir().expect("cache dir");
2557 let cache = cache_dir.path().join("snapshot.fdu");
2558 let progress = Progress::new();
2559 let (_, pending, performance) = prepared_with_progress(
2560 root.path(),
2561 &analyzing(config(CachePolicy::Auto, Some(cache.clone()))),
2562 &tree,
2563 &progress,
2564 )
2565 .expect("cold analyzed report");
2566 let snapshot = progress.snapshot();
2567 assert_eq!(snapshot.phase, Summarizing, "the answer is built while the save runs");
2568 pending.join().expect("save");
2569 assert_eq!(performance.source, ReportSource::ColdScan);
2570 assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "cold with analysis");
2571 assert_eq!(snapshot.allocated, performance.walked_allocated);
2572 assert_eq!(snapshot.directories, 7);
2573 assert_eq!(performance.fresh_files, files, "every file is a lines candidate");
2574 assert_eq!(snapshot.analysis, Some((files, files)));
2575
2576 let progress = Progress::new();
2577 let (_, pending, performance) = prepared_with_progress(
2578 root.path(),
2579 &analyzing(config(CachePolicy::Auto, Some(cache))),
2580 &tree,
2581 &progress,
2582 )
2583 .expect("warm analyzed report");
2584 pending.join().expect("nothing to save");
2585 let snapshot = progress.snapshot();
2586 assert_eq!(performance.source, ReportSource::WarmRevalidate);
2587 assert_eq!((performance.walked_files, performance.walked_bytes), (files, bytes));
2588 assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "warm revalidation");
2589 assert_eq!(snapshot.allocated, performance.walked_allocated);
2590 assert_eq!(snapshot.directories, 7);
2591 assert_eq!(performance.fresh_files, 0, "the sidecar answered every candidate");
2592 assert_eq!((snapshot.phase, snapshot.analysis), (Summarizing, Some((0, 0))));
2593 }
2594
2595 #[test]
2598 fn a_cache_only_report_ends_summarizing_and_walks_nothing() {
2599 use crate::ProgressPhase::Summarizing;
2600 let (root, _, _) = wide_tree(3, 2);
2601 let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
2602 let cache_dir = tempfile::tempdir().expect("cache dir");
2603 let cache = cache_dir.path().join("snapshot.fdu");
2604 let (_, pending, _) =
2605 prepared(root.path(), &config(CachePolicy::On, Some(cache.clone())), &tree)
2606 .expect("a report that writes the snapshot");
2607 pending.join().expect("save");
2608
2609 let progress = Progress::new();
2610 let (_, pending, performance) = prepared_with_progress(
2611 root.path(),
2612 &stale(config(CachePolicy::Auto, Some(cache))),
2613 &tree,
2614 &progress,
2615 )
2616 .expect("cache-only report");
2617 pending.join().expect("nothing to save");
2618 let snapshot = progress.snapshot();
2619 assert_eq!(performance.source, ReportSource::CacheOnly);
2620 assert_eq!(snapshot.phase, Summarizing);
2621 assert_eq!((snapshot.directories, snapshot.files, snapshot.bytes), (0, 0, 0));
2622 }
2623
2624 fn rank(phase: crate::ProgressPhase, order: &[crate::ProgressPhase]) -> usize {
2626 order
2627 .iter()
2628 .position(|expected| *expected == phase)
2629 .unwrap_or_else(|| panic!("{phase:?} is not a phase of this route"))
2630 }
2631
2632 #[test]
2639 fn progress_is_monotonic_and_phases_advance_in_order_while_a_report_runs() {
2640 use crate::ProgressPhase::{
2641 Analyzing, Indexing, Loading, Revalidating, Saving, Scanning, Starting, Summarizing,
2642 };
2643 let (root, files, bytes) = wide_tree(48, 6);
2644 let cache_dir = tempfile::tempdir().expect("cache dir");
2645 let cache = cache_dir.path().join("snapshot.fdu");
2646 let fixture = OpenFixture {
2647 scan: ScanConfig { threads: Some(3), batch_size: 4, ..ScanConfig::default() },
2648 ..analyzing(config(CachePolicy::Auto, Some(cache)))
2649 };
2650 let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
2651
2652 let routes: [(&str, &[crate::ProgressPhase]); 2] = [
2653 ("cold", &[Starting, Loading, Scanning, Indexing, Analyzing, Saving, Summarizing]),
2654 ("warm", &[Starting, Loading, Revalidating, Analyzing, Saving, Summarizing]),
2655 ];
2656 for (route, order) in routes {
2657 let progress = Progress::new();
2658 let initial = progress.snapshot();
2661 let done = std::sync::atomic::AtomicBool::new(false);
2662 let (performance, seen) = std::thread::scope(|scope| {
2663 let poller = scope.spawn(|| {
2664 let polled = progress.clone();
2665 let mut previous = initial;
2666 let mut seen = vec![previous.phase];
2667 loop {
2668 let finished = done.load(std::sync::atomic::Ordering::Acquire);
2669 let current = polled.snapshot();
2670 assert!(current.directories >= previous.directories, "{route}");
2671 assert!(current.files >= previous.files, "{route}");
2672 assert!(current.bytes >= previous.bytes, "{route}");
2673 assert!(
2674 rank(current.phase, order) >= rank(previous.phase, order),
2675 "{route}: {:?} after {:?}",
2676 current.phase,
2677 previous.phase
2678 );
2679 if let (Some(before), Some(after)) = (previous.analysis, current.analysis) {
2680 assert!(after.0 >= before.0 && after.0 <= after.1, "{route}");
2681 assert_eq!(after.1, before.1, "{route}: the total is fixed");
2682 }
2683 if current.phase != previous.phase {
2684 seen.push(current.phase);
2685 }
2686 previous = current;
2687 if finished {
2690 break;
2691 }
2692 std::thread::yield_now();
2693 }
2694 seen
2695 });
2696 let (_, pending, performance) =
2697 prepared_with_progress(root.path(), &fixture, &tree, &progress)
2698 .expect("report");
2699 pending.join().expect("save");
2700 done.store(true, std::sync::atomic::Ordering::Release);
2701 (performance, poller.join().expect("poller"))
2702 });
2703 let snapshot = progress.snapshot();
2704 assert_eq!(
2705 (snapshot.files, snapshot.bytes),
2706 (performance.walked_files, performance.walked_bytes),
2707 "{route}"
2708 );
2709 assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "{route}");
2710 assert_eq!(snapshot.directories, 49, "{route}");
2711 assert_eq!(seen.first(), Some(&Starting), "{route}: {seen:?}");
2712 assert_eq!(
2713 seen.last().copied(),
2714 Some(snapshot.phase),
2715 "{route}: the poller saw the final phase"
2716 );
2717 assert_eq!(snapshot.phase, Summarizing, "{route}: the answer is built last");
2718 if route == "cold" {
2719 assert_eq!(snapshot.analysis, Some((files, files)));
2720 } else {
2721 assert_eq!(snapshot.analysis, Some((0, 0)));
2722 assert!(!seen.contains(&Indexing), "{route}: a warm run assembles no index");
2723 }
2724 }
2725 }
2726
2727 #[test]
2731 fn a_report_prepared_with_a_handle_is_the_report_prepared_without() {
2732 let (root, _, _) = wide_tree(5, 3);
2733 let tree = Query { views: vec![ViewSpec::Tree, ViewSpec::Files], ..Query::default() };
2734 let cases = [
2735 ("full index", config(CachePolicy::Off, None), tree),
2736 ("compact summary", blind(CachePolicy::Off, None), summary_query()),
2737 ];
2738 for (route, fixture, query) in cases {
2739 let (mut plain, pending, plain_performance) =
2740 prepared(root.path(), &fixture, &query).expect("plain report");
2741 pending.join().expect("no save");
2742 let progress = Progress::new();
2743 let (observed, pending, observed_performance) =
2744 prepared_with_progress(root.path(), &fixture, &query, &progress)
2745 .expect("observed report");
2746 pending.join().expect("no save");
2747
2748 assert_eq!(plain_performance, observed_performance, "{route}");
2749 plain.provenance = observed.provenance.clone();
2750 let json = |report: &Report| {
2751 crate::report_format::render(report, crate::report_format::Format::Json, false)
2752 .expect("render")
2753 };
2754 assert_eq!(json(&plain), json(&observed), "{route}");
2755 assert!(progress.snapshot().files > 0, "{route}: the handle did observe the run");
2756 }
2757 }
2758}