Skip to main content

dejadb_core/
error.rs

1use std::fmt;
2
3/// Content-addressed SHA-256 hash (32 bytes, displayed as lowercase hex).
4#[derive(Clone, Copy, PartialEq, Eq, Hash)]
5pub struct Hash([u8; 32]);
6
7impl Hash {
8    /// Create a hash from a fixed-size 32-byte array (compile-time safe).
9    pub fn from_bytes(bytes: &[u8; 32]) -> Self {
10        Hash(*bytes)
11    }
12
13    /// Create a hash from a variable-length byte slice (fallible).
14    pub fn try_from_bytes(bytes: &[u8]) -> Result<Self> {
15        if bytes.len() < 32 {
16            return Err(DejaDbError::Format(format!(
17                "hash requires 32 bytes, got {}",
18                bytes.len()
19            )));
20        }
21        let mut arr = [0u8; 32];
22        arr.copy_from_slice(&bytes[..32]);
23        Ok(Hash(arr))
24    }
25
26    pub fn from_hex(hex_str: &str) -> Result<Self> {
27        let bytes = hex::decode(hex_str)
28            .map_err(|e| DejaDbError::Format(format!("invalid hex hash: {}", e)))?;
29        if bytes.len() != 32 {
30            return Err(DejaDbError::Format(format!(
31                "hash must be 32 bytes, got {}",
32                bytes.len()
33            )));
34        }
35        Ok(Self::from_bytes(&bytes.try_into().unwrap()))
36    }
37
38    pub fn as_bytes(&self) -> &[u8; 32] {
39        &self.0
40    }
41
42    pub fn to_hex(&self) -> String {
43        hex::encode(self.0)
44    }
45}
46
47impl fmt::Debug for Hash {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        write!(f, "Hash({})", &self.to_hex()[..16])
50    }
51}
52
53impl fmt::Display for Hash {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        write!(f, "{}", self.to_hex())
56    }
57}
58
59impl serde::Serialize for Hash {
60    fn serialize<S: serde::Serializer>(
61        &self,
62        serializer: S,
63    ) -> std::result::Result<S::Ok, S::Error> {
64        serializer.serialize_str(&self.to_hex())
65    }
66}
67
68impl<'de> serde::Deserialize<'de> for Hash {
69    fn deserialize<D: serde::Deserializer<'de>>(
70        deserializer: D,
71    ) -> std::result::Result<Self, D::Error> {
72        let s = String::deserialize(deserializer)?;
73        Hash::from_hex(&s).map_err(serde::de::Error::custom)
74    }
75}
76
77/// All errors in dejadb-core.
78#[derive(Debug)]
79pub enum DejaDbError {
80    NotFound(Hash),
81    Format(String),
82    Validation(String),
83    Serialization(String),
84    ToolRenderUnsupported(String),
85    Storage(String),
86    SupersessionConflict(Hash),
87    CryptoError(String),
88    AccumulateRetryExhausted,
89    AccumulateInternal(String),
90    AccumulateBackpressureRejected,
91    Internal(String),
92}
93
94impl DejaDbError {
95    /// Stable machine-readable error code in `DOMAIN-Ennn` form (see the
96    /// repo-root `ERROR_CODES.md` registry). Every `Display` string begins
97    /// with this code, so a user who reports the leading token points us at
98    /// the exact variant and subsystem. **Codes are append-only debugging
99    /// handles — never renumber or reuse an existing one.**
100    pub fn code(&self) -> &'static str {
101        match self {
102            Self::NotFound(_) => "MEM-E001",
103            Self::SupersessionConflict(_) => "MEM-E002",
104            Self::ToolRenderUnsupported(_) => "MEM-E110",
105            Self::Format(_) => "FMT-E001",
106            Self::Serialization(_) => "FMT-E002",
107            Self::Validation(_) => "VAL-E001",
108            Self::Storage(_) => "STO-E001",
109            Self::CryptoError(_) => "CRY-E001",
110            // These originate in CAL ACCUMULATE semantics and bubble up
111            // through the store, so they keep their CAL-domain codes.
112            Self::AccumulateRetryExhausted => "CAL-E083",
113            Self::AccumulateInternal(_) => "CAL-E084",
114            Self::AccumulateBackpressureRejected => "CAL-E085",
115            Self::Internal(_) => "SYS-E001",
116        }
117    }
118}
119
120impl std::fmt::Display for DejaDbError {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        // Invariant: every arm's message starts with `self.code()` — pinned by
123        // `code_prefixes_every_display` in the tests below.
124        match self {
125            Self::NotFound(h) => write!(f, "MEM-E001: grain not found: {h}"),
126            Self::SupersessionConflict(h) => write!(f, "MEM-E002: already superseded: {h}"),
127            Self::ToolRenderUnsupported(m) => write!(f, "MEM-E110: tool render unsupported: {m}"),
128            Self::Format(m) => write!(f, "FMT-E001: format error: {m}"),
129            Self::Serialization(m) => write!(f, "FMT-E002: serialization error: {m}"),
130            Self::Validation(m) => write!(f, "VAL-E001: validation error: {m}"),
131            Self::Storage(m) => write!(f, "STO-E001: storage error: {m}"),
132            Self::CryptoError(m) => write!(f, "CRY-E001: crypto error: {m}"),
133            Self::AccumulateRetryExhausted => write!(f, "CAL-E083: ACCUMULATE retry budget exhausted"),
134            Self::AccumulateInternal(m) => write!(f, "CAL-E084: ACCUMULATE internal failure: {m}"),
135            Self::AccumulateBackpressureRejected => write!(f, "CAL-E085: ACCUMULATE backpressure: inflight cap exceeded"),
136            Self::Internal(m) => write!(f, "SYS-E001: internal error: {m}"),
137        }
138    }
139}
140
141impl std::error::Error for DejaDbError {}
142
143pub type Result<T> = std::result::Result<T, DejaDbError>;
144
145#[cfg(test)]
146mod error_code_tests {
147    use super::*;
148
149    /// One representative instance of every variant — extend when adding one.
150    fn all_variants() -> Vec<DejaDbError> {
151        let h = Hash::from_bytes(&[0u8; 32]);
152        vec![
153            DejaDbError::NotFound(h),
154            DejaDbError::SupersessionConflict(h),
155            DejaDbError::ToolRenderUnsupported("x".into()),
156            DejaDbError::Format("x".into()),
157            DejaDbError::Serialization("x".into()),
158            DejaDbError::Validation("x".into()),
159            DejaDbError::Storage("x".into()),
160            DejaDbError::CryptoError("x".into()),
161            DejaDbError::AccumulateRetryExhausted,
162            DejaDbError::AccumulateInternal("x".into()),
163            DejaDbError::AccumulateBackpressureRejected,
164            DejaDbError::Internal("x".into()),
165        ]
166    }
167
168    /// The reported code must be the leading token of the message, so a user
169    /// pasting either gives us the same handle.
170    #[test]
171    fn code_prefixes_every_display() {
172        for e in all_variants() {
173            let msg = e.to_string();
174            let code = e.code();
175            assert!(
176                msg.starts_with(&format!("{code}: ")),
177                "`{msg}` must start with its code `{code}`"
178            );
179        }
180    }
181
182    /// Every code matches the `DOMAIN-Ennn` standard (see ERROR_CODES.md):
183    /// a 3-letter uppercase domain, `-E`, then digits.
184    #[test]
185    fn codes_follow_the_repo_standard() {
186        for e in all_variants() {
187            let c = e.code();
188            let (domain, num) = c.split_once("-E").unwrap_or_else(|| panic!("bad code: {c}"));
189            assert_eq!(domain.len(), 3, "{c}: domain must be 3 letters");
190            assert!(domain.chars().all(|ch| ch.is_ascii_uppercase()), "{c}: domain uppercase");
191            assert!(!num.is_empty() && num.chars().all(|ch| ch.is_ascii_digit()), "{c}: numeric suffix");
192        }
193    }
194}