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    fn relative_prefix(&self, path: &Path) -> anyhow::Result<PathBuf> {
106        path.strip_prefix(&self.root)
107            .map(Path::to_path_buf)
108            .with_context(|| {
109                format!(
110                    "manifest dependency {} is outside {}",
111                    path.display(),
112                    self.root.display()
113                )
114            })
115    }
116
117    fn subset(&self, include: impl Fn(&TreeEntry) -> bool) -> anyhow::Result<Checksum> {
118        let mut encoded = Vec::from(SUBSET_DOMAIN);
119        let entries = self
120            .snapshot
121            .entries()
122            .iter()
123            .filter(|entry| include(entry))
124            .collect::<Vec<_>>();
125
126        append_length(&mut encoded, entries.len())?;
127
128        for entry in entries {
129            let relative = entry.relative_path().to_str().with_context(|| {
130                format!(
131                    "manifest checksum path is not UTF-8: {}",
132                    entry.relative_path().display()
133                )
134            })?;
135
136            append_length(&mut encoded, relative.len())?;
137            encoded.extend_from_slice(relative.as_bytes());
138            encoded.extend_from_slice(&entry.len().to_le_bytes());
139            encoded.extend_from_slice(entry.checksum().as_bytes());
140        }
141
142        Ok(checksum::bytes(encoded))
143    }
144}
145
146pub(super) struct CodegenCache {
147    path: PathBuf,
148    generator: Option<Checksum>,
149    manifest_schema_version: u32,
150    inputs: Checksum,
151    output_dir: PathBuf,
152    previous: Option<CacheState>,
153}
154
155impl CodegenCache {
156    pub(super) fn open(
157        workspace_root: &Path,
158        manifests_dir: &Path,
159        output_dir: &Path,
160        inputs: &ManifestInputs,
161    ) -> Self {
162        let Some(generator) = generator_identity().ok() else {
163            return Self {
164                path: cache_path(workspace_root, manifests_dir, output_dir),
165                generator: None,
166                manifest_schema_version: CURRENT_SCHEMA_VERSION,
167                inputs: inputs.checksum(),
168                output_dir: output_dir.to_path_buf(),
169                previous: None,
170            };
171        };
172
173        Self::open_with_identity(
174            workspace_root,
175            manifests_dir,
176            output_dir,
177            inputs,
178            generator,
179            CURRENT_SCHEMA_VERSION,
180        )
181    }
182
183    pub(super) fn open_with_identity(
184        workspace_root: &Path,
185        manifests_dir: &Path,
186        output_dir: &Path,
187        inputs: &ManifestInputs,
188        generator: Checksum,
189        manifest_schema_version: u32,
190    ) -> Self {
191        let path = cache_path(workspace_root, manifests_dir, output_dir);
192        let previous = file::read_text_if_exists(&path)
193            .ok()
194            .flatten()
195            .and_then(|source| toml::from_str::<CacheState>(&source).ok())
196            .filter(|state| state.format_version == CACHE_FORMAT_VERSION);
197
198        Self {
199            path,
200            generator: Some(generator),
201            manifest_schema_version,
202            inputs: inputs.checksum(),
203            output_dir: output_dir.to_path_buf(),
204            previous,
205        }
206    }
207
208    pub(super) fn is_complete_hit(&self) -> anyhow::Result<bool> {
209        let Some(previous) = self.compatible_state() else {
210            return Ok(false);
211        };
212
213        if previous.inputs != self.inputs {
214            return Ok(false);
215        }
216
217        Ok(output_checksum(&self.output_dir)? == Some(previous.outputs))
218    }
219
220    pub(super) fn reuse(
221        &self,
222        relative_path: &str,
223        dependency: Checksum,
224    ) -> anyhow::Result<Option<String>> {
225        let Some(artifact) = self
226            .compatible_state()
227            .and_then(|state| state.artifacts.get(relative_path))
228            .filter(|artifact| artifact.dependency == dependency)
229        else {
230            return Ok(None);
231        };
232        let path = self.output_dir.join(relative_path);
233
234        if checksum::file(&path).ok() != Some(artifact.output) {
235            return Ok(None);
236        }
237
238        file::read_text(&path)
239            .map(Some)
240            .with_context(|| format!("failed to reuse generated output {}", path.display()))
241    }
242
243    pub(super) fn prepare_update(
244        &self,
245        generation: &GenerationResult,
246    ) -> anyhow::Result<Option<CacheUpdate>> {
247        let Some(generator) = self.generator else {
248            return Ok(None);
249        };
250        let outputs = output_checksum(&self.output_dir)?.with_context(|| {
251            format!(
252                "cannot cache incomplete generated output tree {}",
253                self.output_dir.display()
254            )
255        })?;
256        let mut artifacts = BTreeMap::new();
257
258        for (path, contents) in &generation.files {
259            let dependency = *generation
260                .dependencies
261                .get(path)
262                .with_context(|| format!("generated output {path} has no dependency checksum"))?;
263
264            artifacts.insert(
265                path.clone(),
266                ArtifactState {
267                    dependency,
268                    output: checksum::bytes(contents),
269                },
270            );
271        }
272
273        let state = CacheState {
274            format_version: CACHE_FORMAT_VERSION,
275            manifest_schema_version: self.manifest_schema_version,
276            generator,
277            inputs: self.inputs,
278            outputs,
279            artifacts,
280        };
281        let source = toml::to_string(&state).context("failed to serialize codegen cache")?;
282
283        Ok(Some(CacheUpdate {
284            path: self.path.clone(),
285            source,
286        }))
287    }
288
289    fn compatible_state(&self) -> Option<&CacheState> {
290        self.previous.as_ref().filter(|state| {
291            state.manifest_schema_version == self.manifest_schema_version
292                && Some(state.generator) == self.generator
293        })
294    }
295}
296
297pub(super) struct CacheUpdate {
298    path: PathBuf,
299    source: String,
300}
301
302impl CacheUpdate {
303    pub(super) fn persist(self) -> anyhow::Result<()> {
304        let parent = self
305            .path
306            .parent()
307            .context("codegen cache path has no parent")?;
308
309        directory::ensure(parent)
310            .with_context(|| format!("failed to create codegen cache {}", parent.display()))?;
311
312        atomic::replace(&self.path, self.source)
313            .with_context(|| format!("failed to persist codegen cache {}", self.path.display()))
314    }
315}
316
317pub(super) fn constant_output(name: &str) -> Checksum {
318    checksum::bytes(format!("wowlab-codegen:constant-output:v1\0{name}"))
319}
320
321#[derive(Debug, Deserialize, Serialize)]
322struct CacheState {
323    format_version: u32,
324    manifest_schema_version: u32,
325    generator: Checksum,
326    inputs: Checksum,
327    outputs: Checksum,
328    artifacts: BTreeMap<String, ArtifactState>,
329}
330
331#[derive(Debug, Deserialize, Serialize)]
332struct ArtifactState {
333    dependency: Checksum,
334    output: Checksum,
335}
336
337fn generator_identity() -> anyhow::Result<Checksum> {
338    let executable =
339        checksum::current_executable().context("failed to checksum the codegen executable")?;
340    let mut encoded = Vec::from(GENERATOR_IDENTITY_DOMAIN);
341
342    encoded.extend_from_slice(executable.as_bytes());
343    encoded.extend_from_slice(&CURRENT_SCHEMA_VERSION.to_le_bytes());
344    encoded.extend_from_slice(&RUST_EMITTER_FORMAT_VERSION.to_le_bytes());
345
346    Ok(checksum::bytes(encoded))
347}
348
349fn output_checksum(output_dir: &Path) -> anyhow::Result<Option<Checksum>> {
350    let actual_paths = actual_file_set(output_dir)?;
351    let ownership = output_dir.join(OWNERSHIP_MANIFEST);
352
353    match directory::inspect_link(&ownership).with_context(|| {
354        format!(
355            "failed to inspect ownership manifest {}",
356            ownership.display()
357        )
358    })? {
359        None => return Ok(None),
360        Some(info) if info.kind() == EntryKind::File => {}
361        Some(info) => bail!(
362            "codegen ownership manifest is not a regular file: {} ({:?})",
363            ownership.display(),
364            info.kind()
365        ),
366    }
367
368    let paths = actual_paths
369        .iter()
370        .map(|path| output_dir.join(path))
371        .chain(std::iter::once(ownership))
372        .collect::<Vec<_>>();
373    let snapshot = TreeSnapshot::capture(output_dir, &paths).with_context(|| {
374        format!(
375            "failed to checksum generated output tree {}",
376            output_dir.display()
377        )
378    })?;
379
380    Ok(Some(snapshot.checksum()))
381}
382
383fn cache_path(workspace_root: &Path, manifests_dir: &Path, output_dir: &Path) -> PathBuf {
384    let mut encoded = b"wowlab-codegen:cache-location:v1\0".to_vec();
385
386    encoded.extend_from_slice(manifests_dir.as_os_str().as_encoded_bytes());
387    encoded.push(0);
388    encoded.extend_from_slice(output_dir.as_os_str().as_encoded_bytes());
389
390    workspace_root
391        .join(CACHE_DIRECTORY)
392        .join(format!("{}.toml", checksum::bytes(encoded)))
393}
394
395fn append_length(target: &mut Vec<u8>, length: usize) -> anyhow::Result<()> {
396    let length = u64::try_from(length).context("codegen cache input exceeds supported length")?;
397
398    target.extend_from_slice(&length.to_le_bytes());
399
400    Ok(())
401}