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