1use std::fmt;
12
13use crate::{columns::Columns, error::ItemErrors, sanitize};
14
15const LABEL_WIDTH: usize = "Next page".len();
18
19#[derive(Clone, Debug, Default)]
21pub struct Listing<'a> {
22 pub header: Vec<(&'static str, String)>,
24 pub items: Columns<'a>,
26 pub empty: Option<&'static str>,
28 pub errors: ItemErrors,
30}
31
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub enum ListingPage {
35 All,
37 Numbered {
39 page: u64,
41 size: u64,
43 },
44 Token,
46}
47
48impl<'a> Listing<'a> {
49 pub fn new(account: &str, backend: Option<&str>) -> Self {
51 let mut value = sanitize::text(account).into_owned();
52
53 if let Some(backend) = backend {
54 value.push_str(&format!(" ({backend})"));
55 }
56
57 Self {
58 header: vec![("Account", value)],
59 ..Default::default()
60 }
61 }
62
63 pub fn head(&mut self, label: &'static str, value: &str) {
65 let value = sanitize::text(value).into_owned();
66 self.header.push((label, value));
67 }
68
69 pub fn page(&mut self, page: ListingPage, count: usize, nouns: [&str; 2], next: Option<&str>) {
72 let count = count as u64;
73 let [one, many] = nouns;
74
75 let (number, first) = match page {
76 ListingPage::All => (Some(String::from("all")), Some(1)),
77 ListingPage::Numbered { page, size } => {
78 let first = page.saturating_sub(1) * size + 1;
79 (Some(page.to_string()), Some(first))
80 }
81 ListingPage::Token => (None, None),
82 };
83
84 let held = match (count, first) {
85 (0, _) => String::from("empty"),
86 (1, Some(first)) => format!("{one} {first}"),
87 (n, Some(first)) => format!("{many} {first} to {}", first + n - 1),
88 (1, None) => format!("1 {one}"),
89 (n, None) => format!("{n} {many}"),
90 };
91
92 let last = match (page, next) {
93 (ListingPage::All, _) | (_, Some(_)) => "",
94 _ if count == 0 => "",
95 _ => ", the last",
96 };
97
98 let value = match number {
99 Some(number) => format!("{number}, {held}{last}"),
100 None => format!("{held}{last}"),
101 };
102
103 self.header.push(("Page", value));
104
105 if let Some(token) = next {
106 let flag = match page {
107 ListingPage::All | ListingPage::Numbered { .. } => "--page",
108 ListingPage::Token => "--page-token",
109 };
110
111 let token = sanitize::text(token);
112 self.header.push(("Next page", format!("{flag} {token}")));
113 }
114 }
115}
116
117impl fmt::Display for Listing<'_> {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 let width = self.header.iter().map(|(label, _)| label.chars().count());
120 let width = width.max().unwrap_or_default().max(LABEL_WIDTH) + 3;
121
122 for (label, value) in &self.header {
123 let label = format!("{label}:");
124 writeln!(f, "{label:<width$}{value}")?;
125 }
126
127 let body = match (self.items.rows.is_empty(), self.empty) {
128 (false, _) => Some(self.items.to_string()),
129 (true, Some(empty)) => Some(format!("{empty}\n")),
130 (true, None) => None,
131 };
132
133 if let Some(body) = &body {
134 if !self.header.is_empty() {
135 writeln!(f)?;
136 }
137
138 f.write_str(body)?;
139 }
140
141 if self.header.is_empty() && body.is_none() {
142 return write!(f, "{}", self.errors);
143 }
144
145 self.errors.write_after(f)
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use anyhow::anyhow;
152 use schemars::JsonSchema;
153 use serde::Serialize;
154
155 use crate::{
156 error::{CodedError, ItemError},
157 listing::*,
158 };
159
160 #[derive(Clone, Debug, Serialize, JsonSchema)]
161 #[serde(rename_all = "kebab-case")]
162 enum TestCode {
163 MessageUnreadable,
164 }
165
166 fn listing(rows: usize) -> Listing<'static> {
167 let mut listing = Listing::new("work", Some("imap"));
168
169 for i in 0..rows {
170 listing.items.push([i.to_string(), String::from("x")]);
171 }
172
173 listing
174 }
175
176 #[test]
177 fn a_numbered_page_counts_its_items_above_them() {
178 let mut first = listing(2);
179 first.head("Mailbox", "INBOX");
180 first.page(
181 ListingPage::Numbered { page: 2, size: 2 },
182 2,
183 ["envelope", "envelopes"],
184 Some("3"),
185 );
186
187 let expected = "\
188Account: work (imap)
189Mailbox: INBOX
190Page: 2, envelopes 3 to 4
191Next page: --page 3
192
1930 x
1941 x
195";
196 assert_eq!(first.to_string(), expected);
197
198 let mut last = listing(1);
199 last.page(
200 ListingPage::Numbered { page: 3, size: 2 },
201 1,
202 ["envelope", "envelopes"],
203 None,
204 );
205 assert!(
206 last.to_string()
207 .contains("\nPage: 3, envelope 5, the last\n\n0 x\n")
208 );
209 }
210
211 #[test]
212 fn a_token_page_counts_what_it_holds() {
213 let mut first = listing(2);
214 first.page(ListingPage::Token, 2, ["draft", "drafts"], Some("abc"));
215 assert!(
216 first
217 .to_string()
218 .contains("Page: 2 drafts\nNext page: --page-token abc\n")
219 );
220
221 let mut empty = listing(0);
222 empty.page(ListingPage::Token, 0, ["draft", "drafts"], None);
223 assert_eq!(
224 empty.to_string(),
225 "Account: work (imap)\nPage: empty\n"
226 );
227 }
228
229 #[test]
230 fn a_label_longer_than_next_page_widens_the_column() {
231 let mut listing = listing(0);
232 listing.head("Addressbook", "contacts");
233 listing.empty = Some("No card");
234
235 let expected = "\
236Account: work (imap)
237Addressbook: contacts
238
239No card
240";
241 assert_eq!(listing.to_string(), expected);
242 }
243
244 #[test]
245 fn item_errors_print_last_aligned_among_themselves() {
246 let mut listing = listing(1);
247 listing.page(ListingPage::All, 1, ["envelope", "envelopes"], None);
248
249 let unreadable = CodedError::new(TestCode::MessageUnreadable, "Cannot decode it");
250 listing
251 .errors
252 .push(&ItemError::<TestCode>::new(None, &unreadable.into()));
253 listing
254 .errors
255 .push(&ItemError::<TestCode>::new(Some("42"), &anyhow!("Gone")));
256
257 let expected = "\
258Account: work (imap)
259Page: all, envelope 1
260
2610 x
262
263Error: - message-unreadable Cannot decode it
264Error: 42 - Gone
265";
266 assert_eq!(listing.to_string(), expected);
267 }
268
269 #[test]
270 fn a_bare_listing_prints_its_items_alone() {
271 let mut bare = Listing::default();
272 bare.items.push(["a", "b"]);
273 assert_eq!(bare.to_string(), "a b\n");
274
275 let mut errors_only = Listing::default();
276 errors_only
277 .errors
278 .push(&ItemError::<TestCode>::new(None, &anyhow!("Gone")));
279 assert_eq!(errors_only.to_string(), "Error: - - Gone\n");
280 }
281}