Skip to main content

brows3r_lib/
error.rs

1//! Unified application error type and IPC error envelope.
2//!
3//! `AppError` is the only error type that crosses the Tauri IPC boundary.
4//! It serialises to `{ kind, message, retryable, details? }` so the frontend
5//! can map `kind` to a presentation policy (toast / inline / notification-log)
6//! without parsing `message`.
7//!
8//! # OCP contract
9//! Adding a new variant only requires:
10//!   1. A new variant arm in `AppError`.
11//!   2. A new inner struct `<Variant>Details` (when the variant carries data).
12//!   3. A new arm in the exhaustive `match` inside `Serialize`.
13//! No existing arms change. The envelope shape `{ kind, message, retryable, details }` is stable.
14
15use serde::{Serialize, Serializer};
16use serde_json::{json, Value};
17use uuid::Uuid;
18
19// ---------------------------------------------------------------------------
20// Per-variant detail structs
21// ---------------------------------------------------------------------------
22
23/// Details for `AppError::Auth`.
24#[derive(Debug, Clone, Serialize)]
25pub struct AuthDetails {
26    /// Discriminator: `"expired"`, `"invalid"`, or `"missing"`.
27    pub reason: String,
28}
29
30/// Details for `AppError::AccessDenied`.
31#[derive(Debug, Clone, Serialize)]
32pub struct AccessDeniedDetails {
33    pub op: String,
34    pub resource: String,
35}
36
37/// Details for `AppError::NotFound`.
38#[derive(Debug, Clone, Serialize)]
39pub struct NotFoundDetails {
40    pub resource: String,
41}
42
43/// Details for `AppError::Conflict`.
44///
45/// Field names use camelCase so they are ready for the IPC layer as-is.
46#[derive(Debug, Clone, Serialize)]
47#[serde(rename_all = "camelCase")]
48pub struct ConflictDetails {
49    pub etag_expected: String,
50    pub etag_actual: Option<String>,
51}
52
53/// Details for `AppError::RateLimited`.
54#[derive(Debug, Clone, Serialize)]
55#[serde(rename_all = "camelCase")]
56pub struct RateLimitedDetails {
57    pub retry_after_ms: Option<u64>,
58}
59
60/// Details for `AppError::Unsupported`.
61#[derive(Debug, Clone, Serialize)]
62pub struct UnsupportedDetails {
63    pub op: String,
64    pub provider: String,
65}
66
67/// Details for `AppError::Network`.
68#[derive(Debug, Clone, Serialize)]
69pub struct NetworkDetails {
70    /// String-ified upstream error message.
71    pub source: String,
72}
73
74/// Details for `AppError::Locked`.
75#[derive(Debug, Clone, Serialize)]
76#[serde(rename_all = "camelCase")]
77pub struct LockedDetails {
78    pub lock_id: String,
79    pub op_name: String,
80}
81
82/// Details for `AppError::Validation`.
83#[derive(Debug, Clone, Serialize)]
84pub struct ValidationDetails {
85    pub field: String,
86    pub hint: String,
87}
88
89/// Details for `AppError::ProviderSpecific`.
90#[derive(Debug, Clone, Serialize)]
91pub struct ProviderSpecificDetails {
92    pub code: String,
93    pub message: String,
94}
95
96/// Details for `AppError::Internal`.
97#[derive(Debug, Clone, Serialize)]
98#[serde(rename_all = "camelCase")]
99pub struct InternalDetails {
100    /// UUID v4 linking this error to the diagnostics log bundle.
101    pub trace_id: String,
102}
103
104// ---------------------------------------------------------------------------
105// AppError enum
106// ---------------------------------------------------------------------------
107
108/// All errors that can leave a Tauri command.
109///
110/// Serializes to `{ kind, message, retryable, details? }`.
111#[derive(Debug, Clone)]
112pub enum AppError {
113    /// Authentication failure. `reason` is `"expired"`, `"invalid"`, or `"missing"`.
114    Auth { reason: String },
115    /// Caller lacks permission for `op` on `resource`.
116    AccessDenied { op: String, resource: String },
117    /// The requested `resource` does not exist.
118    NotFound { resource: String },
119    /// ETag precondition failure.
120    Conflict {
121        etag_expected: String,
122        etag_actual: Option<String>,
123    },
124    /// AWS / provider rate-limit hit. `retry_after_ms` is the hint from the
125    /// `Retry-After` header when present.
126    RateLimited { retry_after_ms: Option<u64> },
127    /// The requested operation is not supported by this provider.
128    Unsupported { op: String, provider: String },
129    /// Network-level failure. `source` is the stringified upstream error.
130    Network { source: String },
131    /// User-initiated cancellation. Not retryable.
132    Cancelled,
133    /// A resource is held by an active lock.
134    Locked { lock_id: String, op_name: String },
135    /// Input validation failure.
136    Validation { field: String, hint: String },
137    /// Provider-specific error not mappable to another variant.
138    ProviderSpecific { code: String, message: String },
139    /// Catch-all. `trace_id` ties this error to the diagnostics log bundle.
140    Internal { trace_id: String },
141}
142
143impl AppError {
144    /// Construct an `Internal` error with a freshly-minted UUID v4 trace id.
145    pub fn internal_new() -> Self {
146        Self::Internal {
147            trace_id: Uuid::new_v4().to_string(),
148        }
149    }
150
151    /// The `kind` string used in the IPC envelope.
152    pub fn kind(&self) -> &'static str {
153        match self {
154            Self::Auth { .. } => "Auth",
155            Self::AccessDenied { .. } => "AccessDenied",
156            Self::NotFound { .. } => "NotFound",
157            Self::Conflict { .. } => "Conflict",
158            Self::RateLimited { .. } => "RateLimited",
159            Self::Unsupported { .. } => "Unsupported",
160            Self::Network { .. } => "Network",
161            Self::Cancelled => "Cancelled",
162            Self::Locked { .. } => "Locked",
163            Self::Validation { .. } => "Validation",
164            Self::ProviderSpecific { .. } => "ProviderSpecific",
165            Self::Internal { .. } => "Internal",
166        }
167    }
168
169    /// Whether the frontend should offer a retry action.
170    pub fn retryable(&self) -> bool {
171        match self {
172            // Transient conditions that may resolve without user action.
173            Self::RateLimited { .. } | Self::Network { .. } => true,
174            // Everything else is either permanent or user-initiated.
175            Self::Auth { .. }
176            | Self::AccessDenied { .. }
177            | Self::NotFound { .. }
178            | Self::Conflict { .. }
179            | Self::Unsupported { .. }
180            | Self::Cancelled
181            | Self::Locked { .. }
182            | Self::Validation { .. }
183            | Self::ProviderSpecific { .. }
184            | Self::Internal { .. } => false,
185        }
186    }
187
188    /// Human-readable summary for the `message` field of the IPC envelope.
189    pub fn message(&self) -> String {
190        match self {
191            Self::Auth { reason } => format!("Authentication failed: {reason}"),
192            Self::AccessDenied { op, resource } => {
193                format!("Access denied: cannot {op} on {resource}")
194            }
195            Self::NotFound { resource } => format!("Not found: {resource}"),
196            Self::Conflict {
197                etag_expected,
198                etag_actual,
199            } => match etag_actual {
200                Some(actual) => {
201                    format!("Conflict: expected ETag {etag_expected} but found {actual}")
202                }
203                None => format!("Conflict: expected ETag {etag_expected}"),
204            },
205            Self::RateLimited { retry_after_ms } => match retry_after_ms {
206                Some(ms) => format!("Rate limited; retry after {ms} ms"),
207                None => "Rate limited; please retry later".to_string(),
208            },
209            Self::Unsupported { op, provider } => {
210                format!("Unsupported: {op} is not available on {provider}")
211            }
212            Self::Network { source } => format!("Network error: {source}"),
213            Self::Cancelled => "Operation cancelled".to_string(),
214            Self::Locked { lock_id, op_name } => {
215                format!("Resource is locked (lock {lock_id}) by operation {op_name}")
216            }
217            Self::Validation { field, hint } => {
218                format!("Validation error on field '{field}': {hint}")
219            }
220            Self::ProviderSpecific { code, message } => {
221                format!("Provider error [{code}]: {message}")
222            }
223            Self::Internal { trace_id } => {
224                format!("Internal error (trace: {trace_id})")
225            }
226        }
227    }
228
229    /// Variant payload as a JSON `Value`, or `None` for `Cancelled`.
230    fn details(&self) -> Option<Value> {
231        // Exhaustive match — compiler enforces that every new variant is handled.
232        match self {
233            Self::Auth { reason } => Some(
234                serde_json::to_value(AuthDetails {
235                    reason: reason.clone(),
236                })
237                .unwrap(),
238            ),
239            Self::AccessDenied { op, resource } => Some(
240                serde_json::to_value(AccessDeniedDetails {
241                    op: op.clone(),
242                    resource: resource.clone(),
243                })
244                .unwrap(),
245            ),
246            Self::NotFound { resource } => Some(
247                serde_json::to_value(NotFoundDetails {
248                    resource: resource.clone(),
249                })
250                .unwrap(),
251            ),
252            Self::Conflict {
253                etag_expected,
254                etag_actual,
255            } => Some(
256                serde_json::to_value(ConflictDetails {
257                    etag_expected: etag_expected.clone(),
258                    etag_actual: etag_actual.clone(),
259                })
260                .unwrap(),
261            ),
262            Self::RateLimited { retry_after_ms } => Some(
263                serde_json::to_value(RateLimitedDetails {
264                    retry_after_ms: *retry_after_ms,
265                })
266                .unwrap(),
267            ),
268            Self::Unsupported { op, provider } => Some(
269                serde_json::to_value(UnsupportedDetails {
270                    op: op.clone(),
271                    provider: provider.clone(),
272                })
273                .unwrap(),
274            ),
275            Self::Network { source } => Some(
276                serde_json::to_value(NetworkDetails {
277                    source: source.clone(),
278                })
279                .unwrap(),
280            ),
281            // Cancelled carries no data — details is omitted from the envelope.
282            Self::Cancelled => None,
283            Self::Locked { lock_id, op_name } => Some(
284                serde_json::to_value(LockedDetails {
285                    lock_id: lock_id.clone(),
286                    op_name: op_name.clone(),
287                })
288                .unwrap(),
289            ),
290            Self::Validation { field, hint } => Some(
291                serde_json::to_value(ValidationDetails {
292                    field: field.clone(),
293                    hint: hint.clone(),
294                })
295                .unwrap(),
296            ),
297            Self::ProviderSpecific { code, message } => Some(
298                serde_json::to_value(ProviderSpecificDetails {
299                    code: code.clone(),
300                    message: message.clone(),
301                })
302                .unwrap(),
303            ),
304            Self::Internal { trace_id } => Some(
305                serde_json::to_value(InternalDetails {
306                    trace_id: trace_id.clone(),
307                })
308                .unwrap(),
309            ),
310        }
311    }
312}
313
314// ---------------------------------------------------------------------------
315// Serialize impl — IPC envelope { kind, message, retryable, details? }
316// ---------------------------------------------------------------------------
317
318impl Serialize for AppError {
319    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
320    where
321        S: Serializer,
322    {
323        let mut obj = json!({
324            "kind": self.kind(),
325            "message": self.message(),
326            "retryable": self.retryable(),
327        });
328        if let Some(details) = self.details() {
329            obj["details"] = details;
330        }
331        obj.serialize(serializer)
332    }
333}
334
335// ---------------------------------------------------------------------------
336// std::error::Error + Display
337// ---------------------------------------------------------------------------
338
339impl std::fmt::Display for AppError {
340    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
341        f.write_str(&self.message())
342    }
343}
344
345impl std::error::Error for AppError {}
346
347// ---------------------------------------------------------------------------
348// Tests
349// ---------------------------------------------------------------------------
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use serde_json::Value;
355
356    fn ser(e: &AppError) -> Value {
357        serde_json::to_value(e).expect("AppError must serialize")
358    }
359
360    fn assert_envelope(v: &Value, expected_kind: &str, expected_retryable: bool) {
361        assert_eq!(
362            v["kind"], expected_kind,
363            "kind mismatch for {expected_kind}"
364        );
365        assert!(
366            v["message"]
367                .as_str()
368                .map(|s| !s.is_empty())
369                .unwrap_or(false),
370            "message must be non-empty for {expected_kind}"
371        );
372        assert_eq!(
373            v["retryable"],
374            Value::Bool(expected_retryable),
375            "retryable mismatch for {expected_kind}"
376        );
377    }
378
379    #[test]
380    fn auth_serializes() {
381        let e = AppError::Auth {
382            reason: "expired".to_string(),
383        };
384        let v = ser(&e);
385        assert_envelope(&v, "Auth", false);
386        assert_eq!(v["details"]["reason"], "expired");
387    }
388
389    #[test]
390    fn access_denied_serializes() {
391        let e = AppError::AccessDenied {
392            op: "PutObject".to_string(),
393            resource: "my-bucket/file.txt".to_string(),
394        };
395        let v = ser(&e);
396        assert_envelope(&v, "AccessDenied", false);
397        assert_eq!(v["details"]["op"], "PutObject");
398        assert_eq!(v["details"]["resource"], "my-bucket/file.txt");
399    }
400
401    #[test]
402    fn not_found_serializes() {
403        let e = AppError::NotFound {
404            resource: "s3://bucket/key".to_string(),
405        };
406        let v = ser(&e);
407        assert_envelope(&v, "NotFound", false);
408        assert_eq!(v["details"]["resource"], "s3://bucket/key");
409    }
410
411    #[test]
412    fn conflict_with_actual_serializes() {
413        let e = AppError::Conflict {
414            etag_expected: "\"abc123\"".to_string(),
415            etag_actual: Some("\"def456\"".to_string()),
416        };
417        let v = ser(&e);
418        assert_envelope(&v, "Conflict", false);
419        assert_eq!(v["details"]["etagExpected"], "\"abc123\"");
420        assert_eq!(v["details"]["etagActual"], "\"def456\"");
421    }
422
423    #[test]
424    fn conflict_without_actual_serializes() {
425        let e = AppError::Conflict {
426            etag_expected: "\"abc123\"".to_string(),
427            etag_actual: None,
428        };
429        let v = ser(&e);
430        assert_envelope(&v, "Conflict", false);
431        assert_eq!(v["details"]["etagExpected"], "\"abc123\"");
432        assert!(v["details"]["etagActual"].is_null());
433    }
434
435    #[test]
436    fn rate_limited_with_hint_serializes() {
437        let e = AppError::RateLimited {
438            retry_after_ms: Some(5000),
439        };
440        let v = ser(&e);
441        assert_envelope(&v, "RateLimited", true);
442        assert_eq!(v["details"]["retryAfterMs"], 5000_u64);
443    }
444
445    #[test]
446    fn rate_limited_without_hint_serializes() {
447        let e = AppError::RateLimited {
448            retry_after_ms: None,
449        };
450        let v = ser(&e);
451        assert_envelope(&v, "RateLimited", true);
452        assert!(v["details"]["retryAfterMs"].is_null());
453    }
454
455    #[test]
456    fn unsupported_serializes() {
457        let e = AppError::Unsupported {
458            op: "SelectObjectContent".to_string(),
459            provider: "MinIO".to_string(),
460        };
461        let v = ser(&e);
462        assert_envelope(&v, "Unsupported", false);
463        assert_eq!(v["details"]["op"], "SelectObjectContent");
464        assert_eq!(v["details"]["provider"], "MinIO");
465    }
466
467    #[test]
468    fn network_serializes() {
469        let e = AppError::Network {
470            source: "connection refused".to_string(),
471        };
472        let v = ser(&e);
473        assert_envelope(&v, "Network", true);
474        assert_eq!(v["details"]["source"], "connection refused");
475    }
476
477    #[test]
478    fn cancelled_serializes() {
479        let e = AppError::Cancelled;
480        let v = ser(&e);
481        assert_envelope(&v, "Cancelled", false);
482        // Cancelled must NOT carry a details field.
483        assert!(
484            v.get("details").is_none(),
485            "Cancelled must not have a details field"
486        );
487    }
488
489    #[test]
490    fn locked_serializes() {
491        let e = AppError::Locked {
492            lock_id: "lock-001".to_string(),
493            op_name: "DeleteObject".to_string(),
494        };
495        let v = ser(&e);
496        assert_envelope(&v, "Locked", false);
497        assert_eq!(v["details"]["lockId"], "lock-001");
498        assert_eq!(v["details"]["opName"], "DeleteObject");
499    }
500
501    #[test]
502    fn validation_serializes() {
503        let e = AppError::Validation {
504            field: "bucket_name".to_string(),
505            hint: "must not contain uppercase letters".to_string(),
506        };
507        let v = ser(&e);
508        assert_envelope(&v, "Validation", false);
509        assert_eq!(v["details"]["field"], "bucket_name");
510        assert_eq!(v["details"]["hint"], "must not contain uppercase letters");
511    }
512
513    #[test]
514    fn provider_specific_serializes() {
515        let e = AppError::ProviderSpecific {
516            code: "InvalidBucketState".to_string(),
517            message: "bucket is in an invalid state for this operation".to_string(),
518        };
519        let v = ser(&e);
520        assert_envelope(&v, "ProviderSpecific", false);
521        assert_eq!(v["details"]["code"], "InvalidBucketState");
522        assert_eq!(
523            v["details"]["message"],
524            "bucket is in an invalid state for this operation"
525        );
526    }
527
528    #[test]
529    fn internal_via_helper_serializes_valid_uuid() {
530        let e = AppError::internal_new();
531        let v = ser(&e);
532        assert_envelope(&v, "Internal", false);
533        let trace_id = v["details"]["traceId"]
534            .as_str()
535            .expect("traceId must be a string");
536        // Validate that the trace_id parses as a UUID v4.
537        let parsed = Uuid::parse_str(trace_id).expect("traceId must be a valid UUID");
538        assert_eq!(parsed.get_version_num(), 4, "traceId must be a v4 UUID");
539    }
540
541    #[test]
542    fn internal_explicit_trace_id_round_trips() {
543        let trace_id = Uuid::new_v4().to_string();
544        let e = AppError::Internal {
545            trace_id: trace_id.clone(),
546        };
547        let v = ser(&e);
548        assert_envelope(&v, "Internal", false);
549        assert_eq!(v["details"]["traceId"], trace_id);
550    }
551
552    #[test]
553    fn all_non_retryable_variants_are_false() {
554        let cases: Vec<AppError> = vec![
555            AppError::Auth {
556                reason: "missing".to_string(),
557            },
558            AppError::AccessDenied {
559                op: "op".to_string(),
560                resource: "res".to_string(),
561            },
562            AppError::NotFound {
563                resource: "r".to_string(),
564            },
565            AppError::Conflict {
566                etag_expected: "e".to_string(),
567                etag_actual: None,
568            },
569            AppError::Unsupported {
570                op: "op".to_string(),
571                provider: "p".to_string(),
572            },
573            AppError::Cancelled,
574            AppError::Locked {
575                lock_id: "l".to_string(),
576                op_name: "n".to_string(),
577            },
578            AppError::Validation {
579                field: "f".to_string(),
580                hint: "h".to_string(),
581            },
582            AppError::ProviderSpecific {
583                code: "c".to_string(),
584                message: "m".to_string(),
585            },
586            AppError::internal_new(),
587        ];
588        for e in &cases {
589            assert!(!e.retryable(), "{} must not be retryable", e.kind());
590        }
591    }
592}