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) delivery: Delivery,
85}
86
87impl Plan {
88 pub fn outcome(&self, status: &TreeStatus) -> OutcomeClass {
90 if status.complete || self.delivery.accept_partial {
91 OutcomeClass::Success
92 } else {
93 OutcomeClass::Partial
94 }
95 }
96 pub fn basis(&self) -> &crate::query::Basis {
98 &self.basis
99 }
100 pub const fn route(&self) -> Route {
102 self.route
103 }
104 pub const fn load(&self) -> Load {
106 self.load
107 }
108 pub const fn verify(&self) -> Verify {
110 self.verify
111 }
112 pub fn delivery(&self) -> &Delivery {
114 &self.delivery
115 }
116}
117
118pub(crate) struct StoreHeader<'a> {
120 pub(crate) root: &'a std::path::Path,
121 pub(crate) snapshot: crate::SnapshotIdentity,
122 pub(crate) content: Option<&'a crate::ContentTierIdentity>,
123 pub(crate) content_complete: bool,
124}
125
126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub(crate) enum Admission {
129 Serve(crate::Serves),
130 NoLocation,
131 Missing,
132 WrongRoot,
133 WrongScope,
134 IncompleteContent,
135}
136
137impl Plan {
138 pub(crate) fn admit(
145 &self,
146 stored: Option<&StoreHeader<'_>>,
147 basis: &crate::query::Basis,
148 ) -> Admission {
149 if self.delivery.cache_path.is_none() {
150 return Admission::NoLocation;
151 }
152 let Some(stored) = stored else {
153 return Admission::Missing;
154 };
155 if stored.root != basis.root {
156 return Admission::WrongRoot;
157 }
158 let relation = crate::serves_snapshot(stored.snapshot, basis.scope.snapshot_identity());
159 if relation == crate::Serves::Refuse {
160 return Admission::WrongScope;
161 }
162 if self.verify == Verify::None && basis.content.is_enabled() {
163 let wanted = crate::ContentTierIdentity::for_request(
164 basis.scope.snapshot_identity().entries,
165 basis.content,
166 );
167 if !stored.content_complete
168 || stored.content.and_then(|identity| wanted.admit(identity)).is_none()
169 {
170 return Admission::IncompleteContent;
171 }
172 }
173 Admission::Serve(relation)
174 }
175}
176
177#[derive(Clone, Copy, Debug)]
179#[allow(clippy::struct_excessive_bools)]
180pub(crate) struct RunFacts {
181 pub(crate) entries_verified: bool,
182 pub(crate) entries_changed: bool,
183 pub(crate) content_changed: bool,
184 pub(crate) content_requested: bool,
185 pub(crate) projected: bool,
186 pub(crate) paired_entries: bool,
187}
188
189#[derive(Clone, Copy, Debug, PartialEq, Eq)]
191pub(crate) struct SaveTargets {
192 pub(crate) metadata: bool,
193 pub(crate) content: bool,
194}
195
196impl SaveTargets {
197 pub(crate) const fn none(self) -> bool {
198 !self.metadata && !self.content
199 }
200}
201
202impl Plan {
203 pub(crate) fn writes(&self, run: RunFacts) -> SaveTargets {
204 let allowed = self.delivery.cache.writes() && self.delivery.cache_path.is_some();
205 SaveTargets {
206 metadata: allowed && run.entries_verified && run.entries_changed && !run.projected,
207 content: allowed
208 && run.content_requested
209 && run.content_changed
210 && (run.entries_verified || run.paired_entries),
211 }
212 }
213}
214
215#[derive(Clone, Copy, PartialEq, Eq, Debug)]
220pub struct PerformanceSummary {
221 pub walked_files: u64,
223 pub walked_bytes: u64,
225 pub walked_allocated: u64,
228 pub fresh_files: u64,
230 pub bytes_read: u64,
232 pub analysis_ns: u64,
234 pub cached_files: u64,
236 pub cached_bytes: u64,
238 pub source: ReportSource,
240}
241
242impl Default for PerformanceSummary {
243 fn default() -> Self {
244 Self {
245 walked_files: 0,
246 walked_bytes: 0,
247 walked_allocated: 0,
248 fresh_files: 0,
249 bytes_read: 0,
250 analysis_ns: 0,
251 cached_files: 0,
252 cached_bytes: 0,
253 source: ReportSource::ColdScan,
254 }
255 }
256}
257
258impl PerformanceSummary {
259 fn from_open_report(report: &crate::OpenReport) -> Self {
260 let analysis = report.analysis.unwrap_or_default();
261 Self {
262 walked_files: report.scan.files_walked,
263 walked_bytes: report.scan.bytes_walked,
264 walked_allocated: report.scan.allocated_walked,
265 fresh_files: analysis.candidates,
266 bytes_read: analysis.bytes_read,
267 analysis_ns: analysis.elapsed_ns,
268 cached_files: report.content_cache.hits,
269 cached_bytes: report.content_cache.bytes,
270 source: match report.path_taken {
271 OpenPath::ColdScan => ReportSource::ColdScan,
272 OpenPath::WarmRevalidate => ReportSource::WarmRevalidate,
273 OpenPath::CacheOnly => ReportSource::CacheOnly,
274 },
275 }
276 }
277}
278
279pub fn plan(
311 request: &Request,
312 delivery: &Delivery,
313 route: Route,
314) -> std::result::Result<Plan, crate::query::RequestError> {
315 request.validate()?;
316 let mut normalized = delivery.clone();
317 if route == Route::Watch {
318 normalized.watch.get_or_insert_with(crate::query::WatchDelivery::default);
319 }
320 let delivery = &normalized;
321 request.validate_delivery(delivery)?;
322 if route == Route::Opened {
323 if delivery.cache != CachePolicy::Off
324 || delivery.watch.is_some()
325 || request.basis.content.is_enabled()
326 {
327 return Err(crate::query::RequestError::DeliveryUnsupported {
328 route: "opened",
329 reason: "progressive discovery requires cache off, no content analyzers, and observation configured through OpenOptions",
330 });
331 }
332 if delivery.workers.scan.is_some()
336 || delivery.order != crate::ScanOrder::default()
337 || delivery.accept_partial
338 {
339 return Err(crate::query::RequestError::DeliveryUnsupported {
340 route: "opened",
341 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",
342 });
343 }
344 }
345 if route == Route::Refresh && delivery.cache == CachePolicy::Only {
346 return Err(crate::query::RequestError::DeliveryUnsupported {
347 route: "refresh",
348 reason: "the only cache policy cannot verify filesystem state",
349 });
350 }
351 let analysis_requested = request.basis.content.is_enabled();
352 let summary_is_sufficient = request.query.views.as_slice() == [ViewSpec::Summary]
353 && request.query.selection.is_unfiltered()
354 && !request.basis.scope.read_controls;
355 let policy_requires_index = match delivery.cache {
356 CachePolicy::Only => true,
357 CachePolicy::Refresh => delivery.cache_path.is_some(),
358 CachePolicy::Off | CachePolicy::Auto | CachePolicy::ReadOnly => false,
359 };
360 let read_snapshot = match delivery.cache {
365 CachePolicy::Only => true,
366 CachePolicy::Off | CachePolicy::Refresh => false,
367 CachePolicy::Auto | CachePolicy::ReadOnly => route != Route::OneShot || analysis_requested,
368 };
369 Ok(Plan {
370 basis: request.basis.clone(),
371 route,
372 retained: if route == Route::OneShot
373 && !policy_requires_index
374 && !analysis_requested
375 && summary_is_sufficient
376 {
377 RetainedState::Summary
378 } else {
379 RetainedState::FullIndex
380 },
381 load: if read_snapshot { Load::Snapshot } else { Load::None },
382 verify: if delivery.cache == CachePolicy::Only { Verify::None } else { Verify::Filesystem },
383 delivery: delivery.clone(),
384 })
385}
386
387pub fn prepare_report(
415 request: &Request,
416 delivery: &Delivery,
417) -> Result<(Report, PendingSave, PerformanceSummary)> {
418 prepare_report_internal(request, delivery, false, None)
419 .map(|(report, pending, performance, _diagnostics)| (report, pending, performance))
420}
421
422pub fn prepare_report_with_progress(
439 request: &Request,
440 delivery: &Delivery,
441 progress: &Progress,
442) -> Result<(Report, PendingSave, PerformanceSummary)> {
443 prepare_report_internal(request, delivery, false, Some(progress))
444 .map(|(report, pending, performance, _diagnostics)| (report, pending, performance))
445}
446
447pub fn prepare_report_with_scan_diagnostics(
456 request: &Request,
457 delivery: &Delivery,
458) -> Result<(Report, PendingSave, PerformanceSummary, Option<crate::scan::ScanDiagnostics>)> {
459 prepare_report_internal(request, delivery, true, None)
460}
461
462fn prepare_report_internal(
463 request: &Request,
464 delivery: &Delivery,
465 collect_scan_diagnostics: bool,
466 progress: Option<&Progress>,
467) -> Result<(Report, PendingSave, PerformanceSummary, Option<crate::scan::ScanDiagnostics>)> {
468 request.validate().map_err(Error::InvalidRequest)?;
475 let scan_config = crate::ScanConfig {
476 progress: progress.cloned(),
477 ..request.basis.scope.scan_config(delivery)
478 };
479 let root = request.basis.root.as_path();
480 let scan_started_at = SystemTime::now();
481 let plan = plan(request, delivery, Route::OneShot).map_err(Error::InvalidRequest)?;
482 match plan.retained {
483 RetainedState::Summary => {
484 let root = root.canonicalize().map_err(|error| Error::io(root, error))?;
485 let mut summary = SummaryRow::default();
486 let mut reduce = |observed: &crate::ObservationOp| {
487 let crate::Op::Upsert { kind, attrs, .. } = &observed.op else {
488 return;
489 };
490 match kind {
491 EntryKind::File => {
492 summary.files += 1;
493 summary.bytes += attrs.size;
494 summary.allocated += attrs.allocated;
495 summary.newest_mtime_ns = Some(
496 summary
497 .newest_mtime_ns
498 .map_or(attrs.mtime_ns, |current| current.max(attrs.mtime_ns)),
499 );
500 }
501 EntryKind::Dir => summary.dirs += 1,
502 EntryKind::Symlink | EntryKind::Other => {}
503 }
504 };
505 let (mut scan, scan_diagnostics) = if collect_scan_diagnostics {
506 let (scan, diagnostics) = crate::scan::scan_summary_fold_with_diagnostics(
507 &root,
508 &scan_config,
509 &mut reduce,
510 )?;
511 (scan, Some(diagnostics))
512 } else {
513 (crate::scan::scan_summary_fold(&root, &scan_config, &mut reduce)?, None)
514 };
515 let complete = scan.is_complete();
516 let generated_at = SystemTime::now();
517 let report = report_summary(
518 &root,
519 scan_config.scope(),
520 request,
521 summary,
522 TreeStatus::of_walk(&root, &mut scan),
523 ReportProvenance::of_walk(scan_started_at, generated_at, complete),
524 );
525 let performance = PerformanceSummary {
526 walked_files: scan.files_walked,
527 walked_bytes: scan.bytes_walked,
528 walked_allocated: scan.allocated_walked,
529 source: ReportSource::ColdScan,
530 ..PerformanceSummary::default()
531 };
532 Ok((report, PendingSave::none(), performance, scan_diagnostics))
533 }
534 RetainedState::FullIndex => {
535 let (index, open_report, pending_save, scan_diagnostics) =
536 execute(&plan, &request.basis, collect_scan_diagnostics, progress)?;
537 let performance = PerformanceSummary::from_open_report(&open_report);
538 if let Some(progress) = progress {
539 progress.enter(crate::ProgressPhase::Summarizing);
540 }
541 let answer = report(&index, request, SystemTime::now())?;
542 debug_assert_eq!(answer.scope, scan_config.scope());
543 Ok((answer, pending_save, performance, scan_diagnostics))
544 }
545 }
546}
547
548#[cfg(test)]
549mod tests {
550 use std::fs;
551 use std::path::{Path, PathBuf};
552
553 use super::*;
554 use crate::query::{IgnoredEntries, Pattern, Query, Section};
555 use crate::{OpenFixture, ScanConfig};
556
557 #[test]
558 #[cfg(unix)]
559 fn an_unreadable_stored_header_never_authorizes_live_replacement() {
560 use std::os::unix::fs::PermissionsExt;
561 if !crate::test_support::require_permission_bits() {
562 return;
563 }
564 let root = tempfile::tempdir().expect("root");
565 let cache = tempfile::tempdir().expect("cache");
566 let snapshot = cache.path().join("snapshot.fdu");
567 fs::write(root.path().join(".gitignore"), b"ignored\n").expect("control");
568 let observed = crate::query::Basis {
569 root: root.path().into(),
570 scope: crate::query::Scope::default(),
571 content: crate::content::AnalysisSet::NONE,
572 };
573 let delivery = Delivery::new(CachePolicy::Auto, Some(snapshot.clone()));
574 crate::open(&observed, &delivery).expect("stronger snapshot");
575 let original = fs::read(&snapshot).expect("original image");
576 let basis = crate::query::Basis {
577 scope: crate::query::Scope { read_controls: false, ..Default::default() },
578 ..observed
579 };
580 let (mut index, _) =
581 crate::open(&basis, &Delivery::new(CachePolicy::Off, None)).expect("fresh blind index");
582 let request = Request::new(basis, Query::default(), SystemTime::now());
583 let plan = plan(&request, &delivery, Route::Refresh).expect("plan");
584 fs::set_permissions(&snapshot, fs::Permissions::from_mode(0o000))
585 .expect("deny header read");
586 for policy in [CachePolicy::Off, CachePolicy::ReadOnly] {
587 let nonwriting = Delivery { cache: policy, ..delivery.clone() };
588 let nonwriting_plan =
589 super::plan(&request, &nonwriting, Route::Refresh).expect("nonwriting plan");
590 assert!(
591 !crate::persist_index_changes(&index, &nonwriting_plan, true, true)
592 .expect("nonwriting policy never reads the header")
593 );
594 }
595 fs::write(root.path().join("fresh.txt"), b"fresh").expect("mutation");
596 crate::refresh(
597 &mut index,
598 &request.basis,
599 &Delivery { cache: CachePolicy::Off, ..delivery.clone() },
600 )
601 .expect("off refresh does not inspect cache state");
602 let result = crate::persist_index_changes(&index, &plan, true, true);
603 fs::set_permissions(&snapshot, fs::Permissions::from_mode(0o600)).expect("restore");
604 assert!(
605 result.is_err(),
606 "a writable parent must not let unknown identity authorize replacement"
607 );
608 assert_eq!(fs::read(&snapshot).expect("retained image"), original);
609 }
610
611 #[test]
612 fn refresh_rejects_another_root_before_mutating_or_persisting() {
613 let a = tempfile::tempdir().expect("root a");
614 let b = tempfile::tempdir().expect("root b");
615 let cache = tempfile::tempdir().expect("cache");
616 let basis = crate::query::Basis {
617 root: a.path().into(),
618 scope: crate::query::Scope::default(),
619 content: crate::content::AnalysisSet::NONE,
620 };
621 let (mut index, _) =
622 crate::open(&basis, &Delivery::new(CachePolicy::Off, None)).expect("open a");
623 fs::write(a.path().join("new"), b"new facts").expect("mutation a");
624 let before = index.clock();
625 let snapshot = cache.path().join("snapshot.fdu");
626 let wrong = crate::query::Basis { root: b.path().into(), ..basis.clone() };
627 let delivery = Delivery::new(CachePolicy::Auto, Some(snapshot.clone()));
628 let error = crate::refresh(&mut index, &wrong, &delivery).expect_err("different root");
629 assert!(matches!(
630 error,
631 Error::InvalidRequest(crate::query::RequestError::RootMismatch { .. })
632 ));
633 assert_eq!(index.clock(), before);
634 assert!(!snapshot.exists());
635 let alias = crate::query::Basis { root: a.path().join("."), ..basis };
636 crate::refresh(&mut index, &alias, &delivery).expect("same root spelling");
637 assert_eq!(index.total().files, 1);
638 #[cfg(unix)]
639 {
640 let link = cache.path().join("root-alias");
641 std::os::unix::fs::symlink(a.path(), &link).expect("root alias");
642 let symlink_basis = crate::query::Basis { root: link, ..alias };
643 crate::refresh(&mut index, &symlink_basis, &delivery)
644 .expect("same canonical root through symlink");
645 }
646 }
647
648 #[test]
649 fn unchanged_refresh_replaces_an_incompatible_stored_baseline() {
650 let root = tempfile::tempdir().expect("root");
651 let other = tempfile::tempdir().expect("other root");
652 let cache = tempfile::tempdir().expect("cache");
653 fs::write(root.path().join("file"), b"retained").expect("file");
654 let basis = crate::query::Basis {
655 root: root.path().into(),
656 scope: crate::query::Scope::default(),
657 content: crate::content::AnalysisSet::NONE,
658 };
659 let delivery = Delivery::new(CachePolicy::Auto, Some(cache.path().join("snapshot.fdu")));
660 for wrong_root in [false, true] {
661 let wrong = if wrong_root {
662 crate::query::Basis { root: other.path().into(), ..basis.clone() }
663 } else {
664 crate::query::Basis {
665 scope: crate::query::Scope { max_depth: Some(0), ..basis.scope.clone() },
666 ..basis.clone()
667 }
668 };
669 crate::open(&wrong, &Delivery { cache: CachePolicy::Refresh, ..delivery.clone() })
670 .expect("incompatible snapshot");
671 let (mut index, _) = crate::open(&basis, &Delivery::new(CachePolicy::Off, None))
672 .expect("retained index");
673 let refreshed =
674 crate::refresh(&mut index, &basis, &delivery).expect("refresh reseeds cache");
675 assert!(!refreshed.apply.mutated(), "the existing index was already current");
676 let (cached, _) =
677 crate::open(&basis, &Delivery { cache: CachePolicy::Only, ..delivery.clone() })
678 .expect("cache-only can now answer");
679 assert_eq!(cached.total().bytes, 8);
680 }
681 }
682
683 #[test]
684 fn refreshed_metadata_and_content_are_visible_to_a_later_cache_only_open() {
685 let root = tempfile::tempdir().expect("root");
686 let cache = tempfile::tempdir().expect("cache");
687 let path = root.path().join("note.txt");
688 fs::write(&path, b"old\n").expect("old file");
689 let basis = crate::query::Basis {
690 root: root.path().into(),
691 scope: crate::query::Scope::default(),
692 content: crate::content::AnalysisSet::NONE.with_lines(),
693 };
694 let delivery = Delivery::new(CachePolicy::Auto, Some(cache.path().join("snapshot.fdu")));
695 let (mut index, _) = crate::open(&basis, &delivery).expect("initial open");
696 fs::write(&path, b"new longer text\nsecond line\n").expect("mutation");
697 let refreshed = crate::refresh(&mut index, &basis, &delivery).expect("refresh");
698 assert!(refreshed.is_complete());
699 let (cached, report) =
700 crate::open(&basis, &Delivery { cache: CachePolicy::Only, ..delivery })
701 .expect("cache-only sees refreshed tiers");
702 assert_eq!(report.path_taken, OpenPath::CacheOnly);
703 assert_eq!(cached.total(), index.total());
704 assert_eq!(
705 cached.total().bytes,
706 u64::try_from(b"new longer text\nsecond line\n".len()).expect("length")
707 );
708 assert_eq!(report.content_cache.hits, 1);
709 let original = index
710 .content()
711 .expect("fresh content")
712 .file(Path::new("note.txt"))
713 .expect("fresh file");
714 let restored = cached
715 .content()
716 .expect("restored content")
717 .file(Path::new("note.txt"))
718 .expect("restored file");
719 assert_eq!(restored, original);
720 }
721
722 #[cfg(unix)]
725 fn owed_persistence_fixture()
726 -> (tempfile::TempDir, tempfile::TempDir, crate::query::Basis, Delivery) {
727 let root = tempfile::tempdir().expect("root");
728 let cache = tempfile::tempdir().expect("cache");
729 fs::create_dir(root.path().join("locked")).expect("locked dir");
730 fs::write(root.path().join("first"), b"first").expect("first file");
731 let basis = crate::query::Basis {
732 root: root.path().into(),
733 scope: crate::query::Scope::default(),
734 content: crate::content::AnalysisSet::NONE,
735 };
736 let delivery = Delivery::new(CachePolicy::Auto, Some(cache.path().join("snapshot.fdu")));
737 (root, cache, basis, delivery)
738 }
739
740 #[cfg(unix)]
742 fn cached_files(basis: &crate::query::Basis, delivery: &Delivery) -> u64 {
743 let cache_only = Delivery { cache: CachePolicy::Only, ..delivery.clone() };
744 crate::open(basis, &cache_only).expect("cache-only open").0.total().files
745 }
746
747 #[test]
754 #[cfg(unix)]
755 fn an_unchanged_refresh_repeats_the_metadata_write_a_failed_refresh_owed() {
756 use std::os::unix::fs::PermissionsExt;
757 if !crate::test_support::require_permission_bits() {
758 return;
759 }
760 let (root, cache, basis, delivery) = owed_persistence_fixture();
761 let (mut index, _) = crate::open(&basis, &delivery).expect("initial open");
762 assert_eq!(cached_files(&basis, &delivery), 1);
763
764 fs::write(root.path().join("second"), b"second").expect("second file");
765 fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o555)).expect("deny write");
766 let failed = crate::refresh(&mut index, &basis, &delivery);
767 fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o755)).expect("restore");
768 assert!(failed.is_err(), "a read-only cache directory fails the write");
769 assert_eq!(index.total().files, 2, "the index advanced before the write");
770 assert_eq!(cached_files(&basis, &delivery), 1, "the failed write left the old image");
771
772 let unchanged = crate::refresh(&mut index, &basis, &delivery).expect("unchanged refresh");
773 assert!(unchanged.is_complete());
774 assert!(!unchanged.apply.mutated(), "nothing changed between the passes");
775 assert_eq!(cached_files(&basis, &delivery), 2, "the owed write ran");
776
777 let snapshot = delivery.cache_path.as_deref().expect("path");
779 let written = fs::metadata(snapshot).expect("snapshot").modified().expect("mtime");
780 crate::refresh(&mut index, &basis, &delivery).expect("settled refresh");
781 assert_eq!(fs::metadata(snapshot).expect("snapshot").modified().expect("mtime"), written);
782 }
783
784 #[test]
787 #[cfg(unix)]
788 fn an_unchanged_refresh_repeats_the_metadata_write_a_failed_open_owed() {
789 use std::os::unix::fs::PermissionsExt;
790 if !crate::test_support::require_permission_bits() {
791 return;
792 }
793 let (root, cache, basis, delivery) = owed_persistence_fixture();
794 crate::open(&basis, &delivery).expect("complete open writes the snapshot");
795 fs::write(root.path().join("second"), b"second").expect("second file");
796 fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o555)).expect("deny write");
797 let opened = crate::open_with_pending_save(&basis, &delivery);
798 let outcome = opened.map(|(index, report, pending)| (index, report, pending.join()));
800 fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o755)).expect("restore");
801 let (index, report, joined) = outcome.expect("the open itself succeeds");
802 assert_eq!(report.path_taken, OpenPath::WarmRevalidate);
803 assert!(joined.is_err(), "the startup write failed");
804 let mut index = std::sync::Arc::into_inner(index).expect("the writer released the index");
805 assert_eq!(cached_files(&basis, &delivery), 1);
806
807 let unchanged = crate::refresh(&mut index, &basis, &delivery).expect("unchanged refresh");
808 assert!(!unchanged.apply.mutated(), "nothing changed between the passes");
809 assert_eq!(cached_files(&basis, &delivery), 2, "the owed write ran");
810 }
811
812 #[test]
820 #[cfg(unix)]
821 fn a_complete_refresh_persists_the_facts_a_partial_refresh_could_not() {
822 use std::os::unix::fs::PermissionsExt;
823 if !crate::test_support::require_permission_bits() {
824 return;
825 }
826 let (root, _cache, basis, delivery) = owed_persistence_fixture();
827 let locked = root.path().join("locked");
828 let (mut index, _) = crate::open(&basis, &delivery).expect("initial open");
829 assert_eq!(index.total().files, 1);
830
831 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("deny read");
832 fs::write(root.path().join("second"), b"second").expect("second file");
833 let partial = crate::refresh(&mut index, &basis, &delivery);
834 fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).expect("restore");
835 let partial = partial.expect("partial refresh");
836 assert!(!partial.is_complete(), "the locked directory made the pass partial");
837 assert!(partial.apply.mutated(), "the second file was inserted");
838 assert_eq!(index.total().files, 2);
839 assert_eq!(cached_files(&basis, &delivery), 1, "a partial pass never writes entries");
840
841 let complete = crate::refresh(&mut index, &basis, &delivery).expect("complete refresh");
842 assert!(complete.is_complete(), "{:?}", complete.scan.errors);
843 assert_eq!(cached_files(&basis, &delivery), 2, "the complete pass wrote the facts");
844 }
845
846 #[test]
847 fn cache_only_refusals_name_location_root_and_absence_separately() {
848 let root = tempfile::tempdir().expect("root");
849 let other = tempfile::tempdir().expect("other root");
850 let cache = tempfile::tempdir().expect("cache");
851 let basis = crate::query::Basis {
852 root: root.path().into(),
853 scope: crate::query::Scope::default(),
854 content: crate::content::AnalysisSet::NONE,
855 };
856 let snapshot = cache.path().join("snapshot.fdu");
857 let message = |delivery: &Delivery| {
858 crate::open(&basis, delivery).expect_err("cache-only refusal").to_string()
859 };
860 let no_location = message(&Delivery::new(CachePolicy::Only, None));
861 assert!(no_location.contains("no cache location"), "{no_location}");
862 assert!(!no_location.contains("use auto"), "no write can succeed without a location");
863 let missing = message(&Delivery::new(CachePolicy::Only, Some(snapshot.clone())));
864 assert!(missing.contains("no usable snapshot"), "{missing}");
865 assert!(missing.contains("auto"), "{missing}");
866 let other_basis = crate::query::Basis { root: other.path().into(), ..basis.clone() };
867 crate::open(&other_basis, &Delivery::new(CachePolicy::Auto, Some(snapshot.clone())))
868 .expect("other snapshot");
869 let wrong_root = message(&Delivery::new(CachePolicy::Only, Some(snapshot)));
870 assert!(wrong_root.contains("different root"), "{wrong_root}");
871 }
872
873 #[test]
874 fn route_delivery_matrix_rejects_contracts_the_route_cannot_execute() {
875 let basis = crate::query::Basis {
876 root: ".".into(),
877 scope: crate::query::Scope::default(),
878 content: crate::content::AnalysisSet::NONE,
879 };
880 let request = Request::new(basis, Query::default(), SystemTime::now());
881 for delivery in Delivery::enumerate() {
882 for route in
883 [Route::OneShot, Route::Retained, Route::Refresh, Route::Watch, Route::Opened]
884 {
885 let result = plan(&request, &delivery, route);
886 let forbidden = (delivery.watch.is_some()
887 || matches!(route, Route::Watch | Route::Refresh))
888 && delivery.cache == CachePolicy::Only
889 || route == Route::Opened
890 && (delivery.cache != CachePolicy::Off
891 || delivery.watch.is_some()
892 || delivery.accept_partial);
893 assert_eq!(result.is_err(), forbidden, "{route:?} {delivery:?}");
894 if let Ok(plan) = result {
895 if route == Route::Opened {
896 assert_eq!(plan.load(), Load::None);
897 assert_eq!(plan.verify(), Verify::Filesystem);
898 }
899 }
900 }
901 }
902 }
903
904 #[test]
905 fn an_opened_root_refuses_the_scheduling_it_would_otherwise_drop() {
906 let basis = crate::query::Basis {
911 root: ".".into(),
912 scope: crate::query::Scope::default(),
913 content: crate::content::AnalysisSet::NONE,
914 };
915 let request = Request::new(basis, Query::default(), SystemTime::now());
916 let default = Delivery::new(CachePolicy::Off, None);
917 let plan_opened = plan(&request, &default, Route::Opened).expect("defaults plan");
918 assert_eq!(plan_opened.delivery().batch_size, default.batch_size);
919 let unhonored = [
920 (
921 "scan workers",
922 Delivery {
923 workers: crate::query::Workers { scan: Some(4), ..default.workers },
924 ..default.clone()
925 },
926 ),
927 (
928 "depth-first order",
929 Delivery { order: crate::ScanOrder::DepthFirst, ..default.clone() },
930 ),
931 ("accept partial", Delivery { accept_partial: true, ..default.clone() }),
932 ];
933 for (case, delivery) in unhonored {
934 let refused = plan(&request, &delivery, Route::Opened).expect_err(case);
935 assert!(
936 matches!(
937 refused,
938 crate::query::RequestError::DeliveryUnsupported { route: "opened", .. }
939 ),
940 "{case}: {refused}"
941 );
942 plan(&request, &delivery, Route::Retained)
943 .unwrap_or_else(|error| panic!("{case} executes on a retained route: {error}"));
944 }
945 let batched = Delivery { batch_size: default.batch_size * 2, ..default };
947 let plan_batched = plan(&request, &batched, Route::Opened).expect("batch size plans");
948 assert_eq!(plan_batched.delivery().batch_size, batched.batch_size);
949 }
950
951 #[test]
952 fn write_policy_depends_only_on_delivery_and_observed_facts() {
953 let routes = [Route::OneShot, Route::Retained, Route::Refresh, Route::Watch, Route::Opened];
954 for delivery in Delivery::enumerate() {
955 for bits in 0_u8..64 {
956 let facts = RunFacts {
957 entries_verified: bits & 1 != 0,
958 entries_changed: bits & 2 != 0,
959 content_changed: bits & 4 != 0,
960 content_requested: bits & 8 != 0,
961 projected: bits & 16 != 0,
962 paired_entries: bits & 32 != 0,
963 };
964 let allowed = delivery.cache.writes();
965 let expected = SaveTargets {
966 metadata: allowed
967 && facts.entries_verified
968 && facts.entries_changed
969 && !facts.projected,
970 content: allowed
971 && facts.content_requested
972 && facts.content_changed
973 && (facts.entries_verified || facts.paired_entries),
974 };
975 for route in routes {
976 let plan = Plan {
977 basis: crate::query::Basis {
978 root: ".".into(),
979 scope: crate::query::Scope::default(),
980 content: crate::content::AnalysisSet::NONE,
981 },
982 route,
983 retained: RetainedState::FullIndex,
984 load: Load::Snapshot,
985 verify: Verify::Filesystem,
986 delivery: delivery.clone(),
987 };
988 assert_eq!(plan.writes(facts), expected, "{route:?} {delivery:?} {facts:?}");
989 let unavailable = Plan {
990 delivery: Delivery { cache_path: None, ..delivery.clone() },
991 ..plan
992 };
993 assert!(unavailable.writes(facts).none());
994 }
995 }
996 }
997 }
998
999 fn planned(config: &OpenFixture, query: &Query) -> Plan {
1000 let (request, delivery) = split(Path::new("."), config, query);
1001 plan(&request, &delivery, Route::OneShot).expect("valid plan")
1002 }
1003
1004 fn summary_query() -> Query {
1005 Query { views: vec![ViewSpec::Summary], ..Query::default() }
1006 }
1007
1008 fn split(root: &Path, config: &OpenFixture, query: &Query) -> (Request, Delivery) {
1011 let (basis, delivery) = config.split(root);
1012 (Request::new(basis, query.clone(), std::time::UNIX_EPOCH), delivery)
1013 }
1014
1015 fn prepared(
1017 root: &Path,
1018 config: &OpenFixture,
1019 query: &Query,
1020 ) -> Result<(Report, PendingSave, PerformanceSummary)> {
1021 let (request, delivery) = split(root, config, query);
1022 prepare_report(&request, &delivery)
1023 }
1024
1025 fn prepared_with_diagnostics(
1027 root: &Path,
1028 config: &OpenFixture,
1029 query: &Query,
1030 ) -> Result<(Report, PendingSave, PerformanceSummary, Option<crate::scan::ScanDiagnostics>)>
1031 {
1032 let (request, delivery) = split(root, config, query);
1033 prepare_report_with_scan_diagnostics(&request, &delivery)
1034 }
1035
1036 fn config(policy: CachePolicy, cache_path: Option<PathBuf>) -> OpenFixture {
1037 OpenFixture { scan: ScanConfig::default(), cache_path, policy, ..OpenFixture::default() }
1038 }
1039
1040 fn blind(policy: CachePolicy, cache_path: Option<PathBuf>) -> OpenFixture {
1043 OpenFixture {
1044 scan: ScanConfig { read_controls: false, ..ScanConfig::default() },
1045 ..config(policy, cache_path)
1046 }
1047 }
1048
1049 fn controls_config(
1050 policy: CachePolicy,
1051 cache_path: PathBuf,
1052 read_controls: bool,
1053 ) -> OpenFixture {
1054 OpenFixture {
1055 scan: ScanConfig { read_controls, ..ScanConfig::default() },
1056 cache_path: Some(cache_path),
1057 policy,
1058 ..OpenFixture::default()
1059 }
1060 }
1061
1062 fn seed_controls_snapshot(root: &Path, cache_path: PathBuf) {
1063 fs::write(root.join(".gitignore"), b"ignored.log\n").expect("control file");
1064 fs::write(root.join("ignored.log"), b"ignored").expect("ignored file");
1065 crate::open_fixture(root, &controls_config(CachePolicy::Auto, cache_path, true))
1066 .expect("seed controls-on snapshot");
1067 }
1068
1069 #[test]
1070 fn planner_uses_compact_state_only_when_the_request_proves_it_is_sufficient() {
1071 let off = blind(CachePolicy::Off, Some(PathBuf::from("unused.fdu")));
1072 assert_eq!(planned(&off, &summary_query()).retained, RetainedState::Summary);
1073
1074 for policy in [CachePolicy::Auto, CachePolicy::Refresh, CachePolicy::ReadOnly] {
1075 let unavailable = blind(policy, None);
1076 assert_eq!(planned(&unavailable, &summary_query()).retained, RetainedState::Summary);
1077 }
1078
1079 let mut several_views = summary_query();
1080 several_views.views.push(ViewSpec::Types);
1081 assert_eq!(planned(&off, &several_views).retained, RetainedState::FullIndex);
1082
1083 let mut filtered = summary_query();
1084 filtered.selection.include.push(Pattern::parse("*.rs").expect("pattern"));
1085 assert_eq!(planned(&off, &filtered).retained, RetainedState::FullIndex);
1086
1087 let observing = config(CachePolicy::Off, None);
1090 assert!(observing.scan.read_controls, "observation is the default");
1091 assert_eq!(planned(&observing, &summary_query()).retained, RetainedState::FullIndex);
1092 let mut by_ignored = summary_query();
1093 by_ignored.selection.ignored = IgnoredEntries::Exclude;
1094 assert_eq!(planned(&observing, &by_ignored).retained, RetainedState::FullIndex);
1095 }
1096
1097 #[test]
1098 fn an_available_snapshot_does_not_force_the_index_for_a_metadata_summary() {
1099 for policy in [CachePolicy::Auto, CachePolicy::ReadOnly] {
1105 let cached = blind(policy, Some(PathBuf::from("cache.fdu")));
1106 assert_eq!(
1107 planned(&cached, &summary_query()).retained,
1108 RetainedState::Summary,
1109 "{policy:?} must not be forced onto the index by a present snapshot"
1110 );
1111 }
1112 }
1113
1114 #[test]
1115 fn policies_whose_intent_is_the_snapshot_itself_still_retain_the_index() {
1116 for policy in [CachePolicy::Only, CachePolicy::Refresh] {
1120 let cached = config(policy, Some(PathBuf::from("cache.fdu")));
1121 assert_eq!(
1122 planned(&cached, &summary_query()).retained,
1123 RetainedState::FullIndex,
1124 "{policy:?} needs the index to honour its contract"
1125 );
1126 }
1127 }
1128
1129 #[test]
1130 fn a_one_shot_metadata_query_does_not_read_the_snapshot_it_cannot_use() {
1131 let mut tree_query = summary_query();
1137 tree_query.views = vec![ViewSpec::Tree];
1138 for policy in [CachePolicy::Auto, CachePolicy::ReadOnly] {
1139 let cached = config(policy, Some(PathBuf::from("cache.fdu")));
1140 assert!(
1141 planned(&cached, &tree_query).load != Load::Snapshot,
1142 "{policy:?} must not pay for a read that saves no work"
1143 );
1144 }
1145 }
1146
1147 #[test]
1148 fn the_snapshot_is_read_where_reading_pays_or_is_the_contract() {
1149 let only = config(CachePolicy::Only, Some(PathBuf::from("cache.fdu")));
1151 assert_eq!(planned(&only, &summary_query()).load, Load::Snapshot);
1152
1153 let analyzed = OpenFixture {
1156 analysis: crate::content::AnalysisRequest {
1157 profile: crate::content::AnalysisSet::NONE.with_code(),
1158 ..Default::default()
1159 },
1160 ..config(CachePolicy::Auto, Some(PathBuf::from("cache.fdu")))
1161 };
1162 assert_eq!(planned(&analyzed, &summary_query()).load, Load::Snapshot);
1163
1164 for policy in [CachePolicy::Off, CachePolicy::Refresh] {
1166 let never = config(policy, Some(PathBuf::from("cache.fdu")));
1167 assert_eq!(planned(&never, &summary_query()).load, Load::None, "{policy:?}");
1168 }
1169 }
1170
1171 #[test]
1172 fn an_analysis_request_never_selects_the_compact_summary_tier() {
1173 let off = blind(CachePolicy::Off, None);
1177 assert_eq!(planned(&off, &summary_query()).retained, RetainedState::Summary);
1178
1179 for profile in [
1180 crate::content::AnalysisSet::NONE.with_lines(),
1181 crate::content::AnalysisSet::NONE.with_code(),
1182 crate::content::AnalysisSet::NONE.with_words(),
1183 crate::content::AnalysisSet::ALL,
1184 ] {
1185 let analyzed = OpenFixture {
1186 analysis: crate::content::AnalysisRequest { profile, ..Default::default() },
1187 ..blind(CachePolicy::Off, None)
1188 };
1189 assert_eq!(
1190 planned(&analyzed, &summary_query()).retained,
1191 RetainedState::FullIndex,
1192 "{profile:?} must retain the index"
1193 );
1194 }
1195 }
1196
1197 #[test]
1198 fn a_repeated_one_shot_report_scans_cold_while_open_still_revalidates() {
1199 let root = tempfile::tempdir().expect("tempdir");
1204 fs::write(root.path().join("file.txt"), b"contents").expect("file");
1205 let cache = tempfile::tempdir().expect("cache dir");
1206 let auto = config(CachePolicy::Auto, Some(cache.path().join("cache.fdu")));
1207 let mut tree_query = summary_query();
1208 tree_query.views = vec![ViewSpec::Tree];
1209
1210 let (first, pending, _) = prepared(root.path(), &auto, &tree_query).expect("first report");
1211 pending.join().expect("first save");
1212 assert_eq!(first.provenance.source, ReportSource::ColdScan);
1213 assert!(auto.cache_path.as_deref().expect("path").exists(), "first run persists");
1214
1215 let (second, pending, performance) =
1216 prepared(root.path(), &auto, &tree_query).expect("second report");
1217 pending.join().expect("second save");
1218 assert_eq!(
1219 second.provenance.source,
1220 ReportSource::ColdScan,
1221 "a repeated one-shot must not pay for a read that saves no work"
1222 );
1223 assert_eq!(performance.walked_files, 1, "the walk still happened");
1224
1225 let (_, open_report) = crate::open_fixture(root.path(), &auto).expect("library open");
1228 assert_eq!(
1229 open_report.path_taken,
1230 OpenPath::WarmRevalidate,
1231 "a caller holding the index still amortises the load"
1232 );
1233 }
1234
1235 #[test]
1236 fn cache_only_still_answers_from_a_snapshot_left_by_a_one_shot_report() {
1237 let root = tempfile::tempdir().expect("tempdir");
1240 fs::write(root.path().join("file.txt"), b"contents").expect("file");
1241 let cache = tempfile::tempdir().expect("cache dir");
1242 let auto = config(CachePolicy::Auto, Some(cache.path().join("cache.fdu")));
1243 let mut tree_query = summary_query();
1244 tree_query.views = vec![ViewSpec::Tree];
1245
1246 let (_, pending, _) = prepared(root.path(), &auto, &tree_query).expect("report");
1247 pending.join().expect("save");
1248
1249 let only = config(CachePolicy::Only, Some(cache.path().join("cache.fdu")));
1250 let (from_cache, pending, performance, diagnostics) =
1251 prepared_with_diagnostics(root.path(), &only, &tree_query).expect("cache-only report");
1252 pending.join().expect("no save");
1253 assert_eq!(from_cache.provenance.source, ReportSource::CacheOnly);
1254 assert_eq!(performance.walked_files, 0, "cache-only never touches the tree");
1255 assert!(diagnostics.is_none(), "a cache-only open has no scan trace");
1256 }
1257
1258 #[test]
1259 fn controls_on_snapshot_projects_to_an_equivalent_controls_off_cache_only_report() {
1260 let root = tempfile::tempdir().expect("tempdir");
1261 let cache = tempfile::tempdir().expect("cache dir");
1262 let cache_path = cache.path().join("cache.fdu");
1263 fs::create_dir(root.path().join("src")).expect("source dir");
1264 fs::write(root.path().join("src/lib.rs"), b"library").expect("source file");
1265 seed_controls_snapshot(root.path(), cache_path.clone());
1266
1267 let controls_off = controls_config(CachePolicy::Only, cache_path, false);
1268 let query = Query {
1269 views: vec![
1270 ViewSpec::Summary,
1271 ViewSpec::Tree,
1272 ViewSpec::Families,
1273 ViewSpec::Types,
1274 ViewSpec::Extensions,
1275 ViewSpec::Languages,
1276 ViewSpec::Largest,
1277 ViewSpec::Recent,
1278 ViewSpec::Files,
1279 ],
1280 ..Query::default()
1281 };
1282 let (projected, pending, performance) =
1283 prepared(root.path(), &controls_off, &query).expect("projected report");
1284 pending.join().expect("no cache-only save");
1285
1286 let cold = OpenFixture { policy: CachePolicy::Off, cache_path: None, ..controls_off };
1287 let (mut expected, pending, _) =
1288 prepared(root.path(), &cold, &query).expect("controls-off cold report");
1289 pending.join().expect("no cold save");
1290 expected.provenance = projected.provenance.clone();
1291
1292 assert_eq!(performance.source, ReportSource::CacheOnly);
1293 assert_eq!(projected.scope, cold.scan.scope());
1294 assert_eq!(projected.ignore_rules, crate::control::ControlCoverage::NotObserved);
1295 assert_eq!(
1296 crate::report_format::render(&projected, crate::report_format::Format::Json, false,)
1297 .expect("compatible report format"),
1298 crate::report_format::render(&expected, crate::report_format::Format::Json, false,)
1299 .expect("compatible report format"),
1300 );
1301 }
1302
1303 #[test]
1304 fn controls_on_snapshot_projects_to_controls_off_auto_report() {
1305 let root = tempfile::tempdir().expect("tempdir");
1306 let cache = tempfile::tempdir().expect("cache dir");
1307 let cache_path = cache.path().join("cache.fdu");
1308 seed_controls_snapshot(root.path(), cache_path.clone());
1309
1310 let controls_off = OpenFixture {
1311 analysis: crate::content::AnalysisRequest {
1312 profile: crate::content::AnalysisSet::NONE.with_lines(),
1313 ..Default::default()
1314 },
1315 ..controls_config(CachePolicy::Auto, cache_path, false)
1316 };
1317 let (report, pending, performance) = prepared(root.path(), &controls_off, &summary_query())
1318 .expect("controls-off warm projection");
1319 pending.join().expect("save content only");
1320
1321 assert_eq!(report.provenance.source, ReportSource::WarmRevalidate);
1322 assert_eq!(report.scope, controls_off.scan.scope());
1323 assert_eq!(performance.source, ReportSource::WarmRevalidate);
1324 }
1325
1326 fn write_unobservable_controls(root: &Path) {
1332 let mut rule = vec![b'a'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
1333 rule.push(b'\n');
1334 fs::write(root.join(".gitignore"), rule).expect("oversized rule");
1335 fs::create_dir(root.join("vendored")).expect("nested directory");
1336 fs::write(
1337 root.join("vendored/.gitignore"),
1338 b"x\n".repeat(crate::control::DEFAULT_CONTROL_BUDGET / 2),
1339 )
1340 .expect("oversized source");
1341 }
1342
1343 #[test]
1344 fn a_one_shot_report_observes_control_state_as_its_caller_configures() {
1345 let root = tempfile::tempdir().expect("tempdir");
1349 fs::write(root.path().join("file.txt"), b"contents").expect("file");
1350 write_unobservable_controls(root.path());
1351 let mut tree_query = summary_query();
1352 tree_query.views = vec![ViewSpec::Tree];
1353
1354 for read_controls in [true, false] {
1355 let cache = tempfile::tempdir().expect("cache dir");
1356 let cache_path = cache.path().join("cache.fdu");
1357 let caller = controls_config(CachePolicy::Auto, cache_path.clone(), read_controls);
1358 for query in [summary_query(), tree_query.clone()] {
1359 let (report, pending, _) = prepared(root.path(), &caller, &query)
1360 .expect("a refused control file ends nothing");
1361 pending.join().expect("save");
1362 assert!(
1363 report.status.complete,
1364 "a refusal is not a partial: {:?}",
1365 report.status.errors
1366 );
1367 assert_eq!(report.scope, caller.scan.scope());
1368 match &report.ignore_rules {
1369 crate::control::ControlCoverage::Observed(coverage) => {
1370 assert!(read_controls, "observed only when asked");
1371 assert_eq!(coverage.refused, 2, "{coverage:?}");
1372 assert_eq!(report.notes.len(), 1, "{:?}", report.notes);
1373 }
1374 crate::control::ControlCoverage::NotObserved => {
1375 assert!(!read_controls, "unobserved only when turned off");
1376 assert!(report.notes.is_empty(), "{:?}", report.notes);
1377 }
1378 }
1379 }
1380
1381 let saved = crate::snapshot::load(&cache_path)
1382 .expect("load the snapshot")
1383 .expect("the index tier persisted");
1384 assert_eq!(saved.scope(), caller.scan.scope());
1385 assert_eq!(saved.controls().is_ok(), read_controls);
1386 }
1387 }
1388
1389 #[test]
1403 fn a_scope_this_build_cannot_honour_is_refused_before_any_snapshot_is_read() {
1404 let root = tempfile::tempdir().expect("tempdir");
1405 fs::write(root.path().join("file.txt"), b"contents").expect("file");
1406 let cache = tempfile::tempdir().expect("cache dir");
1407 let cache_path = cache.path().join("cache.fdu");
1408
1409 let warm = config(CachePolicy::Auto, Some(cache_path.clone()));
1412 let (_, pending, _) = prepared(root.path(), &warm, &summary_query()).expect("warm");
1413 pending.join().expect("save");
1414 let (_, pending, _) = prepared(
1415 root.path(),
1416 &config(CachePolicy::Only, Some(cache_path.clone())),
1417 &summary_query(),
1418 )
1419 .expect("the snapshot answers a supported scope");
1420 pending.join().expect("no save");
1421
1422 let unsupported = ScanConfig { follow_symlinks: true, ..ScanConfig::default() };
1423 for policy in
1424 [CachePolicy::Only, CachePolicy::Off, CachePolicy::Auto, CachePolicy::ReadOnly]
1425 {
1426 let asked = OpenFixture {
1427 scan: unsupported.clone(),
1428 ..config(policy, Some(cache_path.clone()))
1429 };
1430 let refused = prepared(root.path(), &asked, &summary_query())
1431 .expect_err("a scope this build cannot honour has no answer at any policy");
1432 assert!(
1433 matches!(
1434 refused,
1435 Error::InvalidRequest(crate::query::RequestError::ScopeUnsupported {
1436 axis: crate::query::ScopeAxis::FollowSymlinks,
1437 ..
1438 })
1439 ),
1440 "{policy:?} must refuse the request rather than fail the operation: {refused}"
1441 );
1442 assert_eq!(
1443 refused.to_string(),
1444 "unsupported scan configuration: follow_symlinks requires cycle, root-boundary, \
1445 and filesystem-boundary semantics",
1446 "{policy:?} must name the scope it cannot honour"
1447 );
1448 }
1449 }
1450
1451 #[test]
1452 fn a_report_that_reads_no_gitignore_refuses_to_select_by_ignored_state() {
1453 let root = tempfile::tempdir().expect("tempdir");
1456 fs::write(root.path().join(".gitignore"), b"*.log\n").expect("control file");
1457 fs::write(root.path().join("debug.log"), b"ignored").expect("ignored file");
1458 let mut only = summary_query();
1459 only.selection.ignored = IgnoredEntries::Only;
1460
1461 assert!(matches!(
1465 prepared(root.path(), &blind(CachePolicy::Off, None), &only),
1466 Err(Error::InvalidRequest(crate::query::RequestError::IgnoredWithoutObservation(
1467 IgnoredEntries::Only
1468 )))
1469 ));
1470
1471 let (report, pending, _) =
1472 prepared(root.path(), &config(CachePolicy::Off, None), &only).expect("observed");
1473 pending.join().expect("no save");
1474 let Section::Summary(row) = report.sections[0] else { panic!("a summary") };
1475 assert_eq!((row.files, row.bytes), (1, 7), "only the ignored file is selected");
1476 }
1477
1478 #[test]
1479 fn a_default_reports_snapshot_serves_either_cache_only_report_but_not_the_reverse() {
1480 let root = tempfile::tempdir().expect("tempdir");
1486 fs::write(root.path().join("file.txt"), b"contents").expect("file");
1487 let mut tree_query = summary_query();
1488 tree_query.views = vec![ViewSpec::Tree];
1489
1490 for (writer, reader) in [(true, true), (true, false), (false, false), (false, true)] {
1491 let cache = tempfile::tempdir().expect("cache dir");
1492 let cache_path = cache.path().join("cache.fdu");
1493 let write = controls_config(CachePolicy::Auto, cache_path.clone(), writer);
1494 let (_, pending, _) =
1495 prepared(root.path(), &write, &tree_query).expect("writing report");
1496 pending.join().expect("save");
1497
1498 let read = controls_config(CachePolicy::Only, cache_path, reader);
1499 match prepared(root.path(), &read, &tree_query) {
1500 Ok((report, pending, _)) => {
1501 pending.join().expect("no save");
1502 assert!(writer || !reader, "writer {writer} served reader {reader}");
1503 assert_eq!(report.provenance.source, ReportSource::CacheOnly);
1504 assert_eq!(
1505 matches!(report.ignore_rules, crate::control::ControlCoverage::Observed(_)),
1506 reader,
1507 "a report describes the scope it asked for"
1508 );
1509 }
1510 Err(Error::Snapshot(message)) => {
1511 assert!(!writer && reader, "writer {writer}, reader {reader}: {message}");
1512 assert!(message.contains(".gitignore state"), "names the cause: {message}");
1513 assert!(message.contains("`auto`"), "names the remedy: {message}");
1514 }
1515 Err(other) => panic!("writer {writer}, reader {reader}: {other}"),
1516 }
1517 }
1518 }
1519
1520 #[test]
1521 fn a_cache_only_open_answers_from_a_default_reports_snapshot() {
1522 let root = tempfile::tempdir().expect("tempdir");
1526 fs::write(root.path().join(".gitignore"), b"*.log\n").expect("control file");
1527 fs::write(root.path().join("debug.log"), b"ignored").expect("ignored file");
1528 let cache = tempfile::tempdir().expect("cache dir");
1529 let cache_path = cache.path().join("cache.fdu");
1530 let mut tree_query = summary_query();
1531 tree_query.views = vec![ViewSpec::Tree];
1532
1533 let auto = config(CachePolicy::Auto, Some(cache_path.clone()));
1534 let (_, pending, _) = prepared(root.path(), &auto, &tree_query).expect("report");
1535 pending.join().expect("save");
1536
1537 let only = config(CachePolicy::Only, Some(cache_path));
1538 let (index, report) = crate::open_fixture(root.path(), &only).expect("the shared snapshot");
1539 assert_eq!(report.path_taken, OpenPath::CacheOnly);
1540 assert_eq!(index.is_ignored(Path::new("debug.log")).ok(), Some(Some(true)));
1541 }
1542
1543 #[test]
1544 fn an_open_that_opts_out_of_control_state_shares_an_opted_out_reports_snapshot() {
1545 let root = tempfile::tempdir().expect("tempdir");
1549 fs::write(root.path().join("file.txt"), b"contents").expect("file");
1550 let cache = tempfile::tempdir().expect("cache dir");
1551 let cache_path = cache.path().join("cache.fdu");
1552 let mut tree_query = summary_query();
1553 tree_query.views = vec![ViewSpec::Tree];
1554
1555 let auto = blind(CachePolicy::Auto, Some(cache_path.clone()));
1556 let (_, pending, _) = prepared(root.path(), &auto, &tree_query).expect("report");
1557 pending.join().expect("save");
1558 assert!(cache_path.exists(), "the report left a snapshot");
1559
1560 let only = blind(CachePolicy::Only, Some(cache_path));
1561 let (index, report) = crate::open_fixture(root.path(), &only).expect("the shared snapshot");
1562 assert_eq!(report.path_taken, OpenPath::CacheOnly);
1563 assert!(matches!(
1564 index.is_ignored(Path::new("file.txt")),
1565 Err(Error::ControlStateNotObserved)
1566 ));
1567 }
1568
1569 #[test]
1570 fn compact_summary_matches_the_indexed_summary_exactly() {
1571 let root = tempfile::tempdir().expect("tempdir");
1572 fs::create_dir(root.path().join("src")).expect("directory");
1573 fs::write(root.path().join("src/lib.rs"), b"library").expect("file");
1574 fs::write(root.path().join("README.md"), b"read me").expect("file");
1575 #[cfg(unix)]
1576 std::os::unix::fs::symlink("README.md", root.path().join("readme-link")).expect("symlink");
1577
1578 let query = summary_query();
1579 let off = OpenFixture {
1582 scan: ScanConfig { read_controls: false, threads: Some(2), ..ScanConfig::default() },
1583 ..blind(CachePolicy::Off, None)
1584 };
1585 let (compact, pending, performance) =
1586 prepared(root.path(), &off, &query).expect("compact report");
1587 pending.join().expect("no pending compact save");
1588 assert_eq!(performance.walked_files, 2);
1589 assert_eq!(performance.walked_bytes, 14);
1590
1591 let (index, _open_report) = crate::open_fixture(root.path(), &off).expect("indexed scan");
1594 let indexed = report(
1595 &index,
1596 &crate::test_support::read_of(&index, query.clone()),
1597 compact.provenance.generated_at,
1598 )
1599 .expect("report");
1600
1601 let Section::Summary(compact_row) = compact.sections[0] else {
1602 panic!("compact plan did not return a summary")
1603 };
1604 let Section::Summary(indexed_row) = indexed.sections[0] else {
1605 panic!("indexed plan did not return a summary")
1606 };
1607 assert_eq!(compact_row.files, indexed_row.files);
1608 assert_eq!(compact_row.dirs, indexed_row.dirs);
1609 assert_eq!(compact_row.bytes, indexed_row.bytes);
1610 assert_eq!(compact_row.allocated, indexed_row.allocated);
1611 assert_eq!(compact_row.newest_mtime_ns, indexed_row.newest_mtime_ns);
1612 assert_eq!(compact.root, indexed.root);
1613 assert_eq!(compact.scope, indexed.scope);
1614 assert_eq!(compact.status.complete, indexed.status.complete);
1615 assert_eq!(compact.provenance.freshness, indexed.provenance.freshness);
1616 }
1617
1618 #[test]
1619 fn compact_summary_never_creates_the_configured_snapshot() {
1620 let root = tempfile::tempdir().expect("tempdir");
1621 fs::write(root.path().join("payload"), b"payload").expect("file");
1622 let cache = root.path().join("must-not-exist.fdu");
1623
1624 let (report, pending, _) =
1625 prepared(root.path(), &blind(CachePolicy::Off, Some(cache.clone())), &summary_query())
1626 .expect("compact report");
1627 pending.join().expect("no pending compact save");
1628
1629 assert!(report.status.complete);
1630 assert!(!cache.exists());
1631 }
1632
1633 #[test]
1634 fn full_index_report_exposes_scan_diagnostics_when_requested() {
1635 let root = tempfile::tempdir().expect("tempdir");
1636 fs::create_dir(root.path().join("nested")).expect("directory");
1637 fs::write(root.path().join("nested/file.txt"), b"trace me").expect("file");
1638 let query = Query { views: vec![ViewSpec::Tree], ..Query::default() };
1639
1640 let (report, pending, performance, diagnostics) =
1641 prepared_with_diagnostics(root.path(), &config(CachePolicy::Off, None), &query)
1642 .expect("full-index report");
1643 pending.join().expect("no pending save");
1644
1645 assert!(report.status.complete);
1646 assert_eq!(performance.walked_files, 1);
1647 let diagnostics = diagnostics.expect("full-index scan diagnostics");
1648 assert_eq!(diagnostics.schema, crate::scan::SCAN_DIAGNOSTICS_SCHEMA);
1649 assert_eq!(diagnostics.worker_policy.ready_directories_at_finish, 0);
1650 assert_eq!(diagnostics.worker_policy.in_flight_directories_at_finish, 0);
1651 }
1652
1653 fn wide_tree(dirs: usize, files: usize) -> (tempfile::TempDir, u64, u64) {
1656 let root = tempfile::tempdir().expect("tempdir");
1657 let mut bytes = 0;
1658 for directory in 0..dirs {
1659 let path = root.path().join(format!("d{directory:03}"));
1660 fs::create_dir(&path).expect("directory");
1661 for file in 0..files {
1662 let size = directory * files + file + 1;
1663 fs::write(path.join(format!("f{file}.txt")), vec![b'.'; size]).expect("file");
1664 bytes += size as u64;
1665 }
1666 }
1667 (root, (dirs * files) as u64, bytes)
1668 }
1669
1670 fn prepared_with_progress(
1672 root: &Path,
1673 config: &OpenFixture,
1674 query: &Query,
1675 progress: &Progress,
1676 ) -> Result<(Report, PendingSave, PerformanceSummary)> {
1677 let (request, delivery) = split(root, config, query);
1678 prepare_report_with_progress(&request, &delivery, progress)
1679 }
1680
1681 fn analyzing(fixture: OpenFixture) -> OpenFixture {
1682 OpenFixture {
1683 analysis: crate::content::AnalysisRequest {
1684 profile: crate::content::AnalysisSet::LINES_ONLY,
1685 workers: 0,
1686 },
1687 ..fixture
1688 }
1689 }
1690
1691 #[test]
1699 fn progress_ends_at_the_walked_totals_of_every_one_shot_route() {
1700 use crate::ProgressPhase::{Scanning, Summarizing};
1701 let (root, files, bytes) = wide_tree(6, 4);
1702 let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
1703
1704 let progress = Progress::new();
1705 let (_, pending, performance) =
1706 prepared_with_progress(root.path(), &config(CachePolicy::Off, None), &tree, &progress)
1707 .expect("cold full-index report");
1708 pending.join().expect("no save");
1709 let snapshot = progress.snapshot();
1710 assert_eq!(performance.source, ReportSource::ColdScan);
1711 assert_eq!((performance.walked_files, performance.walked_bytes), (files, bytes));
1712 assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "cold full index");
1713 assert_eq!(snapshot.allocated, performance.walked_allocated);
1714 assert!(snapshot.allocated > 0, "files with content occupy blocks");
1715 assert_eq!(snapshot.directories, 7, "the root and its six children");
1716 assert_eq!(
1717 (snapshot.phase, snapshot.analysis),
1718 (Summarizing, None),
1719 "the walk ended, the index was assembled, then the answer was built"
1720 );
1721
1722 let progress = Progress::new();
1723 let (report, pending, performance) = prepared_with_progress(
1724 root.path(),
1725 &blind(CachePolicy::Off, None),
1726 &summary_query(),
1727 &progress,
1728 )
1729 .expect("compact summary report");
1730 pending.join().expect("no save");
1731 let Section::Summary(row) = report.sections[0] else { panic!("summary section") };
1732 let snapshot = progress.snapshot();
1733 assert_eq!((performance.walked_files, performance.walked_bytes), (files, bytes));
1734 assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "summary fold");
1735 assert_eq!(snapshot.allocated, performance.walked_allocated);
1736 assert_eq!(snapshot.allocated, row.allocated, "the progress figure is the answer's");
1737 assert_eq!(snapshot.directories, row.dirs + 1, "the row's directories and the root");
1738 assert_eq!((snapshot.phase, snapshot.analysis), (Scanning, None));
1739
1740 let cache_dir = tempfile::tempdir().expect("cache dir");
1742 let cache = cache_dir.path().join("snapshot.fdu");
1743 let progress = Progress::new();
1744 let (_, pending, performance) = prepared_with_progress(
1745 root.path(),
1746 &analyzing(config(CachePolicy::Auto, Some(cache.clone()))),
1747 &tree,
1748 &progress,
1749 )
1750 .expect("cold analyzed report");
1751 let snapshot = progress.snapshot();
1752 assert_eq!(snapshot.phase, Summarizing, "the answer is built while the save runs");
1753 pending.join().expect("save");
1754 assert_eq!(performance.source, ReportSource::ColdScan);
1755 assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "cold with analysis");
1756 assert_eq!(snapshot.allocated, performance.walked_allocated);
1757 assert_eq!(snapshot.directories, 7);
1758 assert_eq!(performance.fresh_files, files, "every file is a lines candidate");
1759 assert_eq!(snapshot.analysis, Some((files, files)));
1760
1761 let progress = Progress::new();
1762 let (_, pending, performance) = prepared_with_progress(
1763 root.path(),
1764 &analyzing(config(CachePolicy::Auto, Some(cache))),
1765 &tree,
1766 &progress,
1767 )
1768 .expect("warm analyzed report");
1769 pending.join().expect("nothing to save");
1770 let snapshot = progress.snapshot();
1771 assert_eq!(performance.source, ReportSource::WarmRevalidate);
1772 assert_eq!((performance.walked_files, performance.walked_bytes), (files, bytes));
1773 assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "warm revalidation");
1774 assert_eq!(snapshot.allocated, performance.walked_allocated);
1775 assert_eq!(snapshot.directories, 7);
1776 assert_eq!(performance.fresh_files, 0, "the sidecar answered every candidate");
1777 assert_eq!((snapshot.phase, snapshot.analysis), (Summarizing, Some((0, 0))));
1778 }
1779
1780 #[test]
1783 fn a_cache_only_report_ends_summarizing_and_walks_nothing() {
1784 use crate::ProgressPhase::Summarizing;
1785 let (root, _, _) = wide_tree(3, 2);
1786 let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
1787 let cache_dir = tempfile::tempdir().expect("cache dir");
1788 let cache = cache_dir.path().join("snapshot.fdu");
1789 let (_, pending, _) =
1790 prepared(root.path(), &config(CachePolicy::Auto, Some(cache.clone())), &tree)
1791 .expect("a report that writes the snapshot");
1792 pending.join().expect("save");
1793
1794 let progress = Progress::new();
1795 let (_, pending, performance) = prepared_with_progress(
1796 root.path(),
1797 &config(CachePolicy::Only, Some(cache)),
1798 &tree,
1799 &progress,
1800 )
1801 .expect("cache-only report");
1802 pending.join().expect("nothing to save");
1803 let snapshot = progress.snapshot();
1804 assert_eq!(performance.source, ReportSource::CacheOnly);
1805 assert_eq!(snapshot.phase, Summarizing);
1806 assert_eq!((snapshot.directories, snapshot.files, snapshot.bytes), (0, 0, 0));
1807 }
1808
1809 fn rank(phase: crate::ProgressPhase, order: &[crate::ProgressPhase]) -> usize {
1811 order
1812 .iter()
1813 .position(|expected| *expected == phase)
1814 .unwrap_or_else(|| panic!("{phase:?} is not a phase of this route"))
1815 }
1816
1817 #[test]
1824 fn progress_is_monotonic_and_phases_advance_in_order_while_a_report_runs() {
1825 use crate::ProgressPhase::{
1826 Analyzing, Indexing, Loading, Revalidating, Saving, Scanning, Starting, Summarizing,
1827 };
1828 let (root, files, bytes) = wide_tree(48, 6);
1829 let cache_dir = tempfile::tempdir().expect("cache dir");
1830 let cache = cache_dir.path().join("snapshot.fdu");
1831 let fixture = OpenFixture {
1832 scan: ScanConfig { threads: Some(3), batch_size: 4, ..ScanConfig::default() },
1833 ..analyzing(config(CachePolicy::Auto, Some(cache)))
1834 };
1835 let tree = Query { views: vec![ViewSpec::Tree], ..Query::default() };
1836
1837 let routes: [(&str, &[crate::ProgressPhase]); 2] = [
1838 ("cold", &[Starting, Loading, Scanning, Indexing, Analyzing, Saving, Summarizing]),
1839 ("warm", &[Starting, Loading, Revalidating, Analyzing, Saving, Summarizing]),
1840 ];
1841 for (route, order) in routes {
1842 let progress = Progress::new();
1843 let initial = progress.snapshot();
1846 let done = std::sync::atomic::AtomicBool::new(false);
1847 let (performance, seen) = std::thread::scope(|scope| {
1848 let poller = scope.spawn(|| {
1849 let polled = progress.clone();
1850 let mut previous = initial;
1851 let mut seen = vec![previous.phase];
1852 loop {
1853 let finished = done.load(std::sync::atomic::Ordering::Acquire);
1854 let current = polled.snapshot();
1855 assert!(current.directories >= previous.directories, "{route}");
1856 assert!(current.files >= previous.files, "{route}");
1857 assert!(current.bytes >= previous.bytes, "{route}");
1858 assert!(
1859 rank(current.phase, order) >= rank(previous.phase, order),
1860 "{route}: {:?} after {:?}",
1861 current.phase,
1862 previous.phase
1863 );
1864 if let (Some(before), Some(after)) = (previous.analysis, current.analysis) {
1865 assert!(after.0 >= before.0 && after.0 <= after.1, "{route}");
1866 assert_eq!(after.1, before.1, "{route}: the total is fixed");
1867 }
1868 if current.phase != previous.phase {
1869 seen.push(current.phase);
1870 }
1871 previous = current;
1872 if finished {
1875 break;
1876 }
1877 std::thread::yield_now();
1878 }
1879 seen
1880 });
1881 let (_, pending, performance) =
1882 prepared_with_progress(root.path(), &fixture, &tree, &progress)
1883 .expect("report");
1884 pending.join().expect("save");
1885 done.store(true, std::sync::atomic::Ordering::Release);
1886 (performance, poller.join().expect("poller"))
1887 });
1888 let snapshot = progress.snapshot();
1889 assert_eq!(
1890 (snapshot.files, snapshot.bytes),
1891 (performance.walked_files, performance.walked_bytes),
1892 "{route}"
1893 );
1894 assert_eq!((snapshot.files, snapshot.bytes), (files, bytes), "{route}");
1895 assert_eq!(snapshot.directories, 49, "{route}");
1896 assert_eq!(seen.first(), Some(&Starting), "{route}: {seen:?}");
1897 assert_eq!(
1898 seen.last().copied(),
1899 Some(snapshot.phase),
1900 "{route}: the poller saw the final phase"
1901 );
1902 assert_eq!(snapshot.phase, Summarizing, "{route}: the answer is built last");
1903 if route == "cold" {
1904 assert_eq!(snapshot.analysis, Some((files, files)));
1905 } else {
1906 assert_eq!(snapshot.analysis, Some((0, 0)));
1907 assert!(!seen.contains(&Indexing), "{route}: a warm run assembles no index");
1908 }
1909 }
1910 }
1911
1912 #[test]
1916 fn a_report_prepared_with_a_handle_is_the_report_prepared_without() {
1917 let (root, _, _) = wide_tree(5, 3);
1918 let tree = Query { views: vec![ViewSpec::Tree, ViewSpec::Files], ..Query::default() };
1919 let cases = [
1920 ("full index", config(CachePolicy::Off, None), tree),
1921 ("compact summary", blind(CachePolicy::Off, None), summary_query()),
1922 ];
1923 for (route, fixture, query) in cases {
1924 let (mut plain, pending, plain_performance) =
1925 prepared(root.path(), &fixture, &query).expect("plain report");
1926 pending.join().expect("no save");
1927 let progress = Progress::new();
1928 let (observed, pending, observed_performance) =
1929 prepared_with_progress(root.path(), &fixture, &query, &progress)
1930 .expect("observed report");
1931 pending.join().expect("no save");
1932
1933 assert_eq!(plain_performance, observed_performance, "{route}");
1934 plain.provenance = observed.provenance.clone();
1935 let json = |report: &Report| {
1936 crate::report_format::render(report, crate::report_format::Format::Json, false)
1937 .expect("render")
1938 };
1939 assert_eq!(json(&plain), json(&observed), "{route}");
1940 assert!(progress.snapshot().files > 0, "{route}: the handle did observe the run");
1941 }
1942 }
1943}