Skip to main content

pimalaya_cli/
account.rs

1//! # Account
2//!
3//! The outputs of every binary's `account list` and `account check`
4//! commands: the accounts a configuration declares and the backends each
5//! one carries, then whether each backend of one account answers. The
6//! `account capability` output lives in the capability module.
7
8use std::fmt;
9
10use anyhow::Result;
11use schemars::JsonSchema;
12use serde::Serialize;
13
14use crate::{
15    columns::Columns,
16    error::{ErrorCode, NoErrorCode, code_of},
17    fields::Fields,
18    sanitize,
19};
20
21/// The `account list` output, the accounts the configuration declares.
22#[derive(Clone, Debug, Serialize, JsonSchema)]
23#[serde(rename_all = "camelCase")]
24pub struct AccountListOutput {
25    /// The accounts, sorted by name.
26    pub accounts: Vec<AccountRow>,
27}
28
29impl fmt::Display for AccountListOutput {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        let mut columns = Columns::default();
32
33        for account in &self.accounts {
34            let not_built = account
35                .not_built
36                .iter()
37                .map(|name| format!("{name} (not built)"));
38            let backends: Vec<String> = account
39                .backends
40                .iter()
41                .map(ToString::to_string)
42                .chain(not_built)
43                .collect();
44
45            columns.push([
46                sanitize::text(&account.name).into_owned(),
47                backends.join(","),
48                String::from(if account.default { "default" } else { "" }),
49            ]);
50        }
51
52        write!(f, "{columns}")
53    }
54}
55
56/// One account of the list.
57#[derive(Clone, Debug, Serialize, JsonSchema)]
58#[serde(rename_all = "camelCase")]
59pub struct AccountRow {
60    /// The name of the `[accounts.<name>]` block.
61    pub name: String,
62    /// Whether a command with no `-a/--account` runs against it.
63    pub default: bool,
64    /// The backends it declares a block for that this build serves.
65    pub backends: Vec<&'static str>,
66    /// The backends it declares a block for that this build leaves out,
67    /// which a command reaching one refuses.
68    pub not_built: Vec<&'static str>,
69}
70
71/// The `account check` output, one outcome per backend.
72#[derive(Clone, Debug, Serialize, JsonSchema)]
73#[serde(rename_all = "camelCase")]
74#[schemars(rename = "AccountCheckOutput")]
75pub struct AccountCheckOutput<C = NoErrorCode> {
76    /// The account that was checked.
77    pub account: String,
78    /// One entry per backend the account declares and `--backend` allows.
79    pub backends: Vec<BackendCheck<C>>,
80}
81
82impl<C> fmt::Display for AccountCheckOutput<C> {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        let mut fields = Fields::default();
85        fields.push("Account", sanitize::text(&self.account));
86
87        for check in &self.backends {
88            match &check.error {
89                None => fields.push(check.backend, "OK"),
90                Some(err) => fields.push(check.backend, format!("FAIL ({})", sanitize::text(err))),
91            }
92        }
93
94        write!(f, "{fields}")
95    }
96}
97
98/// Outcome of checking one backend's connection.
99#[derive(Clone, Debug, Serialize, JsonSchema)]
100#[serde(rename_all = "camelCase")]
101#[schemars(rename = "BackendCheck")]
102pub struct BackendCheck<C = NoErrorCode> {
103    /// The backend that was checked.
104    pub backend: &'static str,
105    /// Whether it answered.
106    pub ok: bool,
107    /// The stable code of the failure, `null` when it answered or the
108    /// failure carries none.
109    pub code: Option<C>,
110    /// Why it did not answer, `null` when it did.
111    pub error: Option<String>,
112}
113
114impl<C: ErrorCode> BackendCheck<C> {
115    /// Folds a connection attempt to `backend` into its outcome, coded by
116    /// its chain (see `error::with_code` for a code read off a typed error).
117    pub fn new(backend: &'static str, result: Result<()>) -> Self {
118        match result {
119            Ok(()) => Self {
120                backend,
121                ok: true,
122                code: None,
123                error: None,
124            },
125            Err(err) => Self {
126                backend,
127                ok: false,
128                code: code_of(&err),
129                error: Some(format!("{err:#}")),
130            },
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use anyhow::anyhow;
138    use serde::Serialize;
139    use serde_json::json;
140
141    use crate::{
142        account::*,
143        clap::commands::json_schema::output_schema,
144        error::{CodedError, with_code},
145    };
146
147    #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, JsonSchema)]
148    #[serde(rename_all = "kebab-case")]
149    enum TestCode {
150        AuthFailed,
151    }
152
153    #[test]
154    fn accounts_print_one_line_each_with_the_backends_left_out() {
155        let output = AccountListOutput {
156            accounts: vec![
157                AccountRow {
158                    name: String::from("home"),
159                    default: false,
160                    backends: vec!["maildir"],
161                    not_built: vec![],
162                },
163                AccountRow {
164                    name: String::from("work"),
165                    default: true,
166                    backends: vec!["imap"],
167                    not_built: vec!["jmap", "smtp"],
168                },
169            ],
170        };
171
172        let expected = "\
173home  maildir                                 -
174work  imap,jmap (not built),smtp (not built)  default
175";
176        assert_eq!(output.to_string(), expected);
177
178        let json = serde_json::to_string(&output.accounts[1]).unwrap();
179        let expected =
180            r#"{"name":"work","default":true,"backends":["imap"],"notBuilt":["jmap","smtp"]}"#;
181        assert_eq!(json, expected);
182    }
183
184    #[test]
185    fn a_check_prints_one_field_per_backend() {
186        let refused = Err(anyhow!("Bad credentials").context("Cannot open the session"));
187
188        let output = AccountCheckOutput {
189            account: String::from("work"),
190            backends: vec![
191                BackendCheck::<TestCode>::new("imap", Ok(())),
192                BackendCheck::new("smtp", refused),
193            ],
194        };
195
196        let expected = "\
197Account:  work
198imap:     OK
199smtp:     FAIL (Cannot open the session: Bad credentials)
200";
201        assert_eq!(output.to_string(), expected);
202
203        let json = serde_json::to_string(&output).unwrap();
204        let expected = concat!(
205            r#"{"account":"work","backends":["#,
206            r#"{"backend":"imap","ok":true,"code":null,"error":null},"#,
207            r#"{"backend":"smtp","ok":false,"code":null,"#,
208            r#""error":"Cannot open the session: Bad credentials"}]}"#,
209        );
210        assert_eq!(json, expected);
211    }
212
213    #[test]
214    fn a_failed_check_is_coded_by_its_chain() {
215        let coded = anyhow::Error::new(CodedError::new(TestCode::AuthFailed, "Bad credentials"));
216        let check = BackendCheck::<TestCode>::new("imap", Err(coded.context("Cannot log in")));
217        assert_eq!(check.code, Some(TestCode::AuthFailed));
218        assert_eq!(
219            check.error.as_deref(),
220            Some("Cannot log in: Bad credentials")
221        );
222
223        // A code read off a typed error, handed over first.
224        let typed = anyhow!("401 Unauthorized").context("Cannot log in");
225        let typed = with_code(typed, |_| Some(TestCode::AuthFailed));
226        let check = BackendCheck::<TestCode>::new("jmap", Err(typed));
227        assert_eq!(check.code, Some(TestCode::AuthFailed));
228        assert_eq!(
229            check.error.as_deref(),
230            Some("Cannot log in: 401 Unauthorized")
231        );
232    }
233
234    #[test]
235    fn untrusted_text_is_defused() {
236        let output = AccountCheckOutput {
237            account: String::from("a\u{1b}b"),
238            backends: vec![BackendCheck::<TestCode>::new(
239                "imap",
240                Err(anyhow!("evil\u{202e}reply")),
241            )],
242        };
243
244        let text = output.to_string();
245        assert!(text.contains("a\u{fffd}b"), "{text}");
246        assert!(text.contains("FAIL (evil\u{fffd}reply)"), "{text}");
247    }
248
249    #[test]
250    fn the_check_schema_keeps_the_names_and_types_the_code() {
251        let schema = output_schema::<AccountCheckOutput<TestCode>>();
252        let row = &schema["$defs"]["BackendCheck"];
253
254        assert_eq!(schema["title"], "AccountCheckOutput", "{schema}");
255        assert_eq!(
256            row["required"],
257            json!(["backend", "ok", "code", "error"]),
258            "{schema}"
259        );
260        assert_eq!(schema["$defs"]["TestCode"]["enum"], json!(["auth-failed"]));
261
262        let schema = output_schema::<AccountListOutput>();
263        let row = &schema["$defs"]["AccountRow"];
264        assert_eq!(
265            row["required"],
266            json!(["name", "default", "backends", "notBuilt"]),
267            "{schema}"
268        );
269    }
270}