mcpr_core/protocol/schema_manager/
version.rs1use std::sync::Arc;
5
6use chrono::{DateTime, Utc};
7use serde::Serialize;
8use serde_json::Value;
9use sha2::{Digest, Sha256};
10
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
17pub struct SchemaVersionId(pub String);
18
19impl SchemaVersionId {
20 pub fn as_str(&self) -> &str {
21 &self.0
22 }
23}
24
25impl std::fmt::Display for SchemaVersionId {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 f.write_str(&self.0)
28 }
29}
30
31#[derive(Debug, Clone)]
37pub struct SchemaVersion {
38 pub id: SchemaVersionId,
39 pub upstream_id: String,
40 pub method: String,
41 pub version: u32,
42 pub payload: Arc<Value>,
43 pub content_hash: String,
44 pub captured_at: DateTime<Utc>,
45}
46
47pub(crate) fn hash_payload(payload: &Value) -> String {
53 let canonical = canonicalize(payload);
54 let bytes = serde_json::to_vec(&canonical).expect("canonical json serializes");
55 let digest = Sha256::digest(&bytes);
56 hex_encode(&digest)
57}
58
59fn canonicalize(v: &Value) -> Value {
60 match v {
61 Value::Object(m) => {
62 let mut entries: Vec<(String, Value)> = m
63 .iter()
64 .map(|(k, v)| (k.clone(), canonicalize(v)))
65 .collect();
66 entries.sort_by(|a, b| a.0.cmp(&b.0));
67 Value::Object(entries.into_iter().collect())
68 }
69 Value::Array(a) => Value::Array(a.iter().map(canonicalize).collect()),
70 other => other.clone(),
71 }
72}
73
74fn hex_encode(bytes: &[u8]) -> String {
75 let mut s = String::with_capacity(bytes.len() * 2);
76 for b in bytes {
77 use std::fmt::Write;
78 let _ = write!(&mut s, "{b:02x}");
79 }
80 s
81}
82
83#[cfg(test)]
84#[allow(non_snake_case)]
85mod tests {
86 use super::*;
87 use serde_json::json;
88
89 #[test]
90 fn hash_payload__stable_across_key_order() {
91 let a = json!({"tools": [{"name": "x", "description": "d"}]});
92 let b = json!({"tools": [{"description": "d", "name": "x"}]});
93 assert_eq!(hash_payload(&a), hash_payload(&b));
94 }
95
96 #[test]
97 fn hash_payload__differs_on_value_change() {
98 let a = json!({"tools": [{"name": "x", "description": "old"}]});
99 let b = json!({"tools": [{"name": "x", "description": "new"}]});
100 assert_ne!(hash_payload(&a), hash_payload(&b));
101 }
102
103 #[test]
104 fn hash_payload__differs_on_item_added() {
105 let a = json!({"tools": [{"name": "x"}]});
106 let b = json!({"tools": [{"name": "x"}, {"name": "y"}]});
107 assert_ne!(hash_payload(&a), hash_payload(&b));
108 }
109
110 #[test]
111 fn schema_version_id__display_roundtrip() {
112 let id = SchemaVersionId("abc123".to_string());
113 assert_eq!(id.to_string(), "abc123");
114 assert_eq!(id.as_str(), "abc123");
115 }
116
117 #[test]
118 fn hex_encode__known_bytes() {
119 assert_eq!(hex_encode(&[0x00, 0xff, 0x10]), "00ff10");
120 }
121
122 #[test]
123 fn hash_payload__empty_object_is_deterministic() {
124 let a = json!({});
125 assert_eq!(hash_payload(&a), hash_payload(&a));
126 }
127}