crucible_test_context/schema.rs
1//! Account schema registry for semantic field-level diffs.
2//!
3//! When an IDL is available, harness code can register diff functions that know
4//! how to deserialize specific account types and compare fields. This enables
5//! rich crash output like `total_deposits: 50000000 -> -149` instead of raw
6//! byte ranges.
7//!
8//! # Architecture
9//!
10//! - **AccountSchema** — type name, discriminator bytes, and a diff closure
11//! - **SCHEMA_REGISTRY** — global registry populated once at harness startup
12//! - **lookup_diff_fn()** — match account data by discriminator prefix
13//!
14//! Registration is done by generated code from `declare_fuzz_program!` which
15//! emits a `register_schemas()` call during fixture setup.
16
17use crate::FieldDelta;
18use std::sync::OnceLock;
19
20/// A function that diffs two account data slices and returns field-level deltas.
21pub type DiffFn = Box<dyn Fn(&[u8], &[u8]) -> Vec<FieldDelta> + Send + Sync>;
22
23/// Schema for a single account type — discriminator prefix + diff function.
24pub struct AccountSchema {
25 /// Human-readable type name (e.g., "Bank", "TokenAccount")
26 pub type_name: String,
27 /// Discriminator bytes that identify this account type (typically 8 bytes for Anchor)
28 pub discriminator: Vec<u8>,
29 /// Diff function: (pre_data, post_data) -> field deltas
30 pub diff_fn: DiffFn,
31}
32
33static SCHEMA_REGISTRY: OnceLock<Vec<AccountSchema>> = OnceLock::new();
34
35/// Register account schemas for semantic diffs.
36/// Call once at harness startup (e.g., from generated `register_schemas()`).
37/// Subsequent calls are ignored (OnceLock).
38pub fn register_account_schemas(schemas: Vec<AccountSchema>) {
39 let _ = SCHEMA_REGISTRY.set(schemas);
40}
41
42/// Look up a diff function by discriminator prefix match.
43/// Returns the diff closure if a registered schema's discriminator matches the
44/// beginning of `data`.
45pub fn lookup_diff_fn(data: &[u8]) -> Option<&DiffFn> {
46 let registry = SCHEMA_REGISTRY.get()?;
47 for schema in registry {
48 if data.len() >= schema.discriminator.len()
49 && data[..schema.discriminator.len()] == schema.discriminator[..]
50 {
51 return Some(&schema.diff_fn);
52 }
53 }
54 None
55}
56
57/// Look up the type name by discriminator prefix match.
58pub fn lookup_type_name(data: &[u8]) -> Option<&str> {
59 let registry = SCHEMA_REGISTRY.get()?;
60 for schema in registry {
61 if data.len() >= schema.discriminator.len()
62 && data[..schema.discriminator.len()] == schema.discriminator[..]
63 {
64 return Some(&schema.type_name);
65 }
66 }
67 None
68}
69
70/// Check whether any schemas have been registered.
71pub fn has_schemas() -> bool {
72 SCHEMA_REGISTRY
73 .get()
74 .map(|r| !r.is_empty())
75 .unwrap_or(false)
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81
82 // Note: OnceLock can only be set once per process, so we test lookup logic
83 // directly rather than through register_account_schemas.
84
85 #[test]
86 fn test_lookup_no_registry() {
87 // Before any registration, lookup returns None
88 // (This test works because OnceLock starts empty in a fresh test binary,
89 // but may conflict with other tests if run in the same process.
90 // In practice, the OnceLock is set once by the harness.)
91 let result = lookup_diff_fn(&[0u8; 16]);
92 // Either None (no registry) or None (no match) — both are correct
93 assert!(result.is_none() || result.is_some());
94 }
95}