1pub(crate) mod config;
28mod upload;
29
30pub use mwapi::ApiError;
31use serde::Deserialize;
32use serde_json::Value;
33use std::io;
34use thiserror::Error as ThisError;
35pub use upload::UploadWarning;
36
37#[non_exhaustive]
39#[derive(ThisError, Debug)]
40pub enum Error {
41 #[error("HTTP error: {0}")]
44 HttpError(#[from] reqwest::Error),
45 #[error("Invalid header value: {0}")]
48 InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue),
49 #[error("JSON error: {0}")]
51 InvalidJson(#[from] serde_json::Error),
52 #[error("Unable to get request lock: {0}")]
54 LockFailure(#[from] tokio::sync::AcquireError),
55 #[error("Invalid title: {0}")]
56 InvalidTitle(#[from] mwtitle::Error),
57 #[error("I/O error: {0}")]
58 IoError(io::Error),
59 #[error("HTTP 429 Too Many Requests")]
61 TooManyRequests { retry_after: Option<u64> },
62
63 #[error("The etag for this request is missing or invalid")]
65 InvalidEtag,
66
67 #[error("Invalid CSRF token")]
70 BadToken,
71 #[error("Unable to get token `{0}`")]
73 TokenFailure(String),
74
75 #[error("Heading levels must be between 1 and 6, '{0}' was provided")]
77 InvalidHeadingLevel(u32),
78
79 #[error("You're not logged in")]
82 NotLoggedIn,
83 #[error("You're not logged in as a bot account")]
85 NotLoggedInAsBot,
86 #[error("Missing permission: {0}")]
87 PermissionDenied(String),
88 #[error("Blocked sitewide: {info}")]
89 Blocked { info: String, details: BlockDetails },
90 #[error("Partially blocked: {info}")]
91 PartiallyBlocked { info: String, details: BlockDetails },
92 #[error("Globally blocked: {0}")]
93 GloballyBlocked(String),
94 #[error("Globally range blocked: {0}")]
95 GloballyRangeBlocked(String),
96 #[error("Globally XFF blocked: {0}")]
97 GloballyXFFBlocked(String),
98 #[error("Blocked: {0}")]
100 UnknownBlock(String),
101
102 #[error("You've made too many recent login attempts.")]
104 LoginThrottled,
105 #[error("Incorrect username or password entered.")]
106 WrongPassword,
107
108 #[error("The specified title is not a valid page")]
110 InvalidPage,
111 #[error("{{{{nobots}}}} prevents editing this page")]
113 Nobots,
114 #[error("Page does not exist: {0}")]
116 PageDoesNotExist(String),
117 #[error("Page is protected")]
119 ProtectedPage,
120 #[error("Edit conflict")]
122 EditConflict,
123 #[error("Content too big: {0}")]
124 ContentTooBig(String),
125 #[error("{info}")]
127 SpamFilter { info: String, matches: Vec<String> },
128 #[error("Undo failure: {0}")]
130 UndoFailure(String),
131 #[error("Unknown save failure: {0}")]
133 UnknownSaveFailure(Value),
134
135 #[error("Upload warnings: {0:?}")]
137 UploadWarning(Vec<UploadWarning>),
138
139 #[error("maxlag tripped: {info}")]
141 Maxlag {
142 info: String,
143 retry_after: Option<u64>,
144 },
145 #[error("MediaWiki is readonly: {info}")]
147 Readonly {
148 info: String,
149 retry_after: Option<u64>,
150 },
151 #[error("Internal MediaWiki exception: {0}")]
153 InternalException(ApiError),
154
155 #[error("API error: {0}")]
158 ApiError(ApiError),
159 #[error("Unknown error: {0}")]
162 Unknown(String),
163}
164
165impl Error {
166 pub fn is_page_related(&self) -> bool {
169 matches!(
170 self,
171 Error::InvalidPage
172 | Error::ProtectedPage
173 | Error::Nobots
174 | Error::PartiallyBlocked { .. }
175 | Error::EditConflict
176 | Error::SpamFilter { .. }
177 | Error::ContentTooBig(_)
178 | Error::UndoFailure(_)
179 )
180 }
181
182 pub fn is_sitewide_block(&self) -> bool {
184 matches!(
185 self,
186 Error::Blocked { .. }
187 | Error::GloballyBlocked(_)
188 | Error::GloballyRangeBlocked(_)
189 | Error::GloballyXFFBlocked(_)
190 | Error::UnknownBlock(_)
193 )
194 }
195
196 pub fn retry_after(&self) -> Option<u64> {
198 match self {
199 Error::TooManyRequests { retry_after, .. } => {
200 Some((*retry_after).unwrap_or(1))
201 }
202 Error::Maxlag { retry_after, .. } => {
203 Some((*retry_after).unwrap_or(1))
204 }
205 Error::Readonly { retry_after, .. } => {
206 Some((*retry_after).unwrap_or(1))
207 }
208 _ => None,
209 }
210 }
211}
212
213#[derive(Deserialize)]
214struct SpamFilterData {
215 matches: Vec<String>,
216}
217
218#[derive(Deserialize, Debug, Clone)]
221pub struct BlockDetails {
222 pub blockedby: String,
224 pub blockreason: String,
226 pub blockpartial: bool,
229 }
233
234impl From<ApiError> for Error {
235 fn from(apierr: ApiError) -> Self {
236 match apierr.code.as_str() {
237 "assertuserfailed" => Self::NotLoggedIn,
238 "assertbotfailed" => Self::NotLoggedInAsBot,
239 "badtoken" => Self::BadToken,
240 "blocked" => {
241 let details = if let Some(data) = apierr.data {
242 serde_json::from_value::<BlockDetails>(
243 data["blockinfo"].clone(),
244 )
245 .ok()
246 } else {
247 None
248 };
249 match details {
250 Some(details) => {
251 if details.blockpartial {
252 Self::PartiallyBlocked {
253 info: apierr.text,
254 details,
255 }
256 } else {
257 Self::Blocked {
258 info: apierr.text,
259 details,
260 }
261 }
262 }
263 None => Self::UnknownBlock(apierr.text),
264 }
265 }
266 "contenttoobig" => Self::ContentTooBig(apierr.text),
267 "editconflict" => Self::EditConflict,
268 "globalblocking-ipblocked"
269 | "wikimedia-globalblocking-ipblocked"
270 | "globalblocking-blockedtext-ip"
271 | "wikimedia-globalblocking-blockedtext-ip" => {
272 Self::GloballyBlocked(apierr.text)
273 }
274 "globalblocking-ipblocked-range"
275 | "wikimedia-globalblocking-ipblocked-range"
276 | "globalblocking-blockedtext-range"
277 | "wikimedia-globalblocking-blockedtext-range" => {
278 Self::GloballyRangeBlocked(apierr.text)
279 }
280 "globalblocking-ipblocked-xff"
281 | "wikimedia-globalblocking-ipblocked-xff"
282 | "globalblocking-blockedtext-xff"
283 | "wikimedia-globalblocking-blockedtext-xff" => {
284 Self::GloballyXFFBlocked(apierr.text)
285 }
286 "globalblocking-blockedtext-user"
287 | "wikimedia-globalblocking-blockedtext-user" => {
288 Self::GloballyBlocked(apierr.text)
289 }
290 "login-throttled" => Self::LoginThrottled,
291 "maxlag" => Self::Maxlag {
292 info: apierr.text,
293 retry_after: None,
294 },
295 "protectedpage" => Self::ProtectedPage,
296 "readonly" => Self::Readonly {
297 info: apierr.text,
298 retry_after: None,
299 },
300 "spamblacklist" => {
301 let matches = if let Some(data) = apierr.data {
302 match serde_json::from_value::<SpamFilterData>(
303 data["spamblacklist"].clone(),
304 ) {
305 Ok(data) => data.matches,
306 Err(_) => vec![],
308 }
309 } else {
310 vec![]
311 };
312 Self::SpamFilter {
313 info: apierr.text,
314 matches,
315 }
316 }
317 "undofailure" => Self::UndoFailure(apierr.text),
318 "wrongpassword" => Self::WrongPassword,
319 code => {
320 if code.starts_with("internal_api_error_") {
321 Self::InternalException(apierr)
322 } else {
323 Self::ApiError(apierr)
324 }
325 }
326 }
327 }
328}
329
330impl From<mwapi::Error> for Error {
331 fn from(value: mwapi::Error) -> Self {
332 match value {
333 mwapi::Error::HttpError(err) => Error::HttpError(err),
334 mwapi::Error::InvalidHeaderValue(err) => {
335 Error::InvalidHeaderValue(err)
336 }
337 mwapi::Error::InvalidJson(err) => Error::InvalidJson(err),
338 mwapi::Error::LockFailure(err) => Error::LockFailure(err),
339 mwapi::Error::IoError(err) => Error::IoError(err),
340 mwapi::Error::TooManyRequests { retry_after } => {
341 Error::TooManyRequests { retry_after }
342 }
343 mwapi::Error::BadToken => Error::BadToken,
344 mwapi::Error::TokenFailure(token) => Error::TokenFailure(token),
345 mwapi::Error::NotLoggedIn => Error::NotLoggedIn,
346 mwapi::Error::NotLoggedInAsBot => Error::NotLoggedInAsBot,
347 mwapi::Error::UploadWarning(warnings) => Error::UploadWarning(
348 warnings.into_iter().map(UploadWarning::from_key).collect(),
349 ),
350 mwapi::Error::Maxlag { info, retry_after } => {
351 Error::Maxlag { info, retry_after }
352 }
353 mwapi::Error::Readonly { info, retry_after } => {
354 Error::Readonly { info, retry_after }
355 }
356 mwapi::Error::InternalException(err) => {
357 Error::InternalException(err)
358 }
359 mwapi::Error::ApiError(apierr) => Error::from(apierr),
360 mwapi::Error::Unknown(err) => Error::Unknown(err),
361 err => Error::Unknown(err.to_string()),
362 }
363 }
364}
365
366impl From<parsoid::Error> for Error {
367 fn from(value: parsoid::Error) -> Self {
368 match value {
369 parsoid::Error::Http(err) => Error::HttpError(err),
370 parsoid::Error::InvalidHeaderValue(err) => {
371 Error::InvalidHeaderValue(err)
372 }
373 parsoid::Error::InvalidJson(err) => Error::InvalidJson(err),
374 parsoid::Error::LockFailure(err) => Error::LockFailure(err),
375 parsoid::Error::PageDoesNotExist(title) => {
376 Error::PageDoesNotExist(title)
377 }
378 parsoid::Error::InvalidEtag => Error::InvalidEtag,
379 parsoid::Error::InvalidHeadingLevel(level) => {
380 Error::InvalidHeadingLevel(level)
381 }
382 err => Error::Unknown(err.to_string()),
383 }
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 #[test]
392 fn test_is_page_related() {
393 assert!(Error::EditConflict.is_page_related());
394 assert!(!Error::Unknown("bar".to_string()).is_page_related());
395 }
396
397 #[test]
398 fn test_from_apierror() {
399 let apierr = ApiError {
400 code: "assertbotfailed".to_string(),
401 text: "Something something".to_string(),
402 data: None,
403 };
404 let err = Error::from(apierr);
405 assert!(matches!(err, Error::NotLoggedInAsBot));
406 }
407
408 #[test]
409 fn test_to_string() {
410 let apierr = ApiError {
411 code: "errorcode".to_string(),
412 text: "Some description".to_string(),
413 data: None,
414 };
415 assert_eq!(&apierr.to_string(), "(code: errorcode): Some description");
416 }
417
418 #[test]
419 fn test_spamfilter() {
420 let apierr = ApiError {
421 code: "spamblacklist".to_string(),
422 text: "blah blah".to_string(),
423 data: Some(serde_json::json!({
424 "spamblacklist": {
425 "matches": [
426 "example.org"
427 ]
428 }
429 })),
430 };
431 let err = Error::from(apierr);
432 if let Error::SpamFilter { matches, .. } = err {
433 assert_eq!(matches, vec!["example.org".to_string()]);
434 } else {
435 panic!("Unexpected error: {err:?}");
436 }
437 }
438}