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