fallow_types/cache_rejection.rs
1//! Why a persisted cache was not reused.
2//!
3//! Both persistent caches (the extraction blob in `fallow-extract` and the
4//! module-graph blob in `fallow-graph`) used to collapse every refusal into
5//! `None`, so a run that paid full deserialisation cost and then reused
6//! nothing looked exactly like a run with no cache at all. The reason is a
7//! measurement, not an internal detail: it decides whether a user should fix a
8//! config drift, delete a corrupt blob, or accept a legitimate cold run.
9//!
10//! The variants split by WHO decided. `Absent` through `RootMismatch` are
11//! decided inside a loader, before it hands a store back. `ModeMismatch`
12//! through `FingerprintChanged` are decided by the caller after the load
13//! succeeded, which is exactly the case that costs the most and used to say
14//! the least.
15
16#[cfg(feature = "schema")]
17use schemars::JsonSchema;
18use serde::Serialize;
19
20/// Why a cache load or a cache comparison refused to reuse persisted work.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
22#[cfg_attr(feature = "schema", derive(JsonSchema))]
23#[serde(tag = "reason", rename_all = "kebab-case")]
24pub enum CacheRejection {
25 /// No cache file exists yet. The only variant that is not a refusal of
26 /// existing work: a first run on a project reports this.
27 Absent,
28 /// The cache path exists or could not be inspected, but reading it failed.
29 Unreadable,
30 /// The cache file is larger than the safety ceiling, so it was never
31 /// decoded. Reported with both figures so the operator can raise the
32 /// configured ceiling or delete the blob.
33 Oversize {
34 /// On-disk size of the refused cache file in bytes.
35 size_bytes: u64,
36 /// Ceiling the file exceeded, in bytes.
37 ceiling_bytes: u64,
38 },
39 /// The cache could not be decoded: an older unframed format, foreign data,
40 /// or a damaged payload. This does not establish corruption.
41 Undecodable,
42 /// The decoded cache declares a different format version, so its entries
43 /// cannot be read into the current shape.
44 VersionMismatch,
45 /// The cache was built under a different extraction-affecting config, so
46 /// its entries describe a different analysis.
47 ConfigHashMismatch,
48 /// The graph cache was built for a different project root. Its retained
49 /// absolute paths cannot be reused in the relocated checkout. Extraction
50 /// entries remain independently reusable through their root-relative keys.
51 RootMismatch,
52 /// The graph cache decoded, but it was built with different resolver
53 /// options, entry points, or plugin configuration.
54 ModeMismatch,
55 /// The graph cache decoded, but the set of analysed files changed.
56 FileSetChanged,
57 /// The graph cache decoded and covers the same files, but at least one
58 /// file's content changed.
59 FingerprintChanged,
60}
61
62impl CacheRejection {
63 /// Stable kebab-case identifier for logs, doctor output, and tests.
64 #[must_use]
65 pub const fn id(&self) -> &'static str {
66 match self {
67 Self::Absent => "absent",
68 Self::Unreadable => "unreadable",
69 Self::Oversize { .. } => "oversize",
70 Self::Undecodable => "undecodable",
71 Self::VersionMismatch => "version-mismatch",
72 Self::ConfigHashMismatch => "config-hash-mismatch",
73 Self::RootMismatch => "root-mismatch",
74 Self::ModeMismatch => "mode-mismatch",
75 Self::FileSetChanged => "file-set-changed",
76 Self::FingerprintChanged => "fingerprint-changed",
77 }
78 }
79
80 /// Short human sentence fragment, suitable inside a perf row or a doctor
81 /// message. Never contains a host path.
82 #[must_use]
83 pub fn describe(&self) -> String {
84 match self {
85 Self::Absent => "no cache file yet".to_string(),
86 Self::Unreadable => {
87 "cache file could not be read; check the path and permissions".to_string()
88 }
89 Self::Oversize {
90 size_bytes,
91 ceiling_bytes,
92 } => format!(
93 "cache file is {}, over the {} ceiling",
94 format_mb(*size_bytes),
95 format_mb(*ceiling_bytes)
96 ),
97 Self::Undecodable => {
98 "cache file could not be decoded (older format or damaged data)".to_string()
99 }
100 Self::VersionMismatch => "cache format version changed".to_string(),
101 Self::ConfigHashMismatch => "extraction config changed".to_string(),
102 Self::RootMismatch => "cache was written for a different project root".to_string(),
103 Self::ModeMismatch => "resolver, entry points, or plugins changed".to_string(),
104 Self::FileSetChanged => "the analysed file set changed".to_string(),
105 Self::FingerprintChanged => "at least one file changed".to_string(),
106 }
107 }
108
109 /// Whether the refusal is worth putting on stderr.
110 ///
111 /// True when the cache was refused for a reason the user can act on: a
112 /// stale format, a config or root drift, a blob that would not decode. The
113 /// user paid for the blob and got nothing back, and something on disk or in
114 /// the config has to change before the next run does better.
115 ///
116 /// False for `Absent` and for the two content-drift variants. Editing a
117 /// file and re-running is the ordinary way to use fallow, so
118 /// `FileSetChanged` and `FingerprintChanged` describe a cache doing exactly
119 /// what it should: every edit-then-run cycle hit them, `fallow watch` hit
120 /// them once per save, and `--quiet` did not suppress the warning. Both
121 /// reasons stay on the `doctor` check and the performance table, where a
122 /// reader went looking for them.
123 #[must_use]
124 pub const fn discarded_existing_work(&self) -> bool {
125 !matches!(
126 self,
127 Self::Absent | Self::FileSetChanged | Self::FingerprintChanged
128 )
129 }
130}
131
132/// Render a byte count as a megabyte figure with one decimal place.
133fn format_mb(bytes: u64) -> String {
134 #[expect(
135 clippy::cast_precision_loss,
136 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
137 )]
138 let mb = bytes as f64 / (1024.0 * 1024.0);
139 format!("{mb:.1} MB")
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn actionable_refusals_are_worth_a_warning() {
148 for rejection in [
149 CacheRejection::Oversize {
150 size_bytes: 1,
151 ceiling_bytes: 0,
152 },
153 CacheRejection::Undecodable,
154 CacheRejection::Unreadable,
155 CacheRejection::VersionMismatch,
156 CacheRejection::ConfigHashMismatch,
157 CacheRejection::RootMismatch,
158 CacheRejection::ModeMismatch,
159 ] {
160 assert!(
161 rejection.discarded_existing_work(),
162 "{} needs a change on disk or in the config before the next run does better",
163 rejection.id()
164 );
165 }
166 }
167
168 /// Editing a file and re-running is the ordinary way to use fallow, so the
169 /// two content-drift reasons fired on every edit-then-run cycle and once
170 /// per save under `fallow watch`. They stay in doctor and the performance
171 /// table; they must not be a warning.
172 #[test]
173 fn a_routine_cache_miss_is_not_worth_a_warning() {
174 for rejection in [
175 CacheRejection::Absent,
176 CacheRejection::FileSetChanged,
177 CacheRejection::FingerprintChanged,
178 ] {
179 assert!(
180 !rejection.discarded_existing_work(),
181 "{} is what a cache is supposed to do after an edit",
182 rejection.id()
183 );
184 }
185 }
186
187 /// Without the comma the sentence reads as an excess of 300 MB rather than
188 /// a 300 MB file against a 256 MB ceiling.
189 #[test]
190 fn oversize_names_both_figures_without_reading_as_an_excess() {
191 let described = CacheRejection::Oversize {
192 size_bytes: 300 * 1024 * 1024,
193 ceiling_bytes: 256 * 1024 * 1024,
194 }
195 .describe();
196 assert!(
197 described.contains("300.0 MB, over the 256.0 MB ceiling"),
198 "{described}"
199 );
200 }
201}