use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Value {
Int(i64),
Float(f64),
Str(String),
Bool(bool),
List(Vec<Value>),
Map(BTreeMap<String, Value>),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ValueKey {
Int(i64),
FloatBits(u64), Str(String),
Bool(bool),
}
impl ValueKey {
pub fn from_value(v: &Value) -> Option<ValueKey> {
match v {
Value::Int(i) => Some(ValueKey::Int(*i)),
Value::Float(f) => Some(ValueKey::FloatBits(f.to_bits())),
Value::Str(s) => Some(ValueKey::Str(s.clone())),
Value::Bool(b) => Some(ValueKey::Bool(*b)),
Value::List(_) => None,
Value::Map(_) => None,
}
}
}
pub fn list_tokens(v: &Value) -> Option<std::collections::BTreeSet<ValueKey>> {
match v {
Value::List(items) => Some(items.iter().filter_map(ValueKey::from_value).collect()),
_ => None,
}
}
#[derive(Debug)]
pub enum GraphError {
KeyNotFound {
key: String,
},
DuplicateKey {
key: String,
},
Io(std::io::Error),
Corrupt {
detail: String,
},
RuleInvalid {
detail: String,
},
RuleOwned {
detail: String,
},
RuleNotFound {
name: String,
},
QueryError {
detail: String,
},
IngestError {
detail: String,
},
ReadOnly,
CommitOutOfRange {
commit: u64,
total: u64,
floor: u64,
},
ViewPropReadOnly {
view_name: String,
},
CasConflict {
key: String,
expected: u64,
actual: u64,
},
MaskedReadOnly,
RoleWriteDenied {
reason: String,
},
Busy {
holder: Option<u32>,
},
NamespaceImmutable {
key: String,
from: String,
to: String,
},
CrossNamespace {
src: String,
src_ns: String,
dst: String,
dst_ns: String,
},
}
pub const NS_PROP: &str = "ns";
pub const NS_DEFAULT: &str = "default";
pub const NS_MAX_LEN: usize = 64;
pub fn valid_namespace(name: &str) -> bool {
!name.is_empty()
&& name.chars().count() <= NS_MAX_LEN
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')
}
pub fn namespace_of_value(v: Option<&Value>) -> &str {
match v {
Some(Value::Str(s)) => s.as_str(),
_ => NS_DEFAULT,
}
}
impl std::fmt::Display for GraphError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
GraphError::KeyNotFound { key } => write!(f, "node key not found: {key}"),
GraphError::DuplicateKey { key } => write!(f, "duplicate node key: {key}"),
GraphError::Io(e) => write!(f, "io error: {e}"),
GraphError::Corrupt { detail } => write!(f, "corrupt data: {detail}"),
GraphError::RuleInvalid { detail } => write!(f, "invalid rule: {detail}"),
GraphError::RuleOwned { detail } => write!(f, "edge is rule-owned: {detail}"),
GraphError::RuleNotFound { name } => write!(f, "rule not found: {name}"),
GraphError::QueryError { detail } => write!(f, "query error: {detail}"),
GraphError::IngestError { detail } => write!(f, "ingest error: {detail}"),
GraphError::ReadOnly => write!(f, "as-of instances are read-only"),
GraphError::CommitOutOfRange {
commit,
total,
floor: 0,
} => write!(
f,
"commit {commit} is out of range; valid range is 0..{total}"
),
GraphError::CommitOutOfRange {
commit,
total,
floor,
} => write!(
f,
"commit {commit} is out of range; valid range is {floor}..{total} \
— events before commit {floor} are not retained"
),
GraphError::ViewPropReadOnly { view_name } => write!(
f,
"property is managed by view {:?} and cannot be written directly",
view_name
),
GraphError::CasConflict {
key,
expected,
actual,
} => write!(
f,
"CAS conflict on key {key:?}: expected commit {expected}, actual {actual}"
),
GraphError::MaskedReadOnly => write!(f, "masked queries are read-only"),
GraphError::RoleWriteDenied { reason } => write!(f, "{reason}"),
GraphError::Busy { holder: Some(pid) } => {
write!(f, "store is busy: write lock held by process {pid}")
}
GraphError::Busy { holder: None } => {
write!(f, "store is busy: write lock held by another process")
}
GraphError::NamespaceImmutable { key, from, to } => write!(
f,
"node {key} is in namespace {from}; a namespace is set at insert and cannot \
be changed to {to} — delete and re-insert the node instead"
),
GraphError::CrossNamespace {
src,
src_ns,
dst,
dst_ns,
} => write!(
f,
"edge {src} → {dst} crosses a namespace boundary ({src_ns} → {dst_ns}); \
only a global rule may derive one"
),
}
}
}
impl std::error::Error for GraphError {}
impl From<std::io::Error> for GraphError {
fn from(e: std::io::Error) -> Self {
GraphError::Io(e)
}
}
pub type Result<T> = std::result::Result<T, GraphError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn value_roundtrips_through_bincode() {
let vals = vec![
Value::Int(42),
Value::Float(1.5),
Value::Str("hi".into()),
Value::Bool(true),
];
let bytes = bincode::serialize(&vals).unwrap();
let back: Vec<Value> = bincode::deserialize(&bytes).unwrap();
assert_eq!(vals, back);
}
#[test]
fn errors_display_context() {
let e = GraphError::KeyNotFound { key: "u1".into() };
assert_eq!(e.to_string(), "node key not found: u1");
}
#[test]
fn list_roundtrips_and_old_variants_keep_encoding() {
let l = Value::List(vec![Value::Str("a".into()), Value::Int(2)]);
let back: Value = bincode::deserialize(&bincode::serialize(&l).unwrap()).unwrap();
assert_eq!(l, back);
assert_eq!(
bincode::serialize(&Value::Int(7)).unwrap(),
vec![0, 0, 0, 0, 7, 0, 0, 0, 0, 0, 0, 0]
);
}
#[test]
fn value_keys_normalize_scalars_and_tokenize_lists() {
assert_eq!(ValueKey::from_value(&Value::Int(3)), Some(ValueKey::Int(3)));
assert_eq!(
ValueKey::from_value(&Value::Float(1.5)),
Some(ValueKey::FloatBits(1.5f64.to_bits()))
);
assert_eq!(ValueKey::from_value(&Value::List(vec![])), None);
let toks = list_tokens(&Value::List(vec![
Value::Str("x".into()),
Value::Str("x".into()), Value::List(vec![Value::Int(1)]), ]))
.unwrap();
assert_eq!(toks.len(), 1);
assert!(toks.contains(&ValueKey::Str("x".into())));
assert_eq!(list_tokens(&Value::Int(1)), None);
}
}