1use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Credentials {
13 pub api_key: String,
15
16 pub user_id: String,
18
19 pub email: String,
21
22 #[serde(default)]
24 pub name: Option<String>,
25
26 pub authenticated_at: DateTime<Utc>,
28
29 pub auth_url: String,
31}
32
33impl Credentials {
34 pub fn is_valid(&self) -> bool {
43 !self.api_key.is_empty()
44 }
45
46 pub fn masked_api_key(&self) -> String {
48 if self.api_key.len() > 12 {
49 format!("{}...{}", &self.api_key[..12], &self.api_key[self.api_key.len() - 4..])
50 } else {
51 "***".to_string()
52 }
53 }
54
55 pub fn display_name(&self) -> &str {
57 self.name.as_deref().unwrap_or(&self.email)
58 }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct DeviceCodeResponse {
66 pub device_code: String,
68
69 pub user_code: String,
71
72 pub verification_uri: String,
74
75 #[serde(default)]
80 pub verification_uri_complete: Option<String>,
81
82 pub expires_in: u32,
84
85 pub interval: u32,
87}
88
89impl DeviceCodeResponse {
90 pub fn browser_url(&self) -> &str {
94 self.verification_uri_complete
95 .as_deref()
96 .unwrap_or(&self.verification_uri)
97 }
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102#[serde(tag = "status", rename_all = "snake_case")]
103pub enum DeviceCodeStatus {
104 Pending,
106
107 Authorized {
109 api_key: String,
110 user_id: String,
111 email: String,
112 name: Option<String>,
113 },
114
115 Denied,
117
118 Expired,
120}
121
122impl DeviceCodeStatus {}
123
124#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
126#[serde(tag = "error", rename_all = "snake_case")]
127pub enum AuthError {
128 #[error("Network error: {message}")]
130 NetworkError { message: String },
131
132 #[error("Server error ({status_code:?}): {message}")]
134 ServerError { message: String, status_code: Option<u16> },
135
136 #[error("Device code expired")]
138 ExpiredCode,
139
140 #[error("Access denied by user")]
142 AccessDenied,
143
144 #[error("Invalid credentials: {message}")]
146 InvalidCredentials { message: String },
147
148 #[error("Rate limited, retry after {retry_after:?} seconds")]
150 RateLimited { retry_after: Option<u32> },
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 fn make_credentials(api_key: &str, name: Option<&str>) -> Credentials {
158 Credentials {
159 api_key: api_key.to_string(),
160 user_id: "usr_123".to_string(),
161 email: "user@example.com".to_string(),
162 name: name.map(|n| n.to_string()),
163 authenticated_at: DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
164 .unwrap()
165 .with_timezone(&Utc),
166 auth_url: "https://mecha.industries/api/auth".to_string(),
167 }
168 }
169
170 #[test]
171 fn masked_api_key_masks_long_keys() {
172 let creds = make_credentials("mecha_1234567890abcdef", None);
173 assert_eq!(creds.masked_api_key(), "mecha_123456...cdef");
174 }
175
176 #[test]
177 fn masked_api_key_fully_masks_short_keys() {
178 let creds = make_credentials("mecha_abcdef", None);
179 assert_eq!(creds.masked_api_key(), "***");
180
181 let creds = make_credentials("short", None);
182 assert_eq!(creds.masked_api_key(), "***");
183 }
184
185 #[test]
186 fn display_name_prefers_name_over_email() {
187 let creds = make_credentials("mecha_1234567890abcdef", Some("Ada Lovelace"));
188 assert_eq!(creds.display_name(), "Ada Lovelace");
189 }
190
191 #[test]
192 fn display_name_falls_back_to_email_when_no_name() {
193 let creds = make_credentials("mecha_1234567890abcdef", None);
194 assert_eq!(creds.display_name(), "user@example.com");
195 }
196
197 #[test]
198 fn credentials_roundtrip_serialization() {
199 let creds = make_credentials("mecha_1234567890abcdef", Some("Ada Lovelace"));
200 let json = serde_json::to_string(&creds).unwrap();
201 let deserialized: Credentials = serde_json::from_str(&json).unwrap();
202
203 assert_eq!(deserialized.api_key, creds.api_key);
204 assert_eq!(deserialized.user_id, creds.user_id);
205 assert_eq!(deserialized.email, creds.email);
206 assert_eq!(deserialized.name, creds.name);
207 assert_eq!(deserialized.auth_url, creds.auth_url);
208 }
209
210 #[test]
211 fn credentials_deserialization_defaults_missing_name() {
212 let json = r#"{
213 "api_key": "mecha_abc123",
214 "user_id": "usr_1",
215 "email": "user@example.com",
216 "authenticated_at": "2024-01-01T00:00:00Z",
217 "auth_url": "https://mecha.industries/api/auth"
218 }"#;
219
220 let creds: Credentials = serde_json::from_str(json).unwrap();
221 assert_eq!(creds.name, None);
222 }
223
224 #[test]
225 fn device_code_response_roundtrip_serialization() {
226 let response = DeviceCodeResponse {
227 device_code: "devcode123".to_string(),
228 user_code: "ABCD-1234".to_string(),
229 verification_uri: "https://mecha.industries/device".to_string(),
230 verification_uri_complete: Some("https://mecha.industries/device?code=ABCD-1234".to_string()),
231 expires_in: 900,
232 interval: 5,
233 };
234
235 let json = serde_json::to_string(&response).unwrap();
236 let deserialized: DeviceCodeResponse = serde_json::from_str(&json).unwrap();
237
238 assert_eq!(deserialized.device_code, response.device_code);
239 assert_eq!(deserialized.user_code, response.user_code);
240 assert_eq!(deserialized.verification_uri, response.verification_uri);
241 assert_eq!(
242 deserialized.verification_uri_complete,
243 response.verification_uri_complete
244 );
245 assert_eq!(deserialized.expires_in, response.expires_in);
246 assert_eq!(deserialized.interval, response.interval);
247 }
248
249 #[test]
250 fn device_code_response_deserializes_without_verification_uri_complete() {
251 let json = r#"{
252 "device_code": "devcode123",
253 "user_code": "ABCD-1234",
254 "verification_uri": "https://mecha.industries/device",
255 "expires_in": 900,
256 "interval": 5
257 }"#;
258
259 let response: DeviceCodeResponse = serde_json::from_str(json).unwrap();
260 assert_eq!(response.verification_uri_complete, None);
261 assert_eq!(response.browser_url(), "https://mecha.industries/device");
262 }
263
264 #[test]
265 fn browser_url_prefers_verification_uri_complete_when_present() {
266 let response = DeviceCodeResponse {
267 device_code: "devcode123".to_string(),
268 user_code: "ABCD-1234".to_string(),
269 verification_uri: "https://mecha.industries/device".to_string(),
270 verification_uri_complete: Some("https://mecha.industries/device?code=ABCD-1234".to_string()),
271 expires_in: 900,
272 interval: 5,
273 };
274
275 assert_eq!(response.browser_url(), "https://mecha.industries/device?code=ABCD-1234");
276 }
277
278 #[test]
279 fn device_code_status_serializes_pending_with_tag() {
280 let status = DeviceCodeStatus::Pending;
281 let json = serde_json::to_value(&status).unwrap();
282 assert_eq!(json, serde_json::json!({ "status": "pending" }));
283 }
284
285 #[test]
286 fn device_code_status_serializes_denied_and_expired() {
287 assert_eq!(
288 serde_json::to_value(DeviceCodeStatus::Denied).unwrap(),
289 serde_json::json!({ "status": "denied" })
290 );
291 assert_eq!(
292 serde_json::to_value(DeviceCodeStatus::Expired).unwrap(),
293 serde_json::json!({ "status": "expired" })
294 );
295 }
296
297 #[test]
298 fn device_code_status_roundtrips_authorized_variant() {
299 let status = DeviceCodeStatus::Authorized {
300 api_key: "mecha_abc123".to_string(),
301 user_id: "usr_1".to_string(),
302 email: "user@example.com".to_string(),
303 name: Some("Ada Lovelace".to_string()),
304 };
305
306 let json = serde_json::to_string(&status).unwrap();
307 let deserialized: DeviceCodeStatus = serde_json::from_str(&json).unwrap();
308
309 match deserialized {
310 DeviceCodeStatus::Authorized {
311 api_key,
312 user_id,
313 email,
314 name,
315 } => {
316 assert_eq!(api_key, "mecha_abc123");
317 assert_eq!(user_id, "usr_1");
318 assert_eq!(email, "user@example.com");
319 assert_eq!(name, Some("Ada Lovelace".to_string()));
320 }
321 other => panic!("expected Authorized variant, got {:?}", other),
322 }
323 }
324
325 #[test]
326 fn device_code_status_deserializes_from_status_tag() {
327 let json = r#"{"status": "pending"}"#;
328 let status: DeviceCodeStatus = serde_json::from_str(json).unwrap();
329 assert!(matches!(status, DeviceCodeStatus::Pending));
330 }
331
332 #[test]
333 fn auth_error_display_messages() {
334 assert_eq!(
335 AuthError::NetworkError {
336 message: "connection refused".to_string()
337 }
338 .to_string(),
339 "Network error: connection refused"
340 );
341
342 assert_eq!(
343 AuthError::ServerError {
344 message: "boom".to_string(),
345 status_code: Some(500)
346 }
347 .to_string(),
348 "Server error (Some(500)): boom"
349 );
350
351 assert_eq!(AuthError::ExpiredCode.to_string(), "Device code expired");
352 assert_eq!(AuthError::AccessDenied.to_string(), "Access denied by user");
353
354 assert_eq!(
355 AuthError::InvalidCredentials {
356 message: "bad json".to_string()
357 }
358 .to_string(),
359 "Invalid credentials: bad json"
360 );
361
362 assert_eq!(
363 AuthError::RateLimited { retry_after: Some(30) }.to_string(),
364 "Rate limited, retry after Some(30) seconds"
365 );
366 }
367
368 #[test]
369 fn auth_error_roundtrip_serialization() {
370 let err = AuthError::RateLimited { retry_after: Some(15) };
371 let json = serde_json::to_string(&err).unwrap();
372 let deserialized: AuthError = serde_json::from_str(&json).unwrap();
373
374 match deserialized {
375 AuthError::RateLimited { retry_after } => assert_eq!(retry_after, Some(15)),
376 other => panic!("expected RateLimited variant, got {:?}", other),
377 }
378 }
379}