1pub mod admission;
63pub mod cache;
64pub mod classify;
65pub mod content;
66pub mod control;
67pub mod counters;
68mod emit;
69mod engine_contract;
70mod execution;
71mod index;
72mod opened;
73mod platform_tuning;
74mod progress;
75pub mod query;
76pub mod scan;
77pub mod snapshot;
78mod stored_state;
79#[cfg(test)]
80mod test_support;
81
82#[cfg(doctest)]
88#[doc = include_str!("../README.md")]
89pub struct ReadmeDoctests;
90
91pub mod report_format;
96
97#[cfg(feature = "watch")]
98pub mod watch_session;
99
100#[cfg(feature = "watch")]
101pub mod watch;
102
103#[cfg(feature = "watch")]
104pub use crate::watch_session as session;
105
106pub use crate::admission::HiddenPolicy;
107pub use crate::cache::{
108 CacheScope, CacheState, CacheStatus, ClearSummary, ContentInfo, ContentState, ContentStatus,
109 LeftoverKind, SnapshotInfo, StaleReason, cache_status, clear_all_caches, clear_cache,
110 list_caches,
111};
112pub use crate::control::{
113 CONTROL_FILE_NAME, ControlAdmission, ControlCoverage, ControlIdentity, ControlLimits,
114 ControlMatcher, ControlObservation, ControlRefusalReason, ControlTable, DEFAULT_CONTROL_BUDGET,
115 DEFAULT_CONTROL_LINE_LIMIT, RefusedControl, is_control_file,
116};
117pub use crate::engine_contract::{
118 Attrs, ChangeOutcome, ChangePoll, ChangeRequest, Clock, Commit, ContinuationId, CountResult,
119 Coverage, CoverageReason, DEFAULT_COUNT_CAP, DiscoveryProgress, EffectiveChange, EngineVersion,
120 EntryKind, EntryValue, Error, Expectation, Fingerprint, FlatPage, Freshness, Impact,
121 ImpactDomain, IndexState, InvalidateReason, Issue, IssueKind, IssueSummary, Knowledge,
122 LifecyclePhase, LimitedProjection, MAX_CONTINUATION_RECORD_BYTES, MAX_COUNT_CAP,
123 MAX_DIRTY_PATHS, MAX_ISSUE_MESSAGE_BYTES, MAX_ISSUE_PATH_BYTES, MAX_PAGE_ROWS, MAX_PAGE_WORK,
124 MAX_READ_PROJECTIONS, MAX_REPORT_VIEWS, MAX_RETAINED_ISSUES, MIN_JOURNAL_CAPACITY_BYTES,
125 Observation, ObservationOp, Op, PageRequest, PathExpectation, PathState, PortablePath,
126 ProjectionRefusal, ProjectionResult, Provenance, QueryLimit, ReadDiagnostics, ReadProjection,
127 ReadRequest, ReadResponse, RefreshRejection, RefreshResult, RejectedRefreshPath, ReportRequest,
128 Result, RowShape, ScanScope, SemanticIdentity, SessionId, Source, StateTransition, Status,
129 TreePage, Work,
130};
131pub use crate::index::{
132 ApplyOutcome, ApplyStats, ChildSnapshot, DEFAULT_JOURNAL_CAPACITY_BYTES, EntryId, ExtTally,
133 Index, IndexHandle, PartitionRollUp, PartitionRollUpSummary, RollUp, RollUpSummary, Since,
134};
135pub use crate::opened::{
136 DiscoveryBudget, MAX_PRIORITY_PATHS, MAX_REFRESH_PATHS, OpenOptions, OpenedIndex,
137};
138pub use crate::execution::{
142 Load, OutcomeClass, PerformanceSummary, Plan, Route, Verify, plan, prepare_report,
143 prepare_report_with_progress, prepare_report_with_scan_diagnostics,
144};
145pub use crate::progress::{Progress, ProgressPhase, ProgressSnapshot};
146pub use crate::scan::{ReconcileReport, ScanConfig, ScanOrder, ScanReport};
147pub use crate::stored_state::{
148 AnalyzerProvenance, ContentAdmission, ContentTierIdentity, ControlTierIdentity, EntryScope,
149 EntryTierIdentity, Serves, SnapshotIdentity, serves_snapshot,
150};
151#[cfg(feature = "watch")]
152pub use crate::watch_session::{Batch, Change, ChangeKind, SaveOutcome, Session};
153
154use crate::execution::{Admission, RunFacts, SaveTargets, StoreHeader};
155use std::ffi::OsString;
156use std::path::{Path, PathBuf};
157
158#[cfg(test)]
160#[derive(Clone, Debug, Default)]
161pub(crate) struct OpenFixture {
162 pub scan: ScanConfig,
164 pub cache_path: Option<PathBuf>,
169 pub policy: CachePolicy,
171 pub analysis: content::AnalysisRequest,
173}
174
175#[cfg(test)]
176impl OpenFixture {
177 pub fn split(&self, root: impl Into<PathBuf>) -> (query::Basis, query::Delivery) {
179 (
180 query::Basis {
181 root: root.into(),
182 scope: self.scan.clone().into(),
183 content: self.analysis.profile,
184 },
185 query::Delivery {
186 cache: self.policy,
187 cache_path: self.cache_path.clone(),
188 accept_partial: false,
189 watch: None,
190 workers: query::Workers {
191 scan: self.scan.threads,
192 analysis: self.analysis.workers,
193 },
194 batch_size: self.scan.batch_size,
195 order: self.scan.order,
196 },
197 )
198 }
199}
200
201#[cfg(test)]
202pub(crate) fn open_fixture(root: &Path, config: &OpenFixture) -> Result<(Index, OpenReport)> {
203 let (basis, delivery) = config.split(root);
204 open(&basis, &delivery)
205}
206#[cfg(test)]
207pub(crate) fn open_fixture_with_pending_save(
208 root: &Path,
209 config: &OpenFixture,
210) -> Result<(std::sync::Arc<Index>, OpenReport, PendingSave)> {
211 let (basis, delivery) = config.split(root);
212 open_with_pending_save(&basis, &delivery)
213}
214
215#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
221pub enum CachePolicy {
222 #[default]
239 Auto,
240 Refresh,
242 ReadOnly,
244 Only,
249 Off,
251}
252
253impl CachePolicy {
254 fn reads(self) -> bool {
256 matches!(self, Self::Auto | Self::ReadOnly | Self::Only)
257 }
258
259 pub fn writes(self) -> bool {
266 matches!(self, Self::Auto | Self::Refresh)
267 }
268}
269
270#[derive(Clone, Copy, PartialEq, Eq, Debug)]
272pub enum OpenPath {
273 ColdScan,
275 WarmRevalidate,
277 CacheOnly,
281}
282
283#[derive(Debug)]
290#[must_use = "join the save before exiting or the snapshot may be abandoned"]
291pub struct PendingSave {
292 workers: Vec<(&'static str, std::thread::JoinHandle<Result<()>>)>,
293}
294
295impl PendingSave {
296 pub(crate) fn none() -> Self {
298 Self { workers: Vec::new() }
299 }
300
301 pub(crate) fn writes_metadata(&self) -> bool {
303 self.workers.iter().any(|(name, _)| *name == "metadata")
304 }
305
306 pub fn join(mut self) -> Result<()> {
311 let mut first_error = None;
312 for (name, worker) in self.workers.drain(..) {
313 let outcome = worker
314 .join()
315 .unwrap_or_else(|_| Err(Error::Snapshot(format!("{name} cache writer panicked"))));
316 if first_error.is_none() {
317 first_error = outcome.err();
318 }
319 }
320 first_error.map_or(Ok(()), Err)
321 }
322}
323
324impl Drop for PendingSave {
325 fn drop(&mut self) {
326 for (_, worker) in self.workers.drain(..) {
329 let _ = worker.join();
330 }
331 }
332}
333
334#[derive(Debug)]
336pub struct OpenReport {
337 pub path_taken: OpenPath,
339 pub scan: ScanReport,
341 pub analysis: Option<content::AnalysisReport>,
343 pub content_cache: content::ContentCacheLoad,
345 pub projected: bool,
347}
348
349impl OpenReport {
350 pub fn is_complete(&self) -> bool {
352 self.scan.is_complete()
353 && self.analysis.as_ref().is_none_or(content::AnalysisReport::is_complete)
354 }
355
356 pub fn errors(&self) -> &[Error] {
358 &self.scan.errors
359 }
360
361 pub fn error_messages(&self) -> Vec<String> {
363 let mut errors = self.scan.errors.iter().map(ToString::to_string).collect::<Vec<_>>();
364 if let Some(message) =
365 self.analysis.as_ref().and_then(content::AnalysisReport::failure_message)
366 {
367 errors.push(message);
368 }
369 errors
370 }
371}
372
373pub fn open(basis: &query::Basis, delivery: &query::Delivery) -> Result<(Index, OpenReport)> {
395 let (index, report, pending) = open_with_pending_save(basis, delivery)?;
396 let wrote_metadata = pending.writes_metadata();
402 pending.join()?;
403 let mut index = std::sync::Arc::into_inner(index)
404 .expect("the joined writer released the only other reference");
405 if wrote_metadata {
409 index.set_persistence_owed(false);
410 }
411 Ok((index, report))
412}
413
414pub fn open_with_pending_save(
424 basis: &query::Basis,
425 delivery: &query::Delivery,
426) -> Result<(std::sync::Arc<Index>, OpenReport, PendingSave)> {
427 let request =
428 query::Request::new(basis.clone(), query::Query::default(), std::time::SystemTime::now());
429 let plan = plan(&request, delivery, Route::Retained).map_err(Error::InvalidRequest)?;
430 execute(&plan, &request.basis, false, None)
431 .map(|(index, report, pending, _diagnostics)| (index, report, pending))
432}
433
434pub fn refresh(
445 index: &mut Index,
446 basis: &query::Basis,
447 delivery: &query::Delivery,
448) -> Result<ReconcileReport> {
449 let request =
450 query::Request::new(basis.clone(), query::Query::default(), std::time::SystemTime::now());
451 let plan = plan(&request, delivery, Route::Refresh).map_err(Error::InvalidRequest)?;
452 validate_basis_root(index.root_path(), basis)?;
453 request.validate_read(&query::Basis::held_by(index)).map_err(Error::InvalidRequest)?;
454 let scan = basis.scope.scan_config(delivery);
455 scan.validate_for_scope(index.scope())?;
456 debug_assert_eq!(plan.verify(), Verify::Filesystem, "a refresh plan verifies the tree");
459 let report = scan::reconcile(index, &scan, &mut |_| {})?;
460 let cached = load_content(index, basis, delivery)?;
461 let analysis = basis.content.is_enabled().then(|| {
462 content::analyze_index(
463 index,
464 content::AnalysisRequest { profile: basis.content, workers: delivery.workers.analysis },
465 )
466 });
467 if report.apply.mutated() {
468 index.set_persistence_owed(true);
469 }
470 let written = persist_index_changes(
471 index,
472 &plan,
473 index.persistence_owed(),
474 !cached.usable
475 || cached.stale > 0
476 || analysis.as_ref().is_some_and(|report| report.applied > 0),
477 )?;
478 if written {
479 index.set_persistence_owed(false);
480 }
481 Ok(report)
482}
483
484pub(crate) fn validate_basis_root(held: &Path, basis: &query::Basis) -> Result<()> {
485 let requested = basis.root.canonicalize().map_err(|error| Error::io(&basis.root, error))?;
486 let held = held.canonicalize().map_err(|error| Error::io(held, error))?;
487 if requested != held {
488 return Err(Error::InvalidRequest(query::RequestError::RootMismatch { held, requested }));
489 }
490 Ok(())
491}
492
493fn unusable_snapshot_message(refused: Option<SnapshotIdentity>, wanted: &ScanConfig) -> String {
506 const PREFIX: &str = "no usable snapshot for this root and scan scope";
509 const NEVER_SCANS: &str = "the `only` cache policy never scans";
510 let wanted_scope = wanted.scope();
511 let differs_only_in_ignore_rules = |stored: ScanScope| {
512 ScanScope { ignore_rules_fingerprint: wanted_scope.ignore_rules_fingerprint, ..stored }
513 == wanted_scope
514 };
515 let Some(refused) = refused else {
516 return format!("{PREFIX}; {NEVER_SCANS}, so use `auto`, which scans when none serves");
517 };
518 let stored = refused.scan_scope();
519 if stored.type_rules_fingerprint != wanted_scope.type_rules_fingerprint {
520 return format!(
521 "{PREFIX}: the cached snapshot was taken under different file type rules; \
522 {NEVER_SCANS}, so use the snapshot's type registry or use `auto`, which scans under this request's rules"
523 );
524 }
525 if differs_only_in_ignore_rules(stored) {
526 if stored.ignore_rules_fingerprint == 0 {
529 return format!(
530 "{PREFIX}: the cached snapshot has no .gitignore state, because the request \
531 that wrote it did not observe it, and this request does; {NEVER_SCANS}, so \
532 use `auto`, or turn .gitignore observation off as that request did"
533 );
534 }
535 if wanted_scope.observes_controls() {
536 let stored_limits = match refused.controls {
537 ControlTierIdentity::Observed { limits } => limits,
538 ControlTierIdentity::NotObserved => crate::control::ControlLimits::default(),
539 };
540 let changed = changed_control_limits(stored_limits, wanted.control_limits);
541 if !changed.is_empty() {
542 return format!(
543 "{PREFIX}: the cached snapshot was taken under other .gitignore limits \
544 ({changed}); {NEVER_SCANS}, so repeat the request with the snapshot's \
545 limits, or use `auto`, which scans when none serves"
546 );
547 }
548 }
549 }
550 format!(
551 "{PREFIX}: the cached snapshot has a different scan scope; {NEVER_SCANS}, so use \
552 `auto`, which scans when none serves"
553 )
554}
555
556fn changed_control_limits(
558 stored: crate::control::ControlLimits,
559 wanted: crate::control::ControlLimits,
560) -> String {
561 let display = crate::control::limit_display;
562 let changed: Vec<String> = [
563 ("budget", stored.budget, wanted.budget),
564 ("line limit", stored.line_limit, wanted.line_limit),
565 ]
566 .into_iter()
567 .filter(|(_, stored, wanted)| stored != wanted)
568 .map(|(name, stored, wanted)| {
569 format!("{name} {}, where this request asks for {}", display(stored), display(wanted))
570 })
571 .collect();
572 changed.join(", and ")
573}
574
575pub(crate) fn execute(
589 plan: &Plan,
590 basis: &query::Basis,
591 collect_scan_diagnostics: bool,
592 progress: Option<&Progress>,
593) -> Result<(std::sync::Arc<Index>, OpenReport, PendingSave, Option<scan::ScanDiagnostics>)> {
594 let scan_config =
595 ScanConfig { progress: progress.cloned(), ..basis.scope.scan_config(plan.delivery()) };
596 let analysis_request = content::AnalysisRequest {
597 profile: basis.content,
598 workers: plan.delivery.workers.analysis,
599 };
600 let delivery = plan.delivery();
601 let root = &basis.root;
602 let root = root.canonicalize().map_err(|e| Error::io(root, e))?;
603 scan_config.validate()?;
608 let canonical_basis = query::Basis { root: root.clone(), ..basis.clone() };
609 let mut loaded = None;
613 let mut stored: Option<(PathBuf, SnapshotIdentity)> = None;
614 if let (Load::Snapshot, Some(cache_path)) = (plan.load(), &delivery.cache_path) {
615 if let Some(progress) = progress {
616 progress.enter(ProgressPhase::Loading);
617 }
618 match snapshot::load_serving(
619 cache_path,
620 scan_config.types_shared(),
621 scan_config.snapshot_identity(),
622 )? {
623 snapshot::LoadOutcome::Served { index, stored: identity } => {
624 stored = Some((index.root_path().to_path_buf(), identity));
625 loaded = Some(index);
626 }
627 snapshot::LoadOutcome::Refused { identity, root: stored_root } => {
628 stored = Some((stored_root, identity));
629 }
630 snapshot::LoadOutcome::Absent => {}
631 }
632 }
633 let cache_only = plan.verify() == Verify::None;
634 let content_cache = match (&mut loaded, cache_only) {
640 (Some(index), true) => {
641 index.mark_unverified();
642 Some(load_content(index, basis, delivery)?)
643 }
644 _ => None,
645 };
646 let admission = {
651 let header = stored.as_ref().map(|(stored_root, identity)| StoreHeader {
652 root: stored_root,
653 snapshot: *identity,
654 content: loaded
655 .as_ref()
656 .and_then(Index::content)
657 .and_then(|content| content.identity()),
658 content_complete: content_cache
659 .as_ref()
660 .is_some_and(|cache| cache.usable && cache.hits == cache.candidates),
661 });
662 plan.admit(header.as_ref(), &canonical_basis)
663 };
664
665 if cache_only {
666 let relation = match admission {
667 Admission::Serve(relation) => relation,
668 Admission::NoLocation => {
669 return Err(Error::Snapshot(
670 "no cache location is configured; configure a cache location before requesting cache-only access".into(),
671 ));
672 }
673 Admission::WrongRoot => {
674 return Err(Error::Snapshot(
675 "the configured snapshot belongs to a different root; choose this root's cache location or use auto to replace the snapshot for this root".into(),
676 ));
677 }
678 Admission::Missing => {
679 return Err(Error::Snapshot(unusable_snapshot_message(None, &scan_config)));
680 }
681 Admission::WrongScope => {
682 return Err(Error::Snapshot(unusable_snapshot_message(
683 stored.map(|(_, identity)| identity),
684 &scan_config,
685 )));
686 }
687 Admission::IncompleteContent => {
688 return Err(Error::Snapshot(
689 "no complete usable content sidecar for this root and analysis profile".into(),
690 ));
691 }
692 };
693 let index = loaded.expect("a served admission is of the loaded snapshot");
694 let content_cache =
695 content_cache.expect("the sidecar was loaded beside the served snapshot");
696 return Ok((
697 std::sync::Arc::new(index),
698 OpenReport {
699 path_taken: OpenPath::CacheOnly,
700 scan: ScanReport::default(),
701 analysis: None,
702 content_cache,
703 projected: relation == Serves::ProjectControlsOff,
704 },
705 PendingSave::none(),
706 None,
707 ));
708 }
709
710 if let Admission::Serve(relation) = admission {
711 let mut index = loaded.expect("a served admission is of the loaded snapshot");
712 let projected = relation == Serves::ProjectControlsOff;
713 let reconciled = scan::reconcile(&mut index, &scan_config, &mut |_| {})?;
714 let scan_report = reconciled.scan;
715 index.establish_baseline();
716 let content_cache = load_content(&mut index, basis, delivery)?;
717 let analysis = basis
718 .content
719 .is_enabled()
720 .then(|| content::analyze_index_observed(&mut index, analysis_request, progress));
721 if reconciled.apply.mutated() {
726 index.set_persistence_owed(true);
727 }
728 let facts = run_facts(
729 &index,
730 basis,
731 delivery,
732 index.persistence_owed(),
733 analysis.as_ref().is_some_and(|report| report.applied > 0) || content_cache.stale > 0,
734 projected,
735 || stored_entries(delivery, &root),
736 );
737 let index = std::sync::Arc::new(index);
740 let pending = spawn_save(&index, &plan.delivery, plan.writes(facts), progress);
741 return Ok((
742 index,
743 OpenReport {
744 path_taken: OpenPath::WarmRevalidate,
745 scan: scan_report,
746 analysis,
747 content_cache,
748 projected,
749 },
750 pending,
751 None,
752 ));
753 }
754
755 let (mut index, scan_report, scan_diagnostics) = if collect_scan_diagnostics {
756 let (index, report, diagnostics) =
757 scan::scan_into_index_with_diagnostics(&root, &scan_config)?;
758 (index, report, Some(diagnostics))
759 } else {
760 let (index, report) = scan::scan_into_index(&root, &scan_config)?;
761 (index, report, None)
762 };
763 let content_cache = load_content(&mut index, basis, delivery)?;
764 let analysis = basis
765 .content
766 .is_enabled()
767 .then(|| content::analyze_index_observed(&mut index, analysis_request, progress));
768 let facts =
769 run_facts(&index, basis, delivery, true, true, false, || stored_entries(delivery, &root));
770 let index = std::sync::Arc::new(index);
771 let pending = spawn_save(&index, &plan.delivery, plan.writes(facts), progress);
772 Ok((
773 index,
774 OpenReport {
775 path_taken: OpenPath::ColdScan,
776 scan: scan_report,
777 analysis,
778 content_cache,
779 projected: false,
780 },
781 pending,
782 scan_diagnostics,
783 ))
784}
785
786fn stored_entries(delivery: &query::Delivery, root: &Path) -> Option<EntryTierIdentity> {
791 delivery
792 .cache_path
793 .as_ref()
794 .and_then(|path| snapshot::read_header(path).ok().flatten())
795 .filter(|stored| stored.root == root)
796 .map(|stored| stored.identity.entries)
797}
798
799fn run_facts(
800 index: &Index,
801 basis: &query::Basis,
802 delivery: &query::Delivery,
803 entries_changed: bool,
804 content_changed: bool,
805 projected: bool,
806 stored_entries: impl FnOnce() -> Option<EntryTierIdentity>,
807) -> RunFacts {
808 let entries_verified = stored_state::entries_writable(index);
809 let paired_entries = delivery.cache.writes()
810 && !entries_verified
811 && stored_state::content_tier_writable(index, stored_entries);
812 RunFacts {
813 entries_verified,
814 entries_changed,
815 content_changed,
816 content_requested: basis.content.is_enabled(),
817 projected,
818 paired_entries,
819 }
820}
821
822#[cfg(feature = "watch")]
824pub(crate) fn persist_index(index: &Index, plan: &Plan) -> Result<bool> {
825 persist_index_changes(index, plan, true, true)
826}
827
828fn persist_index_changes(
829 index: &Index,
830 plan: &Plan,
831 entries_changed: bool,
832 content_changed: bool,
833) -> Result<bool> {
834 let basis = query::Basis::held_by(index);
835 let delivery = plan.delivery();
836 if !delivery.cache.writes() || delivery.cache_path.is_none() {
837 return Ok(false);
838 }
839 let stored = delivery.cache_path.as_deref().map(snapshot::read_header).transpose()?.flatten();
840 let admitted = query::Basis { root: index.root_path().to_path_buf(), ..plan.basis().clone() };
844 let header = stored.as_ref().map(|info| StoreHeader {
845 root: &info.root,
846 snapshot: info.identity,
847 content: None,
848 content_complete: false,
849 });
850 let relation = match plan.admit(header.as_ref(), &admitted) {
851 Admission::Serve(relation) => relation,
852 Admission::NoLocation
854 | Admission::Missing
855 | Admission::WrongRoot
856 | Admission::WrongScope
857 | Admission::IncompleteContent => Serves::Refuse,
858 };
859 let projected = relation == Serves::ProjectControlsOff;
860 let writes = plan.writes(run_facts(
861 index,
862 &basis,
863 delivery,
864 entries_changed || relation == Serves::Refuse,
865 content_changed,
866 projected,
867 || {
868 stored
869 .as_ref()
870 .filter(|info| info.root == index.root_path())
871 .map(|info| info.identity.entries)
872 },
873 ));
874 let Some(path) = delivery.cache_path.as_ref() else {
875 return Ok(false);
876 };
877 if writes.metadata {
878 snapshot::save(index, path)?;
879 }
880 if writes.content {
881 content::save_content_cache(index, &content::content_cache_path(path))?;
882 }
883 Ok(writes.metadata)
884}
885
886fn load_content(
887 index: &mut Index,
888 basis: &query::Basis,
889 delivery: &query::Delivery,
890) -> Result<content::ContentCacheLoad> {
891 let (true, Some(snapshot_path)) = (delivery.cache.reads(), delivery.cache_path.as_deref())
892 else {
893 return Ok(content::ContentCacheLoad::default());
894 };
895 let wanted = index.content_identity(basis.content);
896 content::load_content_cache(index, &wanted, &content::content_cache_path(snapshot_path))
897}
898
899fn spawn_save(
908 index: &std::sync::Arc<Index>,
909 delivery: &query::Delivery,
910 writes: SaveTargets,
911 progress: Option<&Progress>,
912) -> PendingSave {
913 let Some(cache_path) = delivery.cache_path.clone().filter(|_| !writes.none()) else {
914 return PendingSave::none();
915 };
916 if let Some(progress) = progress {
920 progress.enter(ProgressPhase::Saving);
921 }
922
923 let snapshot_source = std::sync::Arc::clone(index);
930 let mut workers = Vec::with_capacity(2);
931 if writes.metadata {
932 let metadata_source = std::sync::Arc::clone(&snapshot_source);
933 let metadata_path = cache_path.clone();
934 if let Ok(worker) =
935 std::thread::Builder::new().name("fdu-snapshot".to_string()).spawn(move || {
936 let _counter_guard = counters::thread_flush_guard();
937 snapshot::save(&metadata_source, &metadata_path)
938 })
939 {
940 workers.push(("metadata", worker));
941 }
942 }
943 if writes.content {
944 let content_path = content::content_cache_path(&cache_path);
945 if let Ok(worker) =
946 std::thread::Builder::new().name("fdu-content-cache".to_string()).spawn(move || {
947 let _counter_guard = counters::thread_flush_guard();
948 content::save_content_cache(&snapshot_source, &content_path)
949 })
950 {
951 workers.push(("content", worker));
952 }
953 }
954 PendingSave { workers }
957}
958
959pub fn default_cache_path(root: &Path) -> Option<PathBuf> {
965 let canonical = root.canonicalize().ok()?;
966 let mut hash = 0xcbf2_9ce4_8422_2325_u64;
967 for byte in canonical.as_os_str().as_encoded_bytes() {
968 hash ^= u64::from(*byte);
969 hash = hash.wrapping_mul(0x1000_0000_01b3);
970 }
971 Some(user_cache_dir()?.join("fdu").join(cache::snapshot_file_name(hash)))
972}
973
974fn user_cache_dir() -> Option<PathBuf> {
975 if let Some(xdg) = nonempty_env("XDG_CACHE_HOME") {
976 return Some(PathBuf::from(xdg));
977 }
978 platform_cache_dir()
979}
980
981fn nonempty_env(name: &str) -> Option<OsString> {
982 std::env::var_os(name).filter(|value| !value.is_empty())
983}
984
985#[cfg(target_os = "windows")]
986fn platform_cache_dir() -> Option<PathBuf> {
987 windows_cache_dir(
988 nonempty_env("LOCALAPPDATA"),
989 nonempty_env("USERPROFILE"),
990 nonempty_env("HOME"),
991 )
992}
993
994#[cfg(target_os = "windows")]
995fn windows_cache_dir(
996 local_app_data: Option<OsString>,
997 user_profile: Option<OsString>,
998 home: Option<OsString>,
999) -> Option<PathBuf> {
1000 local_app_data
1001 .map(PathBuf::from)
1002 .or_else(|| user_profile.map(|path| PathBuf::from(path).join("AppData").join("Local")))
1003 .or_else(|| home.map(|path| PathBuf::from(path).join(".cache")))
1004}
1005
1006#[cfg(target_os = "macos")]
1007fn platform_cache_dir() -> Option<PathBuf> {
1008 Some(PathBuf::from(nonempty_env("HOME")?).join("Library").join("Caches"))
1009}
1010
1011#[cfg(not(any(target_os = "windows", target_os = "macos")))]
1012fn platform_cache_dir() -> Option<PathBuf> {
1013 Some(PathBuf::from(nonempty_env("HOME")?).join(".cache"))
1014}
1015
1016#[cfg(test)]
1017mod tests {
1018 use super::*;
1019 use std::fs;
1020
1021 fn write_file(path: &Path, contents: &[u8]) {
1022 if let Some(parent) = path.parent() {
1023 fs::create_dir_all(parent).expect("create parent");
1024 }
1025 fs::write(path, contents).expect("write");
1026 }
1027
1028 fn controls_config(
1029 policy: CachePolicy,
1030 snapshot_path: PathBuf,
1031 read_controls: bool,
1032 ) -> OpenFixture {
1033 OpenFixture {
1034 scan: ScanConfig { read_controls, ..ScanConfig::default() },
1035 cache_path: Some(snapshot_path),
1036 policy,
1037 ..OpenFixture::default()
1038 }
1039 }
1040
1041 fn seed_controls_snapshot(root: &Path, snapshot_path: PathBuf) {
1042 let seed = controls_config(CachePolicy::Auto, snapshot_path, true);
1043 let (index, report) = open_fixture(root, &seed).expect("seed controls-on snapshot");
1044 assert_eq!(report.path_taken, OpenPath::ColdScan);
1045 assert!(
1046 !index.controls().expect("control state observed").is_empty(),
1047 "the fixture must retain a control source"
1048 );
1049 }
1050
1051 #[test]
1056 fn a_default_open_answers_ignore_questions_and_an_opt_out_refuses_them() {
1057 let root = tempfile::tempdir().expect("tempdir");
1058 write_file(&root.path().join(".gitignore"), b"*.log\n");
1059 write_file(&root.path().join("debug.log"), b"ignored");
1060 write_file(&root.path().join("keep.rs"), b"kept");
1061 let uncached = OpenFixture { policy: CachePolicy::Off, ..OpenFixture::default() };
1062 let opted_out = OpenFixture {
1063 scan: ScanConfig { read_controls: false, ..ScanConfig::default() },
1064 ..uncached.clone()
1065 };
1066
1067 let (index, report) = open_fixture(root.path(), &uncached).expect("default open");
1068 assert!(report.is_complete(), "{:?}", report.errors());
1069 assert!(index.observes_controls());
1070 assert_eq!(index.is_ignored(Path::new("debug.log")).ok(), Some(Some(true)));
1071 assert_eq!(index.is_ignored(Path::new("keep.rs")).ok(), Some(Some(false)));
1072 assert_eq!(index.is_ignored(Path::new("absent")).ok(), Some(None));
1073 assert!(
1074 index
1075 .controls()
1076 .is_ok_and(|controls| controls.source_is(Path::new(".gitignore"), b"*.log\n"))
1077 );
1078 assert_eq!(index.partition_total().expect("control state observed").unignored.files, 2);
1079
1080 let mut oversized = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
1081 oversized.extend_from_slice(b"\n*.log\n");
1082 write_file(&root.path().join(".gitignore"), &oversized);
1083 let (index, report) =
1084 open_fixture(root.path(), &uncached).expect("a refused control ends nothing");
1085 assert!(report.is_complete(), "{:?}", report.errors());
1086 let crate::control::ControlCoverage::Observed(coverage) = index.control_coverage() else {
1087 panic!("a default open reads the control file the opt-out skips");
1088 };
1089 assert_eq!((coverage.applied, coverage.refused), (0, 1));
1090
1091 let (index, report) = open_fixture(root.path(), &opted_out)
1092 .expect("an opted-out open reads no control line, however long");
1093 assert!(report.is_complete(), "{:?}", report.errors());
1094 assert!(!index.observes_controls());
1095 for path in ["debug.log", "keep.rs", "absent"] {
1096 assert!(
1097 matches!(index.is_ignored(Path::new(path)), Err(Error::ControlStateNotObserved)),
1098 "{path} must not be called unignored by an index that read no rule"
1099 );
1100 }
1101 assert!(matches!(index.controls(), Err(Error::ControlStateNotObserved)));
1102 assert!(matches!(index.partition_total(), Err(Error::ControlStateNotObserved)));
1103 assert_eq!(index.total().files, 3);
1104 }
1105
1106 #[test]
1109 fn a_snapshot_serves_only_the_control_limits_it_was_taken_under() {
1110 let root = tempfile::tempdir().expect("tempdir");
1111 let cache = tempfile::tempdir().expect("cache dir");
1112 let snapshot_path = cache.path().join("snap.fdu");
1113 let mut long_line = b"*.log\n".to_vec();
1114 long_line.extend(std::iter::repeat_n(b'x', crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1));
1115 write_file(&root.path().join(".gitignore"), &long_line);
1116 write_file(&root.path().join("debug.log"), b"ignored");
1117 let default = controls_config(CachePolicy::Auto, snapshot_path.clone(), true);
1118 let lifted_limits = crate::control::ControlLimits {
1119 line_limit: None,
1120 ..crate::control::ControlLimits::default()
1121 };
1122 let lifted = OpenFixture {
1123 scan: ScanConfig { control_limits: lifted_limits, ..default.scan.clone() },
1124 ..controls_config(CachePolicy::Auto, snapshot_path, true)
1125 };
1126 let refused = |index: &Index| match index.control_coverage() {
1127 crate::control::ControlCoverage::Observed(coverage) => coverage.refused,
1128 crate::control::ControlCoverage::NotObserved => panic!("observed"),
1129 };
1130
1131 let (index, report) = open_fixture(root.path(), &default).expect("default limits");
1132 assert_eq!((report.path_taken, refused(&index)), (OpenPath::ColdScan, 1));
1133 let (index, report) = open_fixture(root.path(), &default).expect("default limits again");
1134 assert_eq!((report.path_taken, refused(&index)), (OpenPath::WarmRevalidate, 1));
1135
1136 let (index, report) = open_fixture(root.path(), &lifted).expect("lifted line limit");
1137 assert_eq!((report.path_taken, refused(&index)), (OpenPath::ColdScan, 0));
1138 assert_eq!(index.is_ignored(Path::new("debug.log")).ok(), Some(Some(true)));
1139 let (index, report) = open_fixture(root.path(), &lifted).expect("lifted line limit again");
1140 assert_eq!((report.path_taken, refused(&index)), (OpenPath::WarmRevalidate, 0));
1141
1142 let (_, report) = open_fixture(root.path(), &default).expect("back to the default");
1143 assert_eq!(report.path_taken, OpenPath::ColdScan);
1144 }
1145
1146 #[test]
1147 fn controls_on_snapshot_projects_to_controls_off_auto_open_without_replacing_it() {
1148 let root = tempfile::tempdir().expect("tempdir");
1149 let cache = tempfile::tempdir().expect("cache dir");
1150 let snapshot_path = cache.path().join("snap.fdu");
1151 write_file(&root.path().join(".gitignore"), b"ignored.log\n");
1152 write_file(&root.path().join("ignored.log"), b"ignored");
1153 seed_controls_snapshot(root.path(), snapshot_path.clone());
1154 let stronger = fs::read(&snapshot_path).expect("stronger snapshot");
1155 write_file(&root.path().join("new.txt"), b"new");
1156
1157 let controls_off = controls_config(CachePolicy::Auto, snapshot_path.clone(), false);
1158 let (index, report) =
1159 open_fixture(root.path(), &controls_off).expect("projected warm open");
1160
1161 assert_eq!(report.path_taken, OpenPath::WarmRevalidate);
1162 assert!(report.projected);
1163 assert_eq!(index.scope(), controls_off.scan.scope());
1164 assert!(matches!(index.controls(), Err(Error::ControlStateNotObserved)));
1165 assert!(matches!(index.path_state(Path::new("new.txt")), PathState::Present { .. }));
1166 assert_eq!(fs::read(snapshot_path).expect("snapshot retained"), stronger);
1167 }
1168
1169 #[test]
1175 fn a_cache_only_open_after_a_limit_change_names_the_limits_that_differ() {
1176 let root = tempfile::tempdir().expect("tempdir");
1177 let cache = tempfile::tempdir().expect("cache dir");
1178 let snapshot_path = cache.path().join("snap.fdu");
1179 write_file(&root.path().join(".gitignore"), b"ignored.log\n");
1180 write_file(&root.path().join("ignored.log"), b"ignored");
1181 seed_controls_snapshot(root.path(), snapshot_path.clone());
1182
1183 let lifted = crate::control::ControlLimits {
1184 line_limit: None,
1185 ..crate::control::ControlLimits::default()
1186 };
1187 let mut wanted = controls_config(CachePolicy::Only, snapshot_path, true);
1188 wanted.scan.control_limits = lifted;
1189 let Err(Error::Snapshot(message)) = open_fixture(root.path(), &wanted) else {
1190 panic!("a snapshot taken under other limits must not serve a cache-only open");
1191 };
1192 assert!(
1193 message.contains("line limit 16 KiB, where this request asks for all"),
1194 "names the limit that differs and both values: {message}"
1195 );
1196 assert!(!message.contains("budget"), "the budget is unchanged: {message}");
1197 assert!(!message.contains("different scan scope"), "says which scope differs: {message}");
1198 assert!(
1199 message.contains("repeat the request with the snapshot's limits"),
1200 "names the remedy: {message}"
1201 );
1202 assert!(message.contains("`auto`"), "names the other remedy: {message}");
1203 }
1204
1205 #[test]
1206 fn controls_on_snapshot_projects_to_controls_off_cache_only_open() {
1207 let root = tempfile::tempdir().expect("tempdir");
1208 let cache = tempfile::tempdir().expect("cache dir");
1209 let snapshot_path = cache.path().join("snap.fdu");
1210 write_file(&root.path().join(".gitignore"), b"ignored.log\n");
1211 write_file(&root.path().join("ignored.log"), b"ignored");
1212 seed_controls_snapshot(root.path(), snapshot_path.clone());
1213
1214 let controls_off = controls_config(CachePolicy::Only, snapshot_path, false);
1215 let (index, report) =
1216 open_fixture(root.path(), &controls_off).expect("projected cache-only open");
1217 assert_eq!(report.path_taken, OpenPath::CacheOnly);
1218 assert!(report.projected);
1219 assert_eq!(index.scope(), controls_off.scan.scope());
1220 assert!(matches!(index.controls(), Err(Error::ControlStateNotObserved)));
1221 }
1222
1223 #[test]
1224 fn cache_only_names_foreign_type_rules_after_validating_the_snapshot_and_root() {
1225 let root = tempfile::tempdir().expect("root");
1226 let other_root = tempfile::tempdir().expect("other root");
1227 let cache = tempfile::tempdir().expect("cache");
1228 let snapshot_path = cache.path().join("snap.fdu");
1229 write_file(&root.path().join("main.rs"), b"fn main() {}\n");
1230 let custom_types = std::sync::Arc::new(
1231 classify::TypeRegistry::from_manifest(
1232 "[[kind]]\nid = \"notes\"\nfamily = \"prose\"\nextensions = [\"rs\"]\n",
1233 )
1234 .expect("custom rules"),
1235 );
1236 let custom = OpenFixture {
1237 scan: ScanConfig::default().with_types(custom_types.clone()),
1238 cache_path: Some(snapshot_path.clone()),
1239 policy: CachePolicy::Auto,
1240 ..OpenFixture::default()
1241 };
1242 open_fixture(root.path(), &custom).expect("write custom snapshot");
1243
1244 let default_only = OpenFixture {
1245 cache_path: Some(snapshot_path.clone()),
1246 policy: CachePolicy::Only,
1247 ..OpenFixture::default()
1248 };
1249 let Err(Error::Snapshot(message)) = open_fixture(root.path(), &default_only) else {
1250 panic!("foreign type rules must not serve cache-only");
1251 };
1252 assert!(message.contains("different file type rules"), "{message}");
1253 assert!(message.contains("type registry") && message.contains("`auto`"), "{message}");
1254 assert!(
1255 snapshot::load(&snapshot_path).expect("direct default-registry load").is_none(),
1256 "a direct load cannot attach the compiled registry to foreign rules"
1257 );
1258 let (_, report) =
1259 open_fixture(root.path(), &OpenFixture { policy: CachePolicy::Only, ..custom.clone() })
1260 .expect("matching registry can use the snapshot");
1261 assert_eq!(report.path_taken, OpenPath::CacheOnly);
1262
1263 let Err(Error::Snapshot(message)) = open_fixture(other_root.path(), &default_only) else {
1264 panic!("a foreign root and rules must not serve cache-only");
1265 };
1266 assert!(message.contains("different root"), "root refusal takes precedence: {message}");
1267 assert!(!message.contains("type rules"), "wrong-root identity must not leak: {message}");
1268
1269 let mut corrupt = fs::read(&snapshot_path).expect("snapshot bytes");
1270 let middle = corrupt.len() / 2;
1271 corrupt[middle] ^= 1;
1272 fs::write(&snapshot_path, corrupt).expect("corrupt payload without updating checksum");
1273 let Err(Error::Snapshot(message)) = open_fixture(root.path(), &default_only) else {
1274 panic!("corrupt snapshot must not yield a type-rules refusal");
1275 };
1276 assert!(!message.contains("type rules"), "corruption is an absent snapshot: {message}");
1277 }
1278
1279 #[test]
1287 fn supplied_type_rules_change_the_answer_and_invalidate_the_snapshot() {
1288 let dir = tempfile::tempdir().expect("tempdir");
1289 let cache = tempfile::tempdir().expect("cache dir");
1290 let snapshot_path = cache.path().join("snap.fdu");
1291 write_file(&dir.path().join("main.rs"), b"fn main() {}\n");
1292
1293 let default_config = OpenFixture {
1294 cache_path: Some(snapshot_path.clone()),
1295 analysis: content::AnalysisRequest {
1296 profile: content::AnalysisSet::NONE.with_lines(),
1297 ..content::AnalysisRequest::default()
1298 },
1299 ..OpenFixture::default()
1300 };
1301 let (index, _) = open_fixture(dir.path(), &default_config).expect("default open");
1302 assert_eq!(index.classify(Path::new("main.rs")).file_type.as_str(), "rust");
1303 drop(index);
1304
1305 let mine = std::sync::Arc::new(
1306 classify::TypeRegistry::from_manifest(
1307 "[[kind]]\nid = \"notes\"\nfamily = \"prose\"\nextensions = [\"rs\"]\n",
1308 )
1309 .expect("a minimal manifest"),
1310 );
1311 let custom_config = OpenFixture {
1312 scan: scan::ScanConfig::default().with_types(mine.clone()),
1313 ..default_config.clone()
1314 };
1315
1316 assert_ne!(
1317 custom_config.scan.scope(),
1318 default_config.scan.scope(),
1319 "different rules are a different scan scope"
1320 );
1321
1322 let (index, report) = open_fixture(dir.path(), &custom_config).expect("custom open");
1323 assert_eq!(
1324 report.path_taken,
1325 OpenPath::ColdScan,
1326 "the snapshot was written under other rules and must not be reused"
1327 );
1328 assert_eq!(index.classify(Path::new("main.rs")).file_type.as_str(), "notes");
1329 assert_eq!(index.types().fingerprint(), mine.fingerprint());
1330 let content = index
1331 .content()
1332 .and_then(|content| content.file(Path::new("main.rs")))
1333 .expect("custom analysis record");
1334 assert_eq!(content.detection.file_type.as_str(), "notes");
1335 assert_eq!(
1336 index
1337 .content()
1338 .and_then(content::ContentIndex::provenance)
1339 .expect("content provenance")
1340 .type_rules_fingerprint,
1341 mine.fingerprint()
1342 );
1343
1344 let (_, report) = open_fixture(dir.path(), &custom_config).expect("second custom open");
1346 assert_eq!(report.path_taken, OpenPath::WarmRevalidate, "same rules, same snapshot");
1347 assert_eq!(report.content_cache.hits, 1, "the matching sidecar is reusable");
1348 assert_eq!(report.analysis.expect("analysis report").candidates, 0);
1349
1350 assert!(
1351 snapshot::load(&snapshot_path).expect("default-registry load").is_none(),
1352 "a direct default-registry load must reject a custom-registry snapshot"
1353 );
1354 let loaded = snapshot::load_with_types(&snapshot_path, mine)
1355 .expect("custom-registry load")
1356 .expect("the matching custom registry makes the snapshot usable");
1357 assert_eq!(loaded.classify(Path::new("main.rs")).file_type.as_str(), "notes");
1358 }
1359
1360 #[test]
1362 fn each_cache_policy_reads_scans_and_writes_as_documented() {
1363 for (policy, expect_write) in [
1364 (CachePolicy::Auto, true),
1365 (CachePolicy::Refresh, true),
1366 (CachePolicy::ReadOnly, false),
1367 (CachePolicy::Off, false),
1368 ] {
1369 let dir = tempfile::tempdir().expect("tempdir");
1370 let cache = tempfile::tempdir().expect("cache dir");
1371 let snapshot_path = cache.path().join("snap.fdu");
1372 write_file(&dir.path().join("a.txt"), b"hello");
1373
1374 let config = OpenFixture {
1375 cache_path: Some(snapshot_path.clone()),
1376 policy,
1377 ..OpenFixture::default()
1378 };
1379 let (index, report) = open_fixture(dir.path(), &config).expect("open");
1380
1381 assert_eq!(index.total().files, 1, "{policy:?} lost an entry");
1382 assert_eq!(report.path_taken, OpenPath::ColdScan, "{policy:?} without a snapshot");
1383 assert_eq!(
1384 snapshot_path.exists(),
1385 expect_write,
1386 "{policy:?} wrote a snapshot: {}",
1387 snapshot_path.exists()
1388 );
1389 }
1390 }
1391
1392 #[test]
1393 fn read_only_takes_the_warm_path_without_rewriting_the_snapshot() {
1394 let dir = tempfile::tempdir().expect("tempdir");
1395 let cache = tempfile::tempdir().expect("cache dir");
1396 let snapshot_path = cache.path().join("snap.fdu");
1397 write_file(&dir.path().join("a.txt"), b"hello");
1398
1399 let seed = OpenFixture {
1401 cache_path: Some(snapshot_path.clone()),
1402 policy: CachePolicy::Auto,
1403 ..OpenFixture::default()
1404 };
1405 open_fixture(dir.path(), &seed).expect("seed");
1406 let before = fs::metadata(&snapshot_path).expect("snapshot exists").len();
1407
1408 let read_only = OpenFixture { policy: CachePolicy::ReadOnly, ..seed };
1409 let (index, report) = open_fixture(dir.path(), &read_only).expect("warm open");
1410 assert_eq!(report.path_taken, OpenPath::WarmRevalidate);
1411 assert_eq!(index.total().files, 1);
1412 assert_eq!(fs::metadata(&snapshot_path).expect("still there").len(), before);
1413 }
1414
1415 #[test]
1421 fn an_unchanged_warm_open_leaves_the_snapshot_alone_and_a_changed_one_rewrites_it() {
1422 let dir = tempfile::tempdir().expect("tempdir");
1423 let cache = tempfile::tempdir().expect("cache dir");
1424 let snapshot_path = cache.path().join("snap.fdu");
1425 write_file(&dir.path().join("a.txt"), b"hello");
1426 write_file(&dir.path().join("sub/b.txt"), b"world");
1427
1428 let auto = OpenFixture {
1429 cache_path: Some(snapshot_path.clone()),
1430 policy: CachePolicy::Auto,
1431 ..OpenFixture::default()
1432 };
1433 open_fixture(dir.path(), &auto).expect("seed");
1434 let seeded = fs::read(&snapshot_path).expect("seeded snapshot");
1435
1436 let (index, report) = open_fixture(dir.path(), &auto).expect("warm open");
1437 assert_eq!(report.path_taken, OpenPath::WarmRevalidate);
1438 assert_eq!(index.total().files, 2);
1439 assert_eq!(
1440 fs::read(&snapshot_path).expect("snapshot still there"),
1441 seeded,
1442 "an unchanged warm open must not rewrite the snapshot"
1443 );
1444
1445 write_file(&dir.path().join("sub/c.txt"), b"new file");
1446 let (index, report) = open_fixture(dir.path(), &auto).expect("warm open after a change");
1447 assert_eq!(report.path_taken, OpenPath::WarmRevalidate);
1448 assert_eq!(index.total().files, 3);
1449 let after_add = fs::read(&snapshot_path).expect("rewritten snapshot");
1450 assert_ne!(after_add, seeded, "a warm open that found a new file must persist it");
1451
1452 fs::remove_file(dir.path().join("sub/c.txt")).expect("remove");
1453 let (index, _) = open_fixture(dir.path(), &auto).expect("warm open after a removal");
1454 assert_eq!(index.total().files, 2);
1455 assert_ne!(
1456 fs::read(&snapshot_path).expect("rewritten snapshot"),
1457 after_add,
1458 "a warm open that found a removal must persist it"
1459 );
1460
1461 let cache_only = OpenFixture { policy: CachePolicy::Only, ..auto };
1463 let (restored, _) = open_fixture(dir.path(), &cache_only).expect("cache-only open");
1464 assert_eq!(restored.total().files, 2);
1465 }
1466
1467 #[test]
1468 fn refresh_ignores_an_existing_snapshot_and_rewrites_it() {
1469 let dir = tempfile::tempdir().expect("tempdir");
1470 let cache = tempfile::tempdir().expect("cache dir");
1471 let snapshot_path = cache.path().join("snap.fdu");
1472 write_file(&dir.path().join("a.txt"), b"hello");
1473
1474 let auto = OpenFixture {
1475 cache_path: Some(snapshot_path.clone()),
1476 policy: CachePolicy::Auto,
1477 ..OpenFixture::default()
1478 };
1479 open_fixture(dir.path(), &auto).expect("seed");
1480
1481 let refresh = OpenFixture { policy: CachePolicy::Refresh, ..auto };
1484 let (_, report) = open_fixture(dir.path(), &refresh).expect("refresh open");
1485 assert_eq!(report.path_taken, OpenPath::ColdScan);
1486 assert!(snapshot_path.exists());
1487 }
1488
1489 #[test]
1490 fn cache_only_answers_from_the_snapshot_without_touching_the_tree() {
1491 let dir = tempfile::tempdir().expect("tempdir");
1492 let cache = tempfile::tempdir().expect("cache dir");
1493 let snapshot_path = cache.path().join("snap.fdu");
1494 write_file(&dir.path().join("a.txt"), b"hello");
1495
1496 let auto = OpenFixture {
1497 cache_path: Some(snapshot_path.clone()),
1498 policy: CachePolicy::Auto,
1499 ..OpenFixture::default()
1500 };
1501 open_fixture(dir.path(), &auto).expect("seed");
1502
1503 write_file(&dir.path().join("b.txt"), b"new file");
1506
1507 let only = OpenFixture { policy: CachePolicy::Only, ..auto };
1508 let (index, report) = open_fixture(dir.path(), &only).expect("cache-only open");
1509 assert_eq!(report.path_taken, OpenPath::CacheOnly);
1510 assert_eq!(index.total().files, 1, "the new file must not appear");
1511 assert_ne!(index.freshness(), Freshness::Fresh, "a stale answer must not claim currency");
1512 }
1513
1514 #[test]
1515 fn cache_only_fails_closed_when_no_snapshot_is_usable() {
1516 let dir = tempfile::tempdir().expect("tempdir");
1517 let cache = tempfile::tempdir().expect("cache dir");
1518 write_file(&dir.path().join("a.txt"), b"hello");
1519
1520 let only = OpenFixture {
1523 cache_path: Some(cache.path().join("absent.fdu")),
1524 policy: CachePolicy::Only,
1525 ..OpenFixture::default()
1526 };
1527 let Err(Error::Snapshot(message)) = open_fixture(dir.path(), &only) else {
1528 panic!("a cache-only open with no snapshot must fail");
1529 };
1530 assert!(message.contains("`auto`"), "names the remedy: {message}");
1533 }
1534
1535 #[test]
1536 fn content_sidecar_skips_unchanged_reads_and_serves_cache_only() {
1537 let dir = tempfile::tempdir().expect("tempdir");
1538 let cache = tempfile::tempdir().expect("cache dir");
1539 let snapshot_path = cache.path().join("snap.fdu");
1540 write_file(&dir.path().join("notes.md"), b"one two\n");
1541 let analysis = content::AnalysisRequest {
1542 profile: content::AnalysisSet::NONE.with_lines(),
1543 ..content::AnalysisRequest::default()
1544 };
1545 let auto = OpenFixture {
1546 cache_path: Some(snapshot_path.clone()),
1547 policy: CachePolicy::Auto,
1548 analysis,
1549 ..OpenFixture::default()
1550 };
1551
1552 let (first, first_report) = open_fixture(dir.path(), &auto).expect("cold analyzed open");
1553 assert_eq!(first_report.analysis.expect("analysis").lines.analyzed, 1);
1554 assert_eq!(
1555 first.content_rollup(Path::new("")).expect("content").total.lines.metrics.raw_words,
1556 2
1557 );
1558 assert!(content::content_cache_path(&snapshot_path).exists());
1559
1560 let (_, warm_report) = open_fixture(dir.path(), &auto).expect("warm analyzed open");
1561 assert_eq!(warm_report.content_cache.hits, 1);
1562 assert_eq!(warm_report.content_cache.bytes, 8);
1563 assert_eq!(warm_report.analysis.expect("analysis").candidates, 0);
1564
1565 fs::remove_file(dir.path().join("notes.md")).expect("remove source");
1566 let only = OpenFixture { policy: CachePolicy::Only, ..auto };
1567 let (cached, cached_report) = open_fixture(dir.path(), &only).expect("cache-only content");
1568 assert_eq!(cached_report.content_cache.hits, 1);
1569 assert_eq!(cached_report.content_cache.bytes, 8);
1570 assert_eq!(
1571 cached.content_rollup(Path::new("")).expect("content").total.lines.metrics.raw_words,
1572 2
1573 );
1574 }
1575
1576 #[test]
1577 fn cached_coverage_exclusions_remain_visible_without_making_the_run_partial() {
1578 let dir = tempfile::tempdir().expect("tempdir");
1579 let cache = tempfile::tempdir().expect("cache dir");
1580 let snapshot_path = cache.path().join("snap.fdu");
1581 write_file(&dir.path().join("invalid.txt"), b"valid prefix\xff");
1582 let auto = OpenFixture {
1583 cache_path: Some(snapshot_path),
1584 policy: CachePolicy::Auto,
1585 analysis: content::AnalysisRequest {
1586 profile: content::AnalysisSet::NONE.with_lines(),
1587 ..content::AnalysisRequest::default()
1588 },
1589 ..OpenFixture::default()
1590 };
1591
1592 let (_, cold_report) = open_fixture(dir.path(), &auto).expect("cold analyzed open");
1593 assert!(cold_report.is_complete());
1594 assert_eq!(cold_report.analysis.expect("analysis").lines.invalid_utf8, 1);
1595 assert!(cold_report.error_messages().is_empty());
1596
1597 let (_, warm_report) = open_fixture(dir.path(), &auto).expect("warm analyzed open");
1598 assert_eq!(warm_report.content_cache.hits, 1);
1599 assert_eq!(warm_report.content_cache.coverage_exclusions, 1);
1600 assert_eq!(warm_report.analysis.expect("analysis").candidates, 0);
1601 assert!(warm_report.is_complete());
1602 assert!(warm_report.error_messages().is_empty());
1603
1604 let only = OpenFixture { policy: CachePolicy::Only, ..auto };
1605 let (_, cached_report) = open_fixture(dir.path(), &only).expect("cache-only analyzed open");
1606 assert_eq!(cached_report.content_cache.coverage_exclusions, 1);
1607 assert!(cached_report.is_complete());
1608 assert!(cached_report.error_messages().is_empty());
1609 }
1610
1611 #[test]
1612 fn cache_only_analysis_fails_closed_without_its_sidecar() {
1613 let dir = tempfile::tempdir().expect("tempdir");
1614 let cache = tempfile::tempdir().expect("cache dir");
1615 let snapshot_path = cache.path().join("snap.fdu");
1616 write_file(&dir.path().join("notes.md"), b"one two\n");
1617 let metadata_only = OpenFixture {
1618 cache_path: Some(snapshot_path),
1619 policy: CachePolicy::Auto,
1620 ..OpenFixture::default()
1621 };
1622 open_fixture(dir.path(), &metadata_only).expect("seed metadata");
1623
1624 let only = OpenFixture {
1625 policy: CachePolicy::Only,
1626 analysis: content::AnalysisRequest {
1627 profile: content::AnalysisSet::NONE.with_lines(),
1628 ..content::AnalysisRequest::default()
1629 },
1630 ..metadata_only
1631 };
1632 assert!(matches!(open_fixture(dir.path(), &only), Err(Error::Snapshot(_))));
1633
1634 let with_set = |policy, profile| OpenFixture {
1637 policy,
1638 analysis: content::AnalysisRequest { profile, ..content::AnalysisRequest::default() },
1639 ..only.clone()
1640 };
1641 let lines = content::AnalysisSet::NONE.with_lines();
1642 for (stored, wanted) in
1643 [(content::AnalysisSet::ALL, lines), (lines, content::AnalysisSet::ALL)]
1644 {
1645 open_fixture(dir.path(), &with_set(CachePolicy::Auto, stored))
1646 .expect("write a sidecar");
1647 let refused = open_fixture(dir.path(), &with_set(CachePolicy::Only, wanted));
1648 assert!(
1649 matches!(refused, Err(Error::Snapshot(_))),
1650 "a {stored:?} sidecar must not serve a cache-only {wanted:?} request"
1651 );
1652 }
1653
1654 open_fixture(dir.path(), &with_set(CachePolicy::Auto, lines))
1655 .expect("write the lines sidecar");
1656 let (cached, report) = open_fixture(dir.path(), &only).expect("restore the analyzed state");
1657 assert!(report.content_cache.usable);
1658 assert_eq!(report.content_cache.hits, 1, "one record per candidate is complete");
1659 assert_eq!(
1660 report.content_cache.candidates, report.content_cache.hits,
1661 "completeness uses the count restore already walked"
1662 );
1663 assert_eq!(cached.content_set(), lines);
1664 }
1665
1666 #[test]
1667 fn cache_only_analysis_fails_closed_when_the_sidecar_is_incomplete() {
1668 let dir = tempfile::tempdir().expect("tempdir");
1669 let cache = tempfile::tempdir().expect("cache dir");
1670 let snapshot_path = cache.path().join("snap.fdu");
1671 write_file(&dir.path().join("notes.md"), b"one two\n");
1672 let analysis = content::AnalysisRequest {
1673 profile: content::AnalysisSet::NONE.with_lines(),
1674 ..content::AnalysisRequest::default()
1675 };
1676 let auto = OpenFixture {
1677 cache_path: Some(snapshot_path),
1678 policy: CachePolicy::Auto,
1679 analysis,
1680 ..OpenFixture::default()
1681 };
1682 open_fixture(dir.path(), &auto).expect("seed one analyzed file");
1683
1684 write_file(&dir.path().join("extra.txt"), b"three\n");
1687 let metadata_only =
1688 OpenFixture { analysis: content::AnalysisRequest::default(), ..auto.clone() };
1689 open_fixture(dir.path(), &metadata_only).expect("widen the snapshot");
1690
1691 let only = OpenFixture { policy: CachePolicy::Only, ..auto };
1692 assert!(
1693 matches!(open_fixture(dir.path(), &only), Err(Error::Snapshot(_))),
1694 "a one-record sidecar must not serve a two-file cache-only analysis request"
1695 );
1696 }
1697
1698 #[cfg(unix)]
1699 #[test]
1700 fn cache_only_serves_checksummed_native_non_utf8_names() {
1701 use std::ffi::OsString;
1702 use std::os::unix::ffi::OsStringExt;
1703
1704 let dir = tempfile::tempdir().expect("tempdir");
1705 let cache = tempfile::tempdir().expect("cache dir");
1706 let snapshot_path = cache.path().join("snap.fdu");
1707 let root = dir.path().canonicalize().expect("canonical root");
1708 let native = PathBuf::from(OsString::from_vec(vec![b'n', 0x80]));
1709 let mut index = Index::new(&root);
1710 index.apply_ok(&Observation::new(vec![
1711 Op::Upsert {
1712 path: PathBuf::from("ok.txt"),
1713 kind: EntryKind::File,
1714 attrs: Attrs {
1715 size: 1,
1716 allocated: 512,
1717 mtime_ns: 1,
1718 ctime_ns: 1,
1719 inode: 1,
1720 dev: 1,
1721 },
1722 },
1723 Op::Upsert {
1724 path: native.clone(),
1725 kind: EntryKind::File,
1726 attrs: Attrs {
1727 size: 2,
1728 allocated: 512,
1729 mtime_ns: 2,
1730 ctime_ns: 2,
1731 inode: 2,
1732 dev: 1,
1733 },
1734 },
1735 ]));
1736 snapshot::save(&index, &snapshot_path).expect("save native names");
1737
1738 let only = OpenFixture {
1739 cache_path: Some(snapshot_path),
1740 policy: CachePolicy::Only,
1741 ..OpenFixture::default()
1742 };
1743 let (cached, report) = open_fixture(&root, &only).expect("cache-only native name");
1744 assert_eq!(cached.total().files, 2);
1745 assert!(cached.lookup(&native).is_some());
1746 assert!(report.is_complete());
1747 }
1748
1749 #[test]
1750 fn cache_only_empty_analysis_still_requires_a_usable_sidecar() {
1751 let dir = tempfile::tempdir().expect("tempdir");
1752 let cache = tempfile::tempdir().expect("cache dir");
1753 let snapshot_path = cache.path().join("snap.fdu");
1754 let metadata_only = OpenFixture {
1755 cache_path: Some(snapshot_path),
1756 policy: CachePolicy::Auto,
1757 ..OpenFixture::default()
1758 };
1759 open_fixture(dir.path(), &metadata_only).expect("seed empty metadata");
1760
1761 let only = OpenFixture {
1762 policy: CachePolicy::Only,
1763 analysis: content::AnalysisRequest {
1764 profile: content::AnalysisSet::NONE.with_lines(),
1765 ..content::AnalysisRequest::default()
1766 },
1767 ..metadata_only
1768 };
1769 assert!(matches!(open_fixture(dir.path(), &only), Err(Error::Snapshot(_))));
1770 }
1771
1772 #[test]
1773 fn a_snapshot_for_another_root_is_treated_as_absent() {
1774 let one = tempfile::tempdir().expect("tempdir");
1775 let two = tempfile::tempdir().expect("tempdir");
1776 let cache = tempfile::tempdir().expect("cache dir");
1777 let snapshot_path = cache.path().join("snap.fdu");
1778 write_file(&one.path().join("a.txt"), b"hello");
1779 write_file(&two.path().join("b.txt"), b"other tree");
1780
1781 let config = OpenFixture {
1782 cache_path: Some(snapshot_path.clone()),
1783 policy: CachePolicy::Auto,
1784 ..OpenFixture::default()
1785 };
1786 open_fixture(one.path(), &config).expect("seed from the first root");
1787
1788 let (index, report) = open_fixture(two.path(), &config).expect("second root");
1790 assert_eq!(report.path_taken, OpenPath::ColdScan);
1791 assert_eq!(index.total().files, 1);
1792 assert_eq!(index.root_path(), two.path().canonicalize().expect("canonical").as_path());
1793 }
1794
1795 #[test]
1796 fn open_without_a_cache_always_scans_cold() {
1797 let dir = tempfile::tempdir().expect("tempdir");
1798 write_file(&dir.path().join("a.txt"), b"hello");
1799
1800 let (index, report) = open_fixture(dir.path(), &OpenFixture::default()).expect("open");
1801 assert_eq!(report.path_taken, OpenPath::ColdScan);
1802 assert_eq!(index.total().files, 1);
1803 }
1804
1805 #[test]
1806 fn second_open_takes_the_warm_path_and_stays_correct() {
1807 let dir = tempfile::tempdir().expect("tempdir");
1808 let cache = tempfile::tempdir().expect("cache dir");
1809 write_file(&dir.path().join("a.txt"), b"hello");
1810 write_file(&dir.path().join("src/main.rs"), b"fn main() {}");
1811
1812 let config = OpenFixture {
1813 cache_path: Some(cache.path().join("snap.fdu")),
1814 policy: CachePolicy::Auto,
1815 ..OpenFixture::default()
1816 };
1817
1818 let (first, first_report) = open_fixture(dir.path(), &config).expect("cold open");
1819 assert_eq!(first_report.path_taken, OpenPath::ColdScan);
1820 assert_eq!(first.total().files, 2);
1821
1822 write_file(&dir.path().join("added.md"), b"new");
1824 fs::remove_file(dir.path().join("a.txt")).expect("remove");
1825
1826 let (second, second_report) = open_fixture(dir.path(), &config).expect("warm open");
1827 assert_eq!(second_report.path_taken, OpenPath::WarmRevalidate);
1828 assert_eq!(second.total().files, 2);
1829 assert!(second.lookup(Path::new("added.md")).is_some());
1830 assert!(second.lookup(Path::new("a.txt")).is_none());
1831 }
1832
1833 #[test]
1834 fn a_snapshot_from_another_root_is_ignored() {
1835 let a = tempfile::tempdir().expect("tempdir a");
1836 let b = tempfile::tempdir().expect("tempdir b");
1837 let cache = tempfile::tempdir().expect("cache dir");
1838 write_file(&a.path().join("only-in-a.txt"), b"x");
1839 write_file(&b.path().join("only-in-b.txt"), b"y");
1840
1841 let cache_path = cache.path().join("snap.fdu");
1842 let config = OpenFixture {
1843 cache_path: Some(cache_path),
1844 policy: CachePolicy::Auto,
1845 ..OpenFixture::default()
1846 };
1847
1848 open_fixture(a.path(), &config).expect("open a");
1849 let (index, report) = open_fixture(b.path(), &config).expect("open b");
1850
1851 assert_eq!(report.path_taken, OpenPath::ColdScan);
1852 assert!(index.lookup(Path::new("only-in-b.txt")).is_some());
1853 assert!(index.lookup(Path::new("only-in-a.txt")).is_none());
1854 }
1855
1856 #[test]
1857 fn snapshot_scope_mismatch_forces_a_cold_scan() {
1858 let dir = tempfile::tempdir().expect("tempdir");
1859 let cache = tempfile::tempdir().expect("cache dir");
1860 write_file(&dir.path().join("top.txt"), b"top");
1861 write_file(&dir.path().join("deep/nested.txt"), b"nested");
1862
1863 let cache_path = cache.path().join("snap.fdu");
1864 let full = OpenFixture {
1865 cache_path: Some(cache_path.clone()),
1866 policy: CachePolicy::Auto,
1867 ..OpenFixture::default()
1868 };
1869 open_fixture(dir.path(), &full).expect("full open");
1870
1871 let shallow = OpenFixture {
1872 scan: ScanConfig { max_depth: Some(1), ..ScanConfig::default() },
1873 cache_path: Some(cache_path),
1874 policy: CachePolicy::ReadOnly,
1875 analysis: content::AnalysisRequest::default(),
1876 };
1877 let (index, report) = open_fixture(dir.path(), &shallow).expect("shallow open");
1878
1879 assert_eq!(report.path_taken, OpenPath::ColdScan);
1880 assert!(index.lookup(Path::new("deep")).is_some());
1881 assert!(index.lookup(Path::new("deep/nested.txt")).is_none());
1882 }
1883
1884 #[test]
1885 fn admission_scope_mismatch_cannot_reinterpret_a_snapshot() {
1886 let dir = tempfile::tempdir().expect("tempdir");
1887 let cache = tempfile::tempdir().expect("cache dir");
1888 write_file(&dir.path().join(".hidden"), b"hidden");
1889 let cache_path = cache.path().join("snap.fdu");
1890 let seed = OpenFixture {
1891 cache_path: Some(cache_path.clone()),
1892 policy: CachePolicy::Auto,
1893 ..OpenFixture::default()
1894 };
1895 open_fixture(dir.path(), &seed).expect("seed snapshot");
1896
1897 let changed_scopes = [
1898 ScanConfig {
1899 hidden: Some(std::sync::Arc::new(
1900 HiddenPolicy::prune_hidden::<[&str; 0], &str>([]),
1901 )),
1902 ..ScanConfig::default()
1903 },
1904 ScanConfig { exclude_special: true, ..ScanConfig::default() },
1905 ];
1906 for scan in changed_scopes {
1907 let only = OpenFixture {
1908 scan,
1909 cache_path: Some(cache_path.clone()),
1910 policy: CachePolicy::Only,
1911 analysis: content::AnalysisRequest::default(),
1912 };
1913 assert!(matches!(open_fixture(dir.path(), &only), Err(Error::Snapshot(_))));
1914 }
1915 }
1916
1917 #[test]
1918 fn operational_batch_size_does_not_invalidate_a_snapshot() {
1919 let dir = tempfile::tempdir().expect("tempdir");
1920 let cache = tempfile::tempdir().expect("cache dir");
1921 write_file(&dir.path().join("a.txt"), b"a");
1922 let cache_path = cache.path().join("snap.fdu");
1923
1924 let first = OpenFixture {
1925 scan: ScanConfig { batch_size: 1, ..ScanConfig::default() },
1926 cache_path: Some(cache_path.clone()),
1927 policy: CachePolicy::Auto,
1928 analysis: content::AnalysisRequest::default(),
1929 };
1930 open_fixture(dir.path(), &first).expect("first open");
1931
1932 let second = OpenFixture {
1933 scan: ScanConfig { batch_size: 17, ..ScanConfig::default() },
1934 cache_path: Some(cache_path),
1935 policy: CachePolicy::ReadOnly,
1936 analysis: content::AnalysisRequest::default(),
1937 };
1938 let (_, report) = open_fixture(dir.path(), &second).expect("second open");
1939 assert_eq!(report.path_taken, OpenPath::WarmRevalidate);
1940 }
1941
1942 #[test]
1943 fn cache_paths_differ_per_root() {
1944 let a = tempfile::tempdir().expect("tempdir a");
1945 let b = tempfile::tempdir().expect("tempdir b");
1946 let (Some(pa), Some(pb)) = (default_cache_path(a.path()), default_cache_path(b.path()))
1947 else {
1948 return; };
1950 assert_ne!(pa, pb);
1951 assert_eq!(pa, default_cache_path(a.path()).expect("stable"));
1952 }
1953
1954 #[cfg(target_os = "windows")]
1955 #[test]
1956 fn windows_cache_discovery_prefers_native_locations() {
1957 let local = OsString::from(r"C:\Users\tester\AppData\Local");
1958 let profile = OsString::from(r"D:\Profile");
1959 let home = OsString::from(r"E:\Home");
1960
1961 assert_eq!(
1962 windows_cache_dir(Some(local.clone()), Some(profile.clone()), Some(home.clone())),
1963 Some(PathBuf::from(local))
1964 );
1965 assert_eq!(
1966 windows_cache_dir(None, Some(profile.clone()), Some(home.clone())),
1967 Some(PathBuf::from(profile).join("AppData").join("Local"))
1968 );
1969 assert_eq!(
1970 windows_cache_dir(None, None, Some(home.clone())),
1971 Some(PathBuf::from(home).join(".cache"))
1972 );
1973 assert_eq!(windows_cache_dir(None, None, None), None);
1974 }
1975}
1976
1977#[cfg(test)]
1978mod save_tests {
1979 use super::*;
1980 use std::fs;
1981
1982 fn write_file(path: &Path, contents: &[u8]) {
1983 if let Some(parent) = path.parent() {
1984 fs::create_dir_all(parent).expect("create parent");
1985 }
1986 fs::write(path, contents).expect("write");
1987 }
1988
1989 fn config(snapshot_path: &Path, policy: CachePolicy) -> OpenFixture {
1990 OpenFixture {
1991 cache_path: Some(snapshot_path.to_path_buf()),
1992 policy,
1993 ..OpenFixture::default()
1994 }
1995 }
1996
1997 #[test]
1998 fn a_pending_save_completes_when_joined() {
1999 let dir = tempfile::tempdir().expect("tempdir");
2000 let cache = tempfile::tempdir().expect("cache dir");
2001 let snapshot_path = cache.path().join("snap.fdu");
2002 write_file(&dir.path().join("a.txt"), b"hello");
2003
2004 let (_index, _report, pending) =
2005 open_fixture_with_pending_save(dir.path(), &config(&snapshot_path, CachePolicy::Auto))
2006 .expect("open");
2007 pending.join().expect("save succeeds");
2008 assert!(snapshot_path.exists(), "a joined save must have landed");
2009 }
2010
2011 #[test]
2012 fn a_dropped_save_still_lands() {
2013 let dir = tempfile::tempdir().expect("tempdir");
2016 let cache = tempfile::tempdir().expect("cache dir");
2017 let snapshot_path = cache.path().join("snap.fdu");
2018 write_file(&dir.path().join("a.txt"), b"hello");
2019
2020 {
2021 let (_index, _report, _pending) = open_fixture_with_pending_save(
2022 dir.path(),
2023 &config(&snapshot_path, CachePolicy::Auto),
2024 )
2025 .expect("open");
2026 }
2027 assert!(snapshot_path.exists());
2028 }
2029
2030 #[test]
2031 #[cfg(unix)]
2032 fn a_partial_scan_leaves_the_previous_snapshot_alone() {
2033 use std::os::unix::fs::PermissionsExt;
2036
2037 if !crate::test_support::require_permission_bits() {
2038 return;
2039 }
2040
2041 let dir = tempfile::tempdir().expect("tempdir");
2042 let cache = tempfile::tempdir().expect("cache dir");
2043 let snapshot_path = cache.path().join("snap.fdu");
2044 write_file(&dir.path().join("a.txt"), b"hello");
2045
2046 let settings = config(&snapshot_path, CachePolicy::Auto);
2047 open_fixture(dir.path(), &settings).expect("seed a complete snapshot");
2048 let complete_len = fs::metadata(&snapshot_path).expect("exists").len();
2049
2050 let denied = dir.path().join("denied");
2051 fs::create_dir(&denied).expect("create");
2052 write_file(&denied.join("hidden.txt"), b"hidden");
2053 fs::set_permissions(&denied, fs::Permissions::from_mode(0o000)).expect("deny");
2054
2055 let opened = open_fixture(dir.path(), &settings);
2056 fs::set_permissions(&denied, fs::Permissions::from_mode(0o700)).expect("restore");
2057 let (_index, report) = opened.expect("partial open still returns a result");
2058
2059 assert!(!report.is_complete(), "the scan should be partial");
2060 assert_eq!(
2061 fs::metadata(&snapshot_path).expect("still there").len(),
2062 complete_len,
2063 "a partial scan must not overwrite a complete snapshot"
2064 );
2065 }
2066
2067 #[test]
2072 #[cfg(unix)]
2073 fn a_partial_scan_writes_only_verified_content_for_the_stored_entry_tier() {
2074 use std::os::unix::fs::PermissionsExt;
2075
2076 if !crate::test_support::require_permission_bits() {
2077 return;
2078 }
2079
2080 let dir = tempfile::tempdir().expect("tempdir");
2081 let cache = tempfile::tempdir().expect("cache dir");
2082 let snapshot_path = cache.path().join("snap.fdu");
2083 let sidecar_path = content::content_cache_path(&snapshot_path);
2084 write_file(&dir.path().join("notes.md"), b"one two\n");
2085 write_file(&dir.path().join("locked/old.md"), b"three\n");
2086 let analysis = content::AnalysisRequest {
2087 profile: content::AnalysisSet::NONE.with_lines(),
2088 ..content::AnalysisRequest::default()
2089 };
2090 let auto = OpenFixture {
2091 cache_path: Some(snapshot_path.clone()),
2092 policy: CachePolicy::Auto,
2093 analysis,
2094 ..OpenFixture::default()
2095 };
2096 let (_, seeded) = open_fixture(dir.path(), &auto).expect("seed both tiers");
2097 assert!(seeded.is_complete());
2098 let snapshot_before = fs::read(&snapshot_path).expect("a snapshot");
2099
2100 write_file(&dir.path().join("notes.md"), b"one two three\n");
2102 let locked = dir.path().join("locked");
2103 let run = |config: &OpenFixture| {
2104 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("deny");
2105 let opened = open_fixture(dir.path(), config);
2106 fs::set_permissions(&locked, fs::Permissions::from_mode(0o700)).expect("restore");
2107 let (_, report) = opened.expect("a partial open still answers");
2108 assert!(!report.is_complete(), "the pass should be partial");
2109 };
2110
2111 let sidecar_before = fs::read(&sidecar_path).expect("a sidecar");
2113 let other_scope = OpenFixture {
2114 scan: ScanConfig { max_depth: Some(8), ..ScanConfig::default() },
2115 ..auto.clone()
2116 };
2117 run(&other_scope);
2118 assert_eq!(fs::read(&snapshot_path).expect("snapshot"), snapshot_before);
2119 assert_eq!(
2120 fs::read(&sidecar_path).expect("sidecar"),
2121 sidecar_before,
2122 "a partial run under another entry identity must not evict the paired sidecar"
2123 );
2124
2125 run(&auto);
2128 assert_eq!(
2129 fs::read(&snapshot_path).expect("snapshot"),
2130 snapshot_before,
2131 "a partial scan must not overwrite a complete snapshot"
2132 );
2133 assert_ne!(fs::read(&sidecar_path).expect("sidecar"), sidecar_before);
2134 let (mut fresh, _) =
2137 scan::scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
2138 let wanted = fresh.content_identity(analysis.profile);
2139 let loaded = content::load_content_cache(&mut fresh, &wanted, &sidecar_path).expect("load");
2140 assert_eq!((loaded.usable, loaded.hits, loaded.stale), (true, 1, 0), "{loaded:?}");
2141 let content = fresh.content().expect("content");
2142 assert!(content.file(Path::new("locked/old.md")).is_none());
2143 assert_eq!(
2144 content
2145 .file(Path::new("notes.md"))
2146 .expect("verified record")
2147 .lines
2148 .value()
2149 .expect("line metrics")
2150 .raw_words,
2151 3
2152 );
2153 }
2154
2155 #[test]
2158 #[cfg(unix)]
2159 fn a_warm_partial_pass_keeps_all_and_only_verified_content_records() {
2160 use std::os::unix::fs::PermissionsExt;
2161
2162 if !crate::test_support::require_permission_bits() {
2163 return;
2164 }
2165
2166 let dir = tempfile::tempdir().expect("tempdir");
2167 let cache = tempfile::tempdir().expect("cache dir");
2168 let snapshot_path = cache.path().join("snap.fdu");
2169 let sidecar_path = content::content_cache_path(&snapshot_path);
2170 for index in 0..7 {
2171 write_file(&dir.path().join(format!("f{index}.md")), b"alpha\nbeta\n");
2172 }
2173 write_file(&dir.path().join("locked/old.md"), b"three\n");
2174 let analysis = content::AnalysisRequest {
2175 profile: content::AnalysisSet::NONE.with_lines(),
2176 ..content::AnalysisRequest::default()
2177 };
2178 let auto = OpenFixture {
2179 cache_path: Some(snapshot_path.clone()),
2180 policy: CachePolicy::Auto,
2181 analysis,
2182 ..OpenFixture::default()
2183 };
2184
2185 let records_in_sidecar = |path: &Path| {
2186 let (mut fresh, _) = scan::scan_into_index(path, &ScanConfig::default()).expect("scan");
2187 let wanted = fresh.content_identity(analysis.profile);
2188 let loaded =
2189 content::load_content_cache(&mut fresh, &wanted, &sidecar_path).expect("load");
2190 assert!(loaded.usable, "the sidecar should still be readable");
2191 loaded.hits + loaded.stale
2192 };
2193
2194 let (_, seeded) = open_fixture(dir.path(), &auto).expect("seed both tiers");
2195 assert!(seeded.is_complete());
2196 assert_eq!(records_in_sidecar(dir.path()), 8, "one record per seeded file");
2197
2198 write_file(&dir.path().join("f0.md"), b"alpha\nbeta\ngamma\n");
2200 let locked = dir.path().join("locked");
2201 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("deny");
2202 let opened = open_fixture(dir.path(), &auto);
2203 fs::set_permissions(&locked, fs::Permissions::from_mode(0o700)).expect("restore");
2204 let (_, report) = opened.expect("a partial open still answers");
2205 assert!(!report.is_complete(), "the pass should be partial");
2206
2207 assert_eq!(
2208 records_in_sidecar(dir.path()),
2209 7,
2210 "all seven verified files survive and the inaccessible record does not"
2211 );
2212 }
2213
2214 #[test]
2215 fn no_snapshot_is_written_when_the_policy_forbids_it() {
2216 let dir = tempfile::tempdir().expect("tempdir");
2217 let cache = tempfile::tempdir().expect("cache dir");
2218 let snapshot_path = cache.path().join("snap.fdu");
2219 write_file(&dir.path().join("a.txt"), b"hello");
2220
2221 for policy in [CachePolicy::ReadOnly, CachePolicy::Off] {
2222 let (_index, _report, pending) =
2223 open_fixture_with_pending_save(dir.path(), &config(&snapshot_path, policy))
2224 .expect("open");
2225 pending.join().expect("nothing to join");
2226 assert!(!snapshot_path.exists(), "{policy:?} wrote a snapshot");
2227 }
2228 }
2229}