talos/client/
errors.rs

1//! Client-side error types for the Talos license client.
2//!
3//! This module provides error types that match the server's API error responses,
4//! allowing clients to handle specific error conditions programmatically.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9/// Error codes returned by the Talos license server API.
10///
11/// These codes correspond to the server's `ClientErrorCode` enum and allow
12/// clients to handle specific error conditions without parsing error messages.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
15pub enum ClientErrorCode {
16    // === License State Errors ===
17    /// License key was not found in the database
18    LicenseNotFound,
19    /// License has expired
20    LicenseExpired,
21    /// License has been revoked
22    LicenseRevoked,
23    /// License is suspended (may have grace period)
24    LicenseSuspended,
25    /// License has been permanently blacklisted
26    LicenseBlacklisted,
27    /// License exists but is not in active state
28    LicenseInactive,
29
30    // === Hardware Binding Errors ===
31    /// License is already bound to a different device
32    AlreadyBound,
33    /// License is not bound to any device
34    NotBound,
35    /// Request hardware ID doesn't match bound device
36    HardwareMismatch,
37
38    // === Feature/Quota Errors ===
39    /// Requested feature is not included in license tier
40    FeatureNotIncluded,
41    /// Usage quota has been exceeded
42    QuotaExceeded,
43
44    // === Grace Period Errors (client-side) ===
45    /// Cached grace period has expired, must go online
46    GracePeriodExpired,
47
48    // === Server Errors ===
49    /// Internal server error
50    InternalError,
51
52    // === Unknown ===
53    /// Unknown error code (forward compatibility)
54    #[serde(other)]
55    Unknown,
56}
57
58impl ClientErrorCode {
59    /// Returns a default human-readable message for this error code.
60    pub fn default_message(&self) -> &'static str {
61        match self {
62            ClientErrorCode::LicenseNotFound => "License not found",
63            ClientErrorCode::LicenseExpired => "License has expired",
64            ClientErrorCode::LicenseRevoked => "License has been revoked",
65            ClientErrorCode::LicenseSuspended => "License is suspended",
66            ClientErrorCode::LicenseBlacklisted => "License has been blacklisted",
67            ClientErrorCode::LicenseInactive => "License is not active",
68            ClientErrorCode::AlreadyBound => "License is already bound to another device",
69            ClientErrorCode::NotBound => "License is not bound to any device",
70            ClientErrorCode::HardwareMismatch => "Hardware ID does not match",
71            ClientErrorCode::FeatureNotIncluded => "Feature not included in license",
72            ClientErrorCode::QuotaExceeded => "Usage quota exceeded",
73            ClientErrorCode::GracePeriodExpired => {
74                "Grace period expired - please connect to license server"
75            }
76            ClientErrorCode::InternalError => "Internal server error",
77            ClientErrorCode::Unknown => "Unknown error",
78        }
79    }
80
81    /// Returns true if this error indicates the license is invalid and cannot be used.
82    pub fn is_license_invalid(&self) -> bool {
83        matches!(
84            self,
85            ClientErrorCode::LicenseNotFound
86                | ClientErrorCode::LicenseExpired
87                | ClientErrorCode::LicenseRevoked
88                | ClientErrorCode::LicenseBlacklisted
89                | ClientErrorCode::GracePeriodExpired
90        )
91    }
92
93    /// Returns true if this error might be resolved by going online.
94    pub fn requires_online(&self) -> bool {
95        matches!(
96            self,
97            ClientErrorCode::GracePeriodExpired | ClientErrorCode::LicenseSuspended
98        )
99    }
100}
101
102impl fmt::Display for ClientErrorCode {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        write!(f, "{}", self.default_message())
105    }
106}
107
108/// The inner error body from a server API error response.
109#[derive(Debug, Clone, Deserialize)]
110pub struct ServerErrorBody {
111    /// Machine-readable error code
112    pub code: ClientErrorCode,
113    /// Human-readable error message
114    pub message: String,
115    /// Optional additional details
116    #[serde(default)]
117    pub details: Option<serde_json::Value>,
118}
119
120/// Server API error response wrapper.
121///
122/// Matches the server's `ApiError` JSON structure:
123/// ```json
124/// {
125///   "error": {
126///     "code": "LICENSE_NOT_FOUND",
127///     "message": "The requested license does not exist",
128///     "details": null
129///   }
130/// }
131/// ```
132#[derive(Debug, Clone, Deserialize)]
133pub struct ServerErrorResponse {
134    pub error: ServerErrorBody,
135}
136
137/// Error returned when a license server API call fails.
138///
139/// This wraps the server's error response and provides convenient access
140/// to the error code for programmatic handling.
141#[derive(Debug, Clone)]
142pub struct ClientApiError {
143    /// Machine-readable error code
144    pub code: ClientErrorCode,
145    /// Human-readable error message from server
146    pub message: String,
147    /// Optional additional details (e.g., field name for validation errors)
148    pub details: Option<serde_json::Value>,
149}
150
151impl ClientApiError {
152    /// Create a new client API error.
153    pub fn new(code: ClientErrorCode, message: impl Into<String>) -> Self {
154        Self {
155            code,
156            message: message.into(),
157            details: None,
158        }
159    }
160
161    /// Create a new client API error with details.
162    pub fn with_details(
163        code: ClientErrorCode,
164        message: impl Into<String>,
165        details: serde_json::Value,
166    ) -> Self {
167        Self {
168            code,
169            message: message.into(),
170            details: Some(details),
171        }
172    }
173
174    /// Create an error for grace period expiration (client-side only).
175    pub fn grace_period_expired() -> Self {
176        Self::new(
177            ClientErrorCode::GracePeriodExpired,
178            "Offline grace period has expired. Please connect to the license server.",
179        )
180    }
181
182    /// Returns true if this error indicates the license is invalid.
183    pub fn is_license_invalid(&self) -> bool {
184        self.code.is_license_invalid()
185    }
186
187    /// Returns true if this error might be resolved by going online.
188    pub fn requires_online(&self) -> bool {
189        self.code.requires_online()
190    }
191}
192
193impl fmt::Display for ClientApiError {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        write!(f, "{}: {}", self.code, self.message)
196    }
197}
198
199impl std::error::Error for ClientApiError {}
200
201impl From<ServerErrorResponse> for ClientApiError {
202    fn from(resp: ServerErrorResponse) -> Self {
203        Self {
204            code: resp.error.code,
205            message: resp.error.message,
206            details: resp.error.details,
207        }
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn parse_server_error_response() {
217        let json = r#"{
218            "error": {
219                "code": "LICENSE_NOT_FOUND",
220                "message": "The requested license does not exist",
221                "details": null
222            }
223        }"#;
224
225        let resp: ServerErrorResponse = serde_json::from_str(json).unwrap();
226        assert_eq!(resp.error.code, ClientErrorCode::LicenseNotFound);
227        assert_eq!(resp.error.message, "The requested license does not exist");
228        assert!(resp.error.details.is_none());
229    }
230
231    #[test]
232    fn parse_already_bound_error() {
233        let json = r#"{
234            "error": {
235                "code": "ALREADY_BOUND",
236                "message": "License is already bound to device 'Work Laptop'",
237                "details": {"device_name": "Work Laptop"}
238            }
239        }"#;
240
241        let resp: ServerErrorResponse = serde_json::from_str(json).unwrap();
242        let err: ClientApiError = resp.into();
243
244        assert_eq!(err.code, ClientErrorCode::AlreadyBound);
245        assert!(err.message.contains("Work Laptop"));
246        assert!(err.details.is_some());
247    }
248
249    #[test]
250    fn parse_unknown_error_code() {
251        let json = r#"{
252            "error": {
253                "code": "SOME_FUTURE_ERROR",
254                "message": "Some new error type",
255                "details": null
256            }
257        }"#;
258
259        let resp: ServerErrorResponse = serde_json::from_str(json).unwrap();
260        assert_eq!(resp.error.code, ClientErrorCode::Unknown);
261    }
262
263    #[test]
264    fn error_code_is_license_invalid() {
265        assert!(ClientErrorCode::LicenseNotFound.is_license_invalid());
266        assert!(ClientErrorCode::LicenseExpired.is_license_invalid());
267        assert!(ClientErrorCode::LicenseRevoked.is_license_invalid());
268        assert!(ClientErrorCode::GracePeriodExpired.is_license_invalid());
269
270        assert!(!ClientErrorCode::AlreadyBound.is_license_invalid());
271        assert!(!ClientErrorCode::NotBound.is_license_invalid());
272        assert!(!ClientErrorCode::LicenseSuspended.is_license_invalid());
273    }
274
275    #[test]
276    fn error_code_requires_online() {
277        assert!(ClientErrorCode::GracePeriodExpired.requires_online());
278        assert!(ClientErrorCode::LicenseSuspended.requires_online());
279
280        assert!(!ClientErrorCode::LicenseExpired.requires_online());
281        assert!(!ClientErrorCode::AlreadyBound.requires_online());
282    }
283
284    #[test]
285    fn client_api_error_display() {
286        let err = ClientApiError::new(
287            ClientErrorCode::LicenseExpired,
288            "Your license expired on 2024-01-01",
289        );
290        let display = format!("{}", err);
291        assert!(display.contains("expired"));
292    }
293}