use sha2::{Digest, Sha256};
use toml_edit::{value, ArrayOfTables, DocumentMut, Item, Table};
use crate::builder::draft::{Draft, Model};
use crate::builder::toml_canon::emit_canonical;
pub(crate) fn model_hash(draft: &Draft, model_name: &str) -> Option<String> {
let model = draft.models.iter().find(|m| m.name == model_name)?;
let mut doc = DocumentMut::new();
let mut aot = ArrayOfTables::new();
aot.push(model_projection(model));
doc["models"] = Item::ArrayOfTables(aot);
Some(hash_canonical(&emit_canonical(&doc)))
}
pub(crate) fn admin_hash(draft: &Draft) -> String {
let mut doc = DocumentMut::new();
let mut aot = ArrayOfTables::new();
for model in &draft.models {
let mut t = Table::new();
t["name"] = value(model.name.clone());
t["table"] = value(model.table.clone());
aot.push(t);
}
doc["models"] = Item::ArrayOfTables(aot);
hash_canonical(&emit_canonical(&doc))
}
pub(crate) fn mod_hash(draft: &Draft) -> String {
let mut doc = DocumentMut::new();
let mut aot = ArrayOfTables::new();
for model in &draft.models {
let mut t = Table::new();
t["name"] = value(model.name.clone());
aot.push(t);
}
doc["models"] = Item::ArrayOfTables(aot);
hash_canonical(&emit_canonical(&doc))
}
pub(crate) fn initial_migration_hash(draft: &Draft) -> String {
let mut doc = DocumentMut::new();
let mut project = Table::new();
project["name"] = value(draft.project.name.clone());
doc["project"] = Item::Table(project);
let mut aot = ArrayOfTables::new();
for model in &draft.models {
aot.push(model_projection(model));
}
doc["models"] = Item::ArrayOfTables(aot);
hash_canonical(&emit_canonical(&doc))
}
fn model_projection(m: &Model) -> Table {
let mut t = Table::new();
t["name"] = value(m.name.clone());
t["table"] = value(m.table.clone());
if !m.fields.is_empty() {
let mut aot = ArrayOfTables::new();
for f in &m.fields {
let mut ft = Table::new();
ft["name"] = value(f.name.clone());
ft["required"] = value(f.required);
ft["type"] = value(f.r#type.clone());
ft["unique"] = value(f.unique);
aot.push(ft);
}
t["fields"] = Item::ArrayOfTables(aot);
}
t
}
fn hash_canonical(bytes: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes.as_bytes());
let digest = hasher.finalize();
let hex = digest
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
format!("sha256:{hex}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::builder::draft::{Field, Project};
fn sample() -> Draft {
Draft {
schema_version: 1,
project: Project {
name: "demo".into(),
rust_version: "1.88".into(),
builder_pinned: "0.15.1".into(),
created_at: "2026-05-15T10:30:00Z".into(),
},
models: vec![Model {
name: "Patient".into(),
table: "patients".into(),
fields: vec![Field {
name: "full_name".into(),
r#type: "text".into(),
required: true,
unique: false,
}],
}],
}
}
#[test]
fn hashes_are_deterministic() {
let d = sample();
assert_eq!(model_hash(&d, "Patient"), model_hash(&d, "Patient"));
assert_eq!(admin_hash(&d), admin_hash(&d));
assert_eq!(mod_hash(&d), mod_hash(&d));
assert_eq!(initial_migration_hash(&d), initial_migration_hash(&d));
}
#[test]
fn hashes_share_sha256_prefix() {
let d = sample();
for h in [
model_hash(&d, "Patient").unwrap(),
admin_hash(&d),
mod_hash(&d),
initial_migration_hash(&d),
] {
assert!(h.starts_with("sha256:"), "{h}");
assert_eq!(h.len(), 71);
}
}
#[test]
fn model_hash_changes_when_field_added() {
let mut d = sample();
let before = model_hash(&d, "Patient").unwrap();
d.models[0].fields.push(Field {
name: "dob".into(),
r#type: "timestamp".into(),
required: false,
unique: false,
});
let after = model_hash(&d, "Patient").unwrap();
assert_ne!(before, after);
}
#[test]
fn admin_hash_ignores_unrelated_model_fields() {
let d = sample();
let admin_before = admin_hash(&d);
let mut after = d.clone();
after.models[0].fields.push(Field {
name: "dob".into(),
r#type: "timestamp".into(),
required: false,
unique: false,
});
assert_eq!(admin_before, admin_hash(&after));
}
#[test]
fn model_hash_returns_none_for_absent_model() {
assert!(model_hash(&sample(), "DoesNotExist").is_none());
}
#[test]
fn hashes_differ_across_projections() {
let d = sample();
let m = model_hash(&d, "Patient").unwrap();
let a = admin_hash(&d);
let mh = mod_hash(&d);
let mig = initial_migration_hash(&d);
let mut all = std::collections::HashSet::new();
for h in [&m, &a, &mh, &mig] {
assert!(all.insert(h.clone()), "projection hash collision: {h}");
}
}
}