Skip to main content

codegen/app/
cache.rs

1//! Content-addressed cache for manifest generation.
2//!
3//! A complete hit requires exact manifest and output-tree snapshots.
4//! One spec's reusable output depends on its private and class-shared subtrees.
5//! Item outputs depend on the complete item manifest.
6
7use std::collections::BTreeMap;
8
9use anyhow::{Context, bail};
10use serde::{Deserialize, Serialize};
11use wowlab_fs::{
12    atomic,
13    checksum::{self, Checksum, TreeEntry, TreeSnapshot},
14    directory::{self, EntryKind},
15    file,
16    path::{Path, PathBuf},
17};
18use wowlab_manifest_schema::CURRENT_SCHEMA_VERSION;
19
20use super::{
21    generation::GenerationResult, output_tree::actual_file_set, ownership::OWNERSHIP_MANIFEST,
22};
23
24const CACHE_FORMAT_VERSION: u32 = 1;
25const CACHE_DIRECTORY: &str = ".cache/codegen";
26const GENERATOR_IDENTITY_DOMAIN: &[u8] = b"wowlab-codegen:generator-identity:v2\0";
27const RUST_EMITTER_FORMAT_VERSION: u32 = 1;
28const SUBSET_DOMAIN: &[u8] = b"wowlab-codegen:manifest-subset:v1\0";
29
30#[derive(Clone, Debug)]
31pub(super) struct ManifestInputs {
32    root: PathBuf,
33    snapshot: TreeSnapshot,
34}
35
36impl ManifestInputs {
37    pub(super) fn capture(root: &Path, paths: &[PathBuf]) -> anyhow::Result<Self> {
38        let snapshot = TreeSnapshot::capture(root, paths).with_context(|| {
39            format!("failed to checksum manifest repository {}", root.display())
40        })?;
41
42        Ok(Self {
43            root: root.to_path_buf(),
44            snapshot,
45        })
46    }
47
48    pub(super) const fn checksum(&self) -> Checksum {
49        self.snapshot.checksum()
50    }
51
52    pub(super) fn spec(&self, entry_path: &Path) -> anyhow::Result<Checksum> {
53        let spec_directory = entry_path.parent().with_context(|| {
54            format!(
55                "spec entry has no containing directory: {}",
56                entry_path.display()
57            )
58        })?;
59        let class_directory = spec_directory.parent().with_context(|| {
60            format!(
61                "spec entry has no class directory: {}",
62                entry_path.display()
63            )
64        })?;
65        let shared_directory = class_directory.join("shared");
66        let spec_prefix = self.relative_prefix(spec_directory)?;
67        let shared_prefix = self.relative_prefix(&shared_directory)?;
68
69        self.subset(|entry| {
70            entry.relative_path().starts_with(&spec_prefix)
71                || entry.relative_path().starts_with(&shared_prefix)
72        })
73    }
74
75    pub(super) fn shared_file(&self, path: &Path) -> anyhow::Result<Checksum> {
76        let relative = self.relative_prefix(path)?;
77
78        self.subset(|entry| entry.relative_path() == relative.as_path())
79    }
80
81    pub(super) fn shared_group(&self, group: &str) -> anyhow::Result<Checksum> {
82        let prefix = PathBuf::from("specs").join(group).join("shared");
83
84        self.subset(|entry| entry.relative_path().starts_with(&prefix))
85    }
86
87    pub(super) fn all_shared(&self) -> anyhow::Result<Checksum> {
88        self.subset(|entry| {
89            let components = entry.relative_path().components().collect::<Vec<_>>();
90
91            components
92                .get(2)
93                .is_some_and(|component| component.as_os_str() == "shared")
94        })
95    }
96
97    pub(super) fn all_specs(&self) -> anyhow::Result<Checksum> {
98        self.subset(|entry| entry.relative_path().starts_with("specs"))
99    }
100
101    pub(super) fn items(&self) -> anyhow::Result<Checksum> {
102        self.subset(|entry| entry.relative_path() == Path::new("items.toml"))
103    }
104
105    pub(super) fn all_manifests(&self) -> anyhow::Result<Checksum> {
106        self.subset(|_| true)
107    }
108
109    fn relative_prefix(&self, path: &Path) -> anyhow::Result<PathBuf> {
110        path.strip_prefix(&self.root)
111            .map(Path::to_path_buf)
112            .with_context(|| {
113                format!(
114                    "manifest dependency {} is outside {}",
115                    path.display(),
116                    self.root.display()
117                )
118            })
119    }
120
121    fn subset(&self, include: impl Fn(&TreeEntry) -> bool) -> anyhow::Result<Checksum> {
122        let mut encoded = Vec::from(SUBSET_DOMAIN);
123        let entries = self
124            .snapshot
125            .entries()
126            .iter()
127            .filter(|entry| include(entry))
128            .collect::<Vec<_>>();
129
130        append_length(&mut encoded, entries.len())?;
131
132        for entry in entries {
133            let relative = entry.relative_path().to_str().with_context(|| {
134                format!(
135                    "manifest checksum path is not UTF-8: {}",
136                    entry.relative_path().display()
137                )
138            })?;
139
140            append_length(&mut encoded, relative.len())?;
141            encoded.extend_from_slice(relative.as_bytes());
142            encoded.extend_from_slice(&entry.len().to_le_bytes());
143            encoded.extend_from_slice(entry.checksum().as_bytes());
144        }
145
146        Ok(checksum::bytes(encoded))
147    }
148}
149
150pub(super) struct CodegenCache {
151    path: PathBuf,
152    generator: Option<Checksum>,
153    manifest_schema_version: u32,
154    inputs: Checksum,
155    output_dir: PathBuf,
156    previous: Option<CacheState>,
157}
158
159impl CodegenCache {
160    pub(super) fn open(
161        workspace_root: &Path,
162        manifests_dir: &Path,
163        output_dir: &Path,
164        inputs: &ManifestInputs,
165    ) -> Self {
166        let Some(generator) = generator_identity().ok() else {
167            return Self {
168                path: cache_path(workspace_root, manifests_dir, output_dir),
169                generator: None,
170                manifest_schema_version: CURRENT_SCHEMA_VERSION,
171                inputs: inputs.checksum(),
172                output_dir: output_dir.to_path_buf(),
173                previous: None,
174            };
175        };
176
177        Self::open_with_identity(
178            workspace_root,
179            manifests_dir,
180            output_dir,
181            inputs,
182            generator,
183            CURRENT_SCHEMA_VERSION,
184        )
185    }
186
187    pub(super) fn open_with_identity(
188        workspace_root: &Path,
189        manifests_dir: &Path,
190        output_dir: &Path,
191        inputs: &ManifestInputs,
192        generator: Checksum,
193        manifest_schema_version: u32,
194    ) -> Self {
195        let path = cache_path(workspace_root, manifests_dir, output_dir);
196        let previous = file::read_text_if_exists(&path)
197            .ok()
198            .flatten()
199            .and_then(|source| toml::from_str::<CacheState>(&source).ok())
200            .filter(|state| state.format_version == CACHE_FORMAT_VERSION);
201
202        Self {
203            path,
204            generator: Some(generator),
205            manifest_schema_version,
206            inputs: inputs.checksum(),
207            output_dir: output_dir.to_path_buf(),
208            previous,
209        }
210    }
211
212    pub(super) fn is_complete_hit(&self) -> anyhow::Result<bool> {
213        let Some(previous) = self.compatible_state() else {
214            return Ok(false);
215        };
216
217        if previous.inputs != self.inputs {
218            return Ok(false);
219        }
220
221        Ok(output_checksum(&self.output_dir)? == Some(previous.outputs))
222    }
223
224    pub(super) fn reuse(
225        &self,
226        relative_path: &str,
227        dependency: Checksum,
228    ) -> anyhow::Result<Option<String>> {
229        let Some(artifact) = self
230            .compatible_state()
231            .and_then(|state| state.artifacts.get(relative_path))
232            .filter(|artifact| artifact.dependency == dependency)
233        else {
234            return Ok(None);
235        };
236        let path = self.output_dir.join(relative_path);
237
238        if checksum::file(&path).ok() != Some(artifact.output) {
239            return Ok(None);
240        }
241
242        file::read_text(&path)
243            .map(Some)
244            .with_context(|| format!("failed to reuse generated output {}", path.display()))
245    }
246
247    pub(super) fn prepare_update(
248        &self,
249        generation: &GenerationResult,
250    ) -> anyhow::Result<Option<CacheUpdate>> {
251        let Some(generator) = self.generator else {
252            return Ok(None);
253        };
254        let outputs = output_checksum(&self.output_dir)?.with_context(|| {
255            format!(
256                "cannot cache incomplete generated output tree {}",
257                self.output_dir.display()
258            )
259        })?;
260        let mut artifacts = BTreeMap::new();
261
262        for (path, contents) in &generation.files {
263            let dependency = *generation
264                .dependencies
265                .get(path)
266                .with_context(|| format!("generated output {path} has no dependency checksum"))?;
267
268            artifacts.insert(
269                path.clone(),
270                ArtifactState {
271                    dependency,
272                    output: checksum::bytes(contents),
273                },
274            );
275        }
276
277        let state = CacheState {
278            format_version: CACHE_FORMAT_VERSION,
279            manifest_schema_version: self.manifest_schema_version,
280            generator,
281            inputs: self.inputs,
282            outputs,
283            artifacts,
284        };
285        let source = toml::to_string(&state).context("failed to serialize codegen cache")?;
286
287        Ok(Some(CacheUpdate {
288            path: self.path.clone(),
289            source,
290        }))
291    }
292
293    fn compatible_state(&self) -> Option<&CacheState> {
294        self.previous.as_ref().filter(|state| {
295            state.manifest_schema_version == self.manifest_schema_version
296                && Some(state.generator) == self.generator
297        })
298    }
299}
300
301pub(super) struct CacheUpdate {
302    path: PathBuf,
303    source: String,
304}
305
306impl CacheUpdate {
307    pub(super) fn persist(self) -> anyhow::Result<()> {
308        let parent = self
309            .path
310            .parent()
311            .context("codegen cache path has no parent")?;
312
313        directory::ensure(parent)
314            .with_context(|| format!("failed to create codegen cache {}", parent.display()))?;
315
316        atomic::replace(&self.path, self.source)
317            .with_context(|| format!("failed to persist codegen cache {}", self.path.display()))
318    }
319}
320
321pub(super) fn constant_output(name: &str) -> Checksum {
322    checksum::bytes(format!("wowlab-codegen:constant-output:v1\0{name}"))
323}
324
325#[derive(Debug, Deserialize, Serialize)]
326struct CacheState {
327    format_version: u32,
328    manifest_schema_version: u32,
329    generator: Checksum,
330    inputs: Checksum,
331    outputs: Checksum,
332    artifacts: BTreeMap<String, ArtifactState>,
333}
334
335#[derive(Debug, Deserialize, Serialize)]
336struct ArtifactState {
337    dependency: Checksum,
338    output: Checksum,
339}
340
341fn generator_identity() -> anyhow::Result<Checksum> {
342    let executable =
343        checksum::current_executable().context("failed to checksum the codegen executable")?;
344    let mut encoded = Vec::from(GENERATOR_IDENTITY_DOMAIN);
345
346    encoded.extend_from_slice(executable.as_bytes());
347    encoded.extend_from_slice(&CURRENT_SCHEMA_VERSION.to_le_bytes());
348    encoded.extend_from_slice(&RUST_EMITTER_FORMAT_VERSION.to_le_bytes());
349
350    Ok(checksum::bytes(encoded))
351}
352
353fn output_checksum(output_dir: &Path) -> anyhow::Result<Option<Checksum>> {
354    let actual_paths = actual_file_set(output_dir)?;
355    let ownership = output_dir.join(OWNERSHIP_MANIFEST);
356
357    match directory::inspect_link(&ownership).with_context(|| {
358        format!(
359            "failed to inspect ownership manifest {}",
360            ownership.display()
361        )
362    })? {
363        None => return Ok(None),
364        Some(info) if info.kind() == EntryKind::File => {}
365        Some(info) => bail!(
366            "codegen ownership manifest is not a regular file: {} ({:?})",
367            ownership.display(),
368            info.kind()
369        ),
370    }
371
372    let paths = actual_paths
373        .iter()
374        .map(|path| output_dir.join(path))
375        .chain(std::iter::once(ownership))
376        .collect::<Vec<_>>();
377    let snapshot = TreeSnapshot::capture(output_dir, &paths).with_context(|| {
378        format!(
379            "failed to checksum generated output tree {}",
380            output_dir.display()
381        )
382    })?;
383
384    Ok(Some(snapshot.checksum()))
385}
386
387fn cache_path(workspace_root: &Path, manifests_dir: &Path, output_dir: &Path) -> PathBuf {
388    let mut encoded = b"wowlab-codegen:cache-location:v1\0".to_vec();
389
390    encoded.extend_from_slice(manifests_dir.as_os_str().as_encoded_bytes());
391    encoded.push(0);
392    encoded.extend_from_slice(output_dir.as_os_str().as_encoded_bytes());
393
394    workspace_root
395        .join(CACHE_DIRECTORY)
396        .join(format!("{}.toml", checksum::bytes(encoded)))
397}
398
399fn append_length(target: &mut Vec<u8>, length: usize) -> anyhow::Result<()> {
400    let length = u64::try_from(length).context("codegen cache input exceeds supported length")?;
401
402    target.extend_from_slice(&length.to_le_bytes());
403
404    Ok(())
405}