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", "EntryNotFound") => BackendError::EntryNotFound {
48            id: extract_id_from_message(&err.message).unwrap_or_default(),
49        }
50        .into(),
51        ("backend", "VerificationStatusNotFound") => BackendError::VerificationStatusNotFound {
52            id: extract_id_from_message(&err.message).unwrap_or_default(),
53        }
54        .into(),
55        ("backend", "EntryNotInTree") => BackendError::EntryNotInTree {
56            entry_id: ID::default(),
57            tree_id: ID::default(),
58        }
59        .into(),
60        ("backend", "NoCommonAncestor") => {
61            BackendError::NoCommonAncestor { entry_ids: vec![] }.into()
62        }
63        ("backend", "EmptyEntryList") => BackendError::EmptyEntryList {
64            operation: err.message.clone(),
65        }
66        .into(),
67        ("instance", "DatabaseNotFound") => InstanceError::DatabaseNotFound {
68            name: err.message.clone(),
69        }
70        .into(),
71        ("instance", "EntryNotFound") => InstanceError::EntryNotFound {
72            entry_id: extract_id_from_message(&err.message).unwrap_or_default(),
73        }
74        .into(),
75        ("instance", "InstanceAlreadyExists") => InstanceError::InstanceAlreadyExists.into(),
76        ("instance", "DeviceKeyNotFound") => InstanceError::DeviceKeyNotFound.into(),
77        ("instance", "AuthenticationRequired") => InstanceError::AuthenticationRequired.into(),
78        _ => {
79            // Fall back to an IO error carrying the original message
80            crate::Error::Io(std::io::Error::other(format!(
81                "[{}::{}] {}",
82                err.module, err.kind, err.message
83            )))
84        }
85    }
86}
87
88/// Extract an ID from an error message like "Entry not found: <id>".
89fn extract_id_from_message(message: &str) -> Option<ID> {
90    message
91        .rsplit(": ")
92        .next()
93        .and_then(|s| ID::parse(s.trim()).ok())
94}
95
96/// Get the discriminant name of an error variant.
97fn error_kind_name(err: &crate::Error) -> String {
98    match err {
99        crate::Error::Io(_) => "Io".to_string(),
100        crate::Error::Serialize(_) => "Serialize".to_string(),
101        crate::Error::Auth(e) => format!("{e:?}")
102            .split_once(|c: char| !c.is_alphanumeric())
103            .map_or_else(|| format!("{e:?}"), |(name, _)| name.to_string()),
104        crate::Error::Backend(e) => format!("{e:?}")
105            .split_once(|c: char| !c.is_alphanumeric())
106            .map_or_else(|| format!("{e:?}"), |(name, _)| name.to_string()),
107        crate::Error::Instance(e) => format!("{e:?}")
108            .split_once(|c: char| !c.is_alphanumeric())
109            .map_or_else(|| format!("{e:?}"), |(name, _)| name.to_string()),
110        crate::Error::CRDT(e) => format!("{e:?}")
111            .split_once(|c: char| !c.is_alphanumeric())
112            .map_or_else(|| format!("{e:?}"), |(name, _)| name.to_string()),
113        crate::Error::Store(e) => format!("{e:?}")
114            .split_once(|c: char| !c.is_alphanumeric())
115            .map_or_else(|| format!("{e:?}"), |(name, _)| name.to_string()),
116        crate::Error::Transaction(e) => format!("{e:?}")
117            .split_once(|c: char| !c.is_alphanumeric())
118            .map_or_else(|| format!("{e:?}"), |(name, _)| name.to_string()),
119        crate::Error::Sync(e) => format!("{e:?}")
120            .split_once(|c: char| !c.is_alphanumeric())
121            .map_or_else(|| format!("{e:?}"), |(name, _)| name.to_string()),
122        crate::Error::Entry(e) => format!("{e:?}")
123            .split_once(|c: char| !c.is_alphanumeric())
124            .map_or_else(|| format!("{e:?}"), |(name, _)| name.to_string()),
125        crate::Error::Id(e) => format!("{e:?}")
126            .split_once(|c: char| !c.is_alphanumeric())
127            .map_or_else(|| format!("{e:?}"), |(name, _)| name.to_string()),
128        crate::Error::User(e) => format!("{e:?}")
129            .split_once(|c: char| !c.is_alphanumeric())
130            .map_or_else(|| format!("{e:?}"), |(name, _)| name.to_string()),
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn test_service_error_from_backend_not_found() {
140        let test_id = ID::from_bytes("abc123");
141        let err = crate::Error::Backend(Box::new(BackendError::EntryNotFound { id: test_id }));
142        let se = ServiceError::from(&err);
143        assert_eq!(se.module, "backend");
144        assert_eq!(se.kind, "EntryNotFound");
145        assert!(se.message.contains("abc123") || se.message.contains("Entry not found"));
146    }
147
148    #[test]
149    fn test_service_error_from_instance_error() {
150        let err = crate::Error::Instance(Box::new(InstanceError::DeviceKeyNotFound));
151        let se = ServiceError::from(&err);
152        assert_eq!(se.module, "instance");
153        assert_eq!(se.kind, "DeviceKeyNotFound");
154    }
155
156    #[test]
157    fn test_roundtrip_backend_entry_not_found() {
158        let original = crate::Error::Backend(Box::new(BackendError::EntryNotFound {
159            id: ID::from_bytes("test-id"),
160        }));
161        let se = ServiceError::from(&original);
162        let reconstructed = service_error_to_eidetica_error(se);
163        assert!(reconstructed.is_not_found());
164    }
165
166    #[test]
167    fn test_roundtrip_instance_already_exists() {
168        let original = crate::Error::Instance(Box::new(InstanceError::InstanceAlreadyExists));
169        let se = ServiceError::from(&original);
170        let reconstructed = service_error_to_eidetica_error(se);
171        assert!(reconstructed.is_conflict());
172    }
173
174    #[test]
175    fn test_unknown_error_falls_back_to_io() {
176        let se = ServiceError {
177            module: "unknown".to_string(),
178            kind: "SomethingWeird".to_string(),
179            message: "something happened".to_string(),
180        };
181        let err = service_error_to_eidetica_error(se);
182        assert!(err.is_io_error());
183    }
184
185    /// Every `(module, kind)` pair that `service_error_to_eidetica_error`
186    /// claims to map specifically must survive a full round-trip with its
187    /// `(module, kind)` intact.
188    ///
189    /// This is the regression net for the stringly-typed wire mapping: the
190    /// reverse table keys off the `{:?}` discriminant string produced by
191    /// `error_kind_name`. If a `BackendError`/`InstanceError` variant is
192    /// renamed (or its mapping arm dropped), reconstruction silently falls
193    /// through the `_ =>` wildcard to a generic IO error, whose `(module,
194    /// kind)` no longer matches the original — failing this test instead of
195    /// silently degrading error fidelity for every wire client.
196    #[test]
197    fn test_all_mapped_pairs_roundtrip_module_and_kind() {
198        let cases: Vec<crate::Error> = vec![
199            crate::Error::Backend(Box::new(BackendError::EntryNotFound {
200                id: ID::from_bytes("rt-entry"),
201            })),
202            crate::Error::Backend(Box::new(BackendError::VerificationStatusNotFound {
203                id: ID::from_bytes("rt-vs"),
204            })),
205            crate::Error::Backend(Box::new(BackendError::EntryNotInTree {
206                entry_id: ID::from_bytes("rt-e"),
207                tree_id: ID::from_bytes("rt-t"),
208            })),
209            crate::Error::Backend(Box::new(BackendError::NoCommonAncestor {
210                entry_ids: vec![ID::from_bytes("rt-a")],
211            })),
212            crate::Error::Backend(Box::new(BackendError::EmptyEntryList {
213                operation: "rt".to_string(),
214            })),
215            crate::Error::Instance(Box::new(InstanceError::DatabaseNotFound {
216                name: "rt-db".to_string(),
217            })),
218            crate::Error::Instance(Box::new(InstanceError::EntryNotFound {
219                entry_id: ID::from_bytes("rt-ie"),
220            })),
221            crate::Error::Instance(Box::new(InstanceError::InstanceAlreadyExists)),
222            crate::Error::Instance(Box::new(InstanceError::DeviceKeyNotFound)),
223            crate::Error::Instance(Box::new(InstanceError::AuthenticationRequired)),
224        ];
225
226        for original in &cases {
227            let se = ServiceError::from(original);
228            let reconstructed = service_error_to_eidetica_error(se.clone());
229            let round = ServiceError::from(&reconstructed);
230            assert_eq!(
231                (round.module.as_str(), round.kind.as_str()),
232                (se.module.as_str(), se.kind.as_str()),
233                "mapped pair ({}::{}) fell through the wildcard on reconstruction \
234                 — its mapping arm or discriminant name has drifted",
235                se.module,
236                se.kind,
237            );
238        }
239    }
240
241    /// Compile-time guard: if a new top-level `crate::Error` variant is added,
242    /// this match stops being exhaustive and the test build fails, forcing the
243    /// author to decide how the variant crosses the service wire (update
244    /// `error_kind_name` and, if it needs a specific client-side reconstruction,
245    /// `service_error_to_eidetica_error`). Mirrors `error_kind_name`'s arms.
246    #[allow(dead_code)]
247    fn wire_mapping_exhaustiveness_guard(err: &crate::Error) {
248        match err {
249            crate::Error::Io(_) => {}
250            crate::Error::Serialize(_) => {}
251            crate::Error::Auth(_) => {}
252            crate::Error::Backend(_) => {}
253            crate::Error::Instance(_) => {}
254            crate::Error::CRDT(_) => {}
255            crate::Error::Store(_) => {}
256            crate::Error::Transaction(_) => {}
257            crate::Error::Sync(_) => {}
258            crate::Error::Entry(_) => {}
259            crate::Error::Id(_) => {}
260            crate::Error::User(_) => {}
261        }
262    }
263
264    #[test]
265    fn test_service_error_serde_roundtrip() {
266        let se = ServiceError {
267            module: "backend".to_string(),
268            kind: "EntryNotFound".to_string(),
269            message: "Entry not found: test123".to_string(),
270        };
271        let json = serde_json::to_string(&se).unwrap();
272        let deserialized: ServiceError = serde_json::from_str(&json).unwrap();
273        assert_eq!(deserialized.module, se.module);
274        assert_eq!(deserialized.kind, se.kind);
275        assert_eq!(deserialized.message, se.message);
276    }
277}