1use std::fmt::Write as _;
9
10use thiserror::Error;
11
12#[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 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#[derive(Debug, Error)]
51pub enum CoreError {
52 #[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 #[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: Vec<SiteHint>,
81 },
82
83 #[error("{}", format_site_ambiguous(.name, .matches))]
84 SiteAmbiguous {
85 name: String,
86 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 #[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 #[error("API error: {message}")]
112 Api {
113 message: String,
114 code: Option<String>,
116 status: Option<u16>,
118 },
119
120 #[error("Configuration error: {message}")]
122 Config { message: String },
123
124 #[error("Internal error: {0}")]
126 Internal(String),
127}
128
129impl 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}