Skip to main content

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}