1use 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#[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 #[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 #[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 #[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 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 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 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}