fallow_graph/cache/store.rs
1//! Persisted graph-cache store: coarse all-or-nothing load / save of a
2//! previously-built [`ModuleGraph`].
3//!
4//! Mirrors the extraction cache store (`fallow_extract::cache::store`): the
5//! payload is postcard-encoded, written atomically via a sibling `.tmp` file
6//! plus best-effort fsync and rename, and a `.gitignore` is written alongside
7//! so `.fallow/` is never committed. Every IO error is swallowed (the graph
8//! cache is best-effort and must never fail analysis); a corrupt or
9//! version-mismatched file simply misses and the graph is rebuilt fresh.
10
11use std::path::Path;
12
13use serde::{Deserialize, Serialize};
14
15use super::{CachedResolvedModule, GRAPH_CACHE_VERSION, GraphCacheManifest};
16use crate::graph::ModuleGraph;
17
18/// Filename of the persisted graph cache inside the cache directory.
19const GRAPH_CACHE_FILE: &str = "graph-cache.bin";
20
21/// On-disk graph cache entry: a manifest plus the graph it validates.
22#[derive(Serialize, Deserialize)]
23pub struct GraphCacheStore {
24 /// Schema version. Checked on load; a mismatch misses so a stale file from
25 /// an older binary is never deserialized into the wrong shape.
26 pub version: u32,
27 /// Inputs that must match the current run for the graph to be trusted.
28 pub manifest: GraphCacheManifest,
29 /// The previously-built graph. Its `namespace_imported` bitset is
30 /// `#[serde(skip)]`, so the loader reconstructs it from the edge set.
31 pub graph: ModuleGraph,
32 /// Resolver outputs aligned with the cached graph. Exact manifest hits use
33 /// these alongside the graph; stable-key resolver hits remap them and
34 /// rebuild the graph with current `FileId`s.
35 pub resolved_modules: Vec<CachedResolvedModule>,
36}
37
38impl GraphCacheStore {
39 /// Load the persisted graph cache from `cache_dir`.
40 ///
41 /// Returns `None` when the file is missing, undecodable, or written for a
42 /// different `GRAPH_CACHE_VERSION`. The caller compares the loaded
43 /// manifest against the current inputs before trusting the graph or
44 /// resolver payload.
45 #[must_use]
46 pub fn load(cache_dir: &Path) -> Option<Self> {
47 let cache_file = cache_dir.join(GRAPH_CACHE_FILE);
48 let data = std::fs::read(&cache_file).ok()?;
49 let mut store: Self = match postcard::from_bytes(&data) {
50 Ok(store) => store,
51 Err(_) => {
52 tracing::info!(
53 "Graph cache format upgraded, rebuilding (one-time cost after version bump)"
54 );
55 return None;
56 }
57 };
58 if store.version != GRAPH_CACHE_VERSION {
59 tracing::info!(
60 "Graph cache format upgraded, rebuilding (one-time cost after version bump)"
61 );
62 return None;
63 }
64 // `namespace_imported` is `#[serde(skip)]`; rebuild it from the persisted
65 // edges so the loaded graph is byte-identical to a fresh build.
66 store.graph.reconstruct_namespace_imported();
67 Some(store)
68 }
69
70 /// Persist this graph cache to `cache_dir`, best-effort.
71 ///
72 /// Creates the cache directory, writes a `.gitignore`, encodes the store
73 /// with postcard, and writes `graph-cache.bin` atomically. Every IO error
74 /// is logged at debug and swallowed; the graph cache must never fail the
75 /// surrounding analysis run.
76 pub fn save(&self, cache_dir: &Path) {
77 if let Err(error) = std::fs::create_dir_all(cache_dir) {
78 tracing::debug!("Failed to create graph cache dir: {error}");
79 return;
80 }
81 if let Err(error) = write_cache_gitignore(cache_dir) {
82 tracing::debug!("Failed to write graph cache .gitignore: {error}");
83 // Continue: a missing .gitignore does not invalidate the cache file.
84 }
85
86 let encoded = match postcard::to_allocvec(self) {
87 Ok(bytes) => bytes,
88 Err(error) => {
89 tracing::debug!("Failed to encode graph cache: {error}");
90 return;
91 }
92 };
93
94 let cache_file = cache_dir.join(GRAPH_CACHE_FILE);
95 if let Err(error) = atomic_write(&cache_file, &encoded) {
96 tracing::debug!("Failed to write graph cache: {error}");
97 }
98 }
99}
100
101/// Write `.fallow/.gitignore` (`*\n`) so the cache directory is never committed.
102fn write_cache_gitignore(cache_dir: &Path) -> std::io::Result<()> {
103 std::fs::write(cache_dir.join(".gitignore"), "*\n")
104}
105
106/// Write `data` atomically via a sibling `.tmp` file, best-effort fsync, then
107/// rename. Copied from the extraction cache store so the two caches share the
108/// same crash-safe write semantics.
109fn atomic_write(cache_file: &Path, data: &[u8]) -> std::io::Result<()> {
110 let tmp_file = match cache_file.file_name() {
111 Some(name) => cache_file.with_file_name({
112 let mut s = name.to_os_string();
113 s.push(".tmp");
114 s
115 }),
116 None => {
117 return Err(std::io::Error::new(
118 std::io::ErrorKind::InvalidInput,
119 "graph cache file path has no filename component",
120 ));
121 }
122 };
123
124 {
125 use std::io::Write as _;
126 let mut f = std::fs::File::create(&tmp_file)?;
127 f.write_all(data)?;
128 let _ = f.sync_all();
129 }
130
131 std::fs::rename(&tmp_file, cache_file)
132}