1use std::fmt;
8
9use serde_json::{Map, Value, json};
10
11#[derive(Clone, Debug, PartialEq)]
13pub struct SurfaceError {
14 pub code: String,
16 pub message: String,
17 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 #[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 #[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 #[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 #[must_use]
85 pub fn other(message: impl Into<String>) -> Self {
86 Self::new("repo_not_found", message)
87 }
88
89 #[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 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 fn from(e: oqx::OqxError) -> Self {
180 Self::filter_invalid(e.message, "OQX")
181 }
182}
183
184pub 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}