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 CachePaths, CacheScope, CacheState, CacheStatus, ClearSummary, ContentInfo, ContentState,
109 ContentStatus, LeftoverKind, SnapshotInfo, StaleReason, cache_status, clear_all_caches,
110 clear_cache, 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, throughput_rates,
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 stale_ok: bool,
173 pub analysis: content::AnalysisRequest,
175}
176
177#[cfg(test)]
178impl OpenFixture {
179 pub fn split(&self, root: impl Into<PathBuf>) -> (query::Basis, query::Delivery) {
181 (
182 query::Basis {
183 root: root.into(),
184 scope: self.scan.clone().into(),
185 content: self.analysis.profile,
186 },
187 query::Delivery {
188 cache: self.policy,
189 stale_ok: self.stale_ok,
190 cache_path: self.cache_path.clone(),
191 accept_partial: false,
192 watch: None,
193 workers: query::Workers {
194 scan: self.scan.threads,
195 analysis: self.analysis.workers,
196 },
197 batch_size: self.scan.batch_size,
198 order: self.scan.order,
199 },
200 )
201 }
202}
203
204#[cfg(test)]
205pub(crate) fn open_fixture(root: &Path, config: &OpenFixture) -> Result<(Index, OpenReport)> {
206 let (basis, delivery) = config.split(root);
207 open(&basis, &delivery)
208}
209#[cfg(test)]
210pub(crate) fn open_fixture_with_pending_save(
211 root: &Path,
212 config: &OpenFixture,
213) -> Result<(std::sync::Arc<Index>, OpenReport, PendingSave)> {
214 let (basis, delivery) = config.split(root);
215 open_with_pending_save(&basis, &delivery)
216}
217
218#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
230pub enum CachePolicy {
231 #[default]
253 Auto,
254 On,
262 Off,
264}
265
266impl CachePolicy {
267 fn reads(self) -> bool {
269 matches!(self, Self::Auto | Self::On)
270 }
271}
272
273#[derive(Clone, Copy, PartialEq, Eq, Debug)]
275pub enum OpenPath {
276 ColdScan,
278 WarmRevalidate,
280 CacheOnly,
284}
285
286const BACKGROUND_RELEASE_MIN_ENTRIES: u64 = 4 * 1024;
299
300pub(crate) fn release_index(index: std::sync::Arc<Index>) {
319 let Some(index) = std::sync::Arc::into_inner(index) else {
320 return;
321 };
322 if index.len() < BACKGROUND_RELEASE_MIN_ENTRIES || crate::counters::enabled() || cfg!(windows) {
323 return;
324 }
325 let spawned = std::thread::Builder::new()
326 .name("fdu-index-release".to_string())
327 .spawn(move || drop(index));
328 drop(spawned);
330}
331
332#[derive(Debug)]
339#[must_use = "join the save before exiting or the snapshot may be abandoned"]
340pub struct PendingSave {
341 workers: Vec<(&'static str, std::thread::JoinHandle<Result<()>>)>,
342}
343
344impl PendingSave {
345 pub(crate) fn none() -> Self {
347 Self { workers: Vec::new() }
348 }
349
350 pub(crate) fn writes_metadata(&self) -> bool {
352 self.workers.iter().any(|(name, _)| *name == "metadata")
353 }
354
355 pub fn join(mut self) -> Result<()> {
360 let mut first_error = None;
361 for (name, worker) in self.workers.drain(..) {
362 let outcome = worker
363 .join()
364 .unwrap_or_else(|_| Err(Error::Snapshot(format!("{name} cache writer panicked"))));
365 if first_error.is_none() {
366 first_error = outcome.err();
367 }
368 }
369 first_error.map_or(Ok(()), Err)
370 }
371}
372
373impl Drop for PendingSave {
374 fn drop(&mut self) {
375 for (_, worker) in self.workers.drain(..) {
378 let _ = worker.join();
379 }
380 }
381}
382
383#[derive(Debug)]
385pub struct OpenReport {
386 pub path_taken: OpenPath,
388 pub scan: ScanReport,
390 pub analysis: Option<content::AnalysisReport>,
392 pub content_cache: content::ContentCacheLoad,
394 pub projected: bool,
396}
397
398impl OpenReport {
399 pub fn is_complete(&self) -> bool {
401 self.scan.is_complete()
402 && self.analysis.as_ref().is_none_or(content::AnalysisReport::is_complete)
403 }
404
405 pub fn errors(&self) -> &[Error] {
407 &self.scan.errors
408 }
409
410 pub fn error_messages(&self) -> Vec<String> {
412 let mut errors = self.scan.errors.iter().map(ToString::to_string).collect::<Vec<_>>();
413 if let Some(message) =
414 self.analysis.as_ref().and_then(content::AnalysisReport::failure_message)
415 {
416 errors.push(message);
417 }
418 errors
419 }
420}
421
422pub fn open(basis: &query::Basis, delivery: &query::Delivery) -> Result<(Index, OpenReport)> {
444 let (index, report, pending) = open_with_pending_save(basis, delivery)?;
445 let wrote_metadata = pending.writes_metadata();
451 pending.join()?;
452 let mut index = std::sync::Arc::into_inner(index)
453 .expect("the joined writer released the only other reference");
454 if wrote_metadata {
458 index.set_persistence_owed(false);
459 }
460 Ok((index, report))
461}
462
463pub fn open_with_pending_save(
473 basis: &query::Basis,
474 delivery: &query::Delivery,
475) -> Result<(std::sync::Arc<Index>, OpenReport, PendingSave)> {
476 let mut query = query::Query::default();
477 query.selection.ignored = basis.scope.population;
478 let request = query::Request::new(basis.clone(), query, std::time::SystemTime::now());
479 let plan = plan(&request, delivery, Route::Retained).map_err(Error::InvalidRequest)?;
480 execute(&plan, &request.basis, false, None)
481 .map(|(index, report, pending, _diagnostics)| (index, report, pending))
482}
483
484pub fn refresh(
495 index: &mut Index,
496 basis: &query::Basis,
497 delivery: &query::Delivery,
498) -> Result<ReconcileReport> {
499 let mut query = query::Query::default();
500 query.selection.ignored = basis.scope.population;
501 let request = query::Request::new(basis.clone(), query, std::time::SystemTime::now());
502 let plan = plan(&request, delivery, Route::Refresh).map_err(Error::InvalidRequest)?;
503 validate_basis_root(index.root_path(), basis)?;
504 request.validate_read(&query::Basis::held_by(index)).map_err(Error::InvalidRequest)?;
505 let scan = basis.scope.scan_config(delivery);
506 scan.validate_for_scope(index.scope())?;
507 debug_assert_eq!(plan.verify(), Verify::Filesystem, "a refresh plan verifies the tree");
510 let report = scan::reconcile(index, &scan, &mut |_| {})?;
511 let cached = load_content(index, basis, delivery)?;
512 let analysis = basis.content.is_enabled().then(|| {
513 content::analyze_index(
514 index,
515 content::AnalysisRequest { profile: basis.content, workers: delivery.workers.analysis },
516 )
517 });
518 if report.apply.mutated() {
519 index.set_persistence_owed(true);
520 }
521 let written = persist_index_changes(
522 index,
523 &plan,
524 index.persistence_owed(),
525 !cached.usable
526 || cached.stale > 0
527 || analysis.as_ref().is_some_and(|report| report.applied > 0),
528 )?;
529 if written {
530 index.set_persistence_owed(false);
531 }
532 Ok(report)
533}
534
535pub(crate) fn validate_basis_root(held: &Path, basis: &query::Basis) -> Result<()> {
536 let requested = basis.root.canonicalize().map_err(|error| Error::io(&basis.root, error))?;
537 let held = held.canonicalize().map_err(|error| Error::io(held, error))?;
538 if requested != held {
539 return Err(Error::InvalidRequest(query::RequestError::RootMismatch { held, requested }));
540 }
541 Ok(())
542}
543
544fn unusable_snapshot_message(refused: Option<SnapshotIdentity>, wanted: &ScanConfig) -> String {
557 const PREFIX: &str = "no usable snapshot for this root and scan scope";
561 const NEVER_SCANS: &str = "a stale answer never scans";
562 const VERIFIED: &str = "ask for a verified answer, which scans when none serves";
563 const LEAVE_ONE: &str = "run the request once with the `on` cache policy to leave one";
564 let wanted_scope = wanted.scope();
565 let differs_only_in_ignore_rules = |stored: ScanScope| {
566 ScanScope { ignore_rules_fingerprint: wanted_scope.ignore_rules_fingerprint, ..stored }
567 == wanted_scope
568 };
569 let Some(refused) = refused else {
570 return format!("{PREFIX}; {NEVER_SCANS}, so {LEAVE_ONE}, or {VERIFIED}");
571 };
572 let stored = refused.scan_scope();
573 if stored.type_rules_fingerprint != wanted_scope.type_rules_fingerprint {
574 return format!(
575 "{PREFIX}: the cached snapshot was taken under different file type rules; \
576 {NEVER_SCANS}, so use the snapshot's type registry, {LEAVE_ONE} under this request's rules, or {VERIFIED}"
577 );
578 }
579 if differs_only_in_ignore_rules(stored) {
580 if stored.ignore_rules_fingerprint == 0 {
583 return format!(
584 "{PREFIX}: the cached snapshot has no .gitignore state, because the request \
585 that wrote it did not observe it, and this request does; {NEVER_SCANS}, so \
586 {LEAVE_ONE} for this scope, {VERIFIED}, or turn .gitignore observation off as \
587 that request did"
588 );
589 }
590 if wanted_scope.observes_controls() {
591 let stored_limits = match refused.controls {
592 ControlTierIdentity::Observed { limits } => limits,
593 ControlTierIdentity::NotObserved => crate::control::ControlLimits::default(),
594 };
595 let changed = changed_control_limits(stored_limits, wanted.control_limits);
596 if !changed.is_empty() {
597 return format!(
598 "{PREFIX}: the cached snapshot was taken under other .gitignore limits \
599 ({changed}); {NEVER_SCANS}, so repeat the request with the snapshot's \
600 limits, {LEAVE_ONE} for these limits, or {VERIFIED}"
601 );
602 }
603 }
604 }
605 format!(
606 "{PREFIX}: the cached snapshot has a different scan scope; {NEVER_SCANS}, so \
607 {LEAVE_ONE} for this scope, or {VERIFIED}"
608 )
609}
610
611fn changed_control_limits(
613 stored: crate::control::ControlLimits,
614 wanted: crate::control::ControlLimits,
615) -> String {
616 let display = crate::control::limit_display;
617 let changed: Vec<String> = [
618 ("budget", stored.budget, wanted.budget),
619 ("line limit", stored.line_limit, wanted.line_limit),
620 ]
621 .into_iter()
622 .filter(|(_, stored, wanted)| stored != wanted)
623 .map(|(name, stored, wanted)| {
624 format!("{name} {}, where this request asks for {}", display(stored), display(wanted))
625 })
626 .collect();
627 changed.join(", and ")
628}
629
630pub(crate) fn execute(
643 plan: &Plan,
644 basis: &query::Basis,
645 collect_scan_diagnostics: bool,
646 progress: Option<&Progress>,
647) -> Result<(std::sync::Arc<Index>, OpenReport, PendingSave, Option<scan::ScanDiagnostics>)> {
648 debug_assert!(
652 !matches!(plan.retained, execution::RetainedState::Tree(_)),
653 "a folded-index plan is executed only by the one-shot report that made it"
654 );
655 let scan_config =
656 ScanConfig { progress: progress.cloned(), ..basis.scope.scan_config(plan.delivery()) };
657 let analysis_request = content::AnalysisRequest {
658 profile: basis.content,
659 workers: plan.delivery.workers.analysis,
660 };
661 let delivery = plan.delivery();
662 let root = &basis.root;
663 let root = root.canonicalize().map_err(|e| Error::io(root, e))?;
664 scan_config.validate()?;
669 let canonical_basis = query::Basis { root: root.clone(), ..basis.clone() };
670 let mut loaded = None;
674 let mut stored: Option<(PathBuf, SnapshotIdentity)> = None;
675 if let (Load::Snapshot, Some(cache_path)) = (plan.load(), &delivery.cache_path) {
676 if let Some(progress) = progress {
677 progress.enter(ProgressPhase::Loading);
678 }
679 match snapshot::load_serving(
680 cache_path,
681 scan_config.types_shared(),
682 scan_config.snapshot_identity(),
683 )? {
684 snapshot::LoadOutcome::Served { index, stored: identity } => {
685 stored = Some((index.root_path().to_path_buf(), identity));
686 loaded = Some(index);
687 }
688 snapshot::LoadOutcome::Refused { identity, root: stored_root } => {
689 stored = Some((stored_root, identity));
690 }
691 snapshot::LoadOutcome::Absent => {}
692 }
693 }
694 let cache_only = plan.verify() == Verify::None;
695 let content_cache = match (&mut loaded, cache_only) {
701 (Some(index), true) => {
702 index.mark_unverified();
703 Some(load_content(index, basis, delivery)?)
704 }
705 _ => None,
706 };
707 let admission = {
712 let header = stored.as_ref().map(|(stored_root, identity)| StoreHeader {
713 root: stored_root,
714 snapshot: *identity,
715 content: loaded
716 .as_ref()
717 .and_then(Index::content)
718 .and_then(|content| content.identity()),
719 content_complete: content_cache
720 .as_ref()
721 .is_some_and(|cache| cache.usable && cache.hits == cache.candidates),
722 });
723 plan.admit(header.as_ref(), &canonical_basis)
724 };
725
726 if cache_only {
727 let relation = match admission {
728 Admission::Serve(relation) => relation,
729 Admission::NoLocation => {
730 return Err(Error::Snapshot(
731 "no cache location is configured; configure a cache location before asking for a stale answer".into(),
732 ));
733 }
734 Admission::WrongRoot => {
735 return Err(Error::Snapshot(
736 "the configured snapshot belongs to a different root; choose this root's cache location, or run the request once with the `on` cache policy to replace it with this root's snapshot".into(),
737 ));
738 }
739 Admission::Missing => {
740 return Err(Error::Snapshot(unusable_snapshot_message(None, &scan_config)));
741 }
742 Admission::WrongScope => {
743 return Err(Error::Snapshot(unusable_snapshot_message(
744 stored.map(|(_, identity)| identity),
745 &scan_config,
746 )));
747 }
748 Admission::IncompleteContent => {
749 return Err(Error::Snapshot(
750 "no complete usable content sidecar for this root and analysis profile".into(),
751 ));
752 }
753 };
754 let index = loaded.expect("a served admission is of the loaded snapshot");
755 let content_cache =
756 content_cache.expect("the sidecar was loaded beside the served snapshot");
757 return Ok((
758 std::sync::Arc::new(index),
759 OpenReport {
760 path_taken: OpenPath::CacheOnly,
761 scan: ScanReport::default(),
762 analysis: None,
763 content_cache,
764 projected: relation == Serves::ProjectControlsOff,
765 },
766 PendingSave::none(),
767 None,
768 ));
769 }
770
771 if let Admission::Serve(relation) = admission {
772 let mut index = loaded.expect("a served admission is of the loaded snapshot");
773 let projected = relation == Serves::ProjectControlsOff;
774 let reconciled = scan::reconcile(&mut index, &scan_config, &mut |_| {})?;
775 let scan_report = reconciled.scan;
776 index.establish_baseline();
777 let content_cache = load_content(&mut index, basis, delivery)?;
778 let analysis = basis
779 .content
780 .is_enabled()
781 .then(|| content::analyze_index_observed(&mut index, analysis_request, progress));
782 if reconciled.apply.mutated() {
787 index.set_persistence_owed(true);
788 }
789 let facts = run_facts(
790 &index,
791 basis,
792 plan,
793 index.persistence_owed(),
794 analysis.as_ref().is_some_and(|report| report.applied > 0) || content_cache.stale > 0,
795 projected,
796 || stored_entries(delivery, &root),
797 );
798 let index = std::sync::Arc::new(index);
801 let pending = spawn_save(&index, &plan.delivery, plan.writes(facts), progress);
802 return Ok((
803 index,
804 OpenReport {
805 path_taken: OpenPath::WarmRevalidate,
806 scan: scan_report,
807 analysis,
808 content_cache,
809 projected,
810 },
811 pending,
812 None,
813 ));
814 }
815
816 let (mut index, scan_report, scan_diagnostics) = if collect_scan_diagnostics {
817 let (index, report, diagnostics) =
818 scan::scan_into_index_with_diagnostics(&root, &scan_config)?;
819 (index, report, Some(diagnostics))
820 } else {
821 let (index, report) = scan::scan_into_index(&root, &scan_config)?;
822 (index, report, None)
823 };
824 let content_cache = load_content(&mut index, basis, delivery)?;
825 let analysis = basis
826 .content
827 .is_enabled()
828 .then(|| content::analyze_index_observed(&mut index, analysis_request, progress));
829 let facts =
830 run_facts(&index, basis, plan, true, true, false, || stored_entries(delivery, &root));
831 let index = std::sync::Arc::new(index);
832 let pending = spawn_save(&index, &plan.delivery, plan.writes(facts), progress);
833 Ok((
834 index,
835 OpenReport {
836 path_taken: OpenPath::ColdScan,
837 scan: scan_report,
838 analysis,
839 content_cache,
840 projected: false,
841 },
842 pending,
843 scan_diagnostics,
844 ))
845}
846
847fn stored_entries(delivery: &query::Delivery, root: &Path) -> Option<EntryTierIdentity> {
852 delivery
853 .cache_path
854 .as_ref()
855 .and_then(|path| snapshot::read_header(path).ok().flatten())
856 .filter(|stored| stored.root == root)
857 .map(|stored| stored.identity.entries)
858}
859
860fn run_facts(
861 index: &Index,
862 basis: &query::Basis,
863 plan: &Plan,
864 entries_changed: bool,
865 content_changed: bool,
866 projected: bool,
867 stored_entries: impl FnOnce() -> Option<EntryTierIdentity>,
868) -> RunFacts {
869 let entries_verified = stored_state::entries_writable(index);
870 let paired_entries = plan.persists()
871 && !entries_verified
872 && stored_state::content_tier_writable(index, stored_entries);
873 RunFacts {
874 entries_verified,
875 entries_changed,
876 content_changed,
877 content_requested: basis.content.is_enabled(),
878 projected,
879 paired_entries,
880 }
881}
882
883#[cfg(feature = "watch")]
885pub(crate) fn persist_index(index: &Index, plan: &Plan) -> Result<bool> {
886 persist_index_changes(index, plan, true, true)
887}
888
889fn persist_index_changes(
890 index: &Index,
891 plan: &Plan,
892 entries_changed: bool,
893 content_changed: bool,
894) -> Result<bool> {
895 let basis = query::Basis::held_by(index);
896 let delivery = plan.delivery();
897 if !plan.persists() || delivery.cache_path.is_none() {
898 return Ok(false);
899 }
900 let stored = delivery.cache_path.as_deref().map(snapshot::read_header).transpose()?.flatten();
901 let admitted = query::Basis { root: index.root_path().to_path_buf(), ..plan.basis().clone() };
905 let header = stored.as_ref().map(|info| StoreHeader {
906 root: &info.root,
907 snapshot: info.identity,
908 content: None,
909 content_complete: false,
910 });
911 let relation = match plan.admit(header.as_ref(), &admitted) {
912 Admission::Serve(relation) => relation,
913 Admission::NoLocation
915 | Admission::Missing
916 | Admission::WrongRoot
917 | Admission::WrongScope
918 | Admission::IncompleteContent => Serves::Refuse,
919 };
920 let projected = relation == Serves::ProjectControlsOff;
921 let writes = plan.writes(run_facts(
922 index,
923 &basis,
924 plan,
925 entries_changed || relation == Serves::Refuse,
926 content_changed,
927 projected,
928 || {
929 stored
930 .as_ref()
931 .filter(|info| info.root == index.root_path())
932 .map(|info| info.identity.entries)
933 },
934 ));
935 let Some(path) = delivery.cache_path.as_ref() else {
936 return Ok(false);
937 };
938 if writes.metadata {
939 snapshot::save(index, path)?;
940 }
941 if writes.content {
942 content::save_content_cache(index, &content::content_cache_path(path))?;
943 }
944 Ok(writes.metadata)
945}
946
947fn load_content(
948 index: &mut Index,
949 basis: &query::Basis,
950 delivery: &query::Delivery,
951) -> Result<content::ContentCacheLoad> {
952 let (true, Some(snapshot_path)) = (delivery.cache.reads(), delivery.cache_path.as_deref())
953 else {
954 return Ok(content::ContentCacheLoad::default());
955 };
956 let wanted = index.content_identity(basis.content);
957 content::load_content_cache(index, &wanted, &content::content_cache_path(snapshot_path))
958}
959
960fn spawn_save(
969 index: &std::sync::Arc<Index>,
970 delivery: &query::Delivery,
971 writes: SaveTargets,
972 progress: Option<&Progress>,
973) -> PendingSave {
974 let Some(cache_path) = delivery.cache_path.clone().filter(|_| !writes.none()) else {
975 return PendingSave::none();
976 };
977 if let Some(progress) = progress {
981 progress.enter(ProgressPhase::Saving);
982 }
983
984 let snapshot_source = std::sync::Arc::clone(index);
991 let mut workers = Vec::with_capacity(2);
992 if writes.metadata {
993 let metadata_source = std::sync::Arc::clone(&snapshot_source);
994 let metadata_path = cache_path.clone();
995 if let Ok(worker) =
996 std::thread::Builder::new().name("fdu-snapshot".to_string()).spawn(move || {
997 let _counter_guard = counters::thread_flush_guard();
998 let saved = snapshot::save(&metadata_source, &metadata_path);
999 release_index(metadata_source);
1002 saved
1003 })
1004 {
1005 workers.push(("metadata", worker));
1006 }
1007 }
1008 if writes.content {
1009 let content_path = content::content_cache_path(&cache_path);
1010 if let Ok(worker) =
1011 std::thread::Builder::new().name("fdu-content-cache".to_string()).spawn(move || {
1012 let _counter_guard = counters::thread_flush_guard();
1013 let saved = content::save_content_cache(&snapshot_source, &content_path);
1014 release_index(snapshot_source);
1015 saved
1016 })
1017 {
1018 workers.push(("content", worker));
1019 }
1020 }
1021 PendingSave { workers }
1024}
1025
1026pub fn default_cache_dir(explicit: Option<&Path>) -> Result<Option<PathBuf>> {
1032 resolve_cache_dir(
1033 explicit,
1034 nonempty_env("FDU_CACHE_DIR"),
1035 nonempty_env("XDG_CACHE_HOME"),
1036 platform_cache_dir(),
1037 )
1038}
1039
1040fn resolve_cache_dir(
1041 explicit: Option<&Path>,
1042 override_dir: Option<OsString>,
1043 xdg: Option<OsString>,
1044 platform_base: Option<PathBuf>,
1045) -> Result<Option<PathBuf>> {
1046 let directory = if let Some(explicit) = explicit {
1047 if explicit.as_os_str().is_empty() {
1048 return Err(Error::InvalidValue {
1049 kind: "cache directory",
1050 value: String::new(),
1051 hint: "supply a nonempty directory path".to_string(),
1052 });
1053 }
1054 Some(explicit.to_path_buf())
1055 } else if let Some(override_dir) = override_dir.filter(|value| !value.is_empty()) {
1056 Some(PathBuf::from(override_dir))
1057 } else if let Some(xdg) = xdg.filter(|value| !value.is_empty()) {
1058 Some(PathBuf::from(xdg).join("fdu"))
1059 } else {
1060 platform_base.map(|base| base.join("fdu"))
1061 };
1062 directory
1063 .map(|path| std::path::absolute(&path).map_err(|error| Error::io(&path, error)))
1064 .transpose()
1065}
1066
1067pub fn default_cache_path_in(root: &Path, explicit: Option<&Path>) -> Result<Option<PathBuf>> {
1072 let Some(directory) = default_cache_dir(explicit)? else { return Ok(None) };
1073 let canonical = root.canonicalize().map_err(|error| Error::io(root, error))?;
1074 let mut hash = 0xcbf2_9ce4_8422_2325_u64;
1075 for byte in canonical.as_os_str().as_encoded_bytes() {
1076 hash ^= u64::from(*byte);
1077 hash = hash.wrapping_mul(0x1000_0000_01b3);
1078 }
1079 Ok(Some(CachePaths::for_root_hash(&directory, hash).metadata))
1080}
1081
1082pub fn default_cache_path(root: &Path) -> Option<PathBuf> {
1084 default_cache_path_in(root, None).ok().flatten()
1085}
1086
1087fn nonempty_env(name: &str) -> Option<OsString> {
1088 std::env::var_os(name).filter(|value| !value.is_empty())
1089}
1090
1091#[cfg(target_os = "windows")]
1092fn platform_cache_dir() -> Option<PathBuf> {
1093 windows_cache_dir(
1094 nonempty_env("LOCALAPPDATA"),
1095 nonempty_env("USERPROFILE"),
1096 nonempty_env("HOME"),
1097 )
1098}
1099
1100#[cfg(target_os = "windows")]
1101fn windows_cache_dir(
1102 local_app_data: Option<OsString>,
1103 user_profile: Option<OsString>,
1104 home: Option<OsString>,
1105) -> Option<PathBuf> {
1106 local_app_data
1107 .map(PathBuf::from)
1108 .or_else(|| user_profile.map(|path| PathBuf::from(path).join("AppData").join("Local")))
1109 .or_else(|| home.map(|path| PathBuf::from(path).join(".cache")))
1110}
1111
1112#[cfg(target_os = "macos")]
1113fn platform_cache_dir() -> Option<PathBuf> {
1114 Some(PathBuf::from(nonempty_env("HOME")?).join(".cache"))
1115}
1116
1117#[cfg(not(any(target_os = "windows", target_os = "macos")))]
1118fn platform_cache_dir() -> Option<PathBuf> {
1119 Some(PathBuf::from(nonempty_env("HOME")?).join(".cache"))
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124 use super::*;
1125 use std::fs;
1126
1127 fn write_file(path: &Path, contents: &[u8]) {
1128 if let Some(parent) = path.parent() {
1129 fs::create_dir_all(parent).expect("create parent");
1130 }
1131 fs::write(path, contents).expect("write");
1132 }
1133
1134 fn stale(fixture: OpenFixture) -> OpenFixture {
1136 OpenFixture { stale_ok: true, ..fixture }
1137 }
1138
1139 fn controls_config(
1140 policy: CachePolicy,
1141 snapshot_path: PathBuf,
1142 read_controls: bool,
1143 ) -> OpenFixture {
1144 OpenFixture {
1145 scan: ScanConfig { read_controls, ..ScanConfig::default() },
1146 cache_path: Some(snapshot_path),
1147 policy,
1148 ..OpenFixture::default()
1149 }
1150 }
1151
1152 fn seed_controls_snapshot(root: &Path, snapshot_path: PathBuf) {
1153 let seed = controls_config(CachePolicy::Auto, snapshot_path, true);
1154 let (index, report) = open_fixture(root, &seed).expect("seed controls-on snapshot");
1155 assert_eq!(report.path_taken, OpenPath::ColdScan);
1156 assert!(
1157 !index.controls().expect("control state observed").is_empty(),
1158 "the fixture must retain a control source"
1159 );
1160 }
1161
1162 #[test]
1166 fn releasing_a_shared_index_leaves_the_other_holder_reading_it() {
1167 let root = tempfile::tempdir().expect("tempdir");
1168 write_file(&root.path().join("kept.txt"), b"kept");
1169 let (index, _) = open_fixture(root.path(), &OpenFixture::default()).expect("open");
1170 let shared = std::sync::Arc::new(index);
1171 let writer = std::sync::Arc::clone(&shared);
1172
1173 release_index(shared);
1174
1175 assert_eq!(std::sync::Arc::strong_count(&writer), 1);
1176 assert_eq!(writer.total().files, 1);
1177 release_index(writer);
1178 }
1179
1180 #[test]
1185 fn a_default_open_answers_ignore_questions_and_an_opt_out_refuses_them() {
1186 let root = tempfile::tempdir().expect("tempdir");
1187 write_file(&root.path().join(".gitignore"), b"*.log\n");
1188 write_file(&root.path().join("debug.log"), b"ignored");
1189 write_file(&root.path().join("keep.rs"), b"kept");
1190 let uncached = OpenFixture { policy: CachePolicy::Off, ..OpenFixture::default() };
1191 let opted_out = OpenFixture {
1192 scan: ScanConfig { read_controls: false, ..ScanConfig::default() },
1193 ..uncached.clone()
1194 };
1195
1196 let (index, report) = open_fixture(root.path(), &uncached).expect("default open");
1197 assert!(report.is_complete(), "{:?}", report.errors());
1198 assert!(index.observes_controls());
1199 assert_eq!(index.is_ignored(Path::new("debug.log")).ok(), Some(Some(true)));
1200 assert_eq!(index.is_ignored(Path::new("keep.rs")).ok(), Some(Some(false)));
1201 assert_eq!(index.is_ignored(Path::new("absent")).ok(), Some(None));
1202 assert!(
1203 index
1204 .controls()
1205 .is_ok_and(|controls| controls.source_is(Path::new(".gitignore"), b"*.log\n"))
1206 );
1207 assert_eq!(index.partition_total().expect("control state observed").unignored.files, 2);
1208
1209 let mut oversized = vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1];
1210 oversized.extend_from_slice(b"\n*.log\n");
1211 write_file(&root.path().join(".gitignore"), &oversized);
1212 let (index, report) =
1213 open_fixture(root.path(), &uncached).expect("a refused control ends nothing");
1214 assert!(report.is_complete(), "{:?}", report.errors());
1215 let crate::control::ControlCoverage::Observed(coverage) = index.control_coverage() else {
1216 panic!("a default open reads the control file the opt-out skips");
1217 };
1218 assert_eq!((coverage.applied, coverage.refused), (0, 1));
1219
1220 let (index, report) = open_fixture(root.path(), &opted_out)
1221 .expect("an opted-out open reads no control line, however long");
1222 assert!(report.is_complete(), "{:?}", report.errors());
1223 assert!(!index.observes_controls());
1224 for path in ["debug.log", "keep.rs", "absent"] {
1225 assert!(
1226 matches!(index.is_ignored(Path::new(path)), Err(Error::ControlStateNotObserved)),
1227 "{path} must not be called unignored by an index that read no rule"
1228 );
1229 }
1230 assert!(matches!(index.controls(), Err(Error::ControlStateNotObserved)));
1231 assert!(matches!(index.partition_total(), Err(Error::ControlStateNotObserved)));
1232 assert_eq!(index.total().files, 3);
1233 }
1234
1235 #[test]
1238 fn a_snapshot_serves_only_the_control_limits_it_was_taken_under() {
1239 let root = tempfile::tempdir().expect("tempdir");
1240 let cache = tempfile::tempdir().expect("cache dir");
1241 let snapshot_path = cache.path().join("snap.fdu");
1242 let mut long_line = b"*.log\n".to_vec();
1243 long_line.extend(std::iter::repeat_n(b'x', crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1));
1244 write_file(&root.path().join(".gitignore"), &long_line);
1245 write_file(&root.path().join("debug.log"), b"ignored");
1246 let default = controls_config(CachePolicy::Auto, snapshot_path.clone(), true);
1247 let lifted_limits = crate::control::ControlLimits {
1248 line_limit: None,
1249 ..crate::control::ControlLimits::default()
1250 };
1251 let lifted = OpenFixture {
1252 scan: ScanConfig { control_limits: lifted_limits, ..default.scan.clone() },
1253 ..controls_config(CachePolicy::Auto, snapshot_path, true)
1254 };
1255 let refused = |index: &Index| match index.control_coverage() {
1256 crate::control::ControlCoverage::Observed(coverage) => coverage.refused,
1257 crate::control::ControlCoverage::NotObserved => panic!("observed"),
1258 };
1259
1260 let (index, report) = open_fixture(root.path(), &default).expect("default limits");
1261 assert_eq!((report.path_taken, refused(&index)), (OpenPath::ColdScan, 1));
1262 let (index, report) = open_fixture(root.path(), &default).expect("default limits again");
1263 assert_eq!((report.path_taken, refused(&index)), (OpenPath::WarmRevalidate, 1));
1264
1265 let (index, report) = open_fixture(root.path(), &lifted).expect("lifted line limit");
1266 assert_eq!((report.path_taken, refused(&index)), (OpenPath::ColdScan, 0));
1267 assert_eq!(index.is_ignored(Path::new("debug.log")).ok(), Some(Some(true)));
1268 let (index, report) = open_fixture(root.path(), &lifted).expect("lifted line limit again");
1269 assert_eq!((report.path_taken, refused(&index)), (OpenPath::WarmRevalidate, 0));
1270
1271 let (_, report) = open_fixture(root.path(), &default).expect("back to the default");
1272 assert_eq!(report.path_taken, OpenPath::ColdScan);
1273 }
1274
1275 #[test]
1276 fn controls_on_snapshot_projects_to_controls_off_auto_open_without_replacing_it() {
1277 let root = tempfile::tempdir().expect("tempdir");
1278 let cache = tempfile::tempdir().expect("cache dir");
1279 let snapshot_path = cache.path().join("snap.fdu");
1280 write_file(&root.path().join(".gitignore"), b"ignored.log\n");
1281 write_file(&root.path().join("ignored.log"), b"ignored");
1282 seed_controls_snapshot(root.path(), snapshot_path.clone());
1283 let stronger = fs::read(&snapshot_path).expect("stronger snapshot");
1284 write_file(&root.path().join("new.txt"), b"new");
1285
1286 let controls_off = controls_config(CachePolicy::Auto, snapshot_path.clone(), false);
1287 let (index, report) =
1288 open_fixture(root.path(), &controls_off).expect("projected warm open");
1289
1290 assert_eq!(report.path_taken, OpenPath::WarmRevalidate);
1291 assert!(report.projected);
1292 assert_eq!(index.scope(), controls_off.scan.scope());
1293 assert!(matches!(index.controls(), Err(Error::ControlStateNotObserved)));
1294 assert!(matches!(index.path_state(Path::new("new.txt")), PathState::Present { .. }));
1295 assert_eq!(fs::read(snapshot_path).expect("snapshot retained"), stronger);
1296 }
1297
1298 #[test]
1304 fn a_cache_only_open_after_a_limit_change_names_the_limits_that_differ() {
1305 let root = tempfile::tempdir().expect("tempdir");
1306 let cache = tempfile::tempdir().expect("cache dir");
1307 let snapshot_path = cache.path().join("snap.fdu");
1308 write_file(&root.path().join(".gitignore"), b"ignored.log\n");
1309 write_file(&root.path().join("ignored.log"), b"ignored");
1310 seed_controls_snapshot(root.path(), snapshot_path.clone());
1311
1312 let lifted = crate::control::ControlLimits {
1313 line_limit: None,
1314 ..crate::control::ControlLimits::default()
1315 };
1316 let mut wanted = stale(controls_config(CachePolicy::Auto, snapshot_path, true));
1317 wanted.scan.control_limits = lifted;
1318 let Err(Error::Snapshot(message)) = open_fixture(root.path(), &wanted) else {
1319 panic!("a snapshot taken under other limits must not serve a cache-only open");
1320 };
1321 assert!(
1322 message.contains("line limit 16 KiB, where this request asks for all"),
1323 "names the limit that differs and both values: {message}"
1324 );
1325 assert!(!message.contains("budget"), "the budget is unchanged: {message}");
1326 assert!(!message.contains("different scan scope"), "says which scope differs: {message}");
1327 assert!(
1328 message.contains("repeat the request with the snapshot's limits"),
1329 "names the remedy: {message}"
1330 );
1331 assert!(message.contains("verified answer"), "names the other remedy: {message}");
1332 }
1333
1334 #[test]
1335 fn controls_on_snapshot_projects_to_controls_off_cache_only_open() {
1336 let root = tempfile::tempdir().expect("tempdir");
1337 let cache = tempfile::tempdir().expect("cache dir");
1338 let snapshot_path = cache.path().join("snap.fdu");
1339 write_file(&root.path().join(".gitignore"), b"ignored.log\n");
1340 write_file(&root.path().join("ignored.log"), b"ignored");
1341 seed_controls_snapshot(root.path(), snapshot_path.clone());
1342
1343 let controls_off = stale(controls_config(CachePolicy::Auto, snapshot_path, false));
1344 let (index, report) =
1345 open_fixture(root.path(), &controls_off).expect("projected cache-only open");
1346 assert_eq!(report.path_taken, OpenPath::CacheOnly);
1347 assert!(report.projected);
1348 assert_eq!(index.scope(), controls_off.scan.scope());
1349 assert!(matches!(index.controls(), Err(Error::ControlStateNotObserved)));
1350 }
1351
1352 #[test]
1353 fn cache_only_names_foreign_type_rules_after_validating_the_snapshot_and_root() {
1354 let root = tempfile::tempdir().expect("root");
1355 let other_root = tempfile::tempdir().expect("other root");
1356 let cache = tempfile::tempdir().expect("cache");
1357 let snapshot_path = cache.path().join("snap.fdu");
1358 write_file(&root.path().join("main.rs"), b"fn main() {}\n");
1359 let custom_types = std::sync::Arc::new(
1360 classify::TypeRegistry::from_manifest(
1361 "[[kind]]\nid = \"notes\"\nfamily = \"prose\"\nextensions = [\"rs\"]\n",
1362 )
1363 .expect("custom rules"),
1364 );
1365 let custom = OpenFixture {
1366 scan: ScanConfig::default().with_types(custom_types.clone()),
1367 cache_path: Some(snapshot_path.clone()),
1368 policy: CachePolicy::Auto,
1369 ..OpenFixture::default()
1370 };
1371 open_fixture(root.path(), &custom).expect("write custom snapshot");
1372
1373 let default_only = OpenFixture {
1374 cache_path: Some(snapshot_path.clone()),
1375 stale_ok: true,
1376 ..OpenFixture::default()
1377 };
1378 let Err(Error::Snapshot(message)) = open_fixture(root.path(), &default_only) else {
1379 panic!("foreign type rules must not serve cache-only");
1380 };
1381 assert!(message.contains("different file type rules"), "{message}");
1382 assert!(
1383 message.contains("type registry") && message.contains("verified answer"),
1384 "{message}"
1385 );
1386 assert!(
1387 snapshot::load(&snapshot_path).expect("direct default-registry load").is_none(),
1388 "a direct load cannot attach the compiled registry to foreign rules"
1389 );
1390 let (_, report) =
1391 open_fixture(root.path(), &OpenFixture { stale_ok: true, ..custom.clone() })
1392 .expect("matching registry can use the snapshot");
1393 assert_eq!(report.path_taken, OpenPath::CacheOnly);
1394
1395 let Err(Error::Snapshot(message)) = open_fixture(other_root.path(), &default_only) else {
1396 panic!("a foreign root and rules must not serve cache-only");
1397 };
1398 assert!(message.contains("different root"), "root refusal takes precedence: {message}");
1399 assert!(!message.contains("type rules"), "wrong-root identity must not leak: {message}");
1400
1401 let mut corrupt = fs::read(&snapshot_path).expect("snapshot bytes");
1402 let middle = corrupt.len() / 2;
1403 corrupt[middle] ^= 1;
1404 fs::write(&snapshot_path, corrupt).expect("corrupt payload without updating checksum");
1405 let Err(Error::Snapshot(message)) = open_fixture(root.path(), &default_only) else {
1406 panic!("corrupt snapshot must not yield a type-rules refusal");
1407 };
1408 assert!(!message.contains("type rules"), "corruption is an absent snapshot: {message}");
1409 }
1410
1411 #[test]
1419 fn supplied_type_rules_change_the_answer_and_invalidate_the_snapshot() {
1420 let dir = tempfile::tempdir().expect("tempdir");
1421 let cache = tempfile::tempdir().expect("cache dir");
1422 let snapshot_path = cache.path().join("snap.fdu");
1423 write_file(&dir.path().join("main.rs"), b"fn main() {}\n");
1424
1425 let default_config = OpenFixture {
1426 cache_path: Some(snapshot_path.clone()),
1427 analysis: content::AnalysisRequest {
1428 profile: content::AnalysisSet::NONE.with_lines(),
1429 ..content::AnalysisRequest::default()
1430 },
1431 ..OpenFixture::default()
1432 };
1433 let (index, _) = open_fixture(dir.path(), &default_config).expect("default open");
1434 assert_eq!(index.classify(Path::new("main.rs")).file_type.as_str(), "rust");
1435 drop(index);
1436
1437 let mine = std::sync::Arc::new(
1438 classify::TypeRegistry::from_manifest(
1439 "[[kind]]\nid = \"notes\"\nfamily = \"prose\"\nextensions = [\"rs\"]\n",
1440 )
1441 .expect("a minimal manifest"),
1442 );
1443 let custom_config = OpenFixture {
1444 scan: scan::ScanConfig::default().with_types(mine.clone()),
1445 ..default_config.clone()
1446 };
1447
1448 assert_ne!(
1449 custom_config.scan.scope(),
1450 default_config.scan.scope(),
1451 "different rules are a different scan scope"
1452 );
1453
1454 let (index, report) = open_fixture(dir.path(), &custom_config).expect("custom open");
1455 assert_eq!(
1456 report.path_taken,
1457 OpenPath::ColdScan,
1458 "the snapshot was written under other rules and must not be reused"
1459 );
1460 assert_eq!(index.classify(Path::new("main.rs")).file_type.as_str(), "notes");
1461 assert_eq!(index.types().fingerprint(), mine.fingerprint());
1462 let content = index
1463 .content()
1464 .and_then(|content| content.file(Path::new("main.rs")))
1465 .expect("custom analysis record");
1466 assert_eq!(content.detection.file_type.as_str(), "notes");
1467 assert_eq!(
1468 index
1469 .content()
1470 .and_then(content::ContentIndex::provenance)
1471 .expect("content provenance")
1472 .type_rules_fingerprint,
1473 mine.fingerprint()
1474 );
1475
1476 let (_, report) = open_fixture(dir.path(), &custom_config).expect("second custom open");
1478 assert_eq!(report.path_taken, OpenPath::WarmRevalidate, "same rules, same snapshot");
1479 assert_eq!(report.content_cache.hits, 1, "the matching sidecar is reusable");
1480 assert_eq!(report.analysis.expect("analysis report").candidates, 0);
1481
1482 assert!(
1483 snapshot::load(&snapshot_path).expect("default-registry load").is_none(),
1484 "a direct default-registry load must reject a custom-registry snapshot"
1485 );
1486 let loaded = snapshot::load_with_types(&snapshot_path, mine)
1487 .expect("custom-registry load")
1488 .expect("the matching custom registry makes the snapshot usable");
1489 assert_eq!(loaded.classify(Path::new("main.rs")).file_type.as_str(), "notes");
1490 }
1491
1492 #[test]
1494 fn each_cache_policy_reads_scans_and_writes_as_documented() {
1495 for (policy, expect_write) in
1498 [(CachePolicy::Auto, true), (CachePolicy::On, true), (CachePolicy::Off, false)]
1499 {
1500 let dir = tempfile::tempdir().expect("tempdir");
1501 let cache = tempfile::tempdir().expect("cache dir");
1502 let snapshot_path = cache.path().join("snap.fdu");
1503 write_file(&dir.path().join("a.txt"), b"hello");
1504
1505 let config = OpenFixture {
1506 cache_path: Some(snapshot_path.clone()),
1507 policy,
1508 ..OpenFixture::default()
1509 };
1510 let (index, report) = open_fixture(dir.path(), &config).expect("open");
1511
1512 assert_eq!(index.total().files, 1, "{policy:?} lost an entry");
1513 assert_eq!(report.path_taken, OpenPath::ColdScan, "{policy:?} without a snapshot");
1514 assert_eq!(
1515 snapshot_path.exists(),
1516 expect_write,
1517 "{policy:?} wrote a snapshot: {}",
1518 snapshot_path.exists()
1519 );
1520 }
1521 }
1522
1523 #[test]
1529 fn an_unchanged_warm_open_leaves_the_snapshot_alone_and_a_changed_one_rewrites_it() {
1530 let dir = tempfile::tempdir().expect("tempdir");
1531 let cache = tempfile::tempdir().expect("cache dir");
1532 let snapshot_path = cache.path().join("snap.fdu");
1533 write_file(&dir.path().join("a.txt"), b"hello");
1534 write_file(&dir.path().join("sub/b.txt"), b"world");
1535
1536 let auto = OpenFixture {
1537 cache_path: Some(snapshot_path.clone()),
1538 policy: CachePolicy::Auto,
1539 ..OpenFixture::default()
1540 };
1541 open_fixture(dir.path(), &auto).expect("seed");
1542 let seeded = fs::read(&snapshot_path).expect("seeded snapshot");
1543
1544 let (index, report) = open_fixture(dir.path(), &auto).expect("warm open");
1545 assert_eq!(report.path_taken, OpenPath::WarmRevalidate);
1546 assert_eq!(index.total().files, 2);
1547 assert_eq!(
1548 fs::read(&snapshot_path).expect("snapshot still there"),
1549 seeded,
1550 "an unchanged warm open must not rewrite the snapshot"
1551 );
1552
1553 write_file(&dir.path().join("sub/c.txt"), b"new file");
1554 let (index, report) = open_fixture(dir.path(), &auto).expect("warm open after a change");
1555 assert_eq!(report.path_taken, OpenPath::WarmRevalidate);
1556 assert_eq!(index.total().files, 3);
1557 let after_add = fs::read(&snapshot_path).expect("rewritten snapshot");
1558 assert_ne!(after_add, seeded, "a warm open that found a new file must persist it");
1559
1560 fs::remove_file(dir.path().join("sub/c.txt")).expect("remove");
1561 let (index, _) = open_fixture(dir.path(), &auto).expect("warm open after a removal");
1562 assert_eq!(index.total().files, 2);
1563 assert_ne!(
1564 fs::read(&snapshot_path).expect("rewritten snapshot"),
1565 after_add,
1566 "a warm open that found a removal must persist it"
1567 );
1568
1569 let cache_only = OpenFixture { stale_ok: true, ..auto };
1571 let (restored, _) = open_fixture(dir.path(), &cache_only).expect("cache-only open");
1572 assert_eq!(restored.total().files, 2);
1573 }
1574
1575 #[test]
1576 fn cache_only_answers_from_the_snapshot_without_touching_the_tree() {
1577 let dir = tempfile::tempdir().expect("tempdir");
1578 let cache = tempfile::tempdir().expect("cache dir");
1579 let snapshot_path = cache.path().join("snap.fdu");
1580 write_file(&dir.path().join("a.txt"), b"hello");
1581
1582 let auto = OpenFixture {
1583 cache_path: Some(snapshot_path.clone()),
1584 policy: CachePolicy::Auto,
1585 ..OpenFixture::default()
1586 };
1587 open_fixture(dir.path(), &auto).expect("seed");
1588
1589 write_file(&dir.path().join("b.txt"), b"new file");
1592
1593 let only = OpenFixture { stale_ok: true, ..auto };
1594 let (index, report) = open_fixture(dir.path(), &only).expect("cache-only open");
1595 assert_eq!(report.path_taken, OpenPath::CacheOnly);
1596 assert_eq!(index.total().files, 1, "the new file must not appear");
1597 assert_ne!(index.freshness(), Freshness::Fresh, "a stale answer must not claim currency");
1598 }
1599
1600 #[test]
1601 fn cache_only_fails_closed_when_no_snapshot_is_usable() {
1602 let dir = tempfile::tempdir().expect("tempdir");
1603 let cache = tempfile::tempdir().expect("cache dir");
1604 write_file(&dir.path().join("a.txt"), b"hello");
1605
1606 let only = OpenFixture {
1609 cache_path: Some(cache.path().join("absent.fdu")),
1610 stale_ok: true,
1611 ..OpenFixture::default()
1612 };
1613 let Err(Error::Snapshot(message)) = open_fixture(dir.path(), &only) else {
1614 panic!("a cache-only open with no snapshot must fail");
1615 };
1616 assert!(message.contains("with the `on` cache policy"), "names the remedy: {message}");
1619 assert!(message.contains("verified answer"), "names the other remedy: {message}");
1620 }
1621
1622 #[test]
1623 fn content_sidecar_skips_unchanged_reads_and_serves_cache_only() {
1624 let dir = tempfile::tempdir().expect("tempdir");
1625 let cache = tempfile::tempdir().expect("cache dir");
1626 let snapshot_path = cache.path().join("snap.fdu");
1627 write_file(&dir.path().join("notes.md"), b"one two\n");
1628 let analysis = content::AnalysisRequest {
1629 profile: content::AnalysisSet::NONE.with_lines(),
1630 ..content::AnalysisRequest::default()
1631 };
1632 let auto = OpenFixture {
1633 cache_path: Some(snapshot_path.clone()),
1634 policy: CachePolicy::Auto,
1635 analysis,
1636 ..OpenFixture::default()
1637 };
1638
1639 let (first, first_report) = open_fixture(dir.path(), &auto).expect("cold analyzed open");
1640 assert_eq!(first_report.analysis.expect("analysis").lines.analyzed, 1);
1641 assert_eq!(
1642 first.content_rollup(Path::new("")).expect("content").total.lines.metrics.raw_words,
1643 2
1644 );
1645 assert!(content::content_cache_path(&snapshot_path).exists());
1646
1647 let (_, warm_report) = open_fixture(dir.path(), &auto).expect("warm analyzed open");
1648 assert_eq!(warm_report.content_cache.hits, 1);
1649 assert_eq!(warm_report.content_cache.bytes, 8);
1650 assert_eq!(warm_report.analysis.expect("analysis").candidates, 0);
1651
1652 fs::remove_file(dir.path().join("notes.md")).expect("remove source");
1653 let only = OpenFixture { stale_ok: true, ..auto };
1654 let (cached, cached_report) = open_fixture(dir.path(), &only).expect("cache-only content");
1655 assert_eq!(cached_report.content_cache.hits, 1);
1656 assert_eq!(cached_report.content_cache.bytes, 8);
1657 assert_eq!(
1658 cached.content_rollup(Path::new("")).expect("content").total.lines.metrics.raw_words,
1659 2
1660 );
1661 }
1662
1663 #[test]
1664 fn narrowed_population_snapshot_and_sidecar_reuse_only_their_scope() {
1665 let dir = tempfile::tempdir().expect("tree");
1666 let cache = tempfile::tempdir().expect("cache");
1667 let snapshot_path = cache.path().join("scoped.metadata.bin");
1668 write_file(&dir.path().join(".gitignore"), b"*.log\n");
1669 write_file(&dir.path().join("keep.rs"), b"kept\n");
1670 write_file(&dir.path().join("debug.log"), b"ignored\n");
1671 let analysis = content::AnalysisRequest {
1672 profile: content::AnalysisSet::NONE.with_lines(),
1673 ..content::AnalysisRequest::default()
1674 };
1675 let excluded = OpenFixture {
1676 stale_ok: false,
1677 scan: ScanConfig {
1678 population: query::IgnoredEntries::Exclude,
1679 ..ScanConfig::default()
1680 },
1681 cache_path: Some(snapshot_path.clone()),
1682 policy: CachePolicy::Auto,
1683 analysis,
1684 };
1685 let (cold, report) = open_fixture(dir.path(), &excluded).expect("cold exclude");
1686 assert_eq!(report.path_taken, OpenPath::ColdScan);
1687 assert!(cold.lookup(Path::new("keep.rs")).is_some());
1688 assert!(cold.lookup(Path::new("debug.log")).is_none());
1689 assert!(content::content_cache_path(&snapshot_path).exists());
1690
1691 let only_cache = OpenFixture { stale_ok: true, ..excluded.clone() };
1692 let (restored, report) = open_fixture(dir.path(), &only_cache).expect("cache-only exclude");
1693 assert_eq!(report.path_taken, OpenPath::CacheOnly);
1694 assert!(report.content_cache.hits > 0);
1695 assert!(restored.lookup(Path::new("keep.rs")).is_some());
1696 assert!(restored.lookup(Path::new("debug.log")).is_none());
1697
1698 let opposite = OpenFixture {
1699 scan: ScanConfig { population: query::IgnoredEntries::Only, ..ScanConfig::default() },
1700 ..only_cache
1701 };
1702 assert!(matches!(open_fixture(dir.path(), &opposite), Err(Error::Snapshot(_))));
1703 }
1704
1705 #[test]
1706 fn cached_coverage_exclusions_remain_visible_without_making_the_run_partial() {
1707 let dir = tempfile::tempdir().expect("tempdir");
1708 let cache = tempfile::tempdir().expect("cache dir");
1709 let snapshot_path = cache.path().join("snap.fdu");
1710 write_file(&dir.path().join("invalid.txt"), b"valid prefix\xff");
1711 let auto = OpenFixture {
1712 cache_path: Some(snapshot_path),
1713 policy: CachePolicy::Auto,
1714 analysis: content::AnalysisRequest {
1715 profile: content::AnalysisSet::NONE.with_lines(),
1716 ..content::AnalysisRequest::default()
1717 },
1718 ..OpenFixture::default()
1719 };
1720
1721 let (_, cold_report) = open_fixture(dir.path(), &auto).expect("cold analyzed open");
1722 assert!(cold_report.is_complete());
1723 assert_eq!(cold_report.analysis.expect("analysis").lines.invalid_utf8, 1);
1724 assert!(cold_report.error_messages().is_empty());
1725
1726 let (_, warm_report) = open_fixture(dir.path(), &auto).expect("warm analyzed open");
1727 assert_eq!(warm_report.content_cache.hits, 1);
1728 assert_eq!(warm_report.content_cache.coverage_exclusions, 1);
1729 assert_eq!(warm_report.analysis.expect("analysis").candidates, 0);
1730 assert!(warm_report.is_complete());
1731 assert!(warm_report.error_messages().is_empty());
1732
1733 let only = OpenFixture { stale_ok: true, ..auto };
1734 let (_, cached_report) = open_fixture(dir.path(), &only).expect("cache-only analyzed open");
1735 assert_eq!(cached_report.content_cache.coverage_exclusions, 1);
1736 assert!(cached_report.is_complete());
1737 assert!(cached_report.error_messages().is_empty());
1738 }
1739
1740 #[test]
1741 fn cache_only_analysis_fails_closed_without_its_sidecar() {
1742 let dir = tempfile::tempdir().expect("tempdir");
1743 let cache = tempfile::tempdir().expect("cache dir");
1744 let snapshot_path = cache.path().join("snap.fdu");
1745 write_file(&dir.path().join("notes.md"), b"one two\n");
1746 let metadata_only = OpenFixture {
1747 cache_path: Some(snapshot_path),
1748 policy: CachePolicy::Auto,
1749 ..OpenFixture::default()
1750 };
1751 open_fixture(dir.path(), &metadata_only).expect("seed metadata");
1752
1753 let only = OpenFixture {
1754 stale_ok: true,
1755 analysis: content::AnalysisRequest {
1756 profile: content::AnalysisSet::NONE.with_lines(),
1757 ..content::AnalysisRequest::default()
1758 },
1759 ..metadata_only
1760 };
1761 assert!(matches!(open_fixture(dir.path(), &only), Err(Error::Snapshot(_))));
1762
1763 let with_set = |policy, profile| OpenFixture {
1766 policy,
1767 stale_ok: false,
1768 analysis: content::AnalysisRequest { profile, ..content::AnalysisRequest::default() },
1769 ..only.clone()
1770 };
1771 let lines = content::AnalysisSet::NONE.with_lines();
1772 for (stored, wanted) in
1773 [(content::AnalysisSet::ALL, lines), (lines, content::AnalysisSet::ALL)]
1774 {
1775 open_fixture(dir.path(), &with_set(CachePolicy::Auto, stored))
1776 .expect("write a sidecar");
1777 let refused = open_fixture(dir.path(), &stale(with_set(CachePolicy::Auto, wanted)));
1778 assert!(
1779 matches!(refused, Err(Error::Snapshot(_))),
1780 "a {stored:?} sidecar must not serve a cache-only {wanted:?} request"
1781 );
1782 }
1783
1784 open_fixture(dir.path(), &with_set(CachePolicy::Auto, lines))
1785 .expect("write the lines sidecar");
1786 let (cached, report) = open_fixture(dir.path(), &only).expect("restore the analyzed state");
1787 assert!(report.content_cache.usable);
1788 assert_eq!(report.content_cache.hits, 1, "one record per candidate is complete");
1789 assert_eq!(
1790 report.content_cache.candidates, report.content_cache.hits,
1791 "completeness uses the count restore already walked"
1792 );
1793 assert_eq!(cached.content_set(), lines);
1794 }
1795
1796 #[test]
1797 fn cache_only_analysis_fails_closed_when_the_sidecar_is_incomplete() {
1798 let dir = tempfile::tempdir().expect("tempdir");
1799 let cache = tempfile::tempdir().expect("cache dir");
1800 let snapshot_path = cache.path().join("snap.fdu");
1801 write_file(&dir.path().join("notes.md"), b"one two\n");
1802 let analysis = content::AnalysisRequest {
1803 profile: content::AnalysisSet::NONE.with_lines(),
1804 ..content::AnalysisRequest::default()
1805 };
1806 let auto = OpenFixture {
1807 cache_path: Some(snapshot_path),
1808 policy: CachePolicy::Auto,
1809 analysis,
1810 ..OpenFixture::default()
1811 };
1812 open_fixture(dir.path(), &auto).expect("seed one analyzed file");
1813
1814 write_file(&dir.path().join("extra.txt"), b"three\n");
1817 let metadata_only =
1818 OpenFixture { analysis: content::AnalysisRequest::default(), ..auto.clone() };
1819 open_fixture(dir.path(), &metadata_only).expect("widen the snapshot");
1820
1821 let only = OpenFixture { stale_ok: true, ..auto };
1822 assert!(
1823 matches!(open_fixture(dir.path(), &only), Err(Error::Snapshot(_))),
1824 "a one-record sidecar must not serve a two-file cache-only analysis request"
1825 );
1826 }
1827
1828 #[cfg(unix)]
1829 #[test]
1830 fn cache_only_serves_checksummed_native_non_utf8_names() {
1831 use std::ffi::OsString;
1832 use std::os::unix::ffi::OsStringExt;
1833
1834 let dir = tempfile::tempdir().expect("tempdir");
1835 let cache = tempfile::tempdir().expect("cache dir");
1836 let snapshot_path = cache.path().join("snap.fdu");
1837 let root = dir.path().canonicalize().expect("canonical root");
1838 let native = PathBuf::from(OsString::from_vec(vec![b'n', 0x80]));
1839 let mut index = Index::new(&root);
1840 index.apply_ok(&Observation::new(vec![
1841 Op::Upsert {
1842 path: PathBuf::from("ok.txt"),
1843 kind: EntryKind::File,
1844 attrs: Attrs {
1845 size: 1,
1846 allocated: 512,
1847 mtime_ns: 1,
1848 ctime_ns: 1,
1849 inode: 1,
1850 dev: 1,
1851 },
1852 },
1853 Op::Upsert {
1854 path: native.clone(),
1855 kind: EntryKind::File,
1856 attrs: Attrs {
1857 size: 2,
1858 allocated: 512,
1859 mtime_ns: 2,
1860 ctime_ns: 2,
1861 inode: 2,
1862 dev: 1,
1863 },
1864 },
1865 ]));
1866 snapshot::save(&index, &snapshot_path).expect("save native names");
1867
1868 let only = OpenFixture {
1869 cache_path: Some(snapshot_path),
1870 stale_ok: true,
1871 ..OpenFixture::default()
1872 };
1873 let (cached, report) = open_fixture(&root, &only).expect("cache-only native name");
1874 assert_eq!(cached.total().files, 2);
1875 assert!(cached.lookup(&native).is_some());
1876 assert!(report.is_complete());
1877 }
1878
1879 #[test]
1880 fn cache_only_empty_analysis_still_requires_a_usable_sidecar() {
1881 let dir = tempfile::tempdir().expect("tempdir");
1882 let cache = tempfile::tempdir().expect("cache dir");
1883 let snapshot_path = cache.path().join("snap.fdu");
1884 let metadata_only = OpenFixture {
1885 cache_path: Some(snapshot_path),
1886 policy: CachePolicy::Auto,
1887 ..OpenFixture::default()
1888 };
1889 open_fixture(dir.path(), &metadata_only).expect("seed empty metadata");
1890
1891 let only = OpenFixture {
1892 stale_ok: true,
1893 analysis: content::AnalysisRequest {
1894 profile: content::AnalysisSet::NONE.with_lines(),
1895 ..content::AnalysisRequest::default()
1896 },
1897 ..metadata_only
1898 };
1899 assert!(matches!(open_fixture(dir.path(), &only), Err(Error::Snapshot(_))));
1900 }
1901
1902 #[test]
1903 fn a_snapshot_for_another_root_is_treated_as_absent() {
1904 let one = tempfile::tempdir().expect("tempdir");
1905 let two = tempfile::tempdir().expect("tempdir");
1906 let cache = tempfile::tempdir().expect("cache dir");
1907 let snapshot_path = cache.path().join("snap.fdu");
1908 write_file(&one.path().join("a.txt"), b"hello");
1909 write_file(&two.path().join("b.txt"), b"other tree");
1910
1911 let config = OpenFixture {
1912 cache_path: Some(snapshot_path.clone()),
1913 policy: CachePolicy::Auto,
1914 ..OpenFixture::default()
1915 };
1916 open_fixture(one.path(), &config).expect("seed from the first root");
1917
1918 let (index, report) = open_fixture(two.path(), &config).expect("second root");
1920 assert_eq!(report.path_taken, OpenPath::ColdScan);
1921 assert_eq!(index.total().files, 1);
1922 assert_eq!(index.root_path(), two.path().canonicalize().expect("canonical").as_path());
1923 }
1924
1925 #[test]
1926 fn open_without_a_cache_always_scans_cold() {
1927 let dir = tempfile::tempdir().expect("tempdir");
1928 write_file(&dir.path().join("a.txt"), b"hello");
1929
1930 let (index, report) = open_fixture(dir.path(), &OpenFixture::default()).expect("open");
1931 assert_eq!(report.path_taken, OpenPath::ColdScan);
1932 assert_eq!(index.total().files, 1);
1933 }
1934
1935 #[test]
1936 fn second_open_takes_the_warm_path_and_stays_correct() {
1937 let dir = tempfile::tempdir().expect("tempdir");
1938 let cache = tempfile::tempdir().expect("cache dir");
1939 write_file(&dir.path().join("a.txt"), b"hello");
1940 write_file(&dir.path().join("src/main.rs"), b"fn main() {}");
1941
1942 let config = OpenFixture {
1943 cache_path: Some(cache.path().join("snap.fdu")),
1944 policy: CachePolicy::Auto,
1945 ..OpenFixture::default()
1946 };
1947
1948 let (first, first_report) = open_fixture(dir.path(), &config).expect("cold open");
1949 assert_eq!(first_report.path_taken, OpenPath::ColdScan);
1950 assert_eq!(first.total().files, 2);
1951
1952 write_file(&dir.path().join("added.md"), b"new");
1954 fs::remove_file(dir.path().join("a.txt")).expect("remove");
1955
1956 let (second, second_report) = open_fixture(dir.path(), &config).expect("warm open");
1957 assert_eq!(second_report.path_taken, OpenPath::WarmRevalidate);
1958 assert_eq!(second.total().files, 2);
1959 assert!(second.lookup(Path::new("added.md")).is_some());
1960 assert!(second.lookup(Path::new("a.txt")).is_none());
1961 }
1962
1963 #[test]
1964 fn a_snapshot_from_another_root_is_ignored() {
1965 let a = tempfile::tempdir().expect("tempdir a");
1966 let b = tempfile::tempdir().expect("tempdir b");
1967 let cache = tempfile::tempdir().expect("cache dir");
1968 write_file(&a.path().join("only-in-a.txt"), b"x");
1969 write_file(&b.path().join("only-in-b.txt"), b"y");
1970
1971 let cache_path = cache.path().join("snap.fdu");
1972 let config = OpenFixture {
1973 cache_path: Some(cache_path),
1974 policy: CachePolicy::Auto,
1975 ..OpenFixture::default()
1976 };
1977
1978 open_fixture(a.path(), &config).expect("open a");
1979 let (index, report) = open_fixture(b.path(), &config).expect("open b");
1980
1981 assert_eq!(report.path_taken, OpenPath::ColdScan);
1982 assert!(index.lookup(Path::new("only-in-b.txt")).is_some());
1983 assert!(index.lookup(Path::new("only-in-a.txt")).is_none());
1984 }
1985
1986 #[test]
1987 fn snapshot_scope_mismatch_forces_a_cold_scan() {
1988 let dir = tempfile::tempdir().expect("tempdir");
1989 let cache = tempfile::tempdir().expect("cache dir");
1990 write_file(&dir.path().join("top.txt"), b"top");
1991 write_file(&dir.path().join("deep/nested.txt"), b"nested");
1992
1993 let cache_path = cache.path().join("snap.fdu");
1994 let full = OpenFixture {
1995 cache_path: Some(cache_path.clone()),
1996 policy: CachePolicy::Auto,
1997 ..OpenFixture::default()
1998 };
1999 open_fixture(dir.path(), &full).expect("full open");
2000
2001 let shallow = OpenFixture {
2002 scan: ScanConfig { max_depth: Some(1), ..ScanConfig::default() },
2003 cache_path: Some(cache_path),
2004 ..OpenFixture::default()
2005 };
2006 let (index, report) = open_fixture(dir.path(), &shallow).expect("shallow open");
2007
2008 assert_eq!(report.path_taken, OpenPath::ColdScan);
2009 assert!(index.lookup(Path::new("deep")).is_some());
2010 assert!(index.lookup(Path::new("deep/nested.txt")).is_none());
2011 }
2012
2013 #[test]
2014 fn admission_scope_mismatch_cannot_reinterpret_a_snapshot() {
2015 let dir = tempfile::tempdir().expect("tempdir");
2016 let cache = tempfile::tempdir().expect("cache dir");
2017 write_file(&dir.path().join(".hidden"), b"hidden");
2018 let cache_path = cache.path().join("snap.fdu");
2019 let seed = OpenFixture {
2020 cache_path: Some(cache_path.clone()),
2021 policy: CachePolicy::Auto,
2022 ..OpenFixture::default()
2023 };
2024 open_fixture(dir.path(), &seed).expect("seed snapshot");
2025
2026 let changed_scopes = [
2027 ScanConfig {
2028 hidden: Some(std::sync::Arc::new(
2029 HiddenPolicy::prune_hidden::<[&str; 0], &str>([]),
2030 )),
2031 ..ScanConfig::default()
2032 },
2033 ScanConfig { exclude_special: true, ..ScanConfig::default() },
2034 ];
2035 for scan in changed_scopes {
2036 let only = OpenFixture {
2037 policy: CachePolicy::Auto,
2038 scan,
2039 cache_path: Some(cache_path.clone()),
2040 stale_ok: true,
2041 analysis: content::AnalysisRequest::default(),
2042 };
2043 assert!(matches!(open_fixture(dir.path(), &only), Err(Error::Snapshot(_))));
2044 }
2045 }
2046
2047 #[test]
2048 fn operational_batch_size_does_not_invalidate_a_snapshot() {
2049 let dir = tempfile::tempdir().expect("tempdir");
2050 let cache = tempfile::tempdir().expect("cache dir");
2051 write_file(&dir.path().join("a.txt"), b"a");
2052 let cache_path = cache.path().join("snap.fdu");
2053
2054 let first = OpenFixture {
2055 stale_ok: false,
2056 scan: ScanConfig { batch_size: 1, ..ScanConfig::default() },
2057 cache_path: Some(cache_path.clone()),
2058 policy: CachePolicy::Auto,
2059 analysis: content::AnalysisRequest::default(),
2060 };
2061 open_fixture(dir.path(), &first).expect("first open");
2062
2063 let second = OpenFixture {
2064 scan: ScanConfig { batch_size: 17, ..ScanConfig::default() },
2065 cache_path: Some(cache_path),
2066 ..OpenFixture::default()
2067 };
2068 let (_, report) = open_fixture(dir.path(), &second).expect("second open");
2069 assert_eq!(report.path_taken, OpenPath::WarmRevalidate);
2070 }
2071
2072 #[test]
2073 fn cache_paths_differ_per_root() {
2074 let a = tempfile::tempdir().expect("tempdir a");
2075 let b = tempfile::tempdir().expect("tempdir b");
2076 let (Some(pa), Some(pb)) = (default_cache_path(a.path()), default_cache_path(b.path()))
2077 else {
2078 return; };
2080 assert_ne!(pa, pb);
2081 assert_eq!(pa, default_cache_path(a.path()).expect("stable"));
2082 }
2083
2084 #[test]
2085 fn cache_destination_precedence_and_pairing() {
2086 let fixture = tempfile::tempdir().expect("tempdir");
2087 let explicit = fixture.path().join("explicit");
2088 let override_dir = fixture.path().join("override");
2089 let xdg = fixture.path().join("xdg");
2090 let xdg_app_dir = xdg.join("fdu");
2091 let native = fixture.path().join("native");
2092 let resolve = |choice| {
2093 resolve_cache_dir(
2094 choice,
2095 Some(override_dir.as_os_str().to_os_string()),
2096 Some(xdg.as_os_str().to_os_string()),
2097 Some(native.clone()),
2098 )
2099 .expect("resolve")
2100 .expect("directory")
2101 };
2102 assert_eq!(resolve(Some(&explicit)), explicit);
2103 assert_eq!(resolve(None), override_dir);
2104 assert_eq!(
2105 resolve_cache_dir(None, None, Some(xdg.into_os_string()), Some(native.clone()))
2106 .expect("resolve"),
2107 Some(xdg_app_dir)
2108 );
2109 assert_eq!(
2110 resolve_cache_dir(None, None, None, Some(native.clone())).expect("resolve"),
2111 Some(native.join("fdu"))
2112 );
2113 assert!(resolve_cache_dir(Some(Path::new("")), None, None, None).is_err());
2114
2115 let metadata = default_cache_path_in(fixture.path(), Some(&explicit))
2116 .expect("path")
2117 .expect("directory");
2118 let paths = CachePaths::from_metadata(&metadata);
2119 assert_eq!(paths.metadata.parent(), Some(explicit.as_path()));
2120 assert_eq!(
2121 paths.metadata.file_name().expect("name").to_string_lossy().len(),
2122 16 + ".metadata.bin".len()
2123 );
2124 assert!(
2125 paths.metadata.file_name().expect("name").to_string_lossy().ends_with(".metadata.bin")
2126 );
2127 assert!(
2128 paths.analysis.file_name().expect("name").to_string_lossy().ends_with(".analysis.bin")
2129 );
2130 assert_eq!(paths.analysis.parent(), paths.metadata.parent());
2131 }
2132
2133 #[cfg(target_os = "macos")]
2134 #[test]
2135 fn macos_native_cache_base_is_under_home_dot_cache() {
2136 let home = PathBuf::from(nonempty_env("HOME").expect("HOME for native cache"));
2137 assert_eq!(platform_cache_dir(), Some(home.join(".cache")));
2138 }
2139
2140 #[cfg(target_os = "windows")]
2141 #[test]
2142 fn windows_cache_discovery_prefers_native_locations() {
2143 let local = OsString::from(r"C:\Users\tester\AppData\Local");
2144 let profile = OsString::from(r"D:\Profile");
2145 let home = OsString::from(r"E:\Home");
2146
2147 assert_eq!(
2148 windows_cache_dir(Some(local.clone()), Some(profile.clone()), Some(home.clone())),
2149 Some(PathBuf::from(local))
2150 );
2151 assert_eq!(
2152 windows_cache_dir(None, Some(profile.clone()), Some(home.clone())),
2153 Some(PathBuf::from(profile).join("AppData").join("Local"))
2154 );
2155 assert_eq!(
2156 windows_cache_dir(None, None, Some(home.clone())),
2157 Some(PathBuf::from(home).join(".cache"))
2158 );
2159 assert_eq!(windows_cache_dir(None, None, None), None);
2160 }
2161}
2162
2163#[cfg(test)]
2164mod save_tests {
2165 use super::*;
2166 use std::fs;
2167
2168 fn write_file(path: &Path, contents: &[u8]) {
2169 if let Some(parent) = path.parent() {
2170 fs::create_dir_all(parent).expect("create parent");
2171 }
2172 fs::write(path, contents).expect("write");
2173 }
2174
2175 fn config(snapshot_path: &Path, policy: CachePolicy) -> OpenFixture {
2176 OpenFixture {
2177 cache_path: Some(snapshot_path.to_path_buf()),
2178 policy,
2179 ..OpenFixture::default()
2180 }
2181 }
2182
2183 #[test]
2184 fn a_pending_save_completes_when_joined() {
2185 let dir = tempfile::tempdir().expect("tempdir");
2186 let cache = tempfile::tempdir().expect("cache dir");
2187 let snapshot_path = cache.path().join("snap.fdu");
2188 write_file(&dir.path().join("a.txt"), b"hello");
2189
2190 let (_index, _report, pending) =
2191 open_fixture_with_pending_save(dir.path(), &config(&snapshot_path, CachePolicy::Auto))
2192 .expect("open");
2193 pending.join().expect("save succeeds");
2194 assert!(snapshot_path.exists(), "a joined save must have landed");
2195 }
2196
2197 #[test]
2198 fn a_dropped_save_still_lands() {
2199 let dir = tempfile::tempdir().expect("tempdir");
2202 let cache = tempfile::tempdir().expect("cache dir");
2203 let snapshot_path = cache.path().join("snap.fdu");
2204 write_file(&dir.path().join("a.txt"), b"hello");
2205
2206 {
2207 let (_index, _report, _pending) = open_fixture_with_pending_save(
2208 dir.path(),
2209 &config(&snapshot_path, CachePolicy::Auto),
2210 )
2211 .expect("open");
2212 }
2213 assert!(snapshot_path.exists());
2214 }
2215
2216 #[test]
2217 #[cfg(unix)]
2218 fn a_partial_scan_leaves_the_previous_snapshot_alone() {
2219 use std::os::unix::fs::PermissionsExt;
2222
2223 if !crate::test_support::require_permission_bits() {
2224 return;
2225 }
2226
2227 let dir = tempfile::tempdir().expect("tempdir");
2228 let cache = tempfile::tempdir().expect("cache dir");
2229 let snapshot_path = cache.path().join("snap.fdu");
2230 write_file(&dir.path().join("a.txt"), b"hello");
2231
2232 let settings = config(&snapshot_path, CachePolicy::Auto);
2233 open_fixture(dir.path(), &settings).expect("seed a complete snapshot");
2234 let complete_len = fs::metadata(&snapshot_path).expect("exists").len();
2235
2236 let denied = dir.path().join("denied");
2237 fs::create_dir(&denied).expect("create");
2238 write_file(&denied.join("hidden.txt"), b"hidden");
2239 fs::set_permissions(&denied, fs::Permissions::from_mode(0o000)).expect("deny");
2240
2241 let opened = open_fixture(dir.path(), &settings);
2242 fs::set_permissions(&denied, fs::Permissions::from_mode(0o700)).expect("restore");
2243 let (_index, report) = opened.expect("partial open still returns a result");
2244
2245 assert!(!report.is_complete(), "the scan should be partial");
2246 assert_eq!(
2247 fs::metadata(&snapshot_path).expect("still there").len(),
2248 complete_len,
2249 "a partial scan must not overwrite a complete snapshot"
2250 );
2251 }
2252
2253 #[test]
2258 #[cfg(unix)]
2259 fn a_partial_scan_writes_only_verified_content_for_the_stored_entry_tier() {
2260 use std::os::unix::fs::PermissionsExt;
2261
2262 if !crate::test_support::require_permission_bits() {
2263 return;
2264 }
2265
2266 let dir = tempfile::tempdir().expect("tempdir");
2267 let cache = tempfile::tempdir().expect("cache dir");
2268 let snapshot_path = cache.path().join("snap.fdu");
2269 let sidecar_path = content::content_cache_path(&snapshot_path);
2270 write_file(&dir.path().join("notes.md"), b"one two\n");
2271 write_file(&dir.path().join("locked/old.md"), b"three\n");
2272 let analysis = content::AnalysisRequest {
2273 profile: content::AnalysisSet::NONE.with_lines(),
2274 ..content::AnalysisRequest::default()
2275 };
2276 let auto = OpenFixture {
2277 cache_path: Some(snapshot_path.clone()),
2278 policy: CachePolicy::Auto,
2279 analysis,
2280 ..OpenFixture::default()
2281 };
2282 let (_, seeded) = open_fixture(dir.path(), &auto).expect("seed both tiers");
2283 assert!(seeded.is_complete());
2284 let snapshot_before = fs::read(&snapshot_path).expect("a snapshot");
2285
2286 write_file(&dir.path().join("notes.md"), b"one two three\n");
2288 let locked = dir.path().join("locked");
2289 let run = |config: &OpenFixture| {
2290 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("deny");
2291 let opened = open_fixture(dir.path(), config);
2292 fs::set_permissions(&locked, fs::Permissions::from_mode(0o700)).expect("restore");
2293 let (_, report) = opened.expect("a partial open still answers");
2294 assert!(!report.is_complete(), "the pass should be partial");
2295 };
2296
2297 let sidecar_before = fs::read(&sidecar_path).expect("a sidecar");
2299 let other_scope = OpenFixture {
2300 scan: ScanConfig { max_depth: Some(8), ..ScanConfig::default() },
2301 ..auto.clone()
2302 };
2303 run(&other_scope);
2304 assert_eq!(fs::read(&snapshot_path).expect("snapshot"), snapshot_before);
2305 assert_eq!(
2306 fs::read(&sidecar_path).expect("sidecar"),
2307 sidecar_before,
2308 "a partial run under another entry identity must not evict the paired sidecar"
2309 );
2310
2311 run(&auto);
2314 assert_eq!(
2315 fs::read(&snapshot_path).expect("snapshot"),
2316 snapshot_before,
2317 "a partial scan must not overwrite a complete snapshot"
2318 );
2319 assert_ne!(fs::read(&sidecar_path).expect("sidecar"), sidecar_before);
2320 let (mut fresh, _) =
2323 scan::scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
2324 let wanted = fresh.content_identity(analysis.profile);
2325 let loaded = content::load_content_cache(&mut fresh, &wanted, &sidecar_path).expect("load");
2326 assert_eq!((loaded.usable, loaded.hits, loaded.stale), (true, 1, 0), "{loaded:?}");
2327 let content = fresh.content().expect("content");
2328 assert!(content.file(Path::new("locked/old.md")).is_none());
2329 assert_eq!(
2330 content
2331 .file(Path::new("notes.md"))
2332 .expect("verified record")
2333 .lines
2334 .value()
2335 .expect("line metrics")
2336 .raw_words,
2337 3
2338 );
2339 }
2340
2341 #[test]
2344 #[cfg(unix)]
2345 fn a_warm_partial_pass_keeps_all_and_only_verified_content_records() {
2346 use std::os::unix::fs::PermissionsExt;
2347
2348 if !crate::test_support::require_permission_bits() {
2349 return;
2350 }
2351
2352 let dir = tempfile::tempdir().expect("tempdir");
2353 let cache = tempfile::tempdir().expect("cache dir");
2354 let snapshot_path = cache.path().join("snap.fdu");
2355 let sidecar_path = content::content_cache_path(&snapshot_path);
2356 for index in 0..7 {
2357 write_file(&dir.path().join(format!("f{index}.md")), b"alpha\nbeta\n");
2358 }
2359 write_file(&dir.path().join("locked/old.md"), b"three\n");
2360 let analysis = content::AnalysisRequest {
2361 profile: content::AnalysisSet::NONE.with_lines(),
2362 ..content::AnalysisRequest::default()
2363 };
2364 let auto = OpenFixture {
2365 cache_path: Some(snapshot_path.clone()),
2366 policy: CachePolicy::Auto,
2367 analysis,
2368 ..OpenFixture::default()
2369 };
2370
2371 let records_in_sidecar = |path: &Path| {
2372 let (mut fresh, _) = scan::scan_into_index(path, &ScanConfig::default()).expect("scan");
2373 let wanted = fresh.content_identity(analysis.profile);
2374 let loaded =
2375 content::load_content_cache(&mut fresh, &wanted, &sidecar_path).expect("load");
2376 assert!(loaded.usable, "the sidecar should still be readable");
2377 loaded.hits + loaded.stale
2378 };
2379
2380 let (_, seeded) = open_fixture(dir.path(), &auto).expect("seed both tiers");
2381 assert!(seeded.is_complete());
2382 assert_eq!(records_in_sidecar(dir.path()), 8, "one record per seeded file");
2383
2384 write_file(&dir.path().join("f0.md"), b"alpha\nbeta\ngamma\n");
2386 let locked = dir.path().join("locked");
2387 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("deny");
2388 let opened = open_fixture(dir.path(), &auto);
2389 fs::set_permissions(&locked, fs::Permissions::from_mode(0o700)).expect("restore");
2390 let (_, report) = opened.expect("a partial open still answers");
2391 assert!(!report.is_complete(), "the pass should be partial");
2392
2393 assert_eq!(
2394 records_in_sidecar(dir.path()),
2395 7,
2396 "all seven verified files survive and the inaccessible record does not"
2397 );
2398 }
2399
2400 #[test]
2401 fn no_snapshot_is_written_when_the_policy_forbids_it() {
2402 let dir = tempfile::tempdir().expect("tempdir");
2403 let cache = tempfile::tempdir().expect("cache dir");
2404 let snapshot_path = cache.path().join("snap.fdu");
2405 write_file(&dir.path().join("a.txt"), b"hello");
2406
2407 let (_index, _report, pending) =
2408 open_fixture_with_pending_save(dir.path(), &config(&snapshot_path, CachePolicy::Off))
2409 .expect("open");
2410 pending.join().expect("nothing to join");
2411 assert!(!snapshot_path.exists(), "cache off wrote a snapshot");
2412 }
2413}