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