1use std::time::SystemTime;
11
12use crate::query::{
13 Delivery, Report, ReportProvenance, ReportSource, Request, SummaryRow, TreeStatus, ViewSpec,
14 report, report_summary,
15};
16use crate::{CachePolicy, EntryKind, Error, OpenPath, PendingSave, Progress, Result, execute};
17
18#[derive(Clone, Copy, PartialEq, Eq, Debug)]
24pub(crate) enum RetainedState {
25 Summary,
30 FullIndex,
32}
33
34#[derive(Clone, Copy, PartialEq, Eq, Debug)]
36pub enum Route {
37 OneShot,
39 Retained,
41 Refresh,
43 Watch,
45 Opened,
47}
48
49#[derive(Clone, Copy, PartialEq, Eq, Debug)]
51pub enum Load {
52 None,
54 Snapshot,
56}
57
58#[derive(Clone, Copy, PartialEq, Eq, Debug)]
60pub enum Verify {
61 None,
63 Filesystem,
65}
66
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub enum OutcomeClass {
70 Success,
72 Partial,
74}
75
76#[derive(Clone, Debug)]
78pub struct Plan {
79 pub(crate) basis: crate::query::Basis,
80 pub(crate) route: Route,
81 pub(crate) retained: RetainedState,
82 pub(crate) load: Load,
83 pub(crate) verify: Verify,
84 pub(crate) persist: bool,
85 pub(crate) delivery: Delivery,
86}
87
88impl Plan {
89 pub fn outcome(&self, status: &TreeStatus) -> OutcomeClass {
91 if status.complete || self.delivery.accept_partial {
92 OutcomeClass::Success
93 } else {
94 OutcomeClass::Partial
95 }
96 }
97 pub fn basis(&self) -> &crate::query::Basis {
99 &self.basis
100 }
101 pub const fn route(&self) -> Route {
103 self.route
104 }
105 pub const fn load(&self) -> Load {
107 self.load
108 }
109 pub const fn verify(&self) -> Verify {
111 self.verify
112 }
113 pub const fn persists(&self) -> bool {
120 self.persist
121 }
122 pub fn delivery(&self) -> &Delivery {
124 &self.delivery
125 }
126}
127
128pub(crate) struct StoreHeader<'a> {
130 pub(crate) root: &'a std::path::Path,
131 pub(crate) snapshot: crate::SnapshotIdentity,
132 pub(crate) content: Option<&'a crate::ContentTierIdentity>,
133 pub(crate) content_complete: bool,
134}
135
136#[derive(Clone, Copy, Debug, PartialEq, Eq)]
138pub(crate) enum Admission {
139 Serve(crate::Serves),
140 NoLocation,
141 Missing,
142 WrongRoot,
143 WrongScope,
144 IncompleteContent,
145}
146
147impl Plan {
148 pub(crate) fn admit(
155 &self,
156 stored: Option<&StoreHeader<'_>>,
157 basis: &crate::query::Basis,
158 ) -> Admission {
159 if self.delivery.cache_path.is_none() {
160 return Admission::NoLocation;
161 }
162 let Some(stored) = stored else {
163 return Admission::Missing;
164 };
165 if stored.root != basis.root {
166 return Admission::WrongRoot;
167 }
168 let relation = crate::serves_snapshot(stored.snapshot, basis.scope.snapshot_identity());
169 if relation == crate::Serves::Refuse {
170 return Admission::WrongScope;
171 }
172 if self.verify == Verify::None && basis.content.is_enabled() {
173 let wanted = crate::ContentTierIdentity::for_request(
174 basis.scope.snapshot_identity().entries,
175 basis.content,
176 );
177 if !stored.content_complete
178 || stored.content.and_then(|identity| wanted.admit(identity)).is_none()
179 {
180 return Admission::IncompleteContent;
181 }
182 }
183 Admission::Serve(relation)
184 }
185}
186
187#[derive(Clone, Copy, Debug)]
189#[allow(clippy::struct_excessive_bools)]
190pub(crate) struct RunFacts {
191 pub(crate) entries_verified: bool,
192 pub(crate) entries_changed: bool,
193 pub(crate) content_changed: bool,
194 pub(crate) content_requested: bool,
195 pub(crate) projected: bool,
196 pub(crate) paired_entries: bool,
197}
198
199#[derive(Clone, Copy, Debug, PartialEq, Eq)]
201pub(crate) struct SaveTargets {
202 pub(crate) metadata: bool,
203 pub(crate) content: bool,
204}
205
206impl SaveTargets {
207 pub(crate) const fn none(self) -> bool {
208 !self.metadata && !self.content
209 }
210}
211
212impl Plan {
213 pub(crate) fn writes(&self, run: RunFacts) -> SaveTargets {
214 let allowed = self.persist && self.delivery.cache_path.is_some();
215 SaveTargets {
216 metadata: allowed && run.entries_verified && run.entries_changed && !run.projected,
217 content: allowed
218 && run.content_requested
219 && run.content_changed
220 && (run.entries_verified || run.paired_entries),
221 }
222 }
223}
224
225#[derive(Clone, Copy, PartialEq, Eq, Debug)]
230pub struct PerformanceSummary {
231 pub walked_files: u64,
233 pub walked_bytes: u64,
235 pub walked_allocated: u64,
238 pub fresh_files: u64,
240 pub bytes_read: u64,
242 pub analysis_ns: u64,
244 pub cached_files: u64,
246 pub cached_bytes: u64,
248 pub source: ReportSource,
250}
251
252impl Default for PerformanceSummary {
253 fn default() -> Self {
254 Self {
255 walked_files: 0,
256 walked_bytes: 0,
257 walked_allocated: 0,
258 fresh_files: 0,
259 bytes_read: 0,
260 analysis_ns: 0,
261 cached_files: 0,
262 cached_bytes: 0,
263 source: ReportSource::ColdScan,
264 }
265 }
266}
267
268impl PerformanceSummary {
269 pub fn total_throughput(
272 self,
273 elapsed: std::time::Duration,
274 size: crate::query::SizeMetric,
275 ) -> String {
276 let bytes = match size {
277 crate::query::SizeMetric::Apparent => self.walked_bytes,
278 crate::query::SizeMetric::Allocated => self.walked_allocated,
279 };
280 throughput_rates(self.walked_files, bytes, elapsed).map_or_else(
281 || "throughput unavailable".to_owned(),
282 |(files, gib)| format!("{files} files/s ({gib} GiB/s)"),
283 )
284 }
285
286 fn from_open_report(report: &crate::OpenReport) -> Self {
287 let analysis = report.analysis.unwrap_or_default();
288 Self {
289 walked_files: report.scan.files_walked,
290 walked_bytes: report.scan.bytes_walked,
291 walked_allocated: report.scan.allocated_walked,
292 fresh_files: analysis.candidates,
293 bytes_read: analysis.bytes_read,
294 analysis_ns: analysis.elapsed_ns,
295 cached_files: report.content_cache.hits,
296 cached_bytes: report.content_cache.bytes,
297 source: match report.path_taken {
298 OpenPath::ColdScan => ReportSource::ColdScan,
299 OpenPath::WarmRevalidate => ReportSource::WarmRevalidate,
300 OpenPath::CacheOnly => ReportSource::CacheOnly,
301 },
302 }
303 }
304}
305
306pub fn throughput_rates(
311 files: u64,
312 bytes: u64,
313 elapsed: std::time::Duration,
314) -> Option<(String, String)> {
315 let ns = elapsed.as_nanos();
316 if ns == 0 {
317 return None;
318 }
319 let files_per_second = u128::from(files) * 1_000_000_000 / ns;
320 let gib_denominator = ns * (1_u128 << 30);
321 let gib_thousandths =
322 (u128::from(bytes) * 1_000_000_000 * 1_000 + gib_denominator / 2) / gib_denominator;
323 Some((
324 crate::report_format::human_count_u128(files_per_second),
325 format!("{}.{:03}", gib_thousandths / 1_000, gib_thousandths % 1_000),
326 ))
327}
328
329pub fn plan(
368 request: &Request,
369 delivery: &Delivery,
370 route: Route,
371) -> std::result::Result<Plan, crate::query::RequestError> {
372 request.validate()?;
373 let mut normalized = delivery.clone();
374 if route == Route::Watch {
375 normalized.watch.get_or_insert_with(crate::query::WatchDelivery::default);
376 }
377 let delivery = &normalized;
378 request.validate_delivery(delivery)?;
379 if route == Route::Opened {
380 if delivery.cache != CachePolicy::Off
381 || delivery.watch.is_some()
382 || request.basis.content.is_enabled()
383 {
384 return Err(crate::query::RequestError::DeliveryUnsupported {
385 route: "opened",
386 reason: "progressive discovery requires cache off, no content analyzers, and observation configured through OpenOptions",
387 });
388 }
389 if delivery.workers.scan.is_some()
393 || delivery.order != crate::ScanOrder::default()
394 || delivery.accept_partial
395 {
396 return Err(crate::query::RequestError::DeliveryUnsupported {
397 route: "opened",
398 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",
399 });
400 }
401 }
402 if route == Route::Refresh && delivery.stale_ok {
403 return Err(crate::query::RequestError::DeliveryUnsupported {
404 route: "refresh",
405 reason: "a stale answer cannot verify filesystem state",
406 });
407 }
408 let analysis_requested = request.basis.content.is_enabled();
409 let summary_is_sufficient = request.query.views.as_slice() == [ViewSpec::Summary]
410 && request.query.selection.is_unfiltered()
411 && !request.basis.scope.read_controls;
412 let policy_requires_index =
413 delivery.stale_ok || (delivery.cache == CachePolicy::On && delivery.cache_path.is_some());
414 let stored_state_pays = route != Route::OneShot || analysis_requested;
420 let read_snapshot = delivery.stale_ok
421 || match delivery.cache {
422 CachePolicy::Off => false,
423 CachePolicy::Auto | CachePolicy::On => stored_state_pays,
424 };
425 let persist = !delivery.stale_ok
426 && match delivery.cache {
427 CachePolicy::Off => false,
428 CachePolicy::Auto => stored_state_pays,
429 CachePolicy::On => true,
430 };
431 Ok(Plan {
432 basis: request.basis.clone(),
433 route,
434 retained: if route == Route::OneShot
435 && !policy_requires_index
436 && !analysis_requested
437 && summary_is_sufficient
438 {
439 RetainedState::Summary
440 } else {
441 RetainedState::FullIndex
442 },
443 load: if read_snapshot { Load::Snapshot } else { Load::None },
444 verify: if delivery.stale_ok { Verify::None } else { Verify::Filesystem },
445 persist,
446 delivery: delivery.clone(),
447 })
448}
449
450pub fn prepare_report(
478 request: &Request,
479 delivery: &Delivery,
480) -> Result<(Report, PendingSave, PerformanceSummary)> {
481 prepare_report_internal(request, delivery, false, None)
482 .map(|(report, pending, performance, _diagnostics)| (report, pending, performance))
483}
484
485pub fn prepare_report_with_progress(
502 request: &Request,
503 delivery: &Delivery,
504 progress: &Progress,
505) -> Result<(Report, PendingSave, PerformanceSummary)> {
506 prepare_report_internal(request, delivery, false, Some(progress))
507 .map(|(report, pending, performance, _diagnostics)| (report, pending, performance))
508}
509
510pub fn prepare_report_with_scan_diagnostics(
519 request: &Request,
520 delivery: &Delivery,
521) -> Result<(Report, PendingSave, PerformanceSummary, Option<crate::scan::ScanDiagnostics>)> {
522 prepare_report_internal(request, delivery, true, None)
523}
524
525fn prepare_report_internal(
526 request: &Request,
527 delivery: &Delivery,
528 collect_scan_diagnostics: bool,
529 progress: Option<&Progress>,
530) -> Result<(Report, PendingSave, PerformanceSummary, Option<crate::scan::ScanDiagnostics>)> {
531 request.validate().map_err(Error::InvalidRequest)?;
538 let scan_config = crate::ScanConfig {
539 progress: progress.cloned(),
540 ..request.basis.scope.scan_config(delivery)
541 };
542 let root = request.basis.root.as_path();
543 let scan_started_at = SystemTime::now();
544 let plan = plan(request, delivery, Route::OneShot).map_err(Error::InvalidRequest)?;
545 match plan.retained {
546 RetainedState::Summary => {
547 let root = root.canonicalize().map_err(|error| Error::io(root, error))?;
548 let mut summary = SummaryRow::default();
549 let mut reduce = |observed: &crate::ObservationOp| {
550 let crate::Op::Upsert { kind, attrs, .. } = &observed.op else {
551 return;
552 };
553 match kind {
554 EntryKind::File => {
555 summary.files += 1;
556 summary.bytes += attrs.size;
557 summary.allocated += attrs.allocated;
558 summary.newest_mtime_ns = Some(
559 summary
560 .newest_mtime_ns
561 .map_or(attrs.mtime_ns, |current| current.max(attrs.mtime_ns)),
562 );
563 }
564 EntryKind::Dir => summary.dirs += 1,
565 EntryKind::Symlink | EntryKind::Other => {}
566 }
567 };
568 let (mut scan, scan_diagnostics) = if collect_scan_diagnostics {
569 let (scan, diagnostics) = crate::scan::scan_summary_fold_with_diagnostics(
570 &root,
571 &scan_config,
572 &mut reduce,
573 )?;
574 (scan, Some(diagnostics))
575 } else {
576 (crate::scan::scan_summary_fold(&root, &scan_config, &mut reduce)?, None)
577 };
578 let complete = scan.is_complete();
579 let generated_at = SystemTime::now();
580 let report = report_summary(
581 &root,
582 scan_config.scope(),
583 request,
584 summary,
585 TreeStatus::of_walk(&root, &mut scan),
586 ReportProvenance::of_walk(scan_started_at, generated_at, complete),
587 );
588 let performance = PerformanceSummary {
589 walked_files: scan.files_walked,
590 walked_bytes: scan.bytes_walked,
591 walked_allocated: scan.allocated_walked,
592 source: ReportSource::ColdScan,
593 ..PerformanceSummary::default()
594 };
595 Ok((report, PendingSave::none(), performance, scan_diagnostics))
596 }
597 RetainedState::FullIndex => {
598 let (index, open_report, pending_save, scan_diagnostics) =
599 execute(&plan, &request.basis, collect_scan_diagnostics, progress)?;
600 let performance = PerformanceSummary::from_open_report(&open_report);
601 if let Some(progress) = progress {
602 progress.enter(crate::ProgressPhase::Summarizing);
603 }
604 let answer = report(&index, request, SystemTime::now())?;
605 debug_assert_eq!(answer.scope, scan_config.scope());
606 crate::release_index(index);
609 Ok((answer, pending_save, performance, scan_diagnostics))
610 }
611 }
612}
613
614#[cfg(test)]
615mod tests {
616 use std::fs;
617 use std::path::{Path, PathBuf};
618
619 use super::*;
620 use crate::query::{IgnoredEntries, Pattern, Query, Section};
621 use crate::{OpenFixture, ScanConfig};
622
623 #[test]
624 fn total_throughput_uses_one_elapsed_sample_and_selected_size() {
625 use crate::query::SizeMetric;
626 let work = PerformanceSummary {
627 walked_files: 200,
628 walked_bytes: 4_000_000_000,
629 walked_allocated: 1_000_000_000,
630 ..PerformanceSummary::default()
631 };
632 assert_eq!(
633 work.total_throughput(std::time::Duration::from_secs(2), SizeMetric::Apparent),
634 "100 files/s (1.863 GiB/s)"
635 );
636 assert_eq!(
637 work.total_throughput(std::time::Duration::from_secs(2), SizeMetric::Allocated),
638 "100 files/s (0.466 GiB/s)"
639 );
640 assert_eq!(
641 work.total_throughput(std::time::Duration::ZERO, SizeMetric::Apparent),
642 "throughput unavailable"
643 );
644 assert_eq!(
645 PerformanceSummary::default()
646 .total_throughput(std::time::Duration::from_secs(1), SizeMetric::Apparent),
647 "0 files/s (0.000 GiB/s)"
648 );
649 assert_eq!(
650 throughput_rates(12_345, 3 * (1_u64 << 30), std::time::Duration::from_secs(2)),
651 Some(("6,172".to_owned(), "1.500".to_owned()))
652 );
653 }
654
655 #[test]
656 #[cfg(unix)]
657 fn an_unreadable_stored_header_never_authorizes_live_replacement() {
658 use std::os::unix::fs::PermissionsExt;
659 if !crate::test_support::require_permission_bits() {
660 return;
661 }
662 let root = tempfile::tempdir().expect("root");
663 let cache = tempfile::tempdir().expect("cache");
664 let snapshot = cache.path().join("snapshot.fdu");
665 fs::write(root.path().join(".gitignore"), b"ignored\n").expect("control");
666 let observed = crate::query::Basis {
667 root: root.path().into(),
668 scope: crate::query::Scope::default(),
669 content: crate::content::AnalysisSet::NONE,
670 };
671 let delivery = Delivery::new(CachePolicy::Auto, Some(snapshot.clone()));
672 crate::open(&observed, &delivery).expect("stronger snapshot");
673 let original = fs::read(&snapshot).expect("original image");
674 let basis = crate::query::Basis {
675 scope: crate::query::Scope { read_controls: false, ..Default::default() },
676 ..observed
677 };
678 let (mut index, _) =
679 crate::open(&basis, &Delivery::new(CachePolicy::Off, None)).expect("fresh blind index");
680 let request = Request::new(basis, Query::default(), SystemTime::now());
681 let plan = plan(&request, &delivery, Route::Refresh).expect("plan");
682 fs::set_permissions(&snapshot, fs::Permissions::from_mode(0o000))
683 .expect("deny header read");
684 let nonwriting = Delivery { cache: CachePolicy::Off, ..delivery.clone() };
685 let nonwriting_plan =
686 super::plan(&request, &nonwriting, Route::Refresh).expect("nonwriting plan");
687 assert!(
688 !crate::persist_index_changes(&index, &nonwriting_plan, true, true)
689 .expect("nonwriting policy never reads the header")
690 );
691 fs::write(root.path().join("fresh.txt"), b"fresh").expect("mutation");
692 crate::refresh(
693 &mut index,
694 &request.basis,
695 &Delivery { cache: CachePolicy::Off, ..delivery.clone() },
696 )
697 .expect("off refresh does not inspect cache state");
698 let result = crate::persist_index_changes(&index, &plan, true, true);
699 fs::set_permissions(&snapshot, fs::Permissions::from_mode(0o600)).expect("restore");
700 assert!(
701 result.is_err(),
702 "a writable parent must not let unknown identity authorize replacement"
703 );
704 assert_eq!(fs::read(&snapshot).expect("retained image"), original);
705 }
706
707 #[test]
708 fn refresh_rejects_another_root_before_mutating_or_persisting() {
709 let a = tempfile::tempdir().expect("root a");
710 let b = tempfile::tempdir().expect("root b");
711 let cache = tempfile::tempdir().expect("cache");
712 let basis = crate::query::Basis {
713 root: a.path().into(),
714 scope: crate::query::Scope::default(),
715 content: crate::content::AnalysisSet::NONE,
716 };
717 let (mut index, _) =
718 crate::open(&basis, &Delivery::new(CachePolicy::Off, None)).expect("open a");
719 fs::write(a.path().join("new"), b"new facts").expect("mutation a");
720 let before = index.clock();
721 let snapshot = cache.path().join("snapshot.fdu");
722 let wrong = crate::query::Basis { root: b.path().into(), ..basis.clone() };
723 let delivery = Delivery::new(CachePolicy::Auto, Some(snapshot.clone()));
724 let error = crate::refresh(&mut index, &wrong, &delivery).expect_err("different root");
725 assert!(matches!(
726 error,
727 Error::InvalidRequest(crate::query::RequestError::RootMismatch { .. })
728 ));
729 assert_eq!(index.clock(), before);
730 assert!(!snapshot.exists());
731 let alias = crate::query::Basis { root: a.path().join("."), ..basis };
732 crate::refresh(&mut index, &alias, &delivery).expect("same root spelling");
733 assert_eq!(index.total().files, 1);
734 #[cfg(unix)]
735 {
736 let link = cache.path().join("root-alias");
737 std::os::unix::fs::symlink(a.path(), &link).expect("root alias");
738 let symlink_basis = crate::query::Basis { root: link, ..alias };
739 crate::refresh(&mut index, &symlink_basis, &delivery)
740 .expect("same canonical root through symlink");
741 }
742 }
743
744 #[test]
745 fn unchanged_refresh_replaces_an_incompatible_stored_baseline() {
746 let root = tempfile::tempdir().expect("root");
747 let other = tempfile::tempdir().expect("other root");
748 let cache = tempfile::tempdir().expect("cache");
749 fs::write(root.path().join("file"), b"retained").expect("file");
750 let basis = crate::query::Basis {
751 root: root.path().into(),
752 scope: crate::query::Scope::default(),
753 content: crate::content::AnalysisSet::NONE,
754 };
755 let delivery = Delivery::new(CachePolicy::Auto, Some(cache.path().join("snapshot.fdu")));
756 for wrong_root in [false, true] {
757 let wrong = if wrong_root {
758 crate::query::Basis { root: other.path().into(), ..basis.clone() }
759 } else {
760 crate::query::Basis {
761 scope: crate::query::Scope { max_depth: Some(0), ..basis.scope.clone() },
762 ..basis.clone()
763 }
764 };
765 crate::open(&wrong, &Delivery { cache: CachePolicy::On, ..delivery.clone() })
766 .expect("incompatible snapshot");
767 let (mut index, _) = crate::open(&basis, &Delivery::new(CachePolicy::Off, None))
768 .expect("retained index");
769 let refreshed =
770 crate::refresh(&mut index, &basis, &delivery).expect("refresh reseeds cache");
771 assert!(!refreshed.apply.mutated(), "the existing index was already current");
772 let (cached, _) = crate::open(&basis, &Delivery { stale_ok: true, ..delivery.clone() })
773 .expect("cache-only can now answer");
774 assert_eq!(cached.total().bytes, 8);
775 }
776 }
777
778 #[test]
779 fn refreshed_metadata_and_content_are_visible_to_a_later_cache_only_open() {
780 let root = tempfile::tempdir().expect("root");
781 let cache = tempfile::tempdir().expect("cache");
782 let path = root.path().join("note.txt");
783 fs::write(&path, b"old\n").expect("old file");
784 let basis = crate::query::Basis {
785 root: root.path().into(),
786 scope: crate::query::Scope::default(),
787 content: crate::content::AnalysisSet::NONE.with_lines(),
788 };
789 let delivery = Delivery::new(CachePolicy::Auto, Some(cache.path().join("snapshot.fdu")));
790 let (mut index, _) = crate::open(&basis, &delivery).expect("initial open");
791 fs::write(&path, b"new longer text\nsecond line\n").expect("mutation");
792 let refreshed = crate::refresh(&mut index, &basis, &delivery).expect("refresh");
793 assert!(refreshed.is_complete());
794 let (cached, report) = crate::open(&basis, &Delivery { stale_ok: true, ..delivery })
795 .expect("cache-only sees refreshed tiers");
796 assert_eq!(report.path_taken, OpenPath::CacheOnly);
797 assert_eq!(cached.total(), index.total());
798 assert_eq!(
799 cached.total().bytes,
800 u64::try_from(b"new longer text\nsecond line\n".len()).expect("length")
801 );
802 assert_eq!(report.content_cache.hits, 1);
803 let original = index
804 .content()
805 .expect("fresh content")
806 .file(Path::new("note.txt"))
807 .expect("fresh file");
808 let restored = cached
809 .content()
810 .expect("restored content")
811 .file(Path::new("note.txt"))
812 .expect("restored file");
813 assert_eq!(restored, original);
814 }
815
816 #[cfg(unix)]
819 fn owed_persistence_fixture()
820 -> (tempfile::TempDir, tempfile::TempDir, crate::query::Basis, Delivery) {
821 let root = tempfile::tempdir().expect("root");
822 let cache = tempfile::tempdir().expect("cache");
823 fs::create_dir(root.path().join("locked")).expect("locked dir");
824 fs::write(root.path().join("first"), b"first").expect("first file");
825 let basis = crate::query::Basis {
826 root: root.path().into(),
827 scope: crate::query::Scope::default(),
828 content: crate::content::AnalysisSet::NONE,
829 };
830 let delivery = Delivery::new(CachePolicy::Auto, Some(cache.path().join("snapshot.fdu")));
831 (root, cache, basis, delivery)
832 }
833
834 #[cfg(unix)]
836 fn cached_files(basis: &crate::query::Basis, delivery: &Delivery) -> u64 {
837 let cache_only = Delivery { stale_ok: true, ..delivery.clone() };
838 crate::open(basis, &cache_only).expect("cache-only open").0.total().files
839 }
840
841 #[test]
848 #[cfg(unix)]
849 fn an_unchanged_refresh_repeats_the_metadata_write_a_failed_refresh_owed() {
850 use std::os::unix::fs::PermissionsExt;
851 if !crate::test_support::require_permission_bits() {
852 return;
853 }
854 let (root, cache, basis, delivery) = owed_persistence_fixture();
855 let (mut index, _) = crate::open(&basis, &delivery).expect("initial open");
856 assert_eq!(cached_files(&basis, &delivery), 1);
857
858 fs::write(root.path().join("second"), b"second").expect("second file");
859 fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o555)).expect("deny write");
860 let failed = crate::refresh(&mut index, &basis, &delivery);
861 fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o755)).expect("restore");
862 assert!(failed.is_err(), "a read-only cache directory fails the write");
863 assert_eq!(index.total().files, 2, "the index advanced before the write");
864 assert_eq!(cached_files(&basis, &delivery), 1, "the failed write left the old image");
865
866 let unchanged = crate::refresh(&mut index, &basis, &delivery).expect("unchanged refresh");
867 assert!(unchanged.is_complete());
868 assert!(!unchanged.apply.mutated(), "nothing changed between the passes");
869 assert_eq!(cached_files(&basis, &delivery), 2, "the owed write ran");
870
871 let snapshot = delivery.cache_path.as_deref().expect("path");
873 let written = fs::metadata(snapshot).expect("snapshot").modified().expect("mtime");
874 crate::refresh(&mut index, &basis, &delivery).expect("settled refresh");
875 assert_eq!(fs::metadata(snapshot).expect("snapshot").modified().expect("mtime"), written);
876 }
877
878 #[test]
881 #[cfg(unix)]
882 fn an_unchanged_refresh_repeats_the_metadata_write_a_failed_open_owed() {
883 use std::os::unix::fs::PermissionsExt;
884 if !crate::test_support::require_permission_bits() {
885 return;
886 }
887 let (root, cache, basis, delivery) = owed_persistence_fixture();
888 crate::open(&basis, &delivery).expect("complete open writes the snapshot");
889 fs::write(root.path().join("second"), b"second").expect("second file");
890 fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o555)).expect("deny write");
891 let opened = crate::open_with_pending_save(&basis, &delivery);
892 let outcome = opened.map(|(index, report, pending)| (index, report, pending.join()));
894 fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o755)).expect("restore");
895 let (index, report, joined) = outcome.expect("the open itself succeeds");
896 assert_eq!(report.path_taken, OpenPath::WarmRevalidate);
897 assert!(joined.is_err(), "the startup write failed");
898 let mut index = std::sync::Arc::into_inner(index).expect("the writer released the index");
899 assert_eq!(cached_files(&basis, &delivery), 1);
900
901 let unchanged = crate::refresh(&mut index, &basis, &delivery).expect("unchanged refresh");
902 assert!(!unchanged.apply.mutated(), "nothing changed between the passes");
903 assert_eq!(cached_files(&basis, &delivery), 2, "the owed write ran");
904 }
905
906 #[test]
914 #[cfg(unix)]
915 fn a_complete_refresh_persists_the_facts_a_partial_refresh_could_not() {
916 use std::os::unix::fs::PermissionsExt;
917 if !crate::test_support::require_permission_bits() {
918 return;
919 }
920 let (root, _cache, basis, delivery) = owed_persistence_fixture();
921 let locked = root.path().join("locked");
922 let (mut index, _) = crate::open(&basis, &delivery).expect("initial open");
923 assert_eq!(index.total().files, 1);
924
925 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("deny read");
926 fs::write(root.path().join("second"), b"second").expect("second file");
927 let partial = crate::refresh(&mut index, &basis, &delivery);
928 fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).expect("restore");
929 let partial = partial.expect("partial refresh");
930 assert!(!partial.is_complete(), "the locked directory made the pass partial");
931 assert!(partial.apply.mutated(), "the second file was inserted");
932 assert_eq!(index.total().files, 2);
933 assert_eq!(cached_files(&basis, &delivery), 1, "a partial pass never writes entries");
934
935 let complete = crate::refresh(&mut index, &basis, &delivery).expect("complete refresh");
936 assert!(complete.is_complete(), "{:?}", complete.scan.errors);
937 assert_eq!(cached_files(&basis, &delivery), 2, "the complete pass wrote the facts");
938 }
939
940 #[test]
941 fn cache_only_refusals_name_location_root_and_absence_separately() {
942 let root = tempfile::tempdir().expect("root");
943 let other = tempfile::tempdir().expect("other root");
944 let cache = tempfile::tempdir().expect("cache");
945 let basis = crate::query::Basis {
946 root: root.path().into(),
947 scope: crate::query::Scope::default(),
948 content: crate::content::AnalysisSet::NONE,
949 };
950 let snapshot = cache.path().join("snapshot.fdu");
951 let message = |delivery: &Delivery| {
952 crate::open(&basis, delivery).expect_err("cache-only refusal").to_string()
953 };
954 let no_location = message(&Delivery::stale_ok(None));
955 assert!(no_location.contains("no cache location"), "{no_location}");
956 assert!(!no_location.contains("`on`"), "no write can succeed without a location");
957 let missing = message(&Delivery::stale_ok(Some(snapshot.clone())));
958 assert!(missing.contains("no usable snapshot"), "{missing}");
959 assert!(missing.contains("with the `on` cache policy"), "{missing}");
960 let other_basis = crate::query::Basis { root: other.path().into(), ..basis.clone() };
961 crate::open(&other_basis, &Delivery::new(CachePolicy::Auto, Some(snapshot.clone())))
962 .expect("other snapshot");
963 let wrong_root = message(&Delivery::stale_ok(Some(snapshot)));
964 assert!(wrong_root.contains("different root"), "{wrong_root}");
965 }
966
967 #[test]
968 fn route_delivery_matrix_rejects_contracts_the_route_cannot_execute() {
969 let basis = crate::query::Basis {
970 root: ".".into(),
971 scope: crate::query::Scope::default(),
972 content: crate::content::AnalysisSet::NONE,
973 };
974 let request = Request::new(basis, Query::default(), SystemTime::now());
975 for delivery in Delivery::enumerate() {
976 for route in
977 [Route::OneShot, Route::Retained, Route::Refresh, Route::Watch, Route::Opened]
978 {
979 let result = plan(&request, &delivery, route);
980 let forbidden = delivery.stale_ok
981 && (delivery.watch.is_some()
982 || matches!(route, Route::Watch | Route::Refresh)
983 || delivery.cache == CachePolicy::Off)
984 || route == Route::Opened
985 && (delivery.cache != CachePolicy::Off
986 || delivery.watch.is_some()
987 || delivery.accept_partial);
988 assert_eq!(result.is_err(), forbidden, "{route:?} {delivery:?}");
989 if let Ok(plan) = result {
990 if route == Route::Opened {
991 assert_eq!(plan.load(), Load::None);
992 assert_eq!(plan.verify(), Verify::Filesystem);
993 }
994 }
995 }
996 }
997 }
998
999 #[test]
1000 fn an_opened_root_refuses_the_scheduling_it_would_otherwise_drop() {
1001 let basis = crate::query::Basis {
1006 root: ".".into(),
1007 scope: crate::query::Scope::default(),
1008 content: crate::content::AnalysisSet::NONE,
1009 };
1010 let request = Request::new(basis, Query::default(), SystemTime::now());
1011 let default = Delivery::new(CachePolicy::Off, None);
1012 let plan_opened = plan(&request, &default, Route::Opened).expect("defaults plan");
1013 assert_eq!(plan_opened.delivery().batch_size, default.batch_size);
1014 let unhonored = [
1015 (
1016 "scan workers",
1017 Delivery {
1018 workers: crate::query::Workers { scan: Some(4), ..default.workers },
1019 ..default.clone()
1020 },
1021 ),
1022 (
1023 "depth-first order",
1024 Delivery { order: crate::ScanOrder::DepthFirst, ..default.clone() },
1025 ),
1026 ("accept partial", Delivery { accept_partial: true, ..default.clone() }),
1027 ];
1028 for (case, delivery) in unhonored {
1029 let refused = plan(&request, &delivery, Route::Opened).expect_err(case);
1030 assert!(
1031 matches!(
1032 refused,
1033 crate::query::RequestError::DeliveryUnsupported { route: "opened", .. }
1034 ),
1035 "{case}: {refused}"
1036 );
1037 plan(&request, &delivery, Route::Retained)
1038 .unwrap_or_else(|error| panic!("{case} executes on a retained route: {error}"));
1039 }
1040 let batched = Delivery { batch_size: default.batch_size * 2, ..default };
1042 let plan_batched = plan(&request, &batched, Route::Opened).expect("batch size plans");
1043 assert_eq!(plan_batched.delivery().batch_size, batched.batch_size);
1044 }
1045
1046 #[test]
1047 fn tier_writes_depend_only_on_authorization_and_observed_facts() {
1048 let routes = [Route::OneShot, Route::Retained, Route::Refresh, Route::Watch, Route::Opened];
1052 for (delivery, persist) in
1053 Delivery::enumerate().flat_map(|delivery| [(delivery.clone(), false), (delivery, true)])
1054 {
1055 for bits in 0_u8..64 {
1056 let facts = RunFacts {
1057 entries_verified: bits & 1 != 0,
1058 entries_changed: bits & 2 != 0,
1059 content_changed: bits & 4 != 0,
1060 content_requested: bits & 8 != 0,
1061 projected: bits & 16 != 0,
1062 paired_entries: bits & 32 != 0,
1063 };
1064 let allowed = persist;
1065 let expected = SaveTargets {
1066 metadata: allowed
1067 && facts.entries_verified
1068 && facts.entries_changed
1069 && !facts.projected,
1070 content: allowed
1071 && facts.content_requested
1072 && facts.content_changed
1073 && (facts.entries_verified || facts.paired_entries),
1074 };
1075 for route in routes {
1076 let plan = Plan {
1077 basis: crate::query::Basis {
1078 root: ".".into(),
1079 scope: crate::query::Scope::default(),
1080 content: crate::content::AnalysisSet::NONE,
1081 },
1082 route,
1083 retained: RetainedState::FullIndex,
1084 load: Load::Snapshot,
1085 verify: Verify::Filesystem,
1086 persist,
1087 delivery: delivery.clone(),
1088 };
1089 assert_eq!(plan.writes(facts), expected, "{route:?} {delivery:?} {facts:?}");
1090 let unavailable = Plan {
1091 delivery: Delivery { cache_path: None, ..delivery.clone() },
1092 ..plan
1093 };
1094 assert!(unavailable.writes(facts).none());
1095 }
1096 }
1097 }
1098 }
1099
1100 fn planned(config: &OpenFixture, query: &Query) -> Plan {
1101 let (request, delivery) = split(Path::new("."), config, query);
1102 plan(&request, &delivery, Route::OneShot).expect("valid plan")
1103 }
1104
1105 fn summary_query() -> Query {
1106 Query { views: vec![ViewSpec::Summary], ..Query::default() }
1107 }
1108
1109 fn split(root: &Path, config: &OpenFixture, query: &Query) -> (Request, Delivery) {
1112 let (basis, delivery) = config.split(root);
1113 (Request::new(basis, query.clone(), std::time::UNIX_EPOCH), delivery)
1114 }
1115
1116 fn prepared(
1118 root: &Path,
1119 config: &OpenFixture,
1120 query: &Query,
1121 ) -> Result<(Report, PendingSave, PerformanceSummary)> {
1122 let (request, delivery) = split(root, config, query);
1123 prepare_report(&request, &delivery)
1124 }
1125
1126 fn prepared_with_diagnostics(
1128 root: &Path,
1129 config: &OpenFixture,
1130 query: &Query,
1131 ) -> Result<(Report, PendingSave, PerformanceSummary, Option<crate::scan::ScanDiagnostics>)>
1132 {
1133 let (request, delivery) = split(root, config, query);
1134 prepare_report_with_scan_diagnostics(&request, &delivery)
1135 }
1136
1137 fn config(policy: CachePolicy, cache_path: Option<PathBuf>) -> OpenFixture {
1138 OpenFixture { scan: ScanConfig::default(), cache_path, policy, ..OpenFixture::default() }
1139 }
1140
1141 fn blind(policy: CachePolicy, cache_path: Option<PathBuf>) -> OpenFixture {
1144 OpenFixture {
1145 scan: ScanConfig { read_controls: false, ..ScanConfig::default() },
1146 ..config(policy, cache_path)
1147 }
1148 }
1149
1150 fn controls_config(
1151 policy: CachePolicy,
1152 cache_path: PathBuf,
1153 read_controls: bool,
1154 ) -> OpenFixture {
1155 OpenFixture {
1156 scan: ScanConfig { read_controls, ..ScanConfig::default() },
1157 cache_path: Some(cache_path),
1158 policy,
1159 ..OpenFixture::default()
1160 }
1161 }
1162
1163 fn stale(fixture: OpenFixture) -> OpenFixture {
1165 OpenFixture { stale_ok: true, ..fixture }
1166 }
1167
1168 fn seed_controls_snapshot(root: &Path, cache_path: PathBuf) {
1169 fs::write(root.join(".gitignore"), b"ignored.log\n").expect("control file");
1170 fs::write(root.join("ignored.log"), b"ignored").expect("ignored file");
1171 crate::open_fixture(root, &controls_config(CachePolicy::Auto, cache_path, true))
1172 .expect("seed controls-on snapshot");
1173 }
1174
1175 #[test]
1176 fn planner_uses_compact_state_only_when_the_request_proves_it_is_sufficient() {
1177 let off = blind(CachePolicy::Off, Some(PathBuf::from("unused.fdu")));
1178 assert_eq!(planned(&off, &summary_query()).retained, RetainedState::Summary);
1179
1180 for policy in [CachePolicy::Auto, CachePolicy::On] {
1181 let unavailable = blind(policy, None);
1182 assert_eq!(planned(&unavailable, &summary_query()).retained, RetainedState::Summary);
1183 }
1184
1185 let mut several_views = summary_query();
1186 several_views.views.push(ViewSpec::Types);
1187 assert_eq!(planned(&off, &several_views).retained, RetainedState::FullIndex);
1188
1189 let mut filtered = summary_query();
1190 filtered.selection.include.push(Pattern::parse("*.rs").expect("pattern"));
1191 assert_eq!(planned(&off, &filtered).retained, RetainedState::FullIndex);
1192
1193 let observing = config(CachePolicy::Off, None);
1196 assert!(observing.scan.read_controls, "observation is the default");
1197 assert_eq!(planned(&observing, &summary_query()).retained, RetainedState::FullIndex);
1198 let mut by_ignored = summary_query();
1199 by_ignored.selection.ignored = IgnoredEntries::Exclude;
1200 assert_eq!(planned(&observing, &by_ignored).retained, RetainedState::FullIndex);
1201 }
1202
1203 #[test]
1204 fn an_available_snapshot_does_not_force_the_index_for_a_metadata_summary() {
1205 let cached = blind(CachePolicy::Auto, Some(PathBuf::from("cache.fdu")));
1211 assert_eq!(
1212 planned(&cached, &summary_query()).retained,
1213 RetainedState::Summary,
1214 "a present snapshot must not force the index"
1215 );
1216 }
1217
1218 #[test]
1219 fn deliveries_whose_intent_is_the_snapshot_itself_still_retain_the_index() {
1220 let cached = || blind(CachePolicy::Auto, Some(PathBuf::from("cache.fdu")));
1224 for (name, delivery) in [
1225 ("stale", stale(cached())),
1226 ("on", OpenFixture { policy: CachePolicy::On, ..cached() }),
1227 ] {
1228 assert_eq!(
1229 planned(&delivery, &summary_query()).retained,
1230 RetainedState::FullIndex,
1231 "{name} needs the index to honour its contract"
1232 );
1233 }
1234 }
1235
1236 #[test]
1237 fn a_one_shot_metadata_query_does_not_read_the_snapshot_it_cannot_use() {
1238 let mut tree_query = summary_query();
1244 tree_query.views = vec![ViewSpec::Tree];
1245 for policy in [CachePolicy::Auto, CachePolicy::On] {
1246 let cached = config(policy, Some(PathBuf::from("cache.fdu")));
1247 assert!(
1248 planned(&cached, &tree_query).load != Load::Snapshot,
1249 "{policy:?} must not pay for a read that saves no work"
1250 );
1251 }
1252 }
1253
1254 #[test]
1255 fn the_snapshot_is_read_where_reading_pays_or_is_the_contract() {
1256 let only = stale(config(CachePolicy::Auto, Some(PathBuf::from("cache.fdu"))));
1258 assert_eq!(planned(&only, &summary_query()).load, Load::Snapshot);
1259
1260 let analyzed = OpenFixture {
1263 analysis: crate::content::AnalysisRequest {
1264 profile: crate::content::AnalysisSet::NONE.with_code(),
1265 ..Default::default()
1266 },
1267 ..config(CachePolicy::Auto, Some(PathBuf::from("cache.fdu")))
1268 };
1269 assert_eq!(planned(&analyzed, &summary_query()).load, Load::Snapshot);
1270
1271 let never = config(CachePolicy::Off, Some(PathBuf::from("cache.fdu")));
1273 assert_eq!(planned(&never, &summary_query()).load, Load::None);
1274 }
1275
1276 #[test]
1277 fn auto_persists_where_a_later_request_reads_what_it_stores() {
1278 let cache = Some(PathBuf::from("cache.fdu"));
1284 let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
1285 let persists = |fixture: &OpenFixture, query: &Query, route: Route| {
1286 let (request, delivery) = split(Path::new("."), fixture, query);
1287 plan(&request, &delivery, route).expect("valid plan").persists()
1288 };
1289 for (policy, one_shot, analysis, retained) in [
1290 (CachePolicy::Auto, false, true, true),
1291 (CachePolicy::On, true, true, true),
1292 (CachePolicy::Off, false, false, false),
1293 ] {
1294 let metadata = config(policy, cache.clone());
1295 assert_eq!(persists(&metadata, &tree, Route::OneShot), one_shot, "{policy:?}");
1296 assert_eq!(
1297 persists(&analyzing(metadata.clone()), &tree, Route::OneShot),
1298 analysis,
1299 "{policy:?} with analysis"
1300 );
1301 for route in [Route::Retained, Route::Refresh, Route::Watch] {
1302 assert_eq!(persists(&metadata, &tree, route), retained, "{policy:?} {route:?}");
1303 }
1304 }
1305 let stale_answer = stale(config(CachePolicy::On, cache));
1306 assert!(!persists(&stale_answer, &tree, Route::OneShot), "a stale answer writes nothing");
1307 assert!(!persists(&stale_answer, &tree, Route::Retained), "a stale answer writes nothing");
1308 }
1309
1310 #[test]
1311 fn an_analysis_request_never_selects_the_compact_summary_tier() {
1312 let off = blind(CachePolicy::Off, None);
1316 assert_eq!(planned(&off, &summary_query()).retained, RetainedState::Summary);
1317
1318 for profile in [
1319 crate::content::AnalysisSet::NONE.with_lines(),
1320 crate::content::AnalysisSet::NONE.with_code(),
1321 crate::content::AnalysisSet::NONE.with_words(),
1322 crate::content::AnalysisSet::ALL,
1323 ] {
1324 let analyzed = OpenFixture {
1325 analysis: crate::content::AnalysisRequest { profile, ..Default::default() },
1326 ..blind(CachePolicy::Off, None)
1327 };
1328 assert_eq!(
1329 planned(&analyzed, &summary_query()).retained,
1330 RetainedState::FullIndex,
1331 "{profile:?} must retain the index"
1332 );
1333 }
1334 }
1335
1336 #[test]
1337 fn a_repeated_one_shot_report_scans_cold_while_open_still_revalidates() {
1338 let root = tempfile::tempdir().expect("tempdir");
1343 fs::write(root.path().join("file.txt"), b"contents").expect("file");
1344 let cache = tempfile::tempdir().expect("cache dir");
1345 let auto = config(CachePolicy::Auto, Some(cache.path().join("cache.fdu")));
1346 let on = OpenFixture { policy: CachePolicy::On, ..auto.clone() };
1347 let mut tree_query = summary_query();
1348 tree_query.views = vec![ViewSpec::Tree];
1349
1350 let (first, pending, _) = prepared(root.path(), &on, &tree_query).expect("first report");
1351 pending.join().expect("first save");
1352 assert_eq!(first.provenance.source, ReportSource::ColdScan);
1353 assert!(on.cache_path.as_deref().expect("path").exists(), "`on` persists");
1354
1355 let (second, pending, performance) =
1356 prepared(root.path(), &auto, &tree_query).expect("second report");
1357 pending.join().expect("second save");
1358 assert_eq!(
1359 second.provenance.source,
1360 ReportSource::ColdScan,
1361 "a repeated one-shot must not pay for a read that saves no work"
1362 );
1363 assert_eq!(performance.walked_files, 1, "the walk still happened");
1364
1365 let (_, open_report) = crate::open_fixture(root.path(), &auto).expect("library open");
1368 assert_eq!(
1369 open_report.path_taken,
1370 OpenPath::WarmRevalidate,
1371 "a caller holding the index still amortises the load"
1372 );
1373 }
1374
1375 #[test]
1376 fn a_stale_answer_reads_what_an_on_report_leaves_and_auto_leaves_nothing() {
1377 let root = tempfile::tempdir().expect("tempdir");
1381 fs::write(root.path().join("file.txt"), b"contents").expect("file");
1382 let cache = tempfile::tempdir().expect("cache dir");
1383 let auto = config(CachePolicy::Auto, Some(cache.path().join("cache.fdu")));
1384 let mut tree_query = summary_query();
1385 tree_query.views = vec![ViewSpec::Tree];
1386
1387 let (_, pending, _) = prepared(root.path(), &auto, &tree_query).expect("report");
1388 assert!(!pending.writes_metadata(), "`auto` starts no metadata write");
1389 pending.join().expect("nothing to save");
1390 assert!(!cache.path().join("cache.fdu").exists(), "`auto` leaves nothing");
1391 let only = stale(config(CachePolicy::Auto, Some(cache.path().join("cache.fdu"))));
1392 let missing = prepared(root.path(), &only, &tree_query).expect_err("nothing to read");
1393 assert!(missing.to_string().contains("with the `on` cache policy"), "{missing}");
1394
1395 let on = OpenFixture { policy: CachePolicy::On, ..auto };
1396 let (_, pending, _) = prepared(root.path(), &on, &tree_query).expect("report");
1397 pending.join().expect("save");
1398
1399 let (from_cache, pending, performance, diagnostics) =
1400 prepared_with_diagnostics(root.path(), &only, &tree_query).expect("cache-only report");
1401 pending.join().expect("no save");
1402 assert_eq!(from_cache.provenance.source, ReportSource::CacheOnly);
1403 assert_eq!(performance.walked_files, 0, "cache-only never touches the tree");
1404 assert!(diagnostics.is_none(), "a cache-only open has no scan trace");
1405 }
1406
1407 #[test]
1408 fn controls_on_snapshot_projects_to_an_equivalent_controls_off_cache_only_report() {
1409 let root = tempfile::tempdir().expect("tempdir");
1410 let cache = tempfile::tempdir().expect("cache dir");
1411 let cache_path = cache.path().join("cache.fdu");
1412 fs::create_dir(root.path().join("src")).expect("source dir");
1413 fs::write(root.path().join("src/lib.rs"), b"library").expect("source file");
1414 seed_controls_snapshot(root.path(), cache_path.clone());
1415
1416 let controls_off = stale(controls_config(CachePolicy::Auto, cache_path, false));
1417 let query = Query {
1418 views: vec![
1419 ViewSpec::Summary,
1420 ViewSpec::Tree,
1421 ViewSpec::Families,
1422 ViewSpec::Types,
1423 ViewSpec::Extensions,
1424 ViewSpec::Languages,
1425 ViewSpec::Largest,
1426 ViewSpec::Recent,
1427 ViewSpec::Files,
1428 ],
1429 ..Query::default()
1430 };
1431 let (projected, pending, performance) =
1432 prepared(root.path(), &controls_off, &query).expect("projected report");
1433 pending.join().expect("no cache-only save");
1434
1435 let cold = OpenFixture {
1436 policy: CachePolicy::Off,
1437 stale_ok: false,
1438 cache_path: None,
1439 ..controls_off
1440 };
1441 let (mut expected, pending, _) =
1442 prepared(root.path(), &cold, &query).expect("controls-off cold report");
1443 pending.join().expect("no cold save");
1444 expected.provenance = projected.provenance.clone();
1445
1446 assert_eq!(performance.source, ReportSource::CacheOnly);
1447 assert_eq!(projected.scope, cold.scan.scope());
1448 assert_eq!(projected.ignore_rules, crate::control::ControlCoverage::NotObserved);
1449 assert_eq!(
1450 crate::report_format::render(&projected, crate::report_format::Format::Json, false,)
1451 .expect("compatible report format"),
1452 crate::report_format::render(&expected, crate::report_format::Format::Json, false,)
1453 .expect("compatible report format"),
1454 );
1455 }
1456
1457 #[test]
1458 fn controls_on_snapshot_projects_to_controls_off_auto_report() {
1459 let root = tempfile::tempdir().expect("tempdir");
1460 let cache = tempfile::tempdir().expect("cache dir");
1461 let cache_path = cache.path().join("cache.fdu");
1462 seed_controls_snapshot(root.path(), cache_path.clone());
1463
1464 let controls_off = OpenFixture {
1465 analysis: crate::content::AnalysisRequest {
1466 profile: crate::content::AnalysisSet::NONE.with_lines(),
1467 ..Default::default()
1468 },
1469 ..controls_config(CachePolicy::Auto, cache_path, false)
1470 };
1471 let (report, pending, performance) = prepared(root.path(), &controls_off, &summary_query())
1472 .expect("controls-off warm projection");
1473 pending.join().expect("save content only");
1474
1475 assert_eq!(report.provenance.source, ReportSource::WarmRevalidate);
1476 assert_eq!(report.scope, controls_off.scan.scope());
1477 assert_eq!(performance.source, ReportSource::WarmRevalidate);
1478 }
1479
1480 fn write_unobservable_controls(root: &Path) {
1486 let mut rule = vec![b'a'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
1487 rule.push(b'\n');
1488 fs::write(root.join(".gitignore"), rule).expect("oversized rule");
1489 fs::create_dir(root.join("vendored")).expect("nested directory");
1490 fs::write(
1491 root.join("vendored/.gitignore"),
1492 b"x\n".repeat(crate::control::DEFAULT_CONTROL_BUDGET / 2),
1493 )
1494 .expect("oversized source");
1495 }
1496
1497 #[test]
1498 fn a_one_shot_report_observes_control_state_as_its_caller_configures() {
1499 let root = tempfile::tempdir().expect("tempdir");
1504 fs::write(root.path().join("file.txt"), b"contents").expect("file");
1505 write_unobservable_controls(root.path());
1506 let mut tree_query = summary_query();
1507 tree_query.views = vec![ViewSpec::Tree];
1508
1509 for read_controls in [true, false] {
1510 let cache = tempfile::tempdir().expect("cache dir");
1511 let cache_path = cache.path().join("cache.fdu");
1512 let caller = controls_config(CachePolicy::On, cache_path.clone(), read_controls);
1513 for query in [summary_query(), tree_query.clone()] {
1514 let (report, pending, _) = prepared(root.path(), &caller, &query)
1515 .expect("a refused control file ends nothing");
1516 pending.join().expect("save");
1517 assert!(
1518 report.status.complete,
1519 "a refusal is not a partial: {:?}",
1520 report.status.errors
1521 );
1522 assert_eq!(report.scope, caller.scan.scope());
1523 match &report.ignore_rules {
1524 crate::control::ControlCoverage::Observed(coverage) => {
1525 assert!(read_controls, "observed only when asked");
1526 assert_eq!(coverage.refused, 2, "{coverage:?}");
1527 assert_eq!(report.notes.len(), 2, "{:?}", report.notes);
1528 assert!(
1529 report.notes[0].contains("2 ignore files not applied"),
1530 "{:?}",
1531 report.notes
1532 );
1533 assert_eq!(
1534 report.notes[1],
1535 "note: gitignored subtotals are unavailable where governing rules could not be verified"
1536 );
1537 }
1538 crate::control::ControlCoverage::NotObserved => {
1539 assert!(!read_controls, "unobserved only when turned off");
1540 assert!(report.notes.is_empty(), "{:?}", report.notes);
1541 }
1542 }
1543 }
1544
1545 let saved = crate::snapshot::load(&cache_path)
1546 .expect("load the snapshot")
1547 .expect("the index tier persisted");
1548 assert_eq!(saved.scope(), caller.scan.scope());
1549 assert_eq!(saved.controls().is_ok(), read_controls);
1550 }
1551 }
1552
1553 #[test]
1567 fn a_scope_this_build_cannot_honour_is_refused_before_any_snapshot_is_read() {
1568 let root = tempfile::tempdir().expect("tempdir");
1569 fs::write(root.path().join("file.txt"), b"contents").expect("file");
1570 let cache = tempfile::tempdir().expect("cache dir");
1571 let cache_path = cache.path().join("cache.fdu");
1572
1573 let warm = config(CachePolicy::On, Some(cache_path.clone()));
1576 let (_, pending, _) = prepared(root.path(), &warm, &summary_query()).expect("warm");
1577 pending.join().expect("save");
1578 let (_, pending, _) = prepared(
1579 root.path(),
1580 &stale(config(CachePolicy::Auto, Some(cache_path.clone()))),
1581 &summary_query(),
1582 )
1583 .expect("the snapshot answers a supported scope");
1584 pending.join().expect("no save");
1585
1586 let unsupported = ScanConfig { follow_symlinks: true, ..ScanConfig::default() };
1587 for (policy, stale_ok) in [
1588 (CachePolicy::Auto, true),
1589 (CachePolicy::Off, false),
1590 (CachePolicy::Auto, false),
1591 (CachePolicy::On, false),
1592 ] {
1593 let asked = OpenFixture {
1594 scan: unsupported.clone(),
1595 stale_ok,
1596 ..config(policy, Some(cache_path.clone()))
1597 };
1598 let refused = prepared(root.path(), &asked, &summary_query())
1599 .expect_err("a scope this build cannot honour has no answer at any policy");
1600 assert!(
1601 matches!(
1602 refused,
1603 Error::InvalidRequest(crate::query::RequestError::ScopeUnsupported {
1604 axis: crate::query::ScopeAxis::FollowSymlinks,
1605 ..
1606 })
1607 ),
1608 "{policy:?} must refuse the request rather than fail the operation: {refused}"
1609 );
1610 assert_eq!(
1611 refused.to_string(),
1612 "unsupported scan configuration: follow_symlinks requires cycle, root-boundary, \
1613 and filesystem-boundary semantics",
1614 "{policy:?} must name the scope it cannot honour"
1615 );
1616 }
1617 }
1618
1619 #[test]
1620 fn a_report_that_reads_no_gitignore_refuses_to_select_by_ignored_state() {
1621 let root = tempfile::tempdir().expect("tempdir");
1624 fs::write(root.path().join(".gitignore"), b"*.log\n").expect("control file");
1625 fs::write(root.path().join("debug.log"), b"ignored").expect("ignored file");
1626 let mut only = summary_query();
1627 only.selection.ignored = IgnoredEntries::Only;
1628
1629 assert!(matches!(
1633 prepared(root.path(), &blind(CachePolicy::Off, None), &only),
1634 Err(Error::InvalidRequest(crate::query::RequestError::IgnoredWithoutObservation(
1635 IgnoredEntries::Only
1636 )))
1637 ));
1638
1639 let (report, pending, _) =
1640 prepared(root.path(), &config(CachePolicy::Off, None), &only).expect("observed");
1641 pending.join().expect("no save");
1642 let Section::Summary(row) = report.sections[0] else { panic!("a summary") };
1643 assert_eq!((row.files, row.bytes), (1, 7), "only the ignored file is selected");
1644 }
1645
1646 #[test]
1647 fn an_on_reports_snapshot_serves_either_cache_only_report_but_not_the_reverse() {
1648 let root = tempfile::tempdir().expect("tempdir");
1654 fs::write(root.path().join("file.txt"), b"contents").expect("file");
1655 let mut tree_query = summary_query();
1656 tree_query.views = vec![ViewSpec::Tree];
1657
1658 for (writer, reader) in [(true, true), (true, false), (false, false), (false, true)] {
1659 let cache = tempfile::tempdir().expect("cache dir");
1660 let cache_path = cache.path().join("cache.fdu");
1661 let write = controls_config(CachePolicy::On, cache_path.clone(), writer);
1662 let (_, pending, _) =
1663 prepared(root.path(), &write, &tree_query).expect("writing report");
1664 pending.join().expect("save");
1665
1666 let read = stale(controls_config(CachePolicy::Auto, cache_path, reader));
1667 match prepared(root.path(), &read, &tree_query) {
1668 Ok((report, pending, _)) => {
1669 pending.join().expect("no save");
1670 assert!(writer || !reader, "writer {writer} served reader {reader}");
1671 assert_eq!(report.provenance.source, ReportSource::CacheOnly);
1672 assert_eq!(
1673 matches!(report.ignore_rules, crate::control::ControlCoverage::Observed(_)),
1674 reader,
1675 "a report describes the scope it asked for"
1676 );
1677 }
1678 Err(Error::Snapshot(message)) => {
1679 assert!(!writer && reader, "writer {writer}, reader {reader}: {message}");
1680 assert!(message.contains(".gitignore state"), "names the cause: {message}");
1681 assert!(message.contains("verified answer"), "names the remedy: {message}");
1682 }
1683 Err(other) => panic!("writer {writer}, reader {reader}: {other}"),
1684 }
1685 }
1686 }
1687
1688 #[test]
1689 fn a_cache_only_open_answers_from_an_on_reports_snapshot() {
1690 let root = tempfile::tempdir().expect("tempdir");
1694 fs::write(root.path().join(".gitignore"), b"*.log\n").expect("control file");
1695 fs::write(root.path().join("debug.log"), b"ignored").expect("ignored file");
1696 let cache = tempfile::tempdir().expect("cache dir");
1697 let cache_path = cache.path().join("cache.fdu");
1698 let mut tree_query = summary_query();
1699 tree_query.views = vec![ViewSpec::Tree];
1700
1701 let on = config(CachePolicy::On, Some(cache_path.clone()));
1702 let (_, pending, _) = prepared(root.path(), &on, &tree_query).expect("report");
1703 pending.join().expect("save");
1704
1705 let only = stale(config(CachePolicy::Auto, Some(cache_path)));
1706 let (index, report) = crate::open_fixture(root.path(), &only).expect("the shared snapshot");
1707 assert_eq!(report.path_taken, OpenPath::CacheOnly);
1708 assert_eq!(index.is_ignored(Path::new("debug.log")).ok(), Some(Some(true)));
1709 }
1710
1711 #[test]
1712 fn an_open_that_opts_out_of_control_state_shares_an_opted_out_reports_snapshot() {
1713 let root = tempfile::tempdir().expect("tempdir");
1718 fs::write(root.path().join("file.txt"), b"contents").expect("file");
1719 let cache = tempfile::tempdir().expect("cache dir");
1720 let cache_path = cache.path().join("cache.fdu");
1721 let mut tree_query = summary_query();
1722 tree_query.views = vec![ViewSpec::Tree];
1723
1724 let on = blind(CachePolicy::On, Some(cache_path.clone()));
1725 let (_, pending, _) = prepared(root.path(), &on, &tree_query).expect("report");
1726 pending.join().expect("save");
1727 assert!(cache_path.exists(), "the report left a snapshot");
1728
1729 let only = stale(blind(CachePolicy::Auto, Some(cache_path)));
1730 let (index, report) = crate::open_fixture(root.path(), &only).expect("the shared snapshot");
1731 assert_eq!(report.path_taken, OpenPath::CacheOnly);
1732 assert!(matches!(
1733 index.is_ignored(Path::new("file.txt")),
1734 Err(Error::ControlStateNotObserved)
1735 ));
1736 }
1737
1738 #[test]
1739 fn compact_summary_matches_the_indexed_summary_exactly() {
1740 let root = tempfile::tempdir().expect("tempdir");
1741 fs::create_dir(root.path().join("src")).expect("directory");
1742 fs::write(root.path().join("src/lib.rs"), b"library").expect("file");
1743 fs::write(root.path().join("README.md"), b"read me").expect("file");
1744 #[cfg(unix)]
1745 std::os::unix::fs::symlink("README.md", root.path().join("readme-link")).expect("symlink");
1746
1747 let query = summary_query();
1748 let off = OpenFixture {
1751 scan: ScanConfig { read_controls: false, threads: Some(2), ..ScanConfig::default() },
1752 ..blind(CachePolicy::Off, None)
1753 };
1754 let (compact, pending, performance) =
1755 prepared(root.path(), &off, &query).expect("compact report");
1756 pending.join().expect("no pending compact save");
1757 assert_eq!(performance.walked_files, 2);
1758 assert_eq!(performance.walked_bytes, 14);
1759
1760 let (index, _open_report) = crate::open_fixture(root.path(), &off).expect("indexed scan");
1763 let indexed = report(
1764 &index,
1765 &crate::test_support::read_of(&index, query.clone()),
1766 compact.provenance.generated_at,
1767 )
1768 .expect("report");
1769
1770 let Section::Summary(compact_row) = compact.sections[0] else {
1771 panic!("compact plan did not return a summary")
1772 };
1773 let Section::Summary(indexed_row) = indexed.sections[0] else {
1774 panic!("indexed plan did not return a summary")
1775 };
1776 assert_eq!(compact_row.files, indexed_row.files);
1777 assert_eq!(compact_row.dirs, indexed_row.dirs);
1778 assert_eq!(compact_row.bytes, indexed_row.bytes);
1779 assert_eq!(compact_row.allocated, indexed_row.allocated);
1780 assert_eq!(compact_row.newest_mtime_ns, indexed_row.newest_mtime_ns);
1781 assert_eq!(compact.root, indexed.root);
1782 assert_eq!(compact.scope, indexed.scope);
1783 assert_eq!(compact.status.complete, indexed.status.complete);
1784 assert_eq!(compact.provenance.freshness, indexed.provenance.freshness);
1785 }
1786
1787 #[test]
1788 fn compact_summary_never_creates_the_configured_snapshot() {
1789 let root = tempfile::tempdir().expect("tempdir");
1790 fs::write(root.path().join("payload"), b"payload").expect("file");
1791 let cache = root.path().join("must-not-exist.fdu");
1792
1793 let (report, pending, _) =
1794 prepared(root.path(), &blind(CachePolicy::Off, Some(cache.clone())), &summary_query())
1795 .expect("compact report");
1796 pending.join().expect("no pending compact save");
1797
1798 assert!(report.status.complete);
1799 assert!(!cache.exists());
1800 }
1801
1802 #[test]
1803 fn full_index_report_exposes_scan_diagnostics_when_requested() {
1804 let root = tempfile::tempdir().expect("tempdir");
1805 fs::create_dir(root.path().join("nested")).expect("directory");
1806 fs::write(root.path().join("nested/file.txt"), b"trace me").expect("file");
1807 let query = Query { views: vec![ViewSpec::Tree], ..Query::default() };
1808
1809 let (report, pending, performance, diagnostics) =
1810 prepared_with_diagnostics(root.path(), &config(CachePolicy::Off, None), &query)
1811 .expect("full-index report");
1812 pending.join().expect("no pending save");
1813
1814 assert!(report.status.complete);
1815 assert_eq!(performance.walked_files, 1);
1816 let diagnostics = diagnostics.expect("full-index scan diagnostics");
1817 assert_eq!(diagnostics.schema, crate::scan::SCAN_DIAGNOSTICS_SCHEMA);
1818 assert_eq!(diagnostics.worker_policy.ready_directories_at_finish, 0);
1819 assert_eq!(diagnostics.worker_policy.in_flight_directories_at_finish, 0);
1820 }
1821
1822 fn wide_tree(dirs: usize, files: usize) -> (tempfile::TempDir, u64, u64) {
1825 let root = tempfile::tempdir().expect("tempdir");
1826 let mut bytes = 0;
1827 for directory in 0..dirs {
1828 let path = root.path().join(format!("d{directory:03}"));
1829 fs::create_dir(&path).expect("directory");
1830 for file in 0..files {
1831 let size = directory * files + file + 1;
1832 fs::write(path.join(format!("f{file}.txt")), vec![b'.'; size]).expect("file");
1833 bytes += size as u64;
1834 }
1835 }
1836 (root, (dirs * files) as u64, bytes)
1837 }
1838
1839 fn prepared_with_progress(
1841 root: &Path,
1842 config: &OpenFixture,
1843 query: &Query,
1844 progress: &Progress,
1845 ) -> Result<(Report, PendingSave, PerformanceSummary)> {
1846 let (request, delivery) = split(root, config, query);
1847 prepare_report_with_progress(&request, &delivery, progress)
1848 }
1849
1850 fn analyzing(fixture: OpenFixture) -> OpenFixture {
1851 OpenFixture {
1852 analysis: crate::content::AnalysisRequest {
1853 profile: crate::content::AnalysisSet::LINES_ONLY,
1854 workers: 0,
1855 },
1856 ..fixture
1857 }
1858 }
1859
1860 #[test]
1868 fn progress_ends_at_the_walked_totals_of_every_one_shot_route() {
1869 use crate::ProgressPhase::{Scanning, Summarizing};
1870 let (root, files, bytes) = wide_tree(6, 4);
1871 let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
1872
1873 let progress = Progress::new();
1874 let (_, pending, performance) =
1875 prepared_with_progress(root.path(), &config(CachePolicy::Off, None), &tree, &progress)
1876 .expect("cold full-index report");
1877 pending.join().expect("no save");
1878 let snapshot = progress.snapshot();
1879 assert_eq!(performance.source, ReportSource::ColdScan);
1880 assert_eq!((performance.walked_files, performance.walked_bytes), (files, bytes));
1881 assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "cold full index");
1882 assert_eq!(snapshot.allocated, performance.walked_allocated);
1883 assert!(snapshot.allocated > 0, "files with content occupy blocks");
1884 assert_eq!(snapshot.directories, 7, "the root and its six children");
1885 assert_eq!(
1886 (snapshot.phase, snapshot.analysis),
1887 (Summarizing, None),
1888 "the walk ended, the index was assembled, then the answer was built"
1889 );
1890
1891 let progress = Progress::new();
1892 let (report, pending, performance) = prepared_with_progress(
1893 root.path(),
1894 &blind(CachePolicy::Off, None),
1895 &summary_query(),
1896 &progress,
1897 )
1898 .expect("compact summary report");
1899 pending.join().expect("no save");
1900 let Section::Summary(row) = report.sections[0] else { panic!("summary section") };
1901 let snapshot = progress.snapshot();
1902 assert_eq!((performance.walked_files, performance.walked_bytes), (files, bytes));
1903 assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "summary fold");
1904 assert_eq!(snapshot.allocated, performance.walked_allocated);
1905 assert_eq!(snapshot.allocated, row.allocated, "the progress figure is the answer's");
1906 assert_eq!(snapshot.directories, row.dirs + 1, "the row's directories and the root");
1907 assert_eq!((snapshot.phase, snapshot.analysis), (Scanning, None));
1908
1909 let cache_dir = tempfile::tempdir().expect("cache dir");
1911 let cache = cache_dir.path().join("snapshot.fdu");
1912 let progress = Progress::new();
1913 let (_, pending, performance) = prepared_with_progress(
1914 root.path(),
1915 &analyzing(config(CachePolicy::Auto, Some(cache.clone()))),
1916 &tree,
1917 &progress,
1918 )
1919 .expect("cold analyzed report");
1920 let snapshot = progress.snapshot();
1921 assert_eq!(snapshot.phase, Summarizing, "the answer is built while the save runs");
1922 pending.join().expect("save");
1923 assert_eq!(performance.source, ReportSource::ColdScan);
1924 assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "cold with analysis");
1925 assert_eq!(snapshot.allocated, performance.walked_allocated);
1926 assert_eq!(snapshot.directories, 7);
1927 assert_eq!(performance.fresh_files, files, "every file is a lines candidate");
1928 assert_eq!(snapshot.analysis, Some((files, files)));
1929
1930 let progress = Progress::new();
1931 let (_, pending, performance) = prepared_with_progress(
1932 root.path(),
1933 &analyzing(config(CachePolicy::Auto, Some(cache))),
1934 &tree,
1935 &progress,
1936 )
1937 .expect("warm analyzed report");
1938 pending.join().expect("nothing to save");
1939 let snapshot = progress.snapshot();
1940 assert_eq!(performance.source, ReportSource::WarmRevalidate);
1941 assert_eq!((performance.walked_files, performance.walked_bytes), (files, bytes));
1942 assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "warm revalidation");
1943 assert_eq!(snapshot.allocated, performance.walked_allocated);
1944 assert_eq!(snapshot.directories, 7);
1945 assert_eq!(performance.fresh_files, 0, "the sidecar answered every candidate");
1946 assert_eq!((snapshot.phase, snapshot.analysis), (Summarizing, Some((0, 0))));
1947 }
1948
1949 #[test]
1952 fn a_cache_only_report_ends_summarizing_and_walks_nothing() {
1953 use crate::ProgressPhase::Summarizing;
1954 let (root, _, _) = wide_tree(3, 2);
1955 let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
1956 let cache_dir = tempfile::tempdir().expect("cache dir");
1957 let cache = cache_dir.path().join("snapshot.fdu");
1958 let (_, pending, _) =
1959 prepared(root.path(), &config(CachePolicy::On, Some(cache.clone())), &tree)
1960 .expect("a report that writes the snapshot");
1961 pending.join().expect("save");
1962
1963 let progress = Progress::new();
1964 let (_, pending, performance) = prepared_with_progress(
1965 root.path(),
1966 &stale(config(CachePolicy::Auto, Some(cache))),
1967 &tree,
1968 &progress,
1969 )
1970 .expect("cache-only report");
1971 pending.join().expect("nothing to save");
1972 let snapshot = progress.snapshot();
1973 assert_eq!(performance.source, ReportSource::CacheOnly);
1974 assert_eq!(snapshot.phase, Summarizing);
1975 assert_eq!((snapshot.directories, snapshot.files, snapshot.bytes), (0, 0, 0));
1976 }
1977
1978 fn rank(phase: crate::ProgressPhase, order: &[crate::ProgressPhase]) -> usize {
1980 order
1981 .iter()
1982 .position(|expected| *expected == phase)
1983 .unwrap_or_else(|| panic!("{phase:?} is not a phase of this route"))
1984 }
1985
1986 #[test]
1993 fn progress_is_monotonic_and_phases_advance_in_order_while_a_report_runs() {
1994 use crate::ProgressPhase::{
1995 Analyzing, Indexing, Loading, Revalidating, Saving, Scanning, Starting, Summarizing,
1996 };
1997 let (root, files, bytes) = wide_tree(48, 6);
1998 let cache_dir = tempfile::tempdir().expect("cache dir");
1999 let cache = cache_dir.path().join("snapshot.fdu");
2000 let fixture = OpenFixture {
2001 scan: ScanConfig { threads: Some(3), batch_size: 4, ..ScanConfig::default() },
2002 ..analyzing(config(CachePolicy::Auto, Some(cache)))
2003 };
2004 let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
2005
2006 let routes: [(&str, &[crate::ProgressPhase]); 2] = [
2007 ("cold", &[Starting, Loading, Scanning, Indexing, Analyzing, Saving, Summarizing]),
2008 ("warm", &[Starting, Loading, Revalidating, Analyzing, Saving, Summarizing]),
2009 ];
2010 for (route, order) in routes {
2011 let progress = Progress::new();
2012 let initial = progress.snapshot();
2015 let done = std::sync::atomic::AtomicBool::new(false);
2016 let (performance, seen) = std::thread::scope(|scope| {
2017 let poller = scope.spawn(|| {
2018 let polled = progress.clone();
2019 let mut previous = initial;
2020 let mut seen = vec![previous.phase];
2021 loop {
2022 let finished = done.load(std::sync::atomic::Ordering::Acquire);
2023 let current = polled.snapshot();
2024 assert!(current.directories >= previous.directories, "{route}");
2025 assert!(current.files >= previous.files, "{route}");
2026 assert!(current.bytes >= previous.bytes, "{route}");
2027 assert!(
2028 rank(current.phase, order) >= rank(previous.phase, order),
2029 "{route}: {:?} after {:?}",
2030 current.phase,
2031 previous.phase
2032 );
2033 if let (Some(before), Some(after)) = (previous.analysis, current.analysis) {
2034 assert!(after.0 >= before.0 && after.0 <= after.1, "{route}");
2035 assert_eq!(after.1, before.1, "{route}: the total is fixed");
2036 }
2037 if current.phase != previous.phase {
2038 seen.push(current.phase);
2039 }
2040 previous = current;
2041 if finished {
2044 break;
2045 }
2046 std::thread::yield_now();
2047 }
2048 seen
2049 });
2050 let (_, pending, performance) =
2051 prepared_with_progress(root.path(), &fixture, &tree, &progress)
2052 .expect("report");
2053 pending.join().expect("save");
2054 done.store(true, std::sync::atomic::Ordering::Release);
2055 (performance, poller.join().expect("poller"))
2056 });
2057 let snapshot = progress.snapshot();
2058 assert_eq!(
2059 (snapshot.files, snapshot.bytes),
2060 (performance.walked_files, performance.walked_bytes),
2061 "{route}"
2062 );
2063 assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "{route}");
2064 assert_eq!(snapshot.directories, 49, "{route}");
2065 assert_eq!(seen.first(), Some(&Starting), "{route}: {seen:?}");
2066 assert_eq!(
2067 seen.last().copied(),
2068 Some(snapshot.phase),
2069 "{route}: the poller saw the final phase"
2070 );
2071 assert_eq!(snapshot.phase, Summarizing, "{route}: the answer is built last");
2072 if route == "cold" {
2073 assert_eq!(snapshot.analysis, Some((files, files)));
2074 } else {
2075 assert_eq!(snapshot.analysis, Some((0, 0)));
2076 assert!(!seen.contains(&Indexing), "{route}: a warm run assembles no index");
2077 }
2078 }
2079 }
2080
2081 #[test]
2085 fn a_report_prepared_with_a_handle_is_the_report_prepared_without() {
2086 let (root, _, _) = wide_tree(5, 3);
2087 let tree = Query { views: vec![ViewSpec::Tree, ViewSpec::Files], ..Query::default() };
2088 let cases = [
2089 ("full index", config(CachePolicy::Off, None), tree),
2090 ("compact summary", blind(CachePolicy::Off, None), summary_query()),
2091 ];
2092 for (route, fixture, query) in cases {
2093 let (mut plain, pending, plain_performance) =
2094 prepared(root.path(), &fixture, &query).expect("plain report");
2095 pending.join().expect("no save");
2096 let progress = Progress::new();
2097 let (observed, pending, observed_performance) =
2098 prepared_with_progress(root.path(), &fixture, &query, &progress)
2099 .expect("observed report");
2100 pending.join().expect("no save");
2101
2102 assert_eq!(plain_performance, observed_performance, "{route}");
2103 plain.provenance = observed.provenance.clone();
2104 let json = |report: &Report| {
2105 crate::report_format::render(report, crate::report_format::Format::Json, false)
2106 .expect("render")
2107 };
2108 assert_eq!(json(&plain), json(&observed), "{route}");
2109 assert!(progress.snapshot().files > 0, "{route}: the handle did observe the run");
2110 }
2111 }
2112}