Skip to main content

wowlab_docgen_cli/infra/
cache.rs

1//! Persistent correctness cache for documentation generation.
2
3use std::{
4    collections::BTreeMap,
5    sync::{Mutex, MutexGuard},
6};
7
8use serde::{Deserialize, Serialize};
9use wowlab_fs::{
10    atomic,
11    checksum::{self, Checksum, TreeSnapshot},
12    directory, file,
13    path::{Path, PathBuf},
14};
15
16const CACHE_SCHEMA: u32 = 1;
17const CACHE_DIRECTORY: &str = ".cache/docgen";
18const CACHE_FILENAME: &str = "state.json";
19const SELECTION_DOMAIN: &[u8] = b"wowlab-docgen:selection:v1\0";
20
21#[derive(Debug, Default, Deserialize, Serialize)]
22struct State {
23    schema: u32,
24    runs: BTreeMap<Box<str>, RunState>,
25    templates: BTreeMap<Box<str>, TemplateState>,
26}
27
28#[derive(Debug, Deserialize, Serialize)]
29struct RunState {
30    workspace: Checksum,
31    executable: Checksum,
32    formatter: Checksum,
33    metadata_validated: bool,
34    outputs: BTreeMap<Box<str>, Checksum>,
35}
36
37#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
38struct TemplateState {
39    rendered: Checksum,
40    formatted: Checksum,
41    executable: Checksum,
42    formatter: Checksum,
43}
44
45/// Cache bound to one workspace and the exact tools executing this run.
46#[derive(Debug)]
47pub struct Cache {
48    root: PathBuf,
49    path: PathBuf,
50    executable: Option<Checksum>,
51    formatter: Option<Checksum>,
52    state: Mutex<State>,
53}
54
55impl Cache {
56    /// Load the cache when it is present and valid.
57    ///
58    /// Malformed or unreadable cache data is treated as a cold cache.
59    #[must_use]
60    pub fn load(root: &Path, executable: Option<Checksum>, formatter: Option<Checksum>) -> Self {
61        let path = root.join(CACHE_DIRECTORY).join(CACHE_FILENAME);
62        let state = load_state(&path);
63
64        Self {
65            root: root.to_path_buf(),
66            path,
67            executable,
68            formatter,
69            state: Mutex::new(state),
70        }
71    }
72
73    /// Return whether a previous successful run proves every selected output current.
74    #[must_use]
75    pub fn is_current(
76        &self,
77        workspace: Option<&TreeSnapshot>,
78        filters: &[Box<str>],
79        require_metadata_validation: bool,
80    ) -> bool {
81        let (Some(workspace), Some(executable), Some(formatter_id)) =
82            (workspace, self.executable, self.formatter)
83        else {
84            return false;
85        };
86        let key = selection_key(filters);
87        let state = self.state();
88        let Some(run) = state.runs.get(key.as_str()) else {
89            return false;
90        };
91
92        if run.workspace != workspace.checksum()
93            || run.executable != executable
94            || run.formatter != formatter_id
95            || (require_metadata_validation && !run.metadata_validated)
96        {
97            return false;
98        }
99
100        run.outputs.iter().all(|(relative, expected)| {
101            let output = self.root.join(relative.as_ref());
102
103            checksum::file(&output).is_ok_and(|actual| actual == *expected)
104        })
105    }
106
107    /// Reuse the existing formatted output when its complete formatter input and output match.
108    #[must_use]
109    pub fn reuse_formatted(&self, output: &Path, rendered: &str) -> Option<String> {
110        let (Some(executable), Some(formatter_id)) = (self.executable, self.formatter) else {
111            return None;
112        };
113        let relative = relative_utf8(output, &self.root)?;
114        let rendered = checksum::bytes(rendered);
115        let cached = {
116            let state = self.state();
117            let cached = *state.templates.get(relative.as_str())?;
118
119            (cached.rendered == rendered
120                && cached.executable == executable
121                && cached.formatter == formatter_id)
122                .then_some(cached)
123        }?;
124        let contents = file::read_text(output).ok()?;
125
126        (checksum::bytes(&contents) == cached.formatted).then_some(contents)
127    }
128
129    /// Record one successfully formatted template.
130    pub fn record_formatted(&self, output: &Path, rendered: &str, formatted: &str) {
131        let (Some(executable), Some(formatter_id), Some(relative)) = (
132            self.executable,
133            self.formatter,
134            relative_utf8(output, &self.root),
135        ) else {
136            return;
137        };
138
139        self.state().templates.insert(
140            relative.into_boxed_str(),
141            TemplateState {
142                rendered: checksum::bytes(rendered),
143                formatted: checksum::bytes(formatted),
144                executable,
145                formatter: formatter_id,
146            },
147        );
148    }
149
150    /// Record a successful run after every selected output is known to be current.
151    pub fn record_success(
152        &self,
153        workspace: &TreeSnapshot,
154        filters: &[Box<str>],
155        output_paths: &[PathBuf],
156        metadata_validated: bool,
157    ) {
158        let (Some(executable), Some(formatter_id)) = (self.executable, self.formatter) else {
159            return;
160        };
161        let Some(outputs) = output_checksums(&self.root, output_paths) else {
162            return;
163        };
164        let key = selection_key(filters).into_boxed_str();
165        let mut state = self.state();
166        let metadata_validated = metadata_validated
167            || state.runs.get(&key).is_some_and(|previous| {
168                previous.workspace == workspace.checksum()
169                    && previous.executable == executable
170                    && previous.formatter == formatter_id
171                    && previous.metadata_validated
172            });
173
174        state.runs.insert(
175            key,
176            RunState {
177                workspace: workspace.checksum(),
178                executable,
179                formatter: formatter_id,
180                metadata_validated,
181                outputs,
182            },
183        );
184    }
185
186    /// Persist the current cache atomically.
187    ///
188    /// Cache persistence failures do not affect generator correctness.
189    pub fn persist(&self) {
190        let Ok(contents) = serde_json::to_vec(&*self.state()) else {
191            return;
192        };
193        let Some(parent) = self.path.parent() else {
194            return;
195        };
196
197        if directory::ensure(parent).is_err() {
198            return;
199        }
200
201        let _result = atomic::replace(&self.path, contents);
202    }
203
204    fn state(&self) -> MutexGuard<'_, State> {
205        self.state
206            .lock()
207            .unwrap_or_else(std::sync::PoisonError::into_inner)
208    }
209}
210
211fn load_state(path: &Path) -> State {
212    let cached = file::read_text_if_exists(path)
213        .ok()
214        .flatten()
215        .and_then(|contents| serde_json::from_str::<State>(&contents).ok());
216
217    cached
218        .filter(|state| state.schema == CACHE_SCHEMA)
219        .unwrap_or_else(|| State {
220            schema: CACHE_SCHEMA,
221            ..State::default()
222        })
223}
224
225fn output_checksums(root: &Path, paths: &[PathBuf]) -> Option<BTreeMap<Box<str>, Checksum>> {
226    paths
227        .iter()
228        .map(|path| {
229            let relative = relative_utf8(path, root)?;
230            let checksum = checksum::file(path).ok()?;
231
232            Some((relative.into_boxed_str(), checksum))
233        })
234        .collect()
235}
236
237fn relative_utf8(path: &Path, root: &Path) -> Option<String> {
238    path.strip_prefix(root).ok()?.to_str().map(str::to_owned)
239}
240
241fn selection_key(filters: &[Box<str>]) -> String {
242    let mut normalized = filters.iter().map(AsRef::as_ref).collect::<Vec<&str>>();
243
244    normalized.sort_unstable();
245    normalized.dedup();
246
247    let mut material = Vec::from(SELECTION_DOMAIN);
248
249    for filter in normalized {
250        material.extend_from_slice(filter.len().to_le_bytes().as_slice());
251        material.extend_from_slice(filter.as_bytes());
252    }
253
254    checksum::bytes(material).to_string()
255}
256
257#[cfg(test)]
258mod tests {
259    use googletest::prelude::*;
260    use wowlab_fs::temporary::Directory;
261
262    use super::*;
263
264    fn checksum(value: &str) -> Checksum {
265        checksum::bytes(value)
266    }
267
268    #[gtest]
269    fn full_hit_requires_workspace_tools_outputs_and_metadata_validation() -> Result<()> {
270        let directory = Directory::new().or_fail()?;
271        let root = directory.path();
272        let source = root.join("README.md.in");
273        let output = root.join("README.md");
274
275        file::write_text(&source, "input").or_fail()?;
276        file::write_text(&output, "output").or_fail()?;
277        let snapshot = TreeSnapshot::capture(root, [&source, &output]).or_fail()?;
278        let cache = Cache::load(root, Some(checksum("docgen")), Some(checksum("prettier")));
279
280        cache.record_success(&snapshot, &[], std::slice::from_ref(&output), true);
281
282        verify_true!(cache.is_current(Some(&snapshot), &[], true))?;
283        verify_false!(cache.is_current(
284            Some(&TreeSnapshot::capture(root, [&source]).or_fail()?),
285            &[],
286            true
287        ))?;
288
289        file::write_text(&output, "tampered").or_fail()?;
290
291        verify_false!(cache.is_current(Some(&snapshot), &[], true))
292    }
293
294    #[gtest]
295    fn full_hit_rejects_deleted_outputs_and_changed_executable() -> Result<()> {
296        let directory = Directory::new().or_fail()?;
297        let root = directory.path();
298        let output = root.join("README.md");
299
300        file::write_text(&output, "output").or_fail()?;
301        let snapshot = TreeSnapshot::capture(root, [&output]).or_fail()?;
302        let cache = Cache::load(root, Some(checksum("docgen")), Some(checksum("prettier")));
303
304        cache.record_success(&snapshot, &[], std::slice::from_ref(&output), false);
305        directory::remove_file_if_exists(&output).or_fail()?;
306
307        verify_false!(cache.is_current(Some(&snapshot), &[], false))?;
308
309        let changed = Cache::load(
310            root,
311            Some(checksum("different docgen")),
312            Some(checksum("prettier")),
313        );
314
315        verify_false!(changed.is_current(Some(&snapshot), &[], false))
316    }
317
318    #[gtest]
319    fn formatted_reuse_requires_rendered_output_and_formatter_identity() -> Result<()> {
320        let directory = Directory::new().or_fail()?;
321        let output = directory.path().join("README.md");
322        let executable = checksum("docgen");
323        let formatter = checksum("prettier");
324        let cache = Cache::load(directory.path(), Some(executable), Some(formatter));
325
326        file::write_text(&output, "formatted").or_fail()?;
327        cache.record_formatted(&output, "rendered", "formatted");
328
329        verify_eq!(
330            cache.reuse_formatted(&output, "rendered"),
331            Some("formatted".to_string())
332        )?;
333        verify_eq!(cache.reuse_formatted(&output, "changed"), None)?;
334
335        file::write_text(&output, "tampered").or_fail()?;
336
337        verify_eq!(cache.reuse_formatted(&output, "rendered"), None)?;
338
339        let changed = Cache::load(
340            directory.path(),
341            Some(executable),
342            Some(checksum("different prettier")),
343        );
344
345        verify_eq!(changed.reuse_formatted(&output, "rendered"), None)
346    }
347}