Skip to main content

rch_common/api/
error.rs

1//! Unified API Error Types
2//!
3//! Provides [`ApiError`] which uses the [`ErrorCode`] enum for consistent
4//! error codes across CLI and daemon.
5
6use crate::errors::catalog::{ErrorCategory, ErrorCode};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Context information for an API error.
12///
13/// Contains key-value pairs providing additional context about the error,
14/// such as worker IDs, file paths, or other relevant identifiers.
15#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)]
16pub struct ErrorContext {
17    /// Key-value pairs of context information.
18    #[serde(flatten)]
19    pub fields: HashMap<String, String>,
20}
21
22impl ErrorContext {
23    /// Create an empty context.
24    #[must_use]
25    pub fn new() -> Self {
26        Self::default()
27    }
28
29    /// Add a context field.
30    #[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    /// Check if context is empty.
37    #[must_use]
38    pub fn is_empty(&self) -> bool {
39        self.fields.is_empty()
40    }
41}
42
43/// Unified API error structure.
44///
45/// This type is used for all error responses in both CLI and daemon APIs.
46/// It uses the [`ErrorCode`] enum for consistent error identification.
47///
48/// # Example
49///
50/// ```rust
51/// use rch_common::api::ApiError;
52/// use rch_common::ErrorCode;
53///
54/// let error = ApiError::from_code(ErrorCode::SshConnectionFailed)
55///     .with_message("Connection refused")
56///     .with_context("worker_id", "worker-1")
57///     .with_context("host", "192.168.1.100");
58///
59/// assert_eq!(error.code, "RCH-E100");
60/// ```
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
62pub struct ApiError {
63    /// Error code in RCH-Exxx format (e.g., "RCH-E100").
64    pub code: String,
65
66    /// Error category for quick classification.
67    pub category: ErrorCategory,
68
69    /// Human-readable error message.
70    pub message: String,
71
72    /// Detailed description of what went wrong.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub details: Option<String>,
75
76    /// Suggested remediation steps.
77    #[serde(skip_serializing_if = "Vec::is_empty", default)]
78    pub remediation: Vec<String>,
79
80    /// Additional context information.
81    #[serde(skip_serializing_if = "ErrorContext::is_empty", default)]
82    pub context: ErrorContext,
83
84    /// Optional retry-after hint in seconds (for rate limiting).
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub retry_after_secs: Option<u64>,
87}
88
89impl ApiError {
90    /// Create an ApiError from an ErrorCode.
91    ///
92    /// Automatically populates the code string, category, message, and remediation
93    /// from the error catalog.
94    #[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    /// Create an ApiError with a custom message.
109    #[must_use]
110    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
111        Self::from_code(code).with_message(message)
112    }
113
114    /// Add or replace the detailed message.
115    #[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    /// Alias for with_message for clarity.
122    #[must_use]
123    pub fn with_details(self, details: impl Into<String>) -> Self {
124        self.with_message(details)
125    }
126
127    /// Add a context field.
128    #[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    /// Add multiple context fields.
135    #[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    /// Set retry-after hint for rate limiting errors.
144    #[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    /// Add additional remediation steps.
151    #[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    /// Create a generic internal error.
158    #[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// =============================================================================
177// Legacy Error Code Mapping
178// =============================================================================
179
180/// Legacy error codes used in older CLI versions.
181///
182/// This enum provides backward compatibility by mapping old string-based
183/// error codes to the new [`ErrorCode`] enum.
184#[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    /// Parse a legacy error code string.
200    #[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    /// Convert to the modern ErrorCode enum.
218    #[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    /// Get the legacy string representation.
235    #[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/// Convert a legacy error code string to an ApiError.
267///
268/// # Arguments
269///
270/// * `legacy_code` - Legacy error code string (e.g., "WORKER_UNREACHABLE")
271/// * `message` - Human-readable error message
272///
273/// # Returns
274///
275/// An [`ApiError`] using the modern [`ErrorCode`] equivalent.
276#[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        // Verify all legacy codes map to valid modern codes
328        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            // Verify it produces a valid RCH-E code
346            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"); // InternalStateError
361    }
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}