use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::smart_suggest;
use crate::store_schema::StoreColumnType;
pub const MANIFEST_VERSION: u32 = 1;
pub const DEFAULT_MANIFEST_DIR: &str = "schemas";
pub const MANIFEST_EXTENSION: &str = "axon-schema.json";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManifestColumn {
pub col_type: StoreColumnType,
pub primary_key: bool,
pub auto_increment: bool,
pub not_null: bool,
pub unique: bool,
pub default_value: String,
pub identity: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManifestStore {
pub columns: BTreeMap<String, ManifestColumn>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Manifest {
pub version: u32,
pub stores: BTreeMap<String, ManifestStore>,
pub content_hash: Option<String>,
}
impl Manifest {
pub fn new() -> Self {
Self {
version: MANIFEST_VERSION,
stores: BTreeMap::new(),
content_hash: None,
}
}
pub fn lookup(&self, qualified_name: &str) -> Option<&ManifestStore> {
self.stores.get(qualified_name)
}
pub fn contains(&self, qualified_name: &str) -> bool {
self.stores.contains_key(qualified_name)
}
}
impl Default for Manifest {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ManifestError {
InvalidJson { detail: String },
InvalidStructure { detail: String },
UnsupportedVersion { found: u32 },
UnknownColumnType {
store: String,
column: String,
observed_type: String,
suggestion: String,
},
ContentHashMismatch { expected: String, observed: String },
DuplicateStore {
store: String,
path_a: PathBuf,
path_b: PathBuf,
},
}
impl std::fmt::Display for ManifestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidJson { detail } => {
write!(f, "store-schema manifest is not valid JSON: {detail}")
}
Self::InvalidStructure { detail } => write!(
f,
"store-schema manifest has an invalid top-level structure: \
{detail}. Expected {{\"version\": 1, \"stores\": {{...}}, \
\"content_hash\"?: \"sha256:...\"}}."
),
Self::UnsupportedVersion { found } => write!(
f,
"store-schema manifest declares version {found}; this \
axon supports version {MANIFEST_VERSION}. Manifests from \
a newer axon are not back-compatible. Either update axon \
OR regenerate the manifest with `axon store introspect` \
against this version."
),
Self::UnknownColumnType {
store,
column,
observed_type,
suggestion,
} => {
let suggest_suffix = if suggestion.is_empty() {
String::new()
} else {
format!(" {suggestion}")
};
let known = StoreColumnType::all_canonical_names().join(", ");
write!(
f,
"store-schema manifest entry `{store}` column `{column}` \
declares unknown type `{observed_type}`. The closed \
v1.38.0 column-type catalog is {{{known}}}.{suggest_suffix}"
)
}
Self::ContentHashMismatch { expected, observed } => write!(
f,
"store-schema manifest `content_hash` mismatch (axon-T805). \
The hash on disk is `{expected}`, but the canonical \
content currently hashes to `{observed}`. Either the \
manifest was hand-edited without recomputing the hash, \
or the file was tampered. Regenerate with \
`axon store introspect <store> > <file>`."
),
Self::DuplicateStore { store, path_a, path_b } => write!(
f,
"store-schema manifest entry `{store}` is declared in two \
manifest files at once: `{}` and `{}`. Manifest files \
must own disjoint store sets. Either merge the files OR \
remove the duplicate entry from one.",
path_a.display(),
path_b.display(),
),
}
}
}
impl std::error::Error for ManifestError {}
impl Manifest {
pub fn parse_json(src: &str) -> Result<Manifest, ManifestError> {
let value: serde_json::Value =
serde_json::from_str(src).map_err(|e| ManifestError::InvalidJson {
detail: e.to_string(),
})?;
Self::from_value(value)
}
pub fn from_value(value: serde_json::Value) -> Result<Manifest, ManifestError> {
let obj = value.as_object().ok_or_else(|| ManifestError::InvalidStructure {
detail: "root must be an object".to_string(),
})?;
let version = obj
.get("version")
.and_then(|v| v.as_u64())
.ok_or_else(|| ManifestError::InvalidStructure {
detail: "missing `version: <u32>`".to_string(),
})? as u32;
if version != MANIFEST_VERSION {
return Err(ManifestError::UnsupportedVersion { found: version });
}
let stores_obj = obj.get("stores").and_then(|v| v.as_object()).ok_or_else(
|| ManifestError::InvalidStructure {
detail: "missing `stores: {...}`".to_string(),
},
)?;
let mut stores: BTreeMap<String, ManifestStore> = BTreeMap::new();
for (store_name, store_value) in stores_obj.iter() {
let store_obj = store_value.as_object().ok_or_else(|| {
ManifestError::InvalidStructure {
detail: format!(
"stores.{store_name} must be an object {{\"columns\": {{...}}}}"
),
}
})?;
let columns_obj = store_obj
.get("columns")
.and_then(|v| v.as_object())
.ok_or_else(|| ManifestError::InvalidStructure {
detail: format!("stores.{store_name}.columns must be an object"),
})?;
let mut columns: BTreeMap<String, ManifestColumn> = BTreeMap::new();
for (col_name, col_value) in columns_obj.iter() {
let col_obj = col_value.as_object().ok_or_else(|| {
ManifestError::InvalidStructure {
detail: format!(
"stores.{store_name}.columns.{col_name} must be an \
object"
),
}
})?;
let type_str = col_obj
.get("type")
.and_then(|v| v.as_str())
.ok_or_else(|| ManifestError::InvalidStructure {
detail: format!(
"stores.{store_name}.columns.{col_name} missing \
`type: \"<CatalogType>\"`"
),
})?;
let col_type = StoreColumnType::from_token(type_str).ok_or_else(|| {
let names = StoreColumnType::all_canonical_names();
let suggestion = smart_suggest::suggest_for(type_str, &names);
ManifestError::UnknownColumnType {
store: store_name.clone(),
column: col_name.clone(),
observed_type: type_str.to_string(),
suggestion,
}
})?;
columns.insert(
col_name.clone(),
ManifestColumn {
col_type,
primary_key: bool_field(col_obj, "primary_key"),
auto_increment: bool_field(col_obj, "auto_increment"),
not_null: bool_field(col_obj, "not_null"),
unique: bool_field(col_obj, "unique"),
default_value: string_field(col_obj, "default_value"),
identity: bool_field(col_obj, "identity"),
},
);
}
stores.insert(store_name.clone(), ManifestStore { columns });
}
let content_hash = obj
.get("content_hash")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok(Manifest {
version,
stores,
content_hash,
})
}
}
fn bool_field(obj: &serde_json::Map<String, serde_json::Value>, name: &str) -> bool {
obj.get(name).and_then(|v| v.as_bool()).unwrap_or(false)
}
fn string_field(obj: &serde_json::Map<String, serde_json::Value>, name: &str) -> String {
obj.get(name)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default()
}
impl Manifest {
pub fn canonical_serialize(&self, include_hash: bool) -> String {
let value = self.to_canonical_value(include_hash);
emit_canonical_json(&value)
}
fn to_canonical_value(&self, include_hash: bool) -> serde_json::Value {
use serde_json::Value;
let mut root = serde_json::Map::new();
root.insert("version".to_string(), Value::from(self.version as u64));
let mut stores = serde_json::Map::new();
for (name, store) in &self.stores {
let mut store_obj = serde_json::Map::new();
let mut cols = serde_json::Map::new();
for (col_name, col) in &store.columns {
let mut col_obj = serde_json::Map::new();
col_obj.insert("type".to_string(), Value::from(col.col_type.canonical_name()));
if col.primary_key {
col_obj.insert("primary_key".to_string(), Value::Bool(true));
}
if col.auto_increment {
col_obj.insert("auto_increment".to_string(), Value::Bool(true));
}
if col.not_null {
col_obj.insert("not_null".to_string(), Value::Bool(true));
}
if col.unique {
col_obj.insert("unique".to_string(), Value::Bool(true));
}
if !col.default_value.is_empty() {
col_obj
.insert("default_value".to_string(), Value::from(col.default_value.clone()));
}
if col.identity {
col_obj.insert("identity".to_string(), Value::Bool(true));
}
cols.insert(col_name.clone(), Value::Object(col_obj));
}
store_obj.insert("columns".to_string(), Value::Object(cols));
stores.insert(name.clone(), Value::Object(store_obj));
}
root.insert("stores".to_string(), Value::Object(stores));
if include_hash {
if let Some(h) = &self.content_hash {
root.insert("content_hash".to_string(), Value::from(h.clone()));
}
}
Value::Object(root)
}
pub fn compute_content_hash(&self) -> String {
let canonical = self.canonical_serialize(false);
format!("sha256:{}", sha256_hex(canonical.as_bytes()))
}
pub fn verify_content_hash(&self) -> Result<(), ManifestError> {
let Some(expected) = &self.content_hash else {
return Ok(());
};
let observed = self.compute_content_hash();
if &observed == expected {
Ok(())
} else {
Err(ManifestError::ContentHashMismatch {
expected: expected.clone(),
observed,
})
}
}
pub fn refresh_content_hash(&mut self) {
self.content_hash = Some(self.compute_content_hash());
}
}
fn emit_canonical_json(value: &serde_json::Value) -> String {
let mut out = String::new();
write_canonical_value(value, &mut out);
out
}
fn write_canonical_value(value: &serde_json::Value, out: &mut String) {
use serde_json::Value;
match value {
Value::Null => out.push_str("null"),
Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
Value::Number(n) => out.push_str(&n.to_string()),
Value::String(s) => write_canonical_string(s, out),
Value::Array(arr) => {
out.push('[');
for (i, v) in arr.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_canonical_value(v, out);
}
out.push(']');
}
Value::Object(map) => {
let mut entries: Vec<(&String, &Value)> = map.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
out.push('{');
for (i, (k, v)) in entries.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_canonical_string(k, out);
out.push(':');
write_canonical_value(v, out);
}
out.push('}');
}
}
}
fn write_canonical_string(s: &str, out: &mut String) {
out.push('"');
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\u{08}' => out.push_str("\\b"),
'\u{0c}' => out.push_str("\\f"),
c if (c as u32) < 0x20 => {
out.push_str(&format!("\\u{:04x}", c as u32));
}
c => out.push(c),
}
}
out.push('"');
}
pub fn sha256_hex(input: &[u8]) -> String {
let digest = sha256(input);
let mut hex = String::with_capacity(64);
for byte in &digest {
hex.push_str(&format!("{byte:02x}"));
}
hex
}
fn sha256(input: &[u8]) -> [u8; 32] {
let mut h: [u32; 8] = [
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
];
let bit_len: u64 = (input.len() as u64).wrapping_mul(8);
let mut padded: Vec<u8> = Vec::with_capacity(input.len() + 72);
padded.extend_from_slice(input);
padded.push(0x80);
while padded.len() % 64 != 56 {
padded.push(0);
}
padded.extend_from_slice(&bit_len.to_be_bytes());
for chunk in padded.chunks_exact(64) {
let mut w = [0u32; 64];
for i in 0..16 {
let start = i * 4;
w[i] = u32::from_be_bytes([
chunk[start],
chunk[start + 1],
chunk[start + 2],
chunk[start + 3],
]);
}
for i in 16..64 {
let s0 = w[i - 15].rotate_right(7)
^ w[i - 15].rotate_right(18)
^ (w[i - 15] >> 3);
let s1 = w[i - 2].rotate_right(17)
^ w[i - 2].rotate_right(19)
^ (w[i - 2] >> 10);
w[i] = w[i - 16]
.wrapping_add(s0)
.wrapping_add(w[i - 7])
.wrapping_add(s1);
}
let mut a = h[0];
let mut b = h[1];
let mut c = h[2];
let mut d = h[3];
let mut e = h[4];
let mut f = h[5];
let mut g = h[6];
let mut hh = h[7];
for i in 0..64 {
let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
let ch = (e & f) ^ ((!e) & g);
let temp1 = hh
.wrapping_add(s1)
.wrapping_add(ch)
.wrapping_add(SHA256_K[i])
.wrapping_add(w[i]);
let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
let maj = (a & b) ^ (a & c) ^ (b & c);
let temp2 = s0.wrapping_add(maj);
hh = g;
g = f;
f = e;
e = d.wrapping_add(temp1);
d = c;
c = b;
b = a;
a = temp1.wrapping_add(temp2);
}
h[0] = h[0].wrapping_add(a);
h[1] = h[1].wrapping_add(b);
h[2] = h[2].wrapping_add(c);
h[3] = h[3].wrapping_add(d);
h[4] = h[4].wrapping_add(e);
h[5] = h[5].wrapping_add(f);
h[6] = h[6].wrapping_add(g);
h[7] = h[7].wrapping_add(hh);
}
let mut out = [0u8; 32];
for (i, word) in h.iter().enumerate() {
out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
}
out
}
const SHA256_K: [u32; 64] = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
];
pub fn discover_manifest_files(start_dir: &Path) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
collect_manifest_files(start_dir, &mut out);
let schemas_dir = start_dir.join(DEFAULT_MANIFEST_DIR);
if schemas_dir.is_dir() {
collect_manifest_files(&schemas_dir, &mut out);
}
out.sort();
out
}
fn collect_manifest_files(dir: &Path, out: &mut Vec<PathBuf>) {
if !dir.is_dir() {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
if name.ends_with(".axon-schema.json")
|| name == "axon-schema.json"
{
out.push(path);
}
}
}
}
}
pub fn load_and_merge_manifests(start_dir: &Path) -> Result<Manifest, ManifestError> {
let files = discover_manifest_files(start_dir);
let mut merged = Manifest::new();
let mut origin: BTreeMap<String, PathBuf> = BTreeMap::new();
for path in &files {
let text = std::fs::read_to_string(path).map_err(|e| ManifestError::InvalidJson {
detail: format!("reading {}: {e}", path.display()),
})?;
let parsed = Manifest::parse_json(&text)?;
parsed.verify_content_hash()?;
for (store_name, store) in parsed.stores {
if let Some(prev_path) = origin.get(&store_name) {
return Err(ManifestError::DuplicateStore {
store: store_name,
path_a: prev_path.clone(),
path_b: path.clone(),
});
}
origin.insert(store_name.clone(), path.clone());
merged.stores.insert(store_name, store);
}
}
Ok(merged)
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_basic_manifest_json() -> &'static str {
r#"{
"stores": {
"tenants": {
"columns": {
"tier": { "type": "Text", "not_null": true },
"tenant_id": { "type": "Uuid", "primary_key": true }
}
}
},
"version": 1
}"#
}
#[test]
fn parse_accepts_a_well_formed_manifest_with_arbitrary_key_order() {
let m = Manifest::parse_json(fixture_basic_manifest_json()).expect("parse");
assert_eq!(m.version, 1);
assert_eq!(m.stores.len(), 1);
let s = m.lookup("tenants").expect("tenants store present");
assert_eq!(s.columns.len(), 2);
let tenant_id = s.columns.get("tenant_id").expect("tenant_id");
assert_eq!(tenant_id.col_type, StoreColumnType::Uuid);
assert!(tenant_id.primary_key);
let tier = s.columns.get("tier").expect("tier");
assert_eq!(tier.col_type, StoreColumnType::Text);
assert!(tier.not_null);
}
#[test]
fn parse_rejects_missing_top_level_version() {
let err = Manifest::parse_json(r#"{"stores": {}}"#).expect_err("must reject");
assert!(matches!(err, ManifestError::InvalidStructure { .. }));
}
#[test]
fn parse_rejects_unsupported_version() {
let err = Manifest::parse_json(r#"{"version": 2, "stores": {}}"#)
.expect_err("must reject");
match err {
ManifestError::UnsupportedVersion { found } => assert_eq!(found, 2),
other => panic!("expected UnsupportedVersion, got {other:?}"),
}
}
#[test]
fn parse_rejects_unknown_column_type_with_levenshtein_suggestion() {
let src = r#"{
"version": 1,
"stores": {
"tenants": {
"columns": {
"tenant_id": { "type": "Uudi" }
}
}
}
}"#;
let err = Manifest::parse_json(src).expect_err("must reject");
match err {
ManifestError::UnknownColumnType { store, column, observed_type, suggestion } => {
assert_eq!(store, "tenants");
assert_eq!(column, "tenant_id");
assert_eq!(observed_type, "Uudi");
assert!(
suggestion.contains("Uuid"),
"expected a Levenshtein hint pointing at `Uuid`, got {suggestion:?}"
);
}
other => panic!("expected UnknownColumnType, got {other:?}"),
}
}
#[test]
fn parse_rejects_unknown_column_type_without_a_suggestion_when_distance_exceeds_cap() {
let src = r#"{
"version": 1,
"stores": {
"tenants": {
"columns": {
"tenant_id": { "type": "MoneyAmount" }
}
}
}
}"#;
let err = Manifest::parse_json(src).expect_err("must reject");
match err {
ManifestError::UnknownColumnType { suggestion, .. } => {
assert!(
suggestion.is_empty(),
"an out-of-distance type must NOT surface a guess; \
got {suggestion:?}"
);
}
other => panic!("expected UnknownColumnType, got {other:?}"),
}
}
#[test]
fn canonical_serialize_is_byte_deterministic_across_input_whitespace_variants() {
let src_a = r#"{"version":1,"stores":{"tenants":{"columns":{"id":{"type":"Uuid"}}}}}"#;
let src_b = r#"{
"version": 1,
"stores": {
"tenants": {
"columns": {
"id": { "type": "Uuid" }
}
}
}
}"#;
let a = Manifest::parse_json(src_a).unwrap().canonical_serialize(false);
let b = Manifest::parse_json(src_b).unwrap().canonical_serialize(false);
assert_eq!(a, b, "canonical form must be byte-deterministic");
assert_eq!(
a,
r#"{"stores":{"tenants":{"columns":{"id":{"type":"Uuid"}}}},"version":1}"#
);
}
#[test]
fn canonical_serialize_emits_constraints_only_when_true() {
let src = r#"{
"version": 1,
"stores": {
"tenants": {
"columns": {
"id": { "type": "Uuid", "primary_key": true, "not_null": false }
}
}
}
}"#;
let m = Manifest::parse_json(src).unwrap();
let canonical = m.canonical_serialize(false);
assert!(canonical.contains(r#""primary_key":true"#));
assert!(
!canonical.contains("not_null"),
"false constraints are OMITTED from canonical form (skip-when-default \
discipline matching the IR serialization)"
);
}
#[test]
fn content_hash_is_stable_across_whitespace_and_key_order() {
let src_a = r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Uuid"}}}}}"#;
let src_b = r#"{
"stores": { "t": { "columns": { "id": { "type": "Uuid" } } } },
"version": 1
}"#;
let h_a = Manifest::parse_json(src_a).unwrap().compute_content_hash();
let h_b = Manifest::parse_json(src_b).unwrap().compute_content_hash();
assert_eq!(h_a, h_b);
assert!(h_a.starts_with("sha256:"));
assert_eq!(h_a.len(), "sha256:".len() + 64);
}
#[test]
fn verify_content_hash_passes_when_hash_matches_canonical_content() {
let mut m = Manifest::parse_json(
r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Uuid"}}}}}"#,
)
.unwrap();
m.refresh_content_hash();
let serialized = m.canonical_serialize(true);
let parsed = Manifest::parse_json(&serialized).unwrap();
parsed.verify_content_hash().expect("verifies");
}
#[test]
fn verify_content_hash_fails_when_hash_mismatches_canonical_content() {
let src = r#"{
"version": 1,
"stores": { "t": { "columns": { "id": { "type": "Uuid" } } } },
"content_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
}"#;
let m = Manifest::parse_json(src).unwrap();
let err = m.verify_content_hash().expect_err("must mismatch");
assert!(matches!(err, ManifestError::ContentHashMismatch { .. }));
}
#[test]
fn verify_content_hash_is_noop_when_field_absent() {
let m = Manifest::parse_json(
r#"{"version":1,"stores":{"t":{"columns":{"id":{"type":"Uuid"}}}}}"#,
)
.unwrap();
m.verify_content_hash().expect("absent hash is Ok");
}
#[test]
fn multi_store_manifest_round_trips_through_canonical_form() {
let src = r#"{
"version": 1,
"stores": {
"tenants": { "columns": { "id": { "type": "Uuid", "primary_key": true } } },
"audit_log": { "columns": { "event_id": { "type": "Uuid" }, "actor": { "type": "Text", "not_null": true } } },
"rates": { "columns": { "amount": { "type": "Numeric" } } }
}
}"#;
let m1 = Manifest::parse_json(src).unwrap();
let canonical = m1.canonical_serialize(false);
let m2 = Manifest::parse_json(&canonical).unwrap();
assert_eq!(canonical, m2.canonical_serialize(false));
assert_eq!(m1, m2);
let pos_audit = canonical.find(r#""audit_log""#).expect("audit_log present");
let pos_rates = canonical.find(r#""rates""#).expect("rates present");
let pos_tenants = canonical.find(r#""tenants""#).expect("tenants present");
assert!(pos_audit < pos_rates && pos_rates < pos_tenants, "stores must be sorted");
}
#[test]
fn lookup_by_qualified_name_returns_the_store_when_present() {
let m = Manifest::parse_json(
r#"{"version":1,"stores":{"public.tenants":{"columns":{"id":{"type":"Uuid"}}}}}"#,
)
.unwrap();
assert!(m.lookup("public.tenants").is_some());
assert!(m.lookup("tenants").is_none());
assert!(m.contains("public.tenants"));
}
#[test]
fn aliases_in_manifest_input_normalize_to_canonical_type_name() {
let src = r#"{
"version": 1,
"stores": {
"t": { "columns": { "id": { "type": "int", "primary_key": true } } }
}
}"#;
let m = Manifest::parse_json(src).unwrap();
let col = m.lookup("t").unwrap().columns.get("id").unwrap();
assert_eq!(col.col_type, StoreColumnType::Int);
let canonical = m.canonical_serialize(false);
assert!(canonical.contains(r#""type":"Int""#));
assert!(!canonical.contains(r#""type":"int""#));
}
#[test]
fn sha256_hex_matches_known_test_vectors() {
assert_eq!(
sha256_hex(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
let long = b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq";
assert_eq!(
sha256_hex(long),
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
);
}
}