openlatch_client/core/
error.rs1use std::fmt;
15
16#[derive(Debug, Clone)]
27pub struct OlError {
28 pub code: &'static str,
30 pub message: String,
32 pub suggestion: Option<String>,
34 pub docs_url: Option<String>,
36}
37
38impl OlError {
39 pub fn new(code: &'static str, message: impl Into<String>) -> Self {
41 Self {
42 code,
43 message: message.into(),
44 suggestion: None,
45 docs_url: None,
46 }
47 }
48
49 pub fn with_suggestion(mut self, s: impl Into<String>) -> Self {
51 self.suggestion = Some(s.into());
52 self
53 }
54
55 pub fn with_docs(mut self, url: impl Into<String>) -> Self {
57 self.docs_url = Some(url.into());
58 self
59 }
60
61 pub fn bug_report(message: impl Into<String>) -> Self {
65 let msg = message.into();
66 let url = format!(
67 "https://github.com/OpenLatch/openlatch-client/issues/new?title={}&body={}",
68 percent_encode(&msg),
69 percent_encode("Version: [auto]\nOS: [auto]\n\nDescription:\n"),
70 );
71 Self {
72 code: "OL-9999",
73 message: msg,
74 suggestion: Some("This is a bug. Please report it.".into()),
75 docs_url: Some(url),
76 }
77 }
78}
79
80impl fmt::Display for OlError {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 write!(f, "Error: {} ({})", self.message, self.code)?;
83
84 let has_suggestion = self.suggestion.is_some();
85 let has_docs = self.docs_url.is_some();
86
87 if has_suggestion || has_docs {
88 writeln!(f)?;
89 writeln!(f)?;
90 if let Some(ref s) = self.suggestion {
91 writeln!(f, " Suggestion: {s}")?;
92 }
93 if let Some(ref url) = self.docs_url {
94 write!(f, " Docs: {url}")?;
95 }
96 }
97
98 Ok(())
99 }
100}
101
102impl std::error::Error for OlError {}
103
104fn percent_encode(input: &str) -> String {
110 let mut out = String::with_capacity(input.len());
111 for c in input.chars() {
112 match c {
113 ' ' => out.push_str("%20"),
114 '\n' => out.push_str("%0A"),
115 '\r' => out.push_str("%0D"),
116 '&' => out.push_str("%26"),
117 '=' => out.push_str("%3D"),
118 '#' => out.push_str("%23"),
119 other => out.push(other),
120 }
121 }
122 out
123}
124
125pub const ERR_UNKNOWN_AGENT: &str = "OL-1001";
131pub const ERR_EVENT_TOO_LARGE: &str = "OL-1002";
133pub const ERR_EVENT_DEDUPED: &str = "OL-1003";
135
136pub const ERR_INVALID_REGEX: &str = "OL-1100";
142
143pub const ERR_INVALID_CONFIG: &str = "OL-1300";
149pub const ERR_MISSING_CONFIG_FIELD: &str = "OL-1301";
151
152pub const ERR_HOOK_AGENT_NOT_FOUND: &str = "OL-1400";
158pub const ERR_HOOK_WRITE_FAILED: &str = "OL-1401";
160pub const ERR_HOOK_MALFORMED_JSONC: &str = "OL-1402";
162pub const ERR_HOOK_CONFLICT: &str = "OL-1403";
164
165pub const ERR_PORT_IN_USE: &str = "OL-1500";
171pub const ERR_ALREADY_RUNNING: &str = "OL-1501";
173pub const ERR_DAEMON_START_FAILED: &str = "OL-1502";
175pub const ERR_VERSION_OUTDATED: &str = "OL-1503";
177
178pub const ERR_BUG: &str = "OL-9999";
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn test_ol_error_display_full_format() {
191 let err = OlError::new(ERR_UNKNOWN_AGENT, "Unknown agent type 'my-agent'")
193 .with_suggestion("Check that your agent type is one of: claude-code, cursor, windsurf")
194 .with_docs("https://docs.openlatch.ai/errors/OL-1001");
195
196 let output = format!("{err}");
197 assert!(
198 output.starts_with("Error: Unknown agent type 'my-agent' (OL-1001)"),
199 "Expected error header, got: {output}"
200 );
201 assert!(
202 output.contains("Suggestion: Check that your agent type"),
203 "Missing suggestion"
204 );
205 assert!(
206 output.contains("Docs: https://docs.openlatch.ai"),
207 "Missing docs URL"
208 );
209 }
210
211 #[test]
212 fn test_ol_error_display_no_suggestion() {
213 let err = OlError::new(ERR_EVENT_TOO_LARGE, "Event body exceeds 1 MB limit")
215 .with_docs("https://docs.openlatch.ai/errors/OL-1002");
216
217 let output = format!("{err}");
218 assert!(
219 !output.contains("Suggestion:"),
220 "Should not contain suggestion line: {output}"
221 );
222 assert!(
223 output.contains("Docs:"),
224 "Should still contain docs line: {output}"
225 );
226 }
227
228 #[test]
229 fn test_ol_error_display_no_docs_url() {
230 let err = OlError::new(ERR_INVALID_REGEX, "Invalid regex pattern")
232 .with_suggestion("Fix the regex in your config");
233
234 let output = format!("{err}");
235 assert!(
236 !output.contains("Docs:"),
237 "Should not contain docs line: {output}"
238 );
239 assert!(
240 output.contains("Suggestion:"),
241 "Should still contain suggestion: {output}"
242 );
243 }
244
245 #[test]
246 fn test_ol_error_display_no_optional_fields() {
247 let err = OlError::new(ERR_PORT_IN_USE, "Port 7443 is already in use");
249
250 let output = format!("{err}");
251 assert_eq!(output, "Error: Port 7443 is already in use (OL-1500)");
252 }
253
254 #[test]
255 fn test_error_code_constants_exist() {
256 assert_eq!(ERR_UNKNOWN_AGENT, "OL-1001");
258 assert_eq!(ERR_EVENT_TOO_LARGE, "OL-1002");
259 assert_eq!(ERR_INVALID_REGEX, "OL-1100");
260 assert_eq!(ERR_INVALID_CONFIG, "OL-1300");
261 assert_eq!(ERR_MISSING_CONFIG_FIELD, "OL-1301");
262 assert_eq!(ERR_EVENT_DEDUPED, "OL-1003");
263 assert_eq!(ERR_HOOK_CONFLICT, "OL-1403");
264 assert_eq!(ERR_PORT_IN_USE, "OL-1500");
265 assert_eq!(ERR_ALREADY_RUNNING, "OL-1501");
266 assert_eq!(ERR_DAEMON_START_FAILED, "OL-1502");
267 assert_eq!(ERR_VERSION_OUTDATED, "OL-1503");
268 }
269
270 #[test]
271 fn test_ol_error_implements_std_error() {
272 let err = OlError::new(ERR_UNKNOWN_AGENT, "test");
274 let _boxed: Box<dyn std::error::Error> = Box::new(err);
276 }
277
278 #[test]
279 fn test_bug_report_sets_code_ol_9999() {
280 let err = OlError::bug_report("Unexpected panic in envelope module");
281 assert_eq!(err.code, "OL-9999");
282 assert!(err.suggestion.is_some());
283 assert!(err.docs_url.is_some());
284 let url = err.docs_url.unwrap();
285 assert!(url.contains("github.com/OpenLatch/openlatch-client/issues/new"));
286 }
287
288 #[test]
289 fn test_percent_encode_spaces_and_newlines() {
290 assert_eq!(percent_encode("hello world"), "hello%20world");
291 assert_eq!(percent_encode("line1\nline2"), "line1%0Aline2");
292 }
293}