use blake3;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ObjectId([u8; 32]);
impl ObjectId {
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
pub fn hash(data: &[u8]) -> Self {
Self(*blake3::hash(data).as_bytes())
}
pub fn short(&self) -> String {
format!("sg_{}", to_hex(&self.0[..6]))
}
pub fn to_hex(&self) -> String {
to_hex(&self.0)
}
pub fn from_hex(s: &str) -> Option<Self> {
let hex = s.trim().strip_prefix("sg_").unwrap_or(s.trim());
if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let mut bytes = [0u8; 32];
for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
let hi = (chunk[0] as char).to_digit(16)?;
let lo = (chunk[1] as char).to_digit(16)?;
bytes[i] = (hi * 16 + lo) as u8;
}
Some(Self(bytes))
}
pub fn normalize_hex_prefix(s: &str) -> Option<String> {
let frag = s.trim().strip_prefix("sg_").unwrap_or(s.trim());
if frag.is_empty() || !frag.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
Some(frag.to_ascii_lowercase())
}
}
impl fmt::Display for ObjectId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "sg_{}", to_hex(&self.0))
}
}
fn to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
impl fmt::Debug for ObjectId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ObjectId({})", self.short())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", content = "value")]
pub enum Atom {
Null,
Bool(bool),
Int(i64),
Float(f64),
String(String),
Bytes(Vec<u8>),
UInt(u64),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", content = "value")]
pub enum Node {
Map(BTreeMap<String, ObjectId>),
List(Vec<ObjectId>),
Set(Vec<ObjectId>),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum Object {
Atom(Atom),
Node(Node),
}
impl Object {
pub fn canonical_bytes(&self) -> Vec<u8> {
serde_json::to_vec(self).expect("Object serialization should never fail")
}
pub fn id(&self) -> ObjectId {
ObjectId::hash(&self.canonical_bytes())
}
pub fn null() -> Self {
Object::Atom(Atom::Null)
}
pub fn bool(v: bool) -> Self {
Object::Atom(Atom::Bool(v))
}
pub fn int(v: i64) -> Self {
Object::Atom(Atom::Int(v))
}
pub fn float(v: f64) -> Self {
Object::Atom(Atom::Float(v))
}
pub fn uint(v: u64) -> Self {
match i64::try_from(v) {
Ok(i) => Object::Atom(Atom::Int(i)),
Err(_) => Object::Atom(Atom::UInt(v)),
}
}
pub fn from_json_number(n: &serde_json::Number) -> Self {
if let Some(i) = n.as_i64() {
Object::int(i)
} else if let Some(u) = n.as_u64() {
Object::uint(u)
} else {
Object::float(n.as_f64().unwrap_or(f64::NAN))
}
}
pub fn string(v: impl Into<String>) -> Self {
Object::Atom(Atom::String(v.into()))
}
pub fn bytes(v: Vec<u8>) -> Self {
Object::Atom(Atom::Bytes(v))
}
pub fn map(entries: BTreeMap<String, ObjectId>) -> Self {
Object::Node(Node::Map(entries))
}
pub fn empty_map() -> Self {
Object::Node(Node::Map(BTreeMap::new()))
}
pub fn list(items: Vec<ObjectId>) -> Self {
Object::Node(Node::List(items))
}
pub fn set(items: Vec<ObjectId>) -> Self {
let mut sorted = items;
sorted.sort();
sorted.dedup();
Object::Node(Node::Set(sorted))
}
}
#[cfg(test)]
mod tests {
#[test]
fn integers_have_one_representation() {
assert_eq!(Object::uint(5), Object::int(5));
assert_eq!(Object::uint(i64::MAX as u64), Object::int(i64::MAX));
assert_eq!(Object::uint(u64::MAX), Object::Atom(Atom::UInt(u64::MAX)));
let n = |v: serde_json::Value| match v {
serde_json::Value::Number(n) => Object::from_json_number(&n),
_ => unreachable!(),
};
assert_eq!(n(serde_json::json!(5u64)), Object::int(5));
assert_eq!(n(serde_json::json!(u64::MAX)), Object::uint(u64::MAX));
assert_eq!(n(serde_json::json!(-3)), Object::int(-3));
assert_eq!(n(serde_json::json!(0.5)), Object::float(0.5));
}
#[test]
fn existing_atoms_serialize_unchanged() {
assert_eq!(
String::from_utf8(Object::int(42).canonical_bytes()).unwrap(),
r#"{"kind":"Atom","type":"Int","value":42}"#
);
assert_eq!(
String::from_utf8(Object::uint(u64::MAX).canonical_bytes()).unwrap(),
r#"{"kind":"Atom","type":"UInt","value":18446744073709551615}"#
);
}
use super::*;
#[test]
fn test_content_addressing_deterministic() {
let obj1 = Object::string("hello");
let obj2 = Object::string("hello");
assert_eq!(
obj1.id(),
obj2.id(),
"identical objects must have the same ObjectId"
);
}
#[test]
fn test_different_content_different_id() {
let obj1 = Object::string("hello");
let obj2 = Object::string("world");
assert_ne!(
obj1.id(),
obj2.id(),
"different objects must have different ObjectIds"
);
}
#[test]
fn test_map_key_order_irrelevant() {
let id_a = Object::string("a").id();
let id_b = Object::string("b").id();
let mut map1 = BTreeMap::new();
map1.insert("first".to_string(), id_a);
map1.insert("second".to_string(), id_b);
let mut map2 = BTreeMap::new();
map2.insert("second".to_string(), id_b);
map2.insert("first".to_string(), id_a);
let obj1 = Object::map(map1);
let obj2 = Object::map(map2);
assert_eq!(
obj1.id(),
obj2.id(),
"maps with same entries in different order must have same ObjectId"
);
}
#[test]
fn test_set_dedup_and_sort() {
let id_a = Object::string("a").id();
let id_b = Object::string("b").id();
let obj1 = Object::set(vec![id_a, id_b, id_a]); let obj2 = Object::set(vec![id_b, id_a]);
assert_eq!(obj1.id(), obj2.id(), "sets must be deduplicated and sorted");
}
#[test]
fn test_object_id_display() {
let obj = Object::string("test");
let id = obj.id();
let display = format!("{}", id);
assert!(
display.starts_with("sg_"),
"ObjectId display should start with sg_ prefix"
);
}
#[test]
fn test_atom_variants() {
let ids: Vec<ObjectId> = vec![
Object::null().id(),
Object::bool(true).id(),
Object::bool(false).id(),
Object::int(42).id(),
Object::float(std::f64::consts::PI).id(),
Object::string("test").id(),
Object::bytes(vec![1, 2, 3]).id(),
];
for i in 0..ids.len() {
for j in (i + 1)..ids.len() {
assert_ne!(
ids[i], ids[j],
"different atom types/values must have different ObjectIds"
);
}
}
}
}