Skip to main content

server_less_core/
error.rs

1//! Error handling and protocol-specific error mapping.
2
3use std::fmt;
4
5/// Protocol-agnostic error code that maps to HTTP status, gRPC code, CLI exit code, etc.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ErrorCode {
8    /// 400 Bad Request / INVALID_ARGUMENT / exit 1
9    InvalidInput,
10    /// 401 Unauthorized / UNAUTHENTICATED / exit 1
11    Unauthenticated,
12    /// 403 Forbidden / PERMISSION_DENIED / exit 1
13    Forbidden,
14    /// 404 Not Found / NOT_FOUND / exit 1
15    NotFound,
16    /// 409 Conflict / ALREADY_EXISTS / exit 1
17    Conflict,
18    /// 422 Unprocessable Entity / FAILED_PRECONDITION / exit 1
19    FailedPrecondition,
20    /// 429 Too Many Requests / RESOURCE_EXHAUSTED / exit 1
21    RateLimited,
22    /// 500 Internal Server Error / INTERNAL / exit 1
23    Internal,
24    /// 501 Not Implemented / UNIMPLEMENTED / exit 1
25    NotImplemented,
26    /// 503 Service Unavailable / UNAVAILABLE / exit 1
27    Unavailable,
28}
29
30impl ErrorCode {
31    /// Convert to HTTP status code
32    pub fn http_status(&self) -> u16 {
33        match self {
34            ErrorCode::InvalidInput => 400,
35            ErrorCode::Unauthenticated => 401,
36            ErrorCode::Forbidden => 403,
37            ErrorCode::NotFound => 404,
38            ErrorCode::Conflict => 409,
39            ErrorCode::FailedPrecondition => 422,
40            ErrorCode::RateLimited => 429,
41            ErrorCode::Internal => 500,
42            ErrorCode::NotImplemented => 501,
43            ErrorCode::Unavailable => 503,
44        }
45    }
46
47    /// Convert to CLI exit code
48    pub fn exit_code(&self) -> i32 {
49        match self {
50            ErrorCode::NotFound => 1,
51            ErrorCode::InvalidInput => 2,
52            ErrorCode::Unauthenticated | ErrorCode::Forbidden => 3,
53            ErrorCode::Conflict | ErrorCode::FailedPrecondition => 4,
54            ErrorCode::RateLimited => 5,
55            ErrorCode::Internal | ErrorCode::Unavailable => 1,
56            ErrorCode::NotImplemented => 1,
57        }
58    }
59
60    /// Convert to gRPC status code name
61    pub fn grpc_code(&self) -> &'static str {
62        match self {
63            ErrorCode::InvalidInput => "INVALID_ARGUMENT",
64            ErrorCode::Unauthenticated => "UNAUTHENTICATED",
65            ErrorCode::Forbidden => "PERMISSION_DENIED",
66            ErrorCode::NotFound => "NOT_FOUND",
67            ErrorCode::Conflict => "ALREADY_EXISTS",
68            ErrorCode::FailedPrecondition => "FAILED_PRECONDITION",
69            ErrorCode::RateLimited => "RESOURCE_EXHAUSTED",
70            ErrorCode::Internal => "INTERNAL",
71            ErrorCode::NotImplemented => "UNIMPLEMENTED",
72            ErrorCode::Unavailable => "UNAVAILABLE",
73        }
74    }
75
76    /// Infer error code from type/variant name (convention-based)
77    pub fn infer_from_name(name: &str) -> Self {
78        let name_lower = name.to_lowercase();
79
80        if name_lower.contains("notfound")
81            || name_lower.contains("not_found")
82            || name_lower.contains("missing")
83        {
84            ErrorCode::NotFound
85        } else if name_lower.contains("invalid")
86            || name_lower.contains("validation")
87            || name_lower.contains("parse")
88        {
89            ErrorCode::InvalidInput
90        } else if name_lower.contains("unauthorized") || name_lower.contains("unauthenticated") {
91            ErrorCode::Unauthenticated
92        } else if name_lower.contains("forbidden")
93            || name_lower.contains("permission")
94            || name_lower.contains("denied")
95        {
96            ErrorCode::Forbidden
97        } else if name_lower.contains("conflict")
98            || name_lower.contains("exists")
99            || name_lower.contains("duplicate")
100        {
101            ErrorCode::Conflict
102        } else if name_lower.contains("ratelimit")
103            || name_lower.contains("rate_limit")
104            || name_lower.contains("throttle")
105        {
106            ErrorCode::RateLimited
107        } else if name_lower.contains("unavailable") || name_lower.contains("temporarily") {
108            ErrorCode::Unavailable
109        } else if name_lower.contains("unimplemented") || name_lower.contains("not_implemented") {
110            ErrorCode::NotImplemented
111        } else {
112            ErrorCode::Internal
113        }
114    }
115}
116
117/// Trait for converting errors to protocol-agnostic error codes.
118///
119/// Implement this for your error types, or use the derive macro.
120pub trait IntoErrorCode {
121    /// Get the error code for this error
122    fn error_code(&self) -> ErrorCode;
123
124    /// Get a human-readable message
125    fn message(&self) -> String;
126}
127
128// Implement for common error types
129impl IntoErrorCode for std::io::Error {
130    fn error_code(&self) -> ErrorCode {
131        match self.kind() {
132            std::io::ErrorKind::NotFound => ErrorCode::NotFound,
133            std::io::ErrorKind::PermissionDenied => ErrorCode::Forbidden,
134            std::io::ErrorKind::InvalidInput | std::io::ErrorKind::InvalidData => {
135                ErrorCode::InvalidInput
136            }
137            _ => ErrorCode::Internal,
138        }
139    }
140
141    fn message(&self) -> String {
142        self.to_string()
143    }
144}
145
146/// A generic error response that can be serialized
147#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
148pub struct ErrorResponse {
149    pub code: String,
150    pub message: String,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub details: Option<serde_json::Value>,
153}
154
155impl ErrorResponse {
156    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
157        Self {
158            code: format!("{:?}", code).to_uppercase(),
159            message: message.into(),
160            details: None,
161        }
162    }
163
164    pub fn with_details(mut self, details: serde_json::Value) -> Self {
165        self.details = Some(details);
166        self
167    }
168}
169
170impl fmt::Display for ErrorResponse {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        write!(f, "{}: {}", self.code, self.message)
173    }
174}
175
176impl std::error::Error for ErrorResponse {}
177
178/// Error type for schema validation failures.
179///
180/// Used by schema validation methods (validate_schema) in generated code.
181#[derive(Debug, Clone)]
182pub struct SchemaValidationError {
183    /// Schema type (proto, capnp, thrift, smithy, etc.)
184    pub schema_type: String,
185    /// Lines present in expected schema but missing from generated
186    pub missing_lines: Vec<String>,
187    /// Lines present in generated schema but not in expected
188    pub extra_lines: Vec<String>,
189}
190
191impl SchemaValidationError {
192    /// Create a new schema validation error
193    pub fn new(schema_type: impl Into<String>) -> Self {
194        Self {
195            schema_type: schema_type.into(),
196            missing_lines: Vec::new(),
197            extra_lines: Vec::new(),
198        }
199    }
200
201    /// Add a line that's missing from the generated schema
202    pub fn add_missing(&mut self, line: impl Into<String>) {
203        self.missing_lines.push(line.into());
204    }
205
206    /// Add a line that's extra in the generated schema
207    pub fn add_extra(&mut self, line: impl Into<String>) {
208        self.extra_lines.push(line.into());
209    }
210
211    /// Check if there are any differences
212    pub fn has_differences(&self) -> bool {
213        !self.missing_lines.is_empty() || !self.extra_lines.is_empty()
214    }
215}
216
217impl fmt::Display for SchemaValidationError {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        writeln!(f, "{} schema validation failed:", self.schema_type)?;
220
221        if !self.missing_lines.is_empty() {
222            writeln!(f, "\nExpected methods/messages not found in generated:")?;
223            for line in &self.missing_lines {
224                writeln!(f, "  - {}", line)?;
225            }
226        }
227
228        if !self.extra_lines.is_empty() {
229            writeln!(f, "\nGenerated methods/messages not in expected:")?;
230            for line in &self.extra_lines {
231                writeln!(f, "  + {}", line)?;
232            }
233        }
234
235        // Add helpful hints
236        writeln!(f)?;
237        writeln!(f, "Hints:")?;
238
239        if !self.missing_lines.is_empty() && !self.extra_lines.is_empty() {
240            writeln!(
241                f,
242                "  - Method signature or type may have changed. Check parameter names and types."
243            )?;
244        }
245
246        if !self.missing_lines.is_empty() {
247            writeln!(
248                f,
249                "  - Missing items may indicate removed or renamed methods in Rust code."
250            )?;
251        }
252
253        if !self.extra_lines.is_empty() {
254            writeln!(
255                f,
256                "  - Extra items may indicate new methods added. Update the schema file."
257            )?;
258        }
259
260        writeln!(
261            f,
262            "  - Run `write_{schema}()` to regenerate the schema file.",
263            schema = self.schema_type.to_lowercase()
264        )?;
265
266        Ok(())
267    }
268}
269
270impl std::error::Error for SchemaValidationError {}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_error_code_inference() {
278        assert_eq!(ErrorCode::infer_from_name("NotFound"), ErrorCode::NotFound);
279        assert_eq!(
280            ErrorCode::infer_from_name("UserNotFound"),
281            ErrorCode::NotFound
282        );
283        assert_eq!(
284            ErrorCode::infer_from_name("InvalidEmail"),
285            ErrorCode::InvalidInput
286        );
287        assert_eq!(
288            ErrorCode::infer_from_name("Forbidden"),
289            ErrorCode::Forbidden
290        );
291        assert_eq!(
292            ErrorCode::infer_from_name("AlreadyExists"),
293            ErrorCode::Conflict
294        );
295    }
296
297    #[test]
298    fn test_http_status_codes() {
299        assert_eq!(ErrorCode::NotFound.http_status(), 404);
300        assert_eq!(ErrorCode::InvalidInput.http_status(), 400);
301        assert_eq!(ErrorCode::Internal.http_status(), 500);
302    }
303}