1use std::time::Duration;
2
3use serde::{
4 Deserialize,
5 Serialize,
6};
7use thiserror::Error;
8
9#[derive(Error, Debug, Clone)]
10pub enum DomainError {
11 #[error("Provider not found: {0}")]
12 ProviderNotFound(String),
13
14 #[error("Pipeline not found: {0}")]
15 PipelineNotFound(String),
16
17 #[error("Not found: {0}")]
18 NotFound(String),
19
20 #[error("Invalid configuration: {0}")]
21 InvalidConfig(String),
22
23 #[error("Authentication failed: {0}")]
24 AuthenticationFailed(String),
25
26 #[error("Authentication failed: {0}")]
27 ProviderAuthenticationFailed(String),
28
29 #[error("Permission denied: {0}")]
30 PermissionDenied(String),
31
32 #[error("API error: {0}")]
33 ApiError(String),
34
35 #[error("Database error: {0}")]
36 DatabaseError(String),
37
38 #[error("Invalid provider type: {0}")]
39 InvalidProviderType(String),
40
41 #[error("Provider error: {0}")]
42 ProviderError(String),
43
44 #[error("Not supported: {0}")]
45 NotSupported(String),
46
47 #[error("Internal error: {0}")]
48 InternalError(String),
49
50 #[error("Rate limited: {message}")]
51 RateLimited {
52 message: String,
53 retry_after: Option<Duration>,
54 },
55
56 #[error("Service unavailable: {message}")]
57 ServiceUnavailable {
58 message: String,
59 retry_after: Option<Duration>,
60 },
61
62 #[error("Timeout: {0}")]
63 Timeout(String),
64
65 #[error("TLS error: {0}")]
66 Tls(String),
67
68 #[error("Network error: {0}")]
69 NetworkError(String),
70
71 #[error("Lock error: {0}")]
72 LockError(String),
73
74 #[error("Concurrent modification: {0}")]
75 ConcurrentModification(String),
76
77 #[error("Configuration file not found - setup wizard required")]
78 ConfigNotFound,
79
80 #[error("Configuration file is invalid: {path} - {errors}")]
81 InvalidConfigFile { path: String, errors: String },
82
83 #[error("Application not initialized - run setup first")]
84 NotInitialized,
85
86 #[error(
87 "Migration failed at step '{step}': {reason}\n\nRECOVERY INSTRUCTIONS:\n{instructions}"
88 )]
89 MigrationFailed {
90 step: String,
91 reason: String,
92 instructions: String,
93 },
94
95 #[error("Data consistency error: {0}")]
96 DataConsistency(String),
97}
98
99pub type DomainResult<T> = Result<T, DomainError>;
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum FailureKind {
104 Network,
105 Timeout,
106 Tls,
107 RateLimited,
108 Unavailable,
109 Auth,
110 Permission,
111 Config,
112 NotFound,
113 Other,
114}
115
116impl FailureKind {
117 pub fn is_transient(self) -> bool {
118 matches!(
119 self,
120 Self::Network | Self::Timeout | Self::Unavailable | Self::RateLimited
121 )
122 }
123}
124
125impl DomainError {
126 pub fn failure_kind(&self) -> FailureKind {
127 match self {
128 Self::NetworkError(_) => FailureKind::Network,
129 Self::Timeout(_) => FailureKind::Timeout,
130 Self::Tls(_) => FailureKind::Tls,
131 Self::RateLimited { .. } => FailureKind::RateLimited,
132 Self::ServiceUnavailable { .. } => FailureKind::Unavailable,
133 Self::AuthenticationFailed(_) | Self::ProviderAuthenticationFailed(_) => {
134 FailureKind::Auth
135 }
136 Self::PermissionDenied(_) => FailureKind::Permission,
137 Self::InvalidConfig(_)
138 | Self::InvalidProviderType(_)
139 | Self::InvalidConfigFile { .. }
140 | Self::ConfigNotFound => FailureKind::Config,
141 Self::ProviderNotFound(_) | Self::PipelineNotFound(_) | Self::NotFound(_) => {
142 FailureKind::NotFound
143 }
144 _ => FailureKind::Other,
145 }
146 }
147
148 pub fn retry_after(&self) -> Option<Duration> {
149 match self {
150 Self::RateLimited { retry_after, .. }
151 | Self::ServiceUnavailable { retry_after, .. } => *retry_after,
152 _ => None,
153 }
154 }
155
156 pub fn code(&self) -> &'static str {
157 match self {
158 Self::ProviderNotFound(_) | Self::PipelineNotFound(_) | Self::NotFound(_) => {
159 "NOT_FOUND"
160 }
161 Self::InvalidConfig(_) | Self::InvalidProviderType(_) => "BAD_REQUEST",
162 Self::AuthenticationFailed(_) => "UNAUTHORIZED",
163 Self::ProviderAuthenticationFailed(_) => "PROVIDER_AUTH_FAILED",
164 Self::PermissionDenied(_) => "FORBIDDEN",
165 Self::NotSupported(_) => "NOT_SUPPORTED",
166 _ => "INTERNAL_ERROR",
167 }
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn failure_kind_classifies_transient_errors() {
177 let transient = [
178 DomainError::NetworkError("x".into()),
179 DomainError::Timeout("x".into()),
180 DomainError::ServiceUnavailable {
181 message: "x".into(),
182 retry_after: None,
183 },
184 DomainError::RateLimited {
185 message: "x".into(),
186 retry_after: Some(Duration::from_secs(5)),
187 },
188 ];
189 for err in transient {
190 assert!(err.failure_kind().is_transient(), "{err:?}");
191 }
192
193 let permanent = [
194 (DomainError::Tls("x".into()), FailureKind::Tls),
195 (
196 DomainError::ProviderAuthenticationFailed("x".into()),
197 FailureKind::Auth,
198 ),
199 (
200 DomainError::PermissionDenied("x".into()),
201 FailureKind::Permission,
202 ),
203 (DomainError::InvalidConfig("x".into()), FailureKind::Config),
204 (
205 DomainError::PipelineNotFound("x".into()),
206 FailureKind::NotFound,
207 ),
208 (DomainError::ApiError("x".into()), FailureKind::Other),
209 ];
210 for (err, kind) in permanent {
211 assert_eq!(err.failure_kind(), kind);
212 assert!(!kind.is_transient());
213 }
214 }
215
216 #[test]
217 fn codes_are_stable_per_variant() {
218 let cases = [
219 (DomainError::ProviderNotFound("x".into()), "NOT_FOUND"),
220 (DomainError::PipelineNotFound("x".into()), "NOT_FOUND"),
221 (DomainError::NotFound("x".into()), "NOT_FOUND"),
222 (DomainError::InvalidConfig("x".into()), "BAD_REQUEST"),
223 (DomainError::InvalidProviderType("x".into()), "BAD_REQUEST"),
224 (
225 DomainError::AuthenticationFailed("x".into()),
226 "UNAUTHORIZED",
227 ),
228 (
229 DomainError::ProviderAuthenticationFailed("x".into()),
230 "PROVIDER_AUTH_FAILED",
231 ),
232 (DomainError::PermissionDenied("x".into()), "FORBIDDEN"),
233 (DomainError::NotSupported("x".into()), "NOT_SUPPORTED"),
234 (DomainError::ApiError("x".into()), "INTERNAL_ERROR"),
235 (DomainError::ConfigNotFound, "INTERNAL_ERROR"),
236 (
237 DomainError::RateLimited {
238 message: "x".into(),
239 retry_after: None,
240 },
241 "INTERNAL_ERROR",
242 ),
243 ];
244 for (err, code) in cases {
245 assert_eq!(err.code(), code, "{err:?}");
246 }
247 }
248}