Skip to main content

pimalaya_cli/
listing.rs

1//! # Listing
2//!
3//! The text frame of every list: the fields placing it (the account, what
4//! is listed, the page it holds and how to reach the next), one blank
5//! line, the items as [`Columns`] with no header row, then the item errors
6//! as [`ItemErrors`] prints them. Nothing prints under the items but those
7//! errors.
8//!
9//! [`ItemErrors`]: crate::error::ItemErrors
10
11use std::fmt;
12
13use crate::{columns::Columns, error::ItemErrors, sanitize};
14
15/// The longest label of the fields, so values stay in one column on every
16/// page of every list.
17const LABEL_WIDTH: usize = "Next page".len();
18
19/// A list printed one item per line under the fields placing it.
20#[derive(Clone, Debug, Default)]
21pub struct Listing<'a> {
22    /// The fields above the items, their values sanitized.
23    pub header: Vec<(&'static str, String)>,
24    /// The items, one per line, with no header row.
25    pub items: Columns<'a>,
26    /// The line printed in place of the items when there are none.
27    pub empty: Option<&'static str>,
28    /// The items the command could not list, printed last.
29    pub errors: ItemErrors,
30}
31
32/// The page a listing holds.
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub enum ListingPage {
35    /// The whole listing, unpaged.
36    All,
37    /// A page of a listing paged by number, reached by `--page`.
38    Numbered {
39        /// The page held, from 1.
40        page: u64,
41        /// The number of items a full page holds.
42        size: u64,
43    },
44    /// A page reached by an opaque token, reached by `--page-token`.
45    Token,
46}
47
48impl<'a> Listing<'a> {
49    /// A listing headed by the account, its backend in parentheses.
50    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    /// Adds a field above the items, its value sanitized.
64    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    /// Adds the page the `count` items fill, named by their singular and
70    /// plural `nouns`, then the flag reaching the `next` page.
71    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}