Skip to main content

omgbase_surface/
error.rs

1//! The error envelope (`spec/surface/README.md` §4): `{ error, message,
2//! data?, retriable }`. Every failure a tool can report is one of these;
3//! `spec/mutate` §8 codes come through unchanged, an OQX or cursor failure is
4//! `filter_invalid`, and anything unexpected is `repo_not_found` with its
5//! message (§9, pinned).
6
7use std::fmt;
8
9use serde_json::{Map, Value, json};
10
11/// A surfaced failure.
12#[derive(Clone, Debug, PartialEq)]
13pub struct SurfaceError {
14    /// The code (`filter_invalid`, `doc_missing`, …).
15    pub code: String,
16    pub message: String,
17    /// The `data` member, when any.
18    pub data: Option<Value>,
19    pub retriable: bool,
20}
21
22impl SurfaceError {
23    #[must_use]
24    pub fn new(code: &str, message: impl Into<String>) -> Self {
25        Self {
26            code: code.to_owned(),
27            message: message.into(),
28            data: None,
29            retriable: false,
30        }
31    }
32
33    #[must_use]
34    pub fn with_data(code: &str, message: impl Into<String>, data: Value) -> Self {
35        Self {
36            code: code.to_owned(),
37            message: message.into(),
38            data: Some(data),
39            retriable: false,
40        }
41    }
42
43    /// The reference's `FilterInvalid(reason, hint)`: `filter_invalid` with
44    /// `{ reason, hint }` — the `reason` is the message itself, the `hint`
45    /// the caller's pointer (`"OQX"` for an engine error).
46    #[must_use]
47    pub fn filter_invalid(message: impl Into<String>, hint: &str) -> Self {
48        let message = message.into();
49        Self::with_data(
50            "filter_invalid",
51            message.clone(),
52            json!({ "reason": message, "hint": hint }),
53        )
54    }
55
56    /// The reference's `CursorInvalid`: a cursor `surface` did not issue.
57    #[must_use]
58    pub fn cursor_invalid(surface: &str) -> Self {
59        Self::with_data(
60            "filter_invalid",
61            "invalid cursor",
62            json!({
63                "reason": format!("cursor was not issued by {surface}"),
64                "hint": "resume only with a `cursor` returned by a truncated page of the same tool",
65            }),
66        )
67    }
68
69    /// A cursor refused for a named cause (§1.4, 2.0: a 1.x cursor whose path
70    /// is not `/`-rooted): the message carries the reason, so does `data`.
71    #[must_use]
72    pub fn cursor_invalid_reason(reason: &str) -> Self {
73        Self::with_data(
74            "filter_invalid",
75            format!("invalid cursor: {reason}"),
76            json!({
77                "reason": reason,
78                "hint": "resume only with a `cursor` returned by a truncated page of the same tool",
79            }),
80        )
81    }
82
83    /// `repo_not_found` — also the catch-all (§9).
84    #[must_use]
85    pub fn other(message: impl Into<String>) -> Self {
86        Self::new("repo_not_found", message)
87    }
88
89    /// The wire envelope.
90    #[must_use]
91    pub fn to_json(&self) -> Value {
92        let mut m = Map::new();
93        m.insert("error".to_owned(), Value::String(self.code.clone()));
94        m.insert("message".to_owned(), Value::String(self.message.clone()));
95        if let Some(d) = &self.data {
96            m.insert("data".to_owned(), d.clone());
97        }
98        m.insert("retriable".to_owned(), Value::Bool(self.retriable));
99        Value::Object(m)
100    }
101}
102
103impl fmt::Display for SurfaceError {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        write!(f, "{}: {}", self.code, self.message)
106    }
107}
108
109impl std::error::Error for SurfaceError {}
110
111impl From<omgbase_store::Error> for SurfaceError {
112    fn from(e: omgbase_store::Error) -> Self {
113        match e {
114            omgbase_store::Error::Mutation(m) => Self::from(m),
115            omgbase_store::Error::Search(s) => Self::new(s.code(), s.to_string()),
116            omgbase_store::Error::InvalidEffect(m) => Self::filter_invalid(m, "arguments"),
117            // `spec/surface` §2 (4.9): a read of an archived revision whose
118            // segment is not attached or not on disk — `{ rev, archive,
119            // path? }`, `path` only when the segment is recorded.
120            e @ omgbase_store::Error::RevisionArchived { .. } => {
121                let message = e.to_string();
122                let omgbase_store::Error::RevisionArchived { rev, archive, path } = e else {
123                    unreachable!("matched above");
124                };
125                let mut data = Map::new();
126                data.insert("rev".to_owned(), Value::String(rev));
127                data.insert("archive".to_owned(), Value::String(archive));
128                if let Some(p) = path {
129                    data.insert("path".to_owned(), Value::String(p));
130                }
131                Self::with_data("revision_archived", message, Value::Object(data))
132            }
133            other => Self::other(other.to_string()),
134        }
135    }
136}
137
138impl From<omgbase_store::MutationError> for SurfaceError {
139    fn from(m: omgbase_store::MutationError) -> Self {
140        let retriable = m
141            .data
142            .get("retriable")
143            .and_then(Value::as_bool)
144            .unwrap_or(false);
145        Self {
146            code: m.code.as_str().to_owned(),
147            message: m.message,
148            data: Some(Value::Object(m.data)),
149            retriable,
150        }
151    }
152}
153
154impl From<omgbase_sync::Error> for SurfaceError {
155    fn from(e: omgbase_sync::Error) -> Self {
156        match e {
157            omgbase_sync::Error::Store(s) => Self::from(s),
158            omgbase_sync::Error::RepoNotFound {
159                message,
160                candidates,
161            } => Self::with_data(
162                "repo_not_found",
163                message,
164                json!({ "candidates": candidates }),
165            ),
166            other => Self::other(other.to_string()),
167        }
168    }
169}
170
171impl From<rusqlite::Error> for SurfaceError {
172    fn from(e: rusqlite::Error) -> Self {
173        Self::other(format!("sqlite: {e}"))
174    }
175}
176
177impl From<oqx::OqxError> for SurfaceError {
178    /// An OQX error is `filter_invalid` with the engine's message (§1.4).
179    fn from(e: oqx::OqxError) -> Self {
180        Self::filter_invalid(e.message, "OQX")
181    }
182}
183
184/// `Result` with this crate's error.
185pub type Result<T> = std::result::Result<T, SurfaceError>;
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn envelope_shape() {
193        let e = SurfaceError::filter_invalid("bad", "OQX");
194        let j = e.to_json();
195        assert_eq!(j["error"], "filter_invalid");
196        assert_eq!(j["message"], "bad");
197        assert_eq!(j["data"]["reason"], "bad");
198        assert_eq!(j["data"]["hint"], "OQX");
199        assert_eq!(j["retriable"], false);
200        let plain = SurfaceError::other("boom").to_json();
201        assert!(plain.get("data").is_none());
202        assert_eq!(plain["error"], "repo_not_found");
203    }
204
205    #[test]
206    fn revision_archived_carries_rev_archive_and_an_optional_path() {
207        let e = SurfaceError::from(omgbase_store::Error::RevisionArchived {
208            rev: "r_1".into(),
209            archive: "s_0".into(),
210            path: None,
211        });
212        assert_eq!(e.code, "revision_archived");
213        assert_eq!(
214            e.message,
215            "revision \"r_1\" is archived in segment s_0, which is not attached (omg history attach <file>)"
216        );
217        assert_eq!(e.data, Some(json!({ "rev": "r_1", "archive": "s_0" })));
218        let e = SurfaceError::from(omgbase_store::Error::RevisionArchived {
219            rev: "r_1".into(),
220            archive: "s_0".into(),
221            path: Some("/x/s.db".into()),
222        });
223        assert_eq!(
224            e.message,
225            "revision \"r_1\" is archived in segment s_0 at /x/s.db, which is not on disk"
226        );
227        assert_eq!(e.data.unwrap()["path"], "/x/s.db");
228    }
229
230    #[test]
231    fn mutation_errors_keep_code_and_data() {
232        let m = omgbase_store::MutationError::with_data(
233            omgbase_store::mutate_kernel::ErrorCode::StaleExpectation,
234            "stale",
235            json!({ "block": "b_1", "retriable": true }),
236        );
237        let e = SurfaceError::from(omgbase_store::Error::from(m));
238        assert_eq!(e.code, "stale_expectation");
239        assert!(e.retriable);
240        assert_eq!(e.data.unwrap()["block"], "b_1");
241    }
242}