1use anyhow::{Context, Result};
4use camino::Utf8Path;
5use sha2::{Digest, Sha256};
6use weaveffi_ir::ir::Api;
7
8const CACHE_DIR: &str = ".weaveffi-cache";
9
10pub const CLI_VERSION: &str = env!("CARGO_PKG_VERSION");
14
15pub fn hash_api(api: &Api) -> String {
24 let value = serde_json::to_value(api).expect("Api serialization should not fail");
25 let json = serde_json::to_string(&value).expect("Value serialization should not fail");
26 let hash = Sha256::digest(json.as_bytes());
27 format!("{hash:x}")
28}
29
30pub fn hash_api_for_generator(api: &Api, generator_name: &str) -> String {
36 let value = serde_json::to_value(api).expect("Api serialization should not fail");
37 let json = serde_json::to_string(&value).expect("Value serialization should not fail");
38 let mut hasher = Sha256::new();
39 hasher.update(generator_name.as_bytes());
40 hasher.update(b":");
41 hasher.update(json.as_bytes());
42 let hash = hasher.finalize();
43 format!("{hash:x}")
44}
45
46pub fn hash_generator_inputs(api: &Api, generator_name: &str, config_bytes: &[u8]) -> String {
57 let api_value = serde_json::to_value(api).expect("Api serialization should not fail");
58 let api_json = serde_json::to_string(&api_value).expect("Value serialization should not fail");
59
60 let mut hasher = Sha256::new();
61 hasher.update(b"v1\0");
62 hasher.update(CLI_VERSION.as_bytes());
63 hasher.update(b"\0");
64 hasher.update(generator_name.as_bytes());
65 hasher.update(b"\0");
66 hasher.update(api_json.as_bytes());
67 hasher.update(b"\0");
68 hasher.update(config_bytes);
69 let hash = hasher.finalize();
70 format!("{hash:x}")
71}
72
73pub fn read_generator_cache(out_dir: &Utf8Path, generator_name: &str) -> Option<String> {
77 let path = out_dir
78 .join(CACHE_DIR)
79 .join(format!("{generator_name}.hash"));
80 std::fs::read_to_string(path)
81 .ok()
82 .map(|s| s.trim().to_string())
83 .filter(|s| !s.is_empty())
84}
85
86pub fn write_generator_cache(out_dir: &Utf8Path, generator_name: &str, hash: &str) -> Result<()> {
92 let cache_dir = out_dir.join(CACHE_DIR);
93 migrate_legacy_cache(out_dir)?;
94 std::fs::create_dir_all(cache_dir.as_std_path())
95 .with_context(|| format!("failed to create cache directory: {cache_dir}"))?;
96 let path = cache_dir.join(format!("{generator_name}.hash"));
97 std::fs::write(path.as_std_path(), hash)
98 .with_context(|| format!("failed to write cache file: {path}"))?;
99 Ok(())
100}
101
102pub fn invalidate_all(out_dir: &Utf8Path) -> Result<()> {
106 let cache_dir = out_dir.join(CACHE_DIR);
107 if cache_dir.is_dir() {
108 std::fs::remove_dir_all(cache_dir.as_std_path())
109 .with_context(|| format!("failed to remove cache directory: {cache_dir}"))?;
110 } else if cache_dir.exists() {
111 std::fs::remove_file(cache_dir.as_std_path())
112 .with_context(|| format!("failed to remove legacy cache file: {cache_dir}"))?;
113 }
114 Ok(())
115}
116
117fn migrate_legacy_cache(out_dir: &Utf8Path) -> Result<()> {
120 let cache_path = out_dir.join(CACHE_DIR);
121 if cache_path.is_file() {
122 std::fs::remove_file(cache_path.as_std_path())
123 .with_context(|| format!("failed to remove legacy cache file: {cache_path}"))?;
124 }
125 Ok(())
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use crate::codegen::{ConfiguredGenerator, Generator, Orchestrator, OrchestratorHooks};
132 use std::sync::atomic::{AtomicUsize, Ordering};
133 use std::sync::Arc;
134 use weaveffi_ir::ir::{Function, Module, Param, TypeRef};
135
136 #[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
139 struct TestConfig {
140 knob: Option<String>,
141 }
142
143 fn config_bytes(c: &TestConfig) -> Vec<u8> {
144 let v = serde_json::to_value(c).unwrap();
145 serde_json::to_vec(&v).unwrap()
146 }
147
148 fn minimal_api() -> Api {
149 Api {
150 version: "0.3.0".to_string(),
151 modules: vec![Module {
152 name: "math".to_string(),
153 functions: vec![Function {
154 name: "add".to_string(),
155 params: vec![
156 Param {
157 name: "a".to_string(),
158 ty: TypeRef::I32,
159 mutable: false,
160 doc: None,
161 },
162 Param {
163 name: "b".to_string(),
164 ty: TypeRef::I32,
165 mutable: false,
166 doc: None,
167 },
168 ],
169 returns: Some(TypeRef::I32),
170 doc: None,
171 r#async: false,
172 cancellable: false,
173 deprecated: None,
174 since: None,
175 }],
176 structs: vec![],
177 enums: vec![],
178 callbacks: vec![],
179 listeners: vec![],
180 errors: None,
181 modules: vec![],
182 }],
183 generators: None,
184 package: None,
185 }
186 }
187
188 struct CountingGenerator {
189 name: &'static str,
190 calls: Arc<AtomicUsize>,
191 }
192
193 impl Generator for CountingGenerator {
194 type Config = TestConfig;
195
196 fn name(&self) -> &'static str {
197 self.name
198 }
199
200 fn capabilities(&self) -> crate::capabilities::TargetCapabilities {
201 crate::capabilities::TargetCapabilities::full()
202 }
203
204 fn generate(
205 &self,
206 _api: &Api,
207 out_dir: &Utf8Path,
208 _config: &Self::Config,
209 ) -> anyhow::Result<()> {
210 self.calls.fetch_add(1, Ordering::SeqCst);
211 let dir = out_dir.join(self.name);
212 std::fs::create_dir_all(dir.as_std_path())?;
213 std::fs::write(dir.join("output.txt").as_std_path(), "generated")?;
214 Ok(())
215 }
216 }
217
218 fn configured(
219 name: &'static str,
220 calls: Arc<AtomicUsize>,
221 cfg: TestConfig,
222 ) -> ConfiguredGenerator<CountingGenerator> {
223 ConfiguredGenerator::new(CountingGenerator { name, calls }, cfg)
224 }
225
226 #[test]
227 fn hash_deterministic() {
228 let api = minimal_api();
229 let h1 = hash_api(&api);
230 let h2 = hash_api(&api);
231 assert_eq!(h1, h2);
232 assert_eq!(h1.len(), 64);
233 }
234
235 #[test]
236 fn hash_is_deterministic_across_runs() {
237 let mut api = minimal_api();
238 let mut generators = std::collections::BTreeMap::new();
239 let mut swift = toml::value::Table::new();
240 swift.insert(
241 "module_name".into(),
242 toml::Value::String("MySwiftModule".into()),
243 );
244 generators.insert("swift".into(), toml::Value::Table(swift));
245 let mut android = toml::value::Table::new();
246 android.insert(
247 "package".into(),
248 toml::Value::String("com.example.app".into()),
249 );
250 generators.insert("android".into(), toml::Value::Table(android));
251 api.generators = Some(generators);
252
253 let baseline = hash_api(&api);
254 for _ in 0..100 {
255 assert_eq!(
256 hash_api(&api),
257 baseline,
258 "hash_api must produce identical output on every call"
259 );
260 }
261 }
262
263 #[test]
264 fn hash_changes_on_modification() {
265 let mut api = minimal_api();
266 let h1 = hash_api(&api);
267
268 api.modules[0].functions.push(Function {
269 name: "subtract".to_string(),
270 params: vec![
271 Param {
272 name: "a".to_string(),
273 ty: TypeRef::I32,
274 mutable: false,
275 doc: None,
276 },
277 Param {
278 name: "b".to_string(),
279 ty: TypeRef::I32,
280 mutable: false,
281 doc: None,
282 },
283 ],
284 returns: Some(TypeRef::I32),
285 doc: None,
286 r#async: false,
287 cancellable: false,
288 deprecated: None,
289 since: None,
290 });
291 let h2 = hash_api(&api);
292
293 assert_ne!(h1, h2);
294 }
295
296 #[test]
297 fn per_generator_hash_includes_name() {
298 let api = minimal_api();
299 let h_c = hash_api_for_generator(&api, "c");
300 let h_swift = hash_api_for_generator(&api, "swift");
301 assert_ne!(h_c, h_swift);
302 assert_eq!(h_c.len(), 64);
303 }
304
305 #[test]
306 fn per_generator_hash_deterministic() {
307 let api = minimal_api();
308 assert_eq!(
309 hash_api_for_generator(&api, "c"),
310 hash_api_for_generator(&api, "c"),
311 );
312 }
313
314 #[test]
315 fn per_generator_cache_round_trip() {
316 let dir = tempfile::tempdir().unwrap();
317 let dir_path = Utf8Path::from_path(dir.path()).unwrap();
318
319 let hash = hash_api_for_generator(&minimal_api(), "c");
320 write_generator_cache(dir_path, "c", &hash).unwrap();
321
322 let read_back = read_generator_cache(dir_path, "c");
323 assert_eq!(read_back, Some(hash));
324 assert_eq!(read_generator_cache(dir_path, "swift"), None);
325 }
326
327 #[test]
328 fn read_generator_cache_returns_none_when_missing() {
329 let dir = tempfile::tempdir().unwrap();
330 let dir_path = Utf8Path::from_path(dir.path()).unwrap();
331 assert_eq!(read_generator_cache(dir_path, "c"), None);
332 }
333
334 #[test]
335 fn invalidate_all_clears_cache() {
336 let dir = tempfile::tempdir().unwrap();
337 let dir_path = Utf8Path::from_path(dir.path()).unwrap();
338 write_generator_cache(dir_path, "c", "abc").unwrap();
339 write_generator_cache(dir_path, "swift", "def").unwrap();
340
341 invalidate_all(dir_path).unwrap();
342 assert_eq!(read_generator_cache(dir_path, "c"), None);
343 assert_eq!(read_generator_cache(dir_path, "swift"), None);
344 }
345
346 #[test]
347 fn legacy_cache_file_is_replaced_by_directory() {
348 let dir = tempfile::tempdir().unwrap();
349 let dir_path = Utf8Path::from_path(dir.path()).unwrap();
350 std::fs::write(dir_path.join(CACHE_DIR), "stale-global-hash").unwrap();
351 assert!(dir_path.join(CACHE_DIR).is_file());
352
353 write_generator_cache(dir_path, "c", "fresh-hash").unwrap();
354
355 assert!(dir_path.join(CACHE_DIR).is_dir());
356 assert_eq!(
357 read_generator_cache(dir_path, "c"),
358 Some("fresh-hash".to_string())
359 );
360 }
361
362 #[test]
363 fn cache_file_written_after_generate() {
364 let dir = tempfile::tempdir().unwrap();
365 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
366 let api = minimal_api();
367 let hooks = OrchestratorHooks::default();
368 let calls = Arc::new(AtomicUsize::new(0));
369 let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
370
371 let orch = Orchestrator::new().with_generator(&gen);
372 orch.run(&api, out_dir, &hooks, false).unwrap();
373
374 assert!(out_dir.join(CACHE_DIR).join("counting.hash").exists());
375 assert_eq!(calls.load(Ordering::SeqCst), 1);
376 }
377
378 #[test]
379 fn cache_prevents_regeneration() {
380 let dir = tempfile::tempdir().unwrap();
381 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
382 let api = minimal_api();
383 let hooks = OrchestratorHooks::default();
384 let calls = Arc::new(AtomicUsize::new(0));
385 let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
386
387 let orch = Orchestrator::new().with_generator(&gen);
388 orch.run(&api, out_dir, &hooks, false).unwrap();
389 assert_eq!(calls.load(Ordering::SeqCst), 1);
390
391 orch.run(&api, out_dir, &hooks, false).unwrap();
392 assert_eq!(
393 calls.load(Ordering::SeqCst),
394 1,
395 "second run should skip generation"
396 );
397 }
398
399 #[test]
400 fn cache_invalidated_on_api_change() {
401 let dir = tempfile::tempdir().unwrap();
402 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
403 let api = minimal_api();
404 let hooks = OrchestratorHooks::default();
405 let calls = Arc::new(AtomicUsize::new(0));
406 let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
407
408 let orch = Orchestrator::new().with_generator(&gen);
409 orch.run(&api, out_dir, &hooks, false).unwrap();
410 assert_eq!(calls.load(Ordering::SeqCst), 1);
411
412 let mut modified_api = api;
413 modified_api.modules[0].functions.push(Function {
414 name: "subtract".to_string(),
415 params: vec![
416 Param {
417 name: "a".to_string(),
418 ty: TypeRef::I32,
419 mutable: false,
420 doc: None,
421 },
422 Param {
423 name: "b".to_string(),
424 ty: TypeRef::I32,
425 mutable: false,
426 doc: None,
427 },
428 ],
429 returns: Some(TypeRef::I32),
430 doc: None,
431 r#async: false,
432 cancellable: false,
433 deprecated: None,
434 since: None,
435 });
436
437 orch.run(&modified_api, out_dir, &hooks, false).unwrap();
438 assert_eq!(
439 calls.load(Ordering::SeqCst),
440 2,
441 "changed API should trigger regeneration"
442 );
443 }
444
445 #[test]
446 fn force_flag_bypasses_cache() {
447 let dir = tempfile::tempdir().unwrap();
448 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
449 let api = minimal_api();
450 let hooks = OrchestratorHooks::default();
451 let calls = Arc::new(AtomicUsize::new(0));
452 let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
453
454 let orch = Orchestrator::new().with_generator(&gen);
455 orch.run(&api, out_dir, &hooks, true).unwrap();
456 assert_eq!(calls.load(Ordering::SeqCst), 1);
457
458 orch.run(&api, out_dir, &hooks, true).unwrap();
459 assert_eq!(
460 calls.load(Ordering::SeqCst),
461 2,
462 "force=true should bypass cache"
463 );
464 }
465
466 #[test]
467 fn legacy_cache_file_ignored_on_first_run() {
468 let dir = tempfile::tempdir().unwrap();
469 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
470 std::fs::write(out_dir.join(CACHE_DIR), "stale-legacy").unwrap();
471
472 let api = minimal_api();
473 let hooks = OrchestratorHooks::default();
474 let calls = Arc::new(AtomicUsize::new(0));
475 let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
476
477 let orch = Orchestrator::new().with_generator(&gen);
478 orch.run(&api, out_dir, &hooks, false).unwrap();
479 assert_eq!(
480 calls.load(Ordering::SeqCst),
481 1,
482 "legacy single-file cache must not skip first run"
483 );
484 assert!(out_dir.join(CACHE_DIR).is_dir());
485 }
486
487 #[test]
488 fn single_generator_cache_invalidates_independently() {
489 let dir = tempfile::tempdir().unwrap();
490 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
491 let hooks = OrchestratorHooks::default();
492 let c_calls = Arc::new(AtomicUsize::new(0));
493 let s_calls = Arc::new(AtomicUsize::new(0));
494 let c_gen = configured("c", Arc::clone(&c_calls), TestConfig::default());
495 let s_gen = configured("swift", Arc::clone(&s_calls), TestConfig::default());
496 let orch = Orchestrator::new()
497 .with_generator(&c_gen)
498 .with_generator(&s_gen);
499
500 let api = minimal_api();
501 orch.run(&api, out_dir, &hooks, false).unwrap();
502 assert_eq!(c_calls.load(Ordering::SeqCst), 1);
503 assert_eq!(s_calls.load(Ordering::SeqCst), 1);
504
505 std::fs::remove_file(out_dir.join(CACHE_DIR).join("c.hash")).unwrap();
507
508 orch.run(&api, out_dir, &hooks, false).unwrap();
509 assert_eq!(
510 c_calls.load(Ordering::SeqCst),
511 2,
512 "C generator should re-run after its cache entry was removed"
513 );
514 assert_eq!(
515 s_calls.load(Ordering::SeqCst),
516 1,
517 "Swift generator's cache is intact and must be skipped"
518 );
519 }
520
521 #[test]
522 fn hash_generator_inputs_changes_when_config_bytes_change() {
523 let api = minimal_api();
524 let base = config_bytes(&TestConfig::default());
525
526 let changed = config_bytes(&TestConfig {
527 knob: Some("flipped".into()),
528 });
529
530 assert_ne!(
531 hash_generator_inputs(&api, "c", &base),
532 hash_generator_inputs(&api, "c", &changed),
533 "changing config bytes must change the per-generator hash"
534 );
535 }
536
537 #[test]
538 fn hash_generator_inputs_includes_cli_version() {
539 let api = minimal_api();
540 let cfg = config_bytes(&TestConfig::default());
541
542 let real = hash_generator_inputs(&api, "c", &cfg);
546
547 let api_value = serde_json::to_value(&api).unwrap();
548 let api_json = serde_json::to_string(&api_value).unwrap();
549
550 let mut h = Sha256::new();
551 h.update(b"v1\0");
552 h.update(b"0.0.0-pretend-old\0");
553 h.update(b"c\0");
554 h.update(api_json.as_bytes());
555 h.update(b"\0");
556 h.update(&cfg);
557 let pretend = format!("{:x}", h.finalize());
558
559 assert_ne!(
560 real, pretend,
561 "CLI_VERSION must be part of the cache key so an upgrade invalidates it"
562 );
563 assert_eq!(CLI_VERSION, env!("CARGO_PKG_VERSION"));
564 }
565
566 #[test]
567 fn cache_invalidated_on_config_only_change() {
568 let dir = tempfile::tempdir().unwrap();
569 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
570 let api = minimal_api();
571 let hooks = OrchestratorHooks::default();
572
573 let calls = Arc::new(AtomicUsize::new(0));
574 let gen = configured("c", Arc::clone(&calls), TestConfig::default());
575 Orchestrator::new()
576 .with_generator(&gen)
577 .run(&api, out_dir, &hooks, false)
578 .unwrap();
579 assert_eq!(calls.load(Ordering::SeqCst), 1);
580
581 let gen2 = configured(
583 "c",
584 Arc::clone(&calls),
585 TestConfig {
586 knob: Some("changed".into()),
587 },
588 );
589 Orchestrator::new()
590 .with_generator(&gen2)
591 .run(&api, out_dir, &hooks, false)
592 .unwrap();
593 assert_eq!(
594 calls.load(Ordering::SeqCst),
595 2,
596 "changing generator config must invalidate the cache and re-run the generator"
597 );
598
599 Orchestrator::new()
601 .with_generator(&gen2)
602 .run(&api, out_dir, &hooks, false)
603 .unwrap();
604 assert_eq!(
605 calls.load(Ordering::SeqCst),
606 2,
607 "running with the same config twice should not regenerate"
608 );
609 }
610
611 #[test]
612 fn cache_invalidated_when_pre_generated_hash_has_wrong_version() {
613 let dir = tempfile::tempdir().unwrap();
614 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
615 let api = minimal_api();
616 let hooks = OrchestratorHooks::default();
617 let calls = Arc::new(AtomicUsize::new(0));
618 let gen = configured("c", Arc::clone(&calls), TestConfig::default());
619 let orch = Orchestrator::new().with_generator(&gen);
620
621 let stale = hash_api_for_generator(&api, "c");
626 write_generator_cache(out_dir, "c", &stale).unwrap();
627
628 orch.run(&api, out_dir, &hooks, false).unwrap();
629 assert_eq!(
630 calls.load(Ordering::SeqCst),
631 1,
632 "legacy IR-only hash must not satisfy the new cache key shape"
633 );
634 }
635}