Skip to main content

unifly_api/
core_error.rs

1// ── Core error types ──
2//
3// User-facing errors from unifly-core. These are NOT API-specific --
4// consumers never see HTTP status codes or JSON parse failures directly.
5// The `From<crate::error::Error>` impl translates transport-layer errors
6// into domain-appropriate variants.
7
8use std::fmt::Write as _;
9
10use thiserror::Error;
11
12/// Lightweight site descriptor returned with `SiteNotFound` to help the
13/// caller see what slugs/labels the controller actually exposes.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct SiteHint {
16    pub internal_reference: String,
17    pub display_name: String,
18}
19
20fn format_site_not_found(name: &str, available: &[SiteHint]) -> String {
21    let mut out = format!("Site not found: {name}");
22    if !available.is_empty() {
23        out.push_str(". Available sites:");
24        for hint in available {
25            // safe: writing into a String never errors
26            let _ = write!(
27                &mut out,
28                " {} ({})",
29                hint.internal_reference, hint.display_name
30            );
31        }
32    }
33    out
34}
35
36fn format_site_ambiguous(name: &str, matches: &[SiteHint]) -> String {
37    let mut out = format!("Site '{name}' is ambiguous; multiple sites match:");
38    for hint in matches {
39        let _ = write!(
40            &mut out,
41            " {} ({})",
42            hint.internal_reference, hint.display_name
43        );
44    }
45    out.push_str(". Use the slug or UUID instead");
46    out
47}
48
49/// Unified error type for the core crate.
50#[derive(Debug, Error)]
51pub enum CoreError {
52    // ── Connection errors ────────────────────────────────────────────
53    #[error("Cannot connect to controller at {url}: {reason}")]
54    ConnectionFailed { url: String, reason: String },
55
56    #[error("Authentication failed: {message}")]
57    AuthenticationFailed { message: String },
58
59    #[error("Controller disconnected")]
60    ControllerDisconnected,
61
62    #[error("Controller connection timed out after {timeout_secs}s")]
63    Timeout { timeout_secs: u64 },
64
65    // ── Data errors ──────────────────────────────────────────────────
66    #[error("Device not found: {identifier}")]
67    DeviceNotFound { identifier: String },
68
69    #[error("Client not found: {identifier}")]
70    ClientNotFound { identifier: String },
71
72    #[error("Network not found: {identifier}")]
73    NetworkNotFound { identifier: String },
74
75    #[error("{}", format_site_not_found(.name, .available))]
76    SiteNotFound {
77        name: String,
78        /// Available sites discovered on the controller, used to render a
79        /// helpful "did you mean..." list in the error message.
80        available: Vec<SiteHint>,
81    },
82
83    #[error("{}", format_site_ambiguous(.name, .matches))]
84    SiteAmbiguous {
85        name: String,
86        /// Sites whose `name` (or case-insensitive variants) all matched.
87        /// At least two entries; the user must disambiguate by slug or UUID.
88        matches: Vec<SiteHint>,
89    },
90
91    #[error("Entity not found: {entity_type} with id {identifier}")]
92    NotFound {
93        entity_type: String,
94        identifier: String,
95    },
96
97    // ── Operation errors ─────────────────────────────────────────────
98    #[error("Operation not supported: {operation} (requires {required})")]
99    Unsupported { operation: String, required: String },
100
101    #[error("Operation rejected by controller: {message}")]
102    Rejected { message: String },
103
104    #[error("Validation failed: {message}")]
105    ValidationFailed { message: String },
106
107    #[error("Operation failed: {message}")]
108    OperationFailed { message: String },
109
110    // ── API errors (wrapped, not exposed raw) ────────────────────────
111    #[error("API error: {message}")]
112    Api {
113        message: String,
114        /// The API-specific error code (e.g., "api.authentication.missing-credentials").
115        code: Option<String>,
116        /// HTTP status code (if applicable).
117        status: Option<u16>,
118    },
119
120    // ── Configuration errors ─────────────────────────────────────────
121    #[error("Configuration error: {message}")]
122    Config { message: String },
123
124    // ── Internal errors ──────────────────────────────────────────────
125    #[error("Internal error: {0}")]
126    Internal(String),
127}
128
129// ── Conversion from transport-layer errors ───────────────────────────
130
131impl From<crate::error::Error> for CoreError {
132    fn from(err: crate::error::Error) -> Self {
133        match err {
134            crate::error::Error::Authentication { message } => {
135                CoreError::AuthenticationFailed { message }
136            }
137            crate::error::Error::TwoFactorRequired => CoreError::AuthenticationFailed {
138                message: "Two-factor authentication token required".into(),
139            },
140            crate::error::Error::SessionExpired => CoreError::AuthenticationFailed {
141                message: "Session expired -- re-authentication required".into(),
142            },
143            crate::error::Error::InvalidApiKey => CoreError::AuthenticationFailed {
144                message: "Invalid API key".into(),
145            },
146            crate::error::Error::WrongAuthStrategy { expected, got } => {
147                CoreError::AuthenticationFailed {
148                    message: format!("Wrong auth strategy: expected {expected}, got {got}"),
149                }
150            }
151            crate::error::Error::Transport(ref e) => {
152                if e.is_timeout() {
153                    CoreError::Timeout { timeout_secs: 0 }
154                } else if e.is_connect() {
155                    CoreError::ConnectionFailed {
156                        url: e
157                            .url()
158                            .map_or_else(|| "<unknown>".into(), ToString::to_string),
159                        reason: e.to_string(),
160                    }
161                } else if e.status().map(|s| s.as_u16()) == Some(404) {
162                    CoreError::NotFound {
163                        entity_type: "resource".into(),
164                        identifier: e.url().map(|u| u.path().to_string()).unwrap_or_default(),
165                    }
166                } else {
167                    CoreError::Api {
168                        message: e.to_string(),
169                        code: None,
170                        status: e.status().map(|s| s.as_u16()),
171                    }
172                }
173            }
174            crate::error::Error::InvalidUrl(e) => CoreError::Config {
175                message: format!("Invalid URL: {e}"),
176            },
177            crate::error::Error::Timeout { timeout_secs } => CoreError::Timeout { timeout_secs },
178            crate::error::Error::Tls(msg) => CoreError::ConnectionFailed {
179                url: String::new(),
180                reason: format!("TLS error: {msg}"),
181            },
182            crate::error::Error::RateLimited { retry_after_secs } => CoreError::Api {
183                message: format!("Rate limited -- retry after {retry_after_secs}s"),
184                code: Some("rate_limited".into()),
185                status: Some(429),
186            },
187            crate::error::Error::ConsoleOffline { host_id } => CoreError::ConnectionFailed {
188                url: format!("https://api.ui.com (host {host_id})"),
189                reason: "cloud console offline or unreachable".into(),
190            },
191            crate::error::Error::ConsoleAccessDenied { host_id } => {
192                CoreError::AuthenticationFailed {
193                    message: format!(
194                        "Not authorized to access cloud console {host_id} with this API key"
195                    ),
196                }
197            }
198            crate::error::Error::Integration {
199                message,
200                code,
201                status,
202            } => CoreError::Api {
203                message,
204                code,
205                status: Some(status),
206            },
207            crate::error::Error::SessionApi { message } => CoreError::Api {
208                message,
209                code: None,
210                status: None,
211            },
212            crate::error::Error::WebSocketConnect(reason) => CoreError::ConnectionFailed {
213                url: String::new(),
214                reason: format!("WebSocket connection failed: {reason}"),
215            },
216            crate::error::Error::WebSocketClosed { code, reason } => CoreError::ConnectionFailed {
217                url: String::new(),
218                reason: format!("WebSocket closed (code {code}): {reason}"),
219            },
220            crate::error::Error::Deserialization { message, body: _ } => {
221                CoreError::Internal(format!("Deserialization error: {message}"))
222            }
223            crate::error::Error::UnsupportedOperation(op) => CoreError::Unsupported {
224                operation: op.to_string(),
225                required: "a newer controller firmware".into(),
226            },
227        }
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::{CoreError, SiteHint};
234
235    #[test]
236    fn site_not_found_renders_with_no_candidates() {
237        let err = CoreError::SiteNotFound {
238            name: "ghost".into(),
239            available: Vec::new(),
240        };
241        assert_eq!(err.to_string(), "Site not found: ghost");
242    }
243
244    #[test]
245    fn site_ambiguous_lists_matches_and_recommends_slug() {
246        let err = CoreError::SiteAmbiguous {
247            name: "Home".into(),
248            matches: vec![
249                SiteHint {
250                    internal_reference: "home1".into(),
251                    display_name: "Home".into(),
252                },
253                SiteHint {
254                    internal_reference: "home2".into(),
255                    display_name: "Home".into(),
256                },
257            ],
258        };
259        let rendered = err.to_string();
260        assert!(rendered.contains("ambiguous"));
261        assert!(rendered.contains("home1 (Home)"));
262        assert!(rendered.contains("home2 (Home)"));
263        assert!(rendered.contains("slug or UUID"));
264    }
265
266    #[test]
267    fn site_not_found_lists_available_sites() {
268        let err = CoreError::SiteNotFound {
269            name: "Default".into(),
270            available: vec![
271                SiteHint {
272                    internal_reference: "default".into(),
273                    display_name: "Main Site".into(),
274                },
275                SiteHint {
276                    internal_reference: "guest".into(),
277                    display_name: "Guest Network".into(),
278                },
279            ],
280        };
281        let rendered = err.to_string();
282        assert!(rendered.contains("Site not found: Default"));
283        assert!(rendered.contains("default (Main Site)"));
284        assert!(rendered.contains("guest (Guest Network)"));
285    }
286}