Development Documentation (main branch) - For stable release docs, see docs.rs/eidetica
Skip to main content

eidetica/service/
error.rs

1//! Wire-format error type for the service protocol.
2//!
3//! `ServiceError` carries enough information to reconstruct an appropriate
4//! `crate::Error` on the client side without requiring the full error type
5//! hierarchy to be serializable.
6
7use serde::{Deserialize, Serialize};
8
9use crate::backend::BackendError;
10use crate::entry::ID;
11use crate::instance::InstanceError;
12
13/// Wire-format error for the service protocol.
14///
15/// Captures the originating module, the discriminant name, and the Display message
16/// of a `crate::Error` so the client can reconstruct an appropriate error variant.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ServiceError {
19    /// The originating module (from `Error::module()`)
20    pub module: String,
21    /// The discriminant name (e.g. "EntryNotFound")
22    pub kind: String,
23    /// The Display message
24    pub message: String,
25}
26
27impl From<&crate::Error> for ServiceError {
28    fn from(err: &crate::Error) -> Self {
29        let module = err.module().to_string();
30        let kind = error_kind_name(err);
31        let message = err.to_string();
32        ServiceError {
33            module,
34            kind,
35            message,
36        }
37    }
38}
39
40/// Reconstruct a `crate::Error` from a `ServiceError`.
41///
42/// Matches on module + kind to produce the most specific error variant possible.
43/// Falls back to a generic IO error with the original message for unrecognized
44/// combinations.
45pub fn service_error_to_eidetica_error(err: ServiceError) -> crate::Error {
46    match (err.module.as_str(), err.kind.as_str()) {
47        ("backend", "InvalidStoreStateStagingToken") => {
48            BackendError::InvalidStoreStateStagingToken.into()
49        }
50        ("backend", "InvalidStoreStateView") => BackendError::InvalidStoreStateView.into(),
51        ("backend", "RecordTooLarge") => BackendError::RecordTooLarge { encoded_bytes: 0 }.into(),
52        ("backend", "EntryNotFound") => BackendError::EntryNotFound {
53            id: extract_id_from_message(&err.message).unwrap_or_default(),
54        }
55        .into(),
56        ("backend", "VerificationStatusNotFound") => BackendError::VerificationStatusNotFound {
57            id: extract_id_from_message(&err.message).unwrap_or_default(),
58        }
59        .into(),
60        ("backend", "EntryNotInTree") => BackendError::EntryNotInTree {
61            entry_id: ID::default(),
62            tree_id: ID::default(),
63        }
64        .into(),
65        // Emitted only by peers running versions whose find_merge_base
66        // errored on disjoint histories; this engine returns Ok(None) there.
67        ("backend", "NoCommonAncestor") => {
68            BackendError::NoCommonAncestor { entry_ids: vec![] }.into()
69        }
70        ("backend", "EmptyEntryList") => BackendError::EmptyEntryList {
71            operation: err.message.clone(),
72        }
73        .into(),
74        ("instance", "DatabaseNotFound") => InstanceError::DatabaseNotFound {
75            name: err.message.clone(),
76        }
77        .into(),
78        ("instance", "EntryNotFound") => InstanceError::EntryNotFound {
79            entry_id: extract_id_from_message(&err.message).unwrap_or_default(),
80        }
81        .into(),
82        ("instance", "InstanceAlreadyExists") => InstanceError::InstanceAlreadyExists.into(),
83        ("instance", "DeviceKeyNotFound") => InstanceError::DeviceKeyNotFound.into(),
84        ("instance", "AuthenticationRequired") => InstanceError::AuthenticationRequired.into(),
85        _ => {
86            // Fall back to an IO error carrying the original message
87            crate::Error::Io(std::io::Error::other(format!(
88                "[{}::{}] {}",
89                err.module, err.kind, err.message
90            )))
91        }
92    }
93}
94
95/// Extract an ID from an error message like "Entry not found: <id>".
96fn extract_id_from_message(message: &str) -> Option<ID> {
97    message
98        .rsplit(": ")
99        .next()
100        .and_then(|s| ID::parse(s.trim()).ok())
101}
102
103/// Get the discriminant name of an error variant.
104fn error_kind_name(err: &crate::Error) -> String {
105    match err {
106        crate::Error::Io(_) => "Io".to_string(),
107        crate::Error::Serialize(_) => "Serialize".to_string(),
108        crate::Error::Auth(e) => format!("{e:?}")
109            .split_once(|c: char| !c.is_alphanumeric())
110            .map_or_else(|| format!("{e:?}"), |(name, _)| name.to_string()),
111        crate::Error::Backend(e) => format!("{e:?}")
112            .split_once(|c: char| !c.is_alphanumeric())
113            .map_or_else(|| format!("{e:?}"), |(name, _)| name.to_string()),
114        crate::Error::Instance(e) => format!("{e:?}")
115            .split_once(|c: char| !c.is_alphanumeric())
116            .map_or_else(|| format!("{e:?}"), |(name, _)| name.to_string()),
117        crate::Error::CRDT(e) => format!("{e:?}")
118            .split_once(|c: char| !c.is_alphanumeric())
119            .map_or_else(|| format!("{e:?}"), |(name, _)| name.to_string()),
120        crate::Error::Store(e) => format!("{e:?}")
121            .split_once(|c: char| !c.is_alphanumeric())
122            .map_or_else(|| format!("{e:?}"), |(name, _)| name.to_string()),
123        crate::Error::Transaction(e) => format!("{e:?}")
124            .split_once(|c: char| !c.is_alphanumeric())
125            .map_or_else(|| format!("{e:?}"), |(name, _)| name.to_string()),
126        crate::Error::Sync(e) => format!("{e:?}")
127            .split_once(|c: char| !c.is_alphanumeric())
128            .map_or_else(|| format!("{e:?}"), |(name, _)| name.to_string()),
129        crate::Error::Entry(e) => format!("{e:?}")
130            .split_once(|c: char| !c.is_alphanumeric())
131            .map_or_else(|| format!("{e:?}"), |(name, _)| name.to_string()),
132        crate::Error::Id(e) => format!("{e:?}")
133            .split_once(|c: char| !c.is_alphanumeric())
134            .map_or_else(|| format!("{e:?}"), |(name, _)| name.to_string()),
135        crate::Error::User(e) => format!("{e:?}")
136            .split_once(|c: char| !c.is_alphanumeric())
137            .map_or_else(|| format!("{e:?}"), |(name, _)| name.to_string()),
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn test_service_error_from_backend_not_found() {
147        let test_id = ID::from_bytes("abc123");
148        let err = crate::Error::Backend(Box::new(BackendError::EntryNotFound { id: test_id }));
149        let se = ServiceError::from(&err);
150        assert_eq!(se.module, "backend");
151        assert_eq!(se.kind, "EntryNotFound");
152        assert!(se.message.contains("abc123") || se.message.contains("Entry not found"));
153    }
154
155    #[test]
156    fn test_service_error_from_instance_error() {
157        let err = crate::Error::Instance(Box::new(InstanceError::DeviceKeyNotFound));
158        let se = ServiceError::from(&err);
159        assert_eq!(se.module, "instance");
160        assert_eq!(se.kind, "DeviceKeyNotFound");
161    }
162
163    #[test]
164    fn test_roundtrip_backend_entry_not_found() {
165        let original = crate::Error::Backend(Box::new(BackendError::EntryNotFound {
166            id: ID::from_bytes("test-id"),
167        }));
168        let se = ServiceError::from(&original);
169        let reconstructed = service_error_to_eidetica_error(se);
170        assert!(reconstructed.is_not_found());
171    }
172
173    #[test]
174    fn test_roundtrip_instance_already_exists() {
175        let original = crate::Error::Instance(Box::new(InstanceError::InstanceAlreadyExists));
176        let se = ServiceError::from(&original);
177        let reconstructed = service_error_to_eidetica_error(se);
178        assert!(reconstructed.is_conflict());
179    }
180
181    #[test]
182    fn test_unknown_error_falls_back_to_io() {
183        let se = ServiceError {
184            module: "unknown".to_string(),
185            kind: "SomethingWeird".to_string(),
186            message: "something happened".to_string(),
187        };
188        let err = service_error_to_eidetica_error(se);
189        assert!(err.is_io_error());
190    }
191
192    #[test]
193    fn test_store_state_view_and_staging_token_stay_distinct_on_wire() {
194        // Published-view expiry must cross the wire as a retryable
195        // `InvalidStoreStateView`; private-build token failures stay
196        // `InvalidStoreStateStagingToken`. Collapsing the two would either
197        // retry staging misuse or stop retrying stale views.
198        let view = crate::Error::Backend(Box::new(BackendError::InvalidStoreStateView));
199        let view_round = service_error_to_eidetica_error(ServiceError::from(&view));
200        assert!(view_round.is_invalid_store_state_view());
201
202        let staging = crate::Error::Backend(Box::new(BackendError::InvalidStoreStateStagingToken));
203        let staging_round = service_error_to_eidetica_error(ServiceError::from(&staging));
204        assert!(
205            matches!(
206                staging_round,
207                crate::Error::Backend(ref e)
208                    if matches!(**e, BackendError::InvalidStoreStateStagingToken)
209            ),
210            "staging-token failure must stay a staging-token error, got {staging_round:?}"
211        );
212        assert!(!staging_round.is_invalid_store_state_view());
213    }
214
215    /// Every `(module, kind)` pair that `service_error_to_eidetica_error`
216    /// claims to map specifically must survive a full round-trip with its
217    /// `(module, kind)` intact.
218    ///
219    /// This is the regression net for the stringly-typed wire mapping: the
220    /// reverse table keys off the `{:?}` discriminant string produced by
221    /// `error_kind_name`. If a `BackendError`/`InstanceError` variant is
222    /// renamed (or its mapping arm dropped), reconstruction silently falls
223    /// through the `_ =>` wildcard to a generic IO error, whose `(module,
224    /// kind)` no longer matches the original — failing this test instead of
225    /// silently degrading error fidelity for every wire client.
226    #[test]
227    fn test_all_mapped_pairs_roundtrip_module_and_kind() {
228        let cases: Vec<crate::Error> = vec![
229            crate::Error::Backend(Box::new(BackendError::InvalidStoreStateStagingToken)),
230            crate::Error::Backend(Box::new(BackendError::InvalidStoreStateView)),
231            crate::Error::Backend(Box::new(BackendError::RecordTooLarge { encoded_bytes: 42 })),
232            crate::Error::Backend(Box::new(BackendError::EntryNotFound {
233                id: ID::from_bytes("rt-entry"),
234            })),
235            crate::Error::Backend(Box::new(BackendError::VerificationStatusNotFound {
236                id: ID::from_bytes("rt-vs"),
237            })),
238            crate::Error::Backend(Box::new(BackendError::EntryNotInTree {
239                entry_id: ID::from_bytes("rt-e"),
240                tree_id: ID::from_bytes("rt-t"),
241            })),
242            crate::Error::Backend(Box::new(BackendError::NoCommonAncestor {
243                entry_ids: vec![ID::from_bytes("rt-a")],
244            })),
245            crate::Error::Backend(Box::new(BackendError::EmptyEntryList {
246                operation: "rt".to_string(),
247            })),
248            crate::Error::Instance(Box::new(InstanceError::DatabaseNotFound {
249                name: "rt-db".to_string(),
250            })),
251            crate::Error::Instance(Box::new(InstanceError::EntryNotFound {
252                entry_id: ID::from_bytes("rt-ie"),
253            })),
254            crate::Error::Instance(Box::new(InstanceError::InstanceAlreadyExists)),
255            crate::Error::Instance(Box::new(InstanceError::DeviceKeyNotFound)),
256            crate::Error::Instance(Box::new(InstanceError::AuthenticationRequired)),
257        ];
258
259        for original in &cases {
260            let se = ServiceError::from(original);
261            let reconstructed = service_error_to_eidetica_error(se.clone());
262            let round = ServiceError::from(&reconstructed);
263            assert_eq!(
264                (round.module.as_str(), round.kind.as_str()),
265                (se.module.as_str(), se.kind.as_str()),
266                "mapped pair ({}::{}) fell through the wildcard on reconstruction \
267                 — its mapping arm or discriminant name has drifted",
268                se.module,
269                se.kind,
270            );
271        }
272    }
273
274    /// Compile-time guard: if a new top-level `crate::Error` variant is added,
275    /// this match stops being exhaustive and the test build fails, forcing the
276    /// author to decide how the variant crosses the service wire (update
277    /// `error_kind_name` and, if it needs a specific client-side reconstruction,
278    /// `service_error_to_eidetica_error`). Mirrors `error_kind_name`'s arms.
279    #[allow(dead_code)]
280    fn wire_mapping_exhaustiveness_guard(err: &crate::Error) {
281        match err {
282            crate::Error::Io(_) => {}
283            crate::Error::Serialize(_) => {}
284            crate::Error::Auth(_) => {}
285            crate::Error::Backend(_) => {}
286            crate::Error::Instance(_) => {}
287            crate::Error::CRDT(_) => {}
288            crate::Error::Store(_) => {}
289            crate::Error::Transaction(_) => {}
290            crate::Error::Sync(_) => {}
291            crate::Error::Entry(_) => {}
292            crate::Error::Id(_) => {}
293            crate::Error::User(_) => {}
294        }
295    }
296
297    #[test]
298    fn test_service_error_serde_roundtrip() {
299        let se = ServiceError {
300            module: "backend".to_string(),
301            kind: "EntryNotFound".to_string(),
302            message: "Entry not found: test123".to_string(),
303        };
304        let json = serde_json::to_string(&se).unwrap();
305        let deserialized: ServiceError = serde_json::from_str(&json).unwrap();
306        assert_eq!(deserialized.module, se.module);
307        assert_eq!(deserialized.kind, se.kind);
308        assert_eq!(deserialized.message, se.message);
309    }
310}