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