1use 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#[derive(Clone, Debug, Serialize, JsonSchema)]
23#[serde(rename_all = "camelCase")]
24pub struct AccountListOutput {
25 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#[derive(Clone, Debug, Serialize, JsonSchema)]
58#[serde(rename_all = "camelCase")]
59pub struct AccountRow {
60 pub name: String,
62 pub default: bool,
64 pub backends: Vec<&'static str>,
66 pub not_built: Vec<&'static str>,
69}
70
71#[derive(Clone, Debug, Serialize, JsonSchema)]
73#[serde(rename_all = "camelCase")]
74#[schemars(rename = "AccountCheckOutput")]
75pub struct AccountCheckOutput<C = NoErrorCode> {
76 pub account: String,
78 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#[derive(Clone, Debug, Serialize, JsonSchema)]
100#[serde(rename_all = "camelCase")]
101#[schemars(rename = "BackendCheck")]
102pub struct BackendCheck<C = NoErrorCode> {
103 pub backend: &'static str,
105 pub ok: bool,
107 pub code: Option<C>,
110 pub error: Option<String>,
112}
113
114impl<C: ErrorCode> BackendCheck<C> {
115 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 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}