1use crate::errors::catalog::{ErrorCategory, ErrorCode};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)]
16pub struct ErrorContext {
17 #[serde(flatten)]
19 pub fields: HashMap<String, String>,
20}
21
22impl ErrorContext {
23 #[must_use]
25 pub fn new() -> Self {
26 Self::default()
27 }
28
29 #[must_use]
31 pub fn with(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
32 self.fields.insert(key.into(), value.into());
33 self
34 }
35
36 #[must_use]
38 pub fn is_empty(&self) -> bool {
39 self.fields.is_empty()
40 }
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
62pub struct ApiError {
63 pub code: String,
65
66 pub category: ErrorCategory,
68
69 pub message: String,
71
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub details: Option<String>,
75
76 #[serde(skip_serializing_if = "Vec::is_empty", default)]
78 pub remediation: Vec<String>,
79
80 #[serde(skip_serializing_if = "ErrorContext::is_empty", default)]
82 pub context: ErrorContext,
83
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub retry_after_secs: Option<u64>,
87}
88
89impl ApiError {
90 #[must_use]
95 pub fn from_code(code: ErrorCode) -> Self {
96 let entry = code.entry();
97 Self {
98 code: entry.code,
99 category: entry.category,
100 message: entry.message,
101 details: None,
102 remediation: entry.remediation,
103 context: ErrorContext::new(),
104 retry_after_secs: None,
105 }
106 }
107
108 #[must_use]
110 pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
111 Self::from_code(code).with_message(message)
112 }
113
114 #[must_use]
116 pub fn with_message(mut self, message: impl Into<String>) -> Self {
117 self.details = Some(message.into());
118 self
119 }
120
121 #[must_use]
123 pub fn with_details(self, details: impl Into<String>) -> Self {
124 self.with_message(details)
125 }
126
127 #[must_use]
129 pub fn with_context(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
130 self.context = self.context.with(key, value);
131 self
132 }
133
134 #[must_use]
136 pub fn with_context_map(mut self, map: HashMap<String, String>) -> Self {
137 for (k, v) in map {
138 self.context = self.context.with(k, v);
139 }
140 self
141 }
142
143 #[must_use]
145 pub fn with_retry_after(mut self, seconds: u64) -> Self {
146 self.retry_after_secs = Some(seconds);
147 self
148 }
149
150 #[must_use]
152 pub fn with_remediation(mut self, steps: impl IntoIterator<Item = impl Into<String>>) -> Self {
153 self.remediation.extend(steps.into_iter().map(Into::into));
154 self
155 }
156
157 #[must_use]
159 pub fn internal(message: impl Into<String>) -> Self {
160 Self::new(ErrorCode::InternalStateError, message)
161 }
162}
163
164impl std::fmt::Display for ApiError {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 write!(f, "[{}] {}", self.code, self.message)?;
167 if let Some(ref details) = self.details {
168 write!(f, ": {}", details)?;
169 }
170 Ok(())
171 }
172}
173
174impl std::error::Error for ApiError {}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
185pub enum LegacyErrorCode {
186 WorkerUnreachable,
187 WorkerNotFound,
188 ConfigInvalid,
189 ConfigNotFound,
190 DaemonNotRunning,
191 DaemonConnectionFailed,
192 SshConnectionFailed,
193 BenchmarkFailed,
194 HookInstallFailed,
195 InternalError,
196}
197
198impl LegacyErrorCode {
199 #[must_use]
201 pub fn parse(s: &str) -> Option<Self> {
202 match s {
203 "WORKER_UNREACHABLE" => Some(Self::WorkerUnreachable),
204 "WORKER_NOT_FOUND" => Some(Self::WorkerNotFound),
205 "CONFIG_INVALID" => Some(Self::ConfigInvalid),
206 "CONFIG_NOT_FOUND" => Some(Self::ConfigNotFound),
207 "DAEMON_NOT_RUNNING" => Some(Self::DaemonNotRunning),
208 "DAEMON_CONNECTION_FAILED" => Some(Self::DaemonConnectionFailed),
209 "SSH_CONNECTION_FAILED" => Some(Self::SshConnectionFailed),
210 "BENCHMARK_FAILED" => Some(Self::BenchmarkFailed),
211 "HOOK_INSTALL_FAILED" => Some(Self::HookInstallFailed),
212 "INTERNAL_ERROR" => Some(Self::InternalError),
213 _ => None,
214 }
215 }
216
217 #[must_use]
219 pub fn to_error_code(self) -> ErrorCode {
220 match self {
221 Self::WorkerUnreachable => ErrorCode::SshConnectionFailed,
222 Self::WorkerNotFound => ErrorCode::ConfigInvalidWorker,
223 Self::ConfigInvalid => ErrorCode::ConfigValidationError,
224 Self::ConfigNotFound => ErrorCode::ConfigNotFound,
225 Self::DaemonNotRunning => ErrorCode::InternalDaemonNotRunning,
226 Self::DaemonConnectionFailed => ErrorCode::InternalDaemonSocket,
227 Self::SshConnectionFailed => ErrorCode::SshConnectionFailed,
228 Self::BenchmarkFailed => ErrorCode::WorkerSelfTestFailed,
229 Self::HookInstallFailed => ErrorCode::InternalHookError,
230 Self::InternalError => ErrorCode::InternalStateError,
231 }
232 }
233
234 #[must_use]
236 pub const fn as_str(&self) -> &'static str {
237 match self {
238 Self::WorkerUnreachable => "WORKER_UNREACHABLE",
239 Self::WorkerNotFound => "WORKER_NOT_FOUND",
240 Self::ConfigInvalid => "CONFIG_INVALID",
241 Self::ConfigNotFound => "CONFIG_NOT_FOUND",
242 Self::DaemonNotRunning => "DAEMON_NOT_RUNNING",
243 Self::DaemonConnectionFailed => "DAEMON_CONNECTION_FAILED",
244 Self::SshConnectionFailed => "SSH_CONNECTION_FAILED",
245 Self::BenchmarkFailed => "BENCHMARK_FAILED",
246 Self::HookInstallFailed => "HOOK_INSTALL_FAILED",
247 Self::InternalError => "INTERNAL_ERROR",
248 }
249 }
250}
251
252impl std::str::FromStr for LegacyErrorCode {
253 type Err = ();
254
255 fn from_str(s: &str) -> Result<Self, Self::Err> {
256 Self::parse(s).ok_or(())
257 }
258}
259
260impl std::fmt::Display for LegacyErrorCode {
261 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262 write!(f, "{}", self.as_str())
263 }
264}
265
266#[must_use]
277#[allow(dead_code)]
278pub fn from_legacy_code(legacy_code: &str, message: impl Into<String>) -> ApiError {
279 let error_code = LegacyErrorCode::parse(legacy_code)
280 .map(|l| l.to_error_code())
281 .unwrap_or(ErrorCode::InternalStateError);
282
283 ApiError::new(error_code, message)
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn test_api_error_from_code() {
292 let error = ApiError::from_code(ErrorCode::ConfigNotFound);
293 assert_eq!(error.code, "RCH-E001");
294 assert_eq!(error.category, ErrorCategory::Config);
295 assert!(!error.remediation.is_empty());
296 }
297
298 #[test]
299 fn test_api_error_with_context() {
300 let error = ApiError::from_code(ErrorCode::SshConnectionFailed)
301 .with_context("worker_id", "test-worker")
302 .with_context("host", "192.168.1.100");
303
304 assert_eq!(
305 error.context.fields.get("worker_id"),
306 Some(&"test-worker".to_string())
307 );
308 assert_eq!(
309 error.context.fields.get("host"),
310 Some(&"192.168.1.100".to_string())
311 );
312 }
313
314 #[test]
315 fn test_api_error_serialization() {
316 let error = ApiError::from_code(ErrorCode::WorkerNoneAvailable)
317 .with_message("All workers are busy");
318
319 let json = serde_json::to_string(&error).unwrap();
320 assert!(json.contains("\"code\":\"RCH-E200\""));
321 assert!(json.contains("\"category\":\"worker\""));
322 assert!(json.contains("\"details\":\"All workers are busy\""));
323 }
324
325 #[test]
326 fn test_legacy_code_all_mappings() {
327 let legacy_codes = [
329 "WORKER_UNREACHABLE",
330 "WORKER_NOT_FOUND",
331 "CONFIG_INVALID",
332 "CONFIG_NOT_FOUND",
333 "DAEMON_NOT_RUNNING",
334 "DAEMON_CONNECTION_FAILED",
335 "SSH_CONNECTION_FAILED",
336 "BENCHMARK_FAILED",
337 "HOOK_INSTALL_FAILED",
338 "INTERNAL_ERROR",
339 ];
340
341 for legacy in legacy_codes {
342 let parsed = LegacyErrorCode::parse(legacy);
343 assert!(parsed.is_some(), "Failed to parse: {}", legacy);
344 let modern = parsed.unwrap().to_error_code();
345 assert!(modern.code_string().starts_with("RCH-E"));
347 }
348 }
349
350 #[test]
351 fn test_from_legacy_code() {
352 let error = from_legacy_code("WORKER_UNREACHABLE", "Connection refused");
353 assert_eq!(error.code, "RCH-E100");
354 assert_eq!(error.details, Some("Connection refused".to_string()));
355 }
356
357 #[test]
358 fn test_unknown_legacy_code_defaults_to_internal() {
359 let error = from_legacy_code("UNKNOWN_CODE", "Something went wrong");
360 assert_eq!(error.code, "RCH-E504"); }
362
363 #[test]
364 fn test_error_context_serialization() {
365 let mut ctx = ErrorContext::new();
366 ctx = ctx.with("key1", "value1").with("key2", "value2");
367
368 let json = serde_json::to_string(&ctx).unwrap();
369 assert!(json.contains("\"key1\":\"value1\""));
370 assert!(json.contains("\"key2\":\"value2\""));
371 }
372
373 #[test]
374 fn test_api_error_display() {
375 let error = ApiError::from_code(ErrorCode::ConfigNotFound)
376 .with_message("File ~/.config/rch/config.toml not found");
377
378 let display = format!("{}", error);
379 assert!(display.contains("RCH-E001"));
380 assert!(display.contains("not found"));
381 }
382
383 #[test]
384 fn test_retry_after() {
385 let error = ApiError::from_code(ErrorCode::WorkerAtCapacity).with_retry_after(30);
386
387 let json = serde_json::to_string(&error).unwrap();
388 assert!(json.contains("\"retry_after_secs\":30"));
389 }
390}