Skip to main content

fallow_engine/
cache_status.rs

1//! Read-only inspection of the persisted caches.
2//!
3//! Exists for `fallow doctor`, which diagnoses project readiness without
4//! running an analysis. A refused cache is invisible in every other read-only
5//! surface: the run that pays for it is the one that reports it, and doctor
6//! never starts one.
7//!
8//! Both persisted caches are inspected. A warm run reuses the extraction blob
9//! and the module graph independently, and the graph blob is the larger of the
10//! two on a real project, so reporting only the extraction cache told a user
11//! their caches were healthy while the expensive half was being discarded on
12//! every run.
13
14use std::path::Path;
15
16use fallow_config::ResolvedConfig;
17use fallow_types::cache_rejection::CacheRejection;
18
19/// On-disk state of the extraction cache for one project.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct ParseCacheStatus {
22    /// Why the cache would not be reused by a run with this configuration, or
23    /// `None` when a run would load it.
24    pub rejection: Option<CacheRejection>,
25    /// Size of `cache.bin` on disk, when the file exists.
26    pub size_bytes: Option<u64>,
27}
28
29/// Inspect the persisted extraction cache the way an analysis run would.
30///
31/// This reads the header and decodes the blob to learn its config hash, so
32/// the cost is that of a cache load and
33/// nothing more: no analysis, no writes, no network. `config.no_cache` is
34/// deliberately ignored, because the question is what state the cache is in,
35/// not whether this particular invocation would consult it.
36///
37/// The expected config hash is recomputed rather than read off `config`:
38/// `ResolvedConfig::cache_config_hash` is zero whenever caching is disabled,
39/// which is exactly how a caller that only inspects resolves its config, and
40/// comparing a real cache against that zero reported every healthy cache as
41/// config drift.
42#[must_use]
43pub fn inspect_parse_cache(config: &ResolvedConfig) -> ParseCacheStatus {
44    let size_bytes = cache_file_size(&config.cache_dir);
45    let rejection = fallow_extract::cache::CacheStore::load(
46        &config.cache_dir,
47        &config.root,
48        fallow_config::cache_config_hash(&config.external_plugins),
49        crate::project_config::resolve_cache_max_size_bytes(config),
50    )
51    .err();
52    ParseCacheStatus {
53        rejection,
54        size_bytes,
55    }
56}
57
58/// On-disk state of the persisted module graph for one project.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct GraphCacheStatus {
61    /// Why the persisted graph could not be loaded, or `None` when it decodes
62    /// into the current shape and belongs to this project root.
63    ///
64    /// Whether a run would REUSE the graph also
65    /// depends on the resolver options, entry points, and per-file
66    /// fingerprints, and comparing those means running discovery and
67    /// extraction, which doctor deliberately does not do.
68    pub rejection: Option<CacheRejection>,
69    /// Size of `graph-cache.bin` on disk, when the file exists.
70    pub size_bytes: Option<u64>,
71}
72
73/// Inspect the persisted module graph the way an analysis run would load it.
74///
75/// Read-only: no analysis, no writes, no network. `config.no_cache` is ignored
76/// for the same reason as in [`inspect_parse_cache`]: the question is what
77/// state the cache is in, not whether this invocation would consult it.
78#[must_use]
79pub fn inspect_graph_cache(config: &ResolvedConfig) -> GraphCacheStatus {
80    let size_bytes = cache_entry_size(&config.cache_dir, fallow_graph::cache::GRAPH_CACHE_FILE);
81    let rejection = match fallow_graph::cache::GraphCacheStore::load(&config.cache_dir) {
82        Ok(store) => (store.manifest.root != config.root).then_some(CacheRejection::RootMismatch),
83        Err(rejection) => Some(rejection),
84    };
85    GraphCacheStatus {
86        rejection,
87        size_bytes,
88    }
89}
90
91fn cache_file_size(cache_dir: &Path) -> Option<u64> {
92    cache_entry_size(cache_dir, "cache.bin")
93}
94
95fn cache_entry_size(cache_dir: &Path, file_name: &str) -> Option<u64> {
96    std::fs::metadata(cache_dir.join(file_name))
97        .ok()
98        .map(|metadata| metadata.len())
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    use fallow_config::{FallowConfig, OutputFormat};
106
107    fn config_for(root: &Path, no_cache: bool) -> ResolvedConfig {
108        FallowConfig::default().resolve(
109            root.to_path_buf(),
110            OutputFormat::Json,
111            1,
112            no_cache,
113            true,
114            None,
115        )
116    }
117
118    #[test]
119    fn an_absent_cache_reports_absent_with_no_size() {
120        let root = tempfile::tempdir().expect("temp root");
121        let status = inspect_parse_cache(&config_for(root.path(), true));
122
123        assert_eq!(status.rejection, Some(CacheRejection::Absent));
124        assert_eq!(status.size_bytes, None);
125    }
126
127    /// A caller that only inspects resolves its config with caching disabled,
128    /// which zeroes `cache_config_hash`. Comparing a real cache against that
129    /// zero reported every healthy cache as config drift, so the expected hash
130    /// is recomputed from the same inputs a run uses.
131    #[test]
132    fn a_cache_written_by_a_run_reads_as_reusable_from_an_inspecting_config() {
133        let root = tempfile::tempdir().expect("temp root");
134        let analysis = config_for(root.path(), false);
135        let mut store = fallow_extract::cache::CacheStore::new(&analysis.root);
136        store
137            .save(
138                &analysis.cache_dir,
139                analysis.cache_config_hash,
140                fallow_extract::cache::DEFAULT_CACHE_MAX_SIZE,
141            )
142            .expect("save cache as a run would");
143
144        let status = inspect_parse_cache(&config_for(root.path(), true));
145
146        assert_eq!(status.rejection, None);
147        assert!(status.size_bytes.is_some_and(|bytes| bytes > 0));
148    }
149
150    /// An unframed blob may be an old cache or foreign data. Report the decode
151    /// failure and size without claiming which one it is.
152    #[test]
153    fn a_foreign_cache_blob_reports_a_decode_failure_with_its_size() {
154        let root = tempfile::tempdir().expect("temp root");
155        let config = config_for(root.path(), true);
156        std::fs::create_dir_all(&config.cache_dir).expect("cache dir");
157        std::fs::write(config.cache_dir.join("cache.bin"), b"garbage").expect("foreign cache");
158
159        let status = inspect_parse_cache(&config);
160
161        assert_eq!(status.rejection, Some(CacheRejection::Undecodable));
162        assert_eq!(status.size_bytes, Some(7));
163    }
164
165    /// A blob that DOES carry fallow's framing, under a version this build
166    /// never writes, came from another fallow build. Reporting that as a
167    /// decode failure told an upgrading user their cache was corrupt.
168    ///
169    /// The header is spelled out here because the magic is an on-disk constant
170    /// rather than a crate export. That is safe in both directions: if the
171    /// magic ever moved, this blob would stop framing and the assertion would
172    /// fail loudly rather than quietly testing the other branch.
173    #[test]
174    fn a_cache_from_another_build_reports_a_format_change_with_its_size() {
175        let root = tempfile::tempdir().expect("temp root");
176        let config = config_for(root.path(), true);
177        std::fs::create_dir_all(&config.cache_dir).expect("cache dir");
178        let mut blob = Vec::from(*b"FLWX");
179        blob.extend_from_slice(&u32::MAX.to_le_bytes());
180        blob.extend_from_slice(b"payload");
181        std::fs::write(config.cache_dir.join("cache.bin"), &blob)
182            .expect("cache from another build");
183
184        let status = inspect_parse_cache(&config);
185
186        assert_eq!(status.rejection, Some(CacheRejection::VersionMismatch));
187        assert_eq!(status.size_bytes, Some(15));
188    }
189
190    #[test]
191    fn an_absent_graph_cache_reports_absent_with_no_size() {
192        let root = tempfile::tempdir().expect("temp root");
193        let status = inspect_graph_cache(&config_for(root.path(), true));
194
195        assert_eq!(status.rejection, Some(CacheRejection::Absent));
196        assert_eq!(status.size_bytes, None);
197    }
198
199    /// The graph blob is framed by the same rule as the extraction blob, with
200    /// its own magic, and preserves the same uncertainty for unframed data.
201    #[test]
202    fn a_foreign_graph_cache_blob_reports_a_decode_failure_with_its_size() {
203        let root = tempfile::tempdir().expect("temp root");
204        let config = config_for(root.path(), true);
205        std::fs::create_dir_all(&config.cache_dir).expect("cache dir");
206        std::fs::write(
207            config.cache_dir.join(fallow_graph::cache::GRAPH_CACHE_FILE),
208            b"garbage",
209        )
210        .expect("foreign graph cache");
211
212        let status = inspect_graph_cache(&config);
213
214        assert_eq!(status.rejection, Some(CacheRejection::Undecodable));
215        assert_eq!(status.size_bytes, Some(7));
216    }
217
218    #[test]
219    fn a_graph_cache_from_another_build_reports_a_format_change_with_its_size() {
220        let root = tempfile::tempdir().expect("temp root");
221        let config = config_for(root.path(), true);
222        std::fs::create_dir_all(&config.cache_dir).expect("cache dir");
223        let mut blob = Vec::from(*b"FLWG");
224        blob.extend_from_slice(&u32::MAX.to_le_bytes());
225        blob.extend_from_slice(b"payload");
226        std::fs::write(
227            config.cache_dir.join(fallow_graph::cache::GRAPH_CACHE_FILE),
228            &blob,
229        )
230        .expect("graph cache from another build");
231
232        let status = inspect_graph_cache(&config);
233
234        assert_eq!(status.rejection, Some(CacheRejection::VersionMismatch));
235        assert_eq!(status.size_bytes, Some(15));
236    }
237
238    #[test]
239    fn a_loadable_graph_from_another_root_reports_the_known_mismatch() {
240        let original = tempfile::tempdir().expect("original root");
241        let root = original.path().canonicalize().expect("canonical root");
242        std::fs::create_dir(root.join("src")).expect("source directory");
243        std::fs::write(root.join("src/index.ts"), "export const entry = 1;\n").expect("source");
244        crate::session::AnalysisSession::load_default(&root)
245            .analyze_dead_code_with_artifacts(false, true)
246            .expect("prime graph cache");
247        let original_config = config_for(&root, true);
248        assert_eq!(inspect_graph_cache(&original_config).rejection, None);
249
250        let relocated = tempfile::tempdir().expect("relocated root");
251        let mut relocated_config = config_for(relocated.path(), true);
252        relocated_config.cache_dir = original_config.cache_dir;
253        assert_eq!(
254            inspect_graph_cache(&relocated_config).rejection,
255            Some(CacheRejection::RootMismatch)
256        );
257    }
258
259    #[test]
260    fn unreadable_cache_paths_are_not_reported_as_absent() {
261        let root = tempfile::tempdir().expect("temp root");
262        let config = config_for(root.path(), true);
263        for name in ["cache.bin", fallow_graph::cache::GRAPH_CACHE_FILE] {
264            std::fs::create_dir_all(config.cache_dir.join(name)).expect("unreadable cache path");
265        }
266        assert_eq!(
267            inspect_parse_cache(&config).rejection,
268            Some(CacheRejection::Unreadable)
269        );
270        assert_eq!(
271            inspect_graph_cache(&config).rejection,
272            Some(CacheRejection::Unreadable)
273        );
274    }
275}