use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::sheet_ir::value::CellValue;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct CellEntry {
pub json_key: String,
pub seed_coord: String,
pub unit: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Tool {
pub name: String,
pub description: Option<String>,
pub input_keys: Vec<String>,
pub outputs: Vec<CellEntry>,
pub oracle: BTreeMap<String, CellValue>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct CellMap {
pub inputs: Vec<CellEntry>,
pub tools: Vec<Tool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ArtifactHashes {
pub executable: String,
pub manifest: String,
pub evidence: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct BundleLock {
pub bundle_id: String,
pub version: String,
pub workbook_hash: String,
pub artifacts: ArtifactHashes,
pub combined: String,
}
pub fn sha256_hex(bytes: &[u8]) -> String {
hex::encode(Sha256::digest(bytes))
}
pub fn update_field(hasher: &mut Sha256, tag: &[u8], data: &[u8]) {
hasher.update(tag);
hasher.update((data.len() as u64).to_le_bytes());
hasher.update(data);
}
pub fn fold_evidence_hash(members: &[(&str, &[u8])]) -> String {
let mut sorted: Vec<&(&str, &[u8])> = members.iter().collect();
sorted.sort_by_key(|(path, _)| *path);
let mut hasher = Sha256::new();
for (path, body) in sorted {
update_field(&mut hasher, b"evidence.path", path.as_bytes());
update_field(&mut hasher, b"evidence.body", body);
}
hex::encode(hasher.finalize())
}
pub fn build_bundle_lock(
bundle_id: &str,
version: &str,
workbook_hash: String,
ir_json: &str,
manifest_json: &str,
evidence_hash: &str,
) -> BundleLock {
let h_exec = sha256_hex(ir_json.as_bytes());
let h_manifest = sha256_hex(manifest_json.as_bytes());
let h_evidence = evidence_hash.to_string();
let combined = sha256_hex(format!("{h_exec}{h_manifest}{h_evidence}").as_bytes());
BundleLock {
bundle_id: bundle_id.to_string(),
version: version.to_string(),
workbook_hash,
artifacts: ArtifactHashes {
executable: h_exec,
manifest: h_manifest,
evidence: h_evidence,
},
combined,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn workbook_hash() -> String {
sha256_hex(b"S!A1|10|\nS!B1|0.37|")
}
fn entry(json_key: &str, seed: &str, unit: Option<&str>) -> CellEntry {
CellEntry {
json_key: json_key.to_string(),
seed_coord: seed.to_string(),
unit: unit.map(str::to_string),
}
}
#[test]
fn artifact_model_tool_round_trips_through_serde() {
let mut oracle = BTreeMap::new();
oracle.insert("tax_owed".to_string(), CellValue::Number(18241.0));
let tool = Tool {
name: "Calculate_Tax".to_string(),
description: Some("Compute the tax owed".to_string()),
input_keys: vec!["income".to_string(), "filing".to_string()],
outputs: vec![entry("tax_owed", "Calc!B3", Some("USD"))],
oracle,
};
let json = serde_json::to_string(&tool).expect("serialize Tool");
let back: Tool = serde_json::from_str(&json).expect("deserialize Tool");
assert_eq!(
tool, back,
"Tool must serde round-trip preserving all fields"
);
assert_eq!(back.name, "Calculate_Tax");
assert_eq!(back.description.as_deref(), Some("Compute the tax owed"));
assert_eq!(back.input_keys, vec!["income", "filing"]);
assert_eq!(back.outputs.len(), 1);
assert_eq!(
back.oracle.get("tax_owed"),
Some(&CellValue::Number(18241.0))
);
}
#[test]
fn artifact_model_cell_map_with_tools_round_trips() {
let map = CellMap {
inputs: vec![entry("income", "In!B4", Some("USD"))],
tools: vec![Tool {
name: "Calculate_Tax".to_string(),
description: None,
input_keys: vec!["income".to_string()],
outputs: vec![entry("tax_owed", "Calc!B3", Some("USD"))],
oracle: BTreeMap::new(),
}],
};
let json = serde_json::to_string(&map).expect("serialize CellMap");
let back: CellMap = serde_json::from_str(&json).expect("deserialize CellMap");
assert_eq!(back.inputs.len(), 1);
assert_eq!(
back.tools.len(),
1,
"a one-Table manifest yields exactly one Tool"
);
assert_eq!(back.tools[0].name, "Calculate_Tax");
}
#[test]
fn artifact_model_per_tool_outputs_are_independent() {
let map = CellMap {
inputs: vec![],
tools: vec![
Tool {
name: "A".to_string(),
description: None,
input_keys: vec![],
outputs: vec![entry("a1", "S!A1", None), entry("a2", "S!A2", None)],
oracle: BTreeMap::new(),
},
Tool {
name: "B".to_string(),
description: None,
input_keys: vec![],
outputs: vec![entry("b1", "S!B1", None)],
oracle: BTreeMap::new(),
},
],
};
let tool_a_keys: Vec<&str> = map.tools[0]
.outputs
.iter()
.map(|e| e.json_key.as_str())
.collect();
let tool_b_keys: Vec<&str> = map.tools[1]
.outputs
.iter()
.map(|e| e.json_key.as_str())
.collect();
assert_eq!(tool_a_keys, vec!["a1", "a2"], "tool A owns its outputs");
assert_eq!(tool_b_keys, vec!["b1"], "tool B owns its outputs");
let total: usize = map.tools.iter().map(|t| t.outputs.len()).sum();
assert_eq!(total, 3, "three output cells across two tools");
}
#[test]
fn bundle_lock_records_three_plus_combined() {
let lock = build_bundle_lock(
"tax-calc",
"1.0.0",
workbook_hash(),
"{IR}",
"{MANIFEST}",
&sha256_hex(b"{EVIDENCE-DIR}"),
);
for h in [
&lock.artifacts.executable,
&lock.artifacts.manifest,
&lock.artifacts.evidence,
&lock.combined,
] {
assert_eq!(h.len(), 64, "each hash is a 64-char sha256 hex");
}
assert_ne!(lock.combined, lock.artifacts.executable);
assert_ne!(lock.combined, lock.artifacts.manifest);
assert_ne!(lock.combined, lock.artifacts.evidence);
}
#[test]
fn bundle_lock_hashes_stable_across_runs() {
let a = build_bundle_lock(
"tax-calc",
"1.0.0",
workbook_hash(),
"{IR}",
"{MANIFEST}",
&sha256_hex(b"{EVID}"),
);
let b = build_bundle_lock(
"tax-calc",
"1.0.0",
workbook_hash(),
"{IR}",
"{MANIFEST}",
&sha256_hex(b"{EVID}"),
);
assert_eq!(a, b, "bundle-lock hashing is stable across runs");
}
#[test]
fn combined_hash_changes_when_any_artifact_changes() {
let base = build_bundle_lock(
"tax-calc",
"1.0.0",
workbook_hash(),
"{IR}",
"{MANIFEST}",
&sha256_hex(b"{EVID}"),
);
let tampered = build_bundle_lock(
"tax-calc",
"1.0.0",
workbook_hash(),
"{IR}",
"{MANIFEST }", &sha256_hex(b"{EVID}"),
);
assert_ne!(base.artifacts.manifest, tampered.artifacts.manifest);
assert_ne!(base.combined, tampered.combined);
let tampered_exec = build_bundle_lock(
"tax-calc",
"1.0.0",
workbook_hash(),
"{IR }",
"{MANIFEST}",
&sha256_hex(b"{EVID}"),
);
assert_ne!(base.combined, tampered_exec.combined);
}
#[test]
fn workbook_hash_reuses_content_projection() {
let wh = workbook_hash();
let lock = build_bundle_lock(
"tax-calc",
"1.0.0",
wh.clone(),
"{IR}",
"{MANIFEST}",
&sha256_hex(b"{EVID}"),
);
assert_eq!(lock.workbook_hash, wh);
assert_ne!(lock.workbook_hash, lock.artifacts.executable);
assert_ne!(lock.workbook_hash, lock.combined);
}
#[test]
fn workflow_and_version_are_parameters_not_hardcoded() {
let lock = build_bundle_lock(
"other-bundle",
"2.3.4",
workbook_hash(),
"{IR}",
"{MANIFEST}",
&sha256_hex(b"{EVID}"),
);
assert_eq!(lock.bundle_id, "other-bundle");
assert_eq!(lock.version, "2.3.4");
}
#[test]
fn update_field_is_length_prefixed() {
let mut a = Sha256::new();
update_field(&mut a, b"t", b"ab");
update_field(&mut a, b"t", b"c");
let mut b = Sha256::new();
update_field(&mut b, b"t", b"a");
update_field(&mut b, b"t", b"bc");
assert_ne!(hex::encode(a.finalize()), hex::encode(b.finalize()));
}
}