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.4.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 r#async: false,
198 cancellable: false,
199 deprecated: None,
200 since: None,
201 }],
202 structs: vec![],
203 enums: vec![],
204 callbacks: vec![],
205 listeners: vec![],
206 errors: None,
207 modules: vec![],
208 }],
209 generators: None,
210 package: None,
211 }
212 }
213
214 struct CountingGenerator {
215 name: &'static str,
216 calls: Arc<AtomicUsize>,
217 }
218
219 impl Generator for CountingGenerator {
220 type Config = TestConfig;
221
222 fn name(&self) -> &'static str {
223 self.name
224 }
225
226 fn capabilities(&self) -> crate::capabilities::TargetCapabilities {
227 crate::capabilities::TargetCapabilities::full()
228 }
229
230 fn generate(
231 &self,
232 _api: &Api,
233 out_dir: &Utf8Path,
234 _config: &Self::Config,
235 ) -> anyhow::Result<()> {
236 self.calls.fetch_add(1, Ordering::SeqCst);
237 let dir = out_dir.join(self.name);
238 std::fs::create_dir_all(dir.as_std_path())?;
239 std::fs::write(dir.join("output.txt").as_std_path(), "generated")?;
240 Ok(())
241 }
242 }
243
244 fn configured(
245 name: &'static str,
246 calls: Arc<AtomicUsize>,
247 cfg: TestConfig,
248 ) -> ConfiguredGenerator<CountingGenerator> {
249 ConfiguredGenerator::new(CountingGenerator { name, calls }, cfg)
250 }
251
252 #[test]
253 fn hash_deterministic() {
254 let api = minimal_api();
255 let h1 = hash_api(&api);
256 let h2 = hash_api(&api);
257 assert_eq!(h1, h2);
258 assert_eq!(h1.len(), 64);
259 }
260
261 #[test]
262 fn hash_is_deterministic_across_runs() {
263 let mut api = minimal_api();
264 let mut generators = std::collections::BTreeMap::new();
265 let mut swift = toml::value::Table::new();
266 swift.insert(
267 "module_name".into(),
268 toml::Value::String("MySwiftModule".into()),
269 );
270 generators.insert("swift".into(), toml::Value::Table(swift));
271 let mut android = toml::value::Table::new();
272 android.insert(
273 "package".into(),
274 toml::Value::String("com.example.app".into()),
275 );
276 generators.insert("android".into(), toml::Value::Table(android));
277 api.generators = Some(generators);
278
279 let baseline = hash_api(&api);
280 for _ in 0..100 {
281 assert_eq!(
282 hash_api(&api),
283 baseline,
284 "hash_api must produce identical output on every call"
285 );
286 }
287 }
288
289 #[test]
290 fn hash_changes_on_modification() {
291 let mut api = minimal_api();
292 let h1 = hash_api(&api);
293
294 api.modules[0].functions.push(Function {
295 name: "subtract".to_string(),
296 params: vec![
297 Param {
298 name: "a".to_string(),
299 ty: TypeRef::I32,
300 mutable: false,
301 doc: None,
302 },
303 Param {
304 name: "b".to_string(),
305 ty: TypeRef::I32,
306 mutable: false,
307 doc: None,
308 },
309 ],
310 returns: Some(TypeRef::I32),
311 doc: None,
312 r#async: false,
313 cancellable: false,
314 deprecated: None,
315 since: None,
316 });
317 let h2 = hash_api(&api);
318
319 assert_ne!(h1, h2);
320 }
321
322 #[test]
323 fn per_generator_hash_includes_name() {
324 let api = minimal_api();
325 let h_c = hash_api_for_generator(&api, "c");
326 let h_swift = hash_api_for_generator(&api, "swift");
327 assert_ne!(h_c, h_swift);
328 assert_eq!(h_c.len(), 64);
329 }
330
331 #[test]
332 fn per_generator_hash_deterministic() {
333 let api = minimal_api();
334 assert_eq!(
335 hash_api_for_generator(&api, "c"),
336 hash_api_for_generator(&api, "c"),
337 );
338 }
339
340 #[test]
341 fn per_generator_cache_round_trip() {
342 let dir = tempfile::tempdir().unwrap();
343 let dir_path = Utf8Path::from_path(dir.path()).unwrap();
344
345 let hash = hash_api_for_generator(&minimal_api(), "c");
346 write_generator_cache(dir_path, "c", &hash).unwrap();
347
348 let read_back = read_generator_cache(dir_path, "c");
349 assert_eq!(read_back, Some(hash));
350 assert_eq!(read_generator_cache(dir_path, "swift"), None);
351 }
352
353 #[test]
354 fn read_generator_cache_returns_none_when_missing() {
355 let dir = tempfile::tempdir().unwrap();
356 let dir_path = Utf8Path::from_path(dir.path()).unwrap();
357 assert_eq!(read_generator_cache(dir_path, "c"), None);
358 }
359
360 #[test]
361 fn invalidate_all_clears_cache() {
362 let dir = tempfile::tempdir().unwrap();
363 let dir_path = Utf8Path::from_path(dir.path()).unwrap();
364 write_generator_cache(dir_path, "c", "abc").unwrap();
365 write_generator_cache(dir_path, "swift", "def").unwrap();
366
367 invalidate_all(dir_path).unwrap();
368 assert_eq!(read_generator_cache(dir_path, "c"), None);
369 assert_eq!(read_generator_cache(dir_path, "swift"), None);
370 }
371
372 #[test]
373 fn legacy_cache_file_is_replaced_by_directory() {
374 let dir = tempfile::tempdir().unwrap();
375 let dir_path = Utf8Path::from_path(dir.path()).unwrap();
376 std::fs::write(dir_path.join(CACHE_DIR), "stale-global-hash").unwrap();
377 assert!(dir_path.join(CACHE_DIR).is_file());
378
379 write_generator_cache(dir_path, "c", "fresh-hash").unwrap();
380
381 assert!(dir_path.join(CACHE_DIR).is_dir());
382 assert_eq!(
383 read_generator_cache(dir_path, "c"),
384 Some("fresh-hash".to_string())
385 );
386 }
387
388 #[test]
389 fn cache_file_written_after_generate() {
390 let dir = tempfile::tempdir().unwrap();
391 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
392 let api = minimal_api();
393 let hooks = OrchestratorHooks::default();
394 let calls = Arc::new(AtomicUsize::new(0));
395 let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
396
397 let orch = Orchestrator::new().with_generator(&gen);
398 orch.run(&api, out_dir, &hooks, false).unwrap();
399
400 assert!(out_dir.join(CACHE_DIR).join("counting.hash").exists());
401 assert_eq!(calls.load(Ordering::SeqCst), 1);
402 }
403
404 #[test]
405 fn cache_prevents_regeneration() {
406 let dir = tempfile::tempdir().unwrap();
407 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
408 let api = minimal_api();
409 let hooks = OrchestratorHooks::default();
410 let calls = Arc::new(AtomicUsize::new(0));
411 let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
412
413 let orch = Orchestrator::new().with_generator(&gen);
414 orch.run(&api, out_dir, &hooks, false).unwrap();
415 assert_eq!(calls.load(Ordering::SeqCst), 1);
416
417 orch.run(&api, out_dir, &hooks, false).unwrap();
418 assert_eq!(
419 calls.load(Ordering::SeqCst),
420 1,
421 "second run should skip generation"
422 );
423 }
424
425 #[test]
426 fn cache_invalidated_on_api_change() {
427 let dir = tempfile::tempdir().unwrap();
428 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
429 let api = minimal_api();
430 let hooks = OrchestratorHooks::default();
431 let calls = Arc::new(AtomicUsize::new(0));
432 let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
433
434 let orch = Orchestrator::new().with_generator(&gen);
435 orch.run(&api, out_dir, &hooks, false).unwrap();
436 assert_eq!(calls.load(Ordering::SeqCst), 1);
437
438 let mut modified_api = api;
439 modified_api.modules[0].functions.push(Function {
440 name: "subtract".to_string(),
441 params: vec![
442 Param {
443 name: "a".to_string(),
444 ty: TypeRef::I32,
445 mutable: false,
446 doc: None,
447 },
448 Param {
449 name: "b".to_string(),
450 ty: TypeRef::I32,
451 mutable: false,
452 doc: None,
453 },
454 ],
455 returns: Some(TypeRef::I32),
456 doc: None,
457 r#async: false,
458 cancellable: false,
459 deprecated: None,
460 since: None,
461 });
462
463 orch.run(&modified_api, out_dir, &hooks, false).unwrap();
464 assert_eq!(
465 calls.load(Ordering::SeqCst),
466 2,
467 "changed API should trigger regeneration"
468 );
469 }
470
471 #[test]
472 fn force_flag_bypasses_cache() {
473 let dir = tempfile::tempdir().unwrap();
474 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
475 let api = minimal_api();
476 let hooks = OrchestratorHooks::default();
477 let calls = Arc::new(AtomicUsize::new(0));
478 let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
479
480 let orch = Orchestrator::new().with_generator(&gen);
481 orch.run(&api, out_dir, &hooks, true).unwrap();
482 assert_eq!(calls.load(Ordering::SeqCst), 1);
483
484 orch.run(&api, out_dir, &hooks, true).unwrap();
485 assert_eq!(
486 calls.load(Ordering::SeqCst),
487 2,
488 "force=true should bypass cache"
489 );
490 }
491
492 #[test]
493 fn legacy_cache_file_ignored_on_first_run() {
494 let dir = tempfile::tempdir().unwrap();
495 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
496 std::fs::write(out_dir.join(CACHE_DIR), "stale-legacy").unwrap();
497
498 let api = minimal_api();
499 let hooks = OrchestratorHooks::default();
500 let calls = Arc::new(AtomicUsize::new(0));
501 let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
502
503 let orch = Orchestrator::new().with_generator(&gen);
504 orch.run(&api, out_dir, &hooks, false).unwrap();
505 assert_eq!(
506 calls.load(Ordering::SeqCst),
507 1,
508 "legacy single-file cache must not skip first run"
509 );
510 assert!(out_dir.join(CACHE_DIR).is_dir());
511 }
512
513 #[test]
514 fn single_generator_cache_invalidates_independently() {
515 let dir = tempfile::tempdir().unwrap();
516 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
517 let hooks = OrchestratorHooks::default();
518 let c_calls = Arc::new(AtomicUsize::new(0));
519 let s_calls = Arc::new(AtomicUsize::new(0));
520 let c_gen = configured("c", Arc::clone(&c_calls), TestConfig::default());
521 let s_gen = configured("swift", Arc::clone(&s_calls), TestConfig::default());
522 let orch = Orchestrator::new()
523 .with_generator(&c_gen)
524 .with_generator(&s_gen);
525
526 let api = minimal_api();
527 orch.run(&api, out_dir, &hooks, false).unwrap();
528 assert_eq!(c_calls.load(Ordering::SeqCst), 1);
529 assert_eq!(s_calls.load(Ordering::SeqCst), 1);
530
531 std::fs::remove_file(out_dir.join(CACHE_DIR).join("c.hash")).unwrap();
533
534 orch.run(&api, out_dir, &hooks, false).unwrap();
535 assert_eq!(
536 c_calls.load(Ordering::SeqCst),
537 2,
538 "C generator should re-run after its cache entry was removed"
539 );
540 assert_eq!(
541 s_calls.load(Ordering::SeqCst),
542 1,
543 "Swift generator's cache is intact and must be skipped"
544 );
545 }
546
547 #[test]
548 fn hash_generator_inputs_changes_when_config_bytes_change() {
549 let api = minimal_api();
550 let base = config_bytes(&TestConfig::default());
551
552 let changed = config_bytes(&TestConfig {
553 knob: Some("flipped".into()),
554 });
555
556 assert_ne!(
557 hash_generator_inputs(&api, "c", &base),
558 hash_generator_inputs(&api, "c", &changed),
559 "changing config bytes must change the per-generator hash"
560 );
561 }
562
563 #[test]
564 fn hash_generator_inputs_includes_cli_version() {
565 let api = minimal_api();
566 let cfg = config_bytes(&TestConfig::default());
567
568 let real = hash_generator_inputs(&api, "c", &cfg);
572
573 let api_value = serde_json::to_value(&api).unwrap();
574 let api_json = serde_json::to_string(&api_value).unwrap();
575
576 let mut h = Sha256::new();
577 h.update(b"v1\0");
578 h.update(b"0.0.0-pretend-old\0");
579 h.update(b"c\0");
580 h.update(api_json.as_bytes());
581 h.update(b"\0");
582 h.update(&cfg);
583 let pretend = format!("{:x}", h.finalize());
584
585 assert_ne!(
586 real, pretend,
587 "CLI_VERSION must be part of the cache key so an upgrade invalidates it"
588 );
589 assert_eq!(CLI_VERSION, env!("CARGO_PKG_VERSION"));
590 }
591
592 #[test]
593 fn cache_invalidated_on_config_only_change() {
594 let dir = tempfile::tempdir().unwrap();
595 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
596 let api = minimal_api();
597 let hooks = OrchestratorHooks::default();
598
599 let calls = Arc::new(AtomicUsize::new(0));
600 let gen = configured("c", Arc::clone(&calls), TestConfig::default());
601 Orchestrator::new()
602 .with_generator(&gen)
603 .run(&api, out_dir, &hooks, false)
604 .unwrap();
605 assert_eq!(calls.load(Ordering::SeqCst), 1);
606
607 let gen2 = configured(
609 "c",
610 Arc::clone(&calls),
611 TestConfig {
612 knob: Some("changed".into()),
613 },
614 );
615 Orchestrator::new()
616 .with_generator(&gen2)
617 .run(&api, out_dir, &hooks, false)
618 .unwrap();
619 assert_eq!(
620 calls.load(Ordering::SeqCst),
621 2,
622 "changing generator config must invalidate the cache and re-run the generator"
623 );
624
625 Orchestrator::new()
627 .with_generator(&gen2)
628 .run(&api, out_dir, &hooks, false)
629 .unwrap();
630 assert_eq!(
631 calls.load(Ordering::SeqCst),
632 2,
633 "running with the same config twice should not regenerate"
634 );
635 }
636
637 #[test]
638 fn cache_invalidated_when_pre_generated_hash_has_wrong_version() {
639 let dir = tempfile::tempdir().unwrap();
640 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
641 let api = minimal_api();
642 let hooks = OrchestratorHooks::default();
643 let calls = Arc::new(AtomicUsize::new(0));
644 let gen = configured("c", Arc::clone(&calls), TestConfig::default());
645 let orch = Orchestrator::new().with_generator(&gen);
646
647 let stale = hash_api_for_generator(&api, "c");
652 write_generator_cache(out_dir, "c", &stale).unwrap();
653
654 orch.run(&api, out_dir, &hooks, false).unwrap();
655 assert_eq!(
656 calls.load(Ordering::SeqCst),
657 1,
658 "legacy IR-only hash must not satisfy the new cache key shape"
659 );
660 }
661}