Skip to main content

escouade_plugin_kit/
errors.rs

1//! The errors of a service that the core tells apart: an HTTP error (401 says « reconnect the account »), a refusal that
2//! asking again would not change. They cross the protocol as codes (`escouade_plugin_protocol::code`) and come back as
3//! these very types in the core (`plugins::adapters`), so that a client in a plugin and one in the core fail alike.
4
5use crate::i18n::Lang;
6use crate::tr_in;
7use serde_json::Value;
8
9/// A service's answer other than a success: its status, and what `http_error_in` says of it (the error's text).
10#[derive(Debug)]
11pub struct HttpError {
12    pub status: u16,
13    pub message: String,
14}
15
16impl std::fmt::Display for HttpError {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        f.write_str(&self.message)
19    }
20}
21
22impl std::error::Error for HttpError {}
23
24/// A refusal that asking again would not change (no transition leads from where the issue is to the state).
25#[derive(Debug)]
26pub struct Refused(pub String);
27
28impl std::fmt::Display for Refused {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        f.write_str(&self.0)
31    }
32}
33
34impl std::error::Error for Refused {}
35
36/// What `name` answering `status` with `body` means, in `lang`.
37pub fn http_error_in(lang: Lang, name: &str, status: u16, body: &str) -> String {
38    let tail = said(body).map(|m| format!(" — {m}")).unwrap_or_default();
39    match status {
40        401 => tr_in!(
41            lang,
42            "{name} refuse ces identifiants (401){tail}",
43            "{name} refuses these credentials (401){tail}"
44        ),
45        403 => tr_in!(
46            lang,
47            "{name} refuse l'accès (403){tail}",
48            "{name} denies access (403){tail}"
49        ),
50        404 => tr_in!(
51            lang,
52            "{name} : introuvable (404){tail}",
53            "{name}: not found (404){tail}"
54        ),
55        429 => tr_in!(
56            lang,
57            "{name} limite les requêtes (429) : réessaie dans un moment",
58            "{name} limits the requests (429): try again in a moment"
59        ),
60        _ => tr_in!(
61            lang,
62            "{name} : erreur {status}{tail}",
63            "{name}: error {status}{tail}"
64        ),
65    }
66}
67
68/// The message of an error's body: Jira's `errorMessages` or `errors`, the first of GraphQL's `errors`, GitHub's
69/// `message`, ClickUp's `err`, or a text's first line; none for a page of HTML.
70pub fn said(body: &str) -> Option<String> {
71    let body = body.trim();
72    let msg = match serde_json::from_str::<Value>(body) {
73        Ok(Value::String(s)) => s,
74        Ok(v) => v["errorMessages"][0]
75            .as_str()
76            .map(str::to_string)
77            .or_else(|| {
78                v["errors"]
79                    .as_object()
80                    .and_then(|o| o.values().find_map(|e| e.as_str().map(str::to_string)))
81            })
82            .or_else(|| v["errors"][0]["message"].as_str().map(str::to_string))
83            .or_else(|| v["message"].as_str().map(str::to_string))
84            .or_else(|| v["error"].as_str().map(str::to_string))
85            // Bitbucket's `{"type": "error", "error": {"message": …}}`.
86            .or_else(|| v["error"]["message"].as_str().map(str::to_string))
87            .or_else(|| v["err"].as_str().map(str::to_string))?,
88        Err(_) if body.starts_with('<') => return None,
89        Err(_) => body.lines().next()?.to_string(),
90    };
91    let msg = msg.trim();
92    (!msg.is_empty()).then(|| first_line(msg))
93}
94
95/// The first line that says something, cut to 200 bytes.
96pub fn first_line(e: &str) -> String {
97    let line = e
98        .lines()
99        .map(str::trim)
100        .find(|l| !l.is_empty())
101        .map(str::to_string)
102        .unwrap_or_else(|| crate::tr!("erreur inconnue", "unknown error"));
103    truncate(&line, 200)
104}
105
106/// At most `max` bytes, an ellipsis after when cut.
107pub fn truncate(s: &str, max: usize) -> String {
108    if s.len() <= max {
109        return s.to_string();
110    }
111    let mut end = max;
112    while !s.is_char_boundary(end) {
113        end -= 1;
114    }
115    format!("{}…", &s[..end])
116}