Skip to main content

openlatch_client/core/
error.rs

1/// Error framework for OpenLatch client using the OL-XXXX code format.
2///
3/// All user-facing errors carry a structured code, message, optional suggestion,
4/// and optional docs URL. The Display format follows the D-06/D-07 convention:
5///
6/// ```text
7/// Error: {message} (OL-XXXX)
8///
9///   Suggestion: {actionable text}
10///   Docs: {url}
11/// ```
12///
13/// Suggestion and Docs lines are omitted when the respective field is `None`.
14use std::fmt;
15
16/// A structured, user-facing error with an OL-XXXX code.
17///
18/// # Display format (D-06/D-07)
19///
20/// ```text
21/// Error: {message} (OL-XXXX)
22///
23///   Suggestion: {actionable text}
24///   Docs: {url}
25/// ```
26#[derive(Debug, Clone)]
27pub struct OlError {
28    /// The OL-XXXX error code (e.g. "OL-1001").
29    pub code: &'static str,
30    /// Human-readable, actionable error description.
31    pub message: String,
32    /// Optional suggestion for how to fix the error.
33    pub suggestion: Option<String>,
34    /// Optional link to documentation for this error code.
35    pub docs_url: Option<String>,
36}
37
38impl OlError {
39    /// Create a new error with the given code and message.
40    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    /// Attach a suggestion to this error.
50    pub fn with_suggestion(mut self, s: impl Into<String>) -> Self {
51        self.suggestion = Some(s.into());
52        self
53    }
54
55    /// Attach a docs URL to this error.
56    pub fn with_docs(mut self, url: impl Into<String>) -> Self {
57        self.docs_url = Some(url.into());
58        self
59    }
60
61    /// Build a "bug report" error pre-filled with a GitHub issue URL.
62    ///
63    /// Use this for unexpected internal errors that indicate a bug in openlatch.
64    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
104/// Minimal percent-encoding for URL query parameters.
105///
106/// Only encodes characters that break URL structure: space, newline, carriage return,
107/// ampersand, equals sign, and the hash character. This avoids pulling in a
108/// full URL-encoding dependency for a single use in bug_report().
109fn 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
125// ---------------------------------------------------------------------------
126// Envelope errors (OL-1000–1099)
127// ---------------------------------------------------------------------------
128
129/// Unknown or unsupported agent type in an incoming event.
130pub const ERR_UNKNOWN_AGENT: &str = "OL-1001";
131/// Event body exceeds the 1 MB size limit.
132pub const ERR_EVENT_TOO_LARGE: &str = "OL-1002";
133/// Event was deduplicated within the TTL window (informational).
134pub const ERR_EVENT_DEDUPED: &str = "OL-1003";
135
136// ---------------------------------------------------------------------------
137// Privacy filter errors (OL-1100–1199)
138// ---------------------------------------------------------------------------
139
140/// A custom regex pattern in config.toml failed to compile.
141pub const ERR_INVALID_REGEX: &str = "OL-1100";
142
143// ---------------------------------------------------------------------------
144// Config errors (OL-1300–1399)
145// ---------------------------------------------------------------------------
146
147/// The configuration file contains an invalid value.
148pub const ERR_INVALID_CONFIG: &str = "OL-1300";
149/// A required configuration field is absent and has no default.
150pub const ERR_MISSING_CONFIG_FIELD: &str = "OL-1301";
151
152// ---------------------------------------------------------------------------
153// Hooks errors (OL-1400–OL-1499)
154// ---------------------------------------------------------------------------
155
156/// No supported AI agent was detected on this machine.
157pub const ERR_HOOK_AGENT_NOT_FOUND: &str = "OL-1400";
158/// Cannot read or write the agent's settings.json (permissions, I/O error).
159pub const ERR_HOOK_WRITE_FAILED: &str = "OL-1401";
160/// The settings.json file contains malformed JSONC that cannot be parsed.
161pub const ERR_HOOK_MALFORMED_JSONC: &str = "OL-1402";
162/// Existing non-OpenLatch hooks detected in settings.json (warning, non-blocking).
163pub const ERR_HOOK_CONFLICT: &str = "OL-1403";
164
165// ---------------------------------------------------------------------------
166// Daemon errors (OL-1500–1599)
167// ---------------------------------------------------------------------------
168
169/// The selected port is already in use by another process.
170pub const ERR_PORT_IN_USE: &str = "OL-1500";
171/// A daemon instance is already running on this machine.
172pub const ERR_ALREADY_RUNNING: &str = "OL-1501";
173/// Daemon process started but health check failed within timeout.
174pub const ERR_DAEMON_START_FAILED: &str = "OL-1502";
175/// A newer version of openlatch is available (warning, non-blocking).
176pub const ERR_VERSION_OUTDATED: &str = "OL-1503";
177
178// ---------------------------------------------------------------------------
179// Bug report sentinel
180// ---------------------------------------------------------------------------
181
182/// Code assigned to all internal/unexpected errors routed through bug_report().
183pub 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        // Test 1: OlError Display output matches D-06/D-07 format
192        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        // Test 2: OlError without suggestion omits the suggestion line
214        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        // Test 3: OlError without docs_url omits the docs line
231        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        // Test 3 (extended): OlError with neither suggestion nor docs
248        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        // Test 4: Error code constants exist for each subsystem range
257        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        // Test 5: OlError implements std::error::Error trait
273        let err = OlError::new(ERR_UNKNOWN_AGENT, "test");
274        // Verify the trait bound by using it as &dyn std::error::Error
275        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}