use std::fmt;
use crate::{columns::Columns, error::ItemErrors, fields::Fields, sanitize};
#[derive(Clone, Debug, Default)]
pub struct Listing<'a> {
pub header: Vec<(&'static str, String)>,
pub items: Columns<'a>,
pub empty: Option<&'static str>,
pub next_page: Option<String>,
pub errors: ItemErrors,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ListingPage {
All,
Numbered {
page: u64,
size: u64,
},
Token,
}
impl<'a> Listing<'a> {
pub fn new(account: &str, backend: Option<&str>) -> Self {
let mut value = sanitize::text(account).into_owned();
if let Some(backend) = backend {
value.push_str(&format!(" ({backend})"));
}
Self {
header: vec![("Account", value)],
..Default::default()
}
}
pub fn head(&mut self, label: &'static str, value: &str) {
let value = sanitize::text(value).into_owned();
self.header.push((label, value));
}
pub fn page(&mut self, page: ListingPage, count: usize, nouns: [&str; 2], next: Option<&str>) {
let count = count as u64;
let [one, many] = nouns;
let (number, first) = match page {
ListingPage::All => (Some(String::from("all")), Some(1)),
ListingPage::Numbered { page, size } => {
let first = page.saturating_sub(1) * size + 1;
(Some(page.to_string()), Some(first))
}
ListingPage::Token => (None, None),
};
let held = match (count, first) {
(0, _) => String::from("empty"),
(1, Some(first)) => format!("{one} {first}"),
(n, Some(first)) => format!("{many} {first} to {}", first + n - 1),
(1, None) => format!("1 {one}"),
(n, None) => format!("{n} {many}"),
};
let last = match (page, next) {
(ListingPage::All, _) | (_, Some(_)) => "",
_ if count == 0 => "",
_ => ", the last",
};
let value = match number {
Some(number) => format!("{number}, {held}{last}"),
None => format!("{held}{last}"),
};
self.header.push(("Page", value));
if let Some(token) = next {
let flag = match page {
ListingPage::All | ListingPage::Numbered { .. } => "--page",
ListingPage::Token => "--page-token",
};
let token = sanitize::text(token);
self.next_page = Some(format!("{flag} {token}"));
}
}
}
impl fmt::Display for Listing<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut header = Fields::default();
for (label, value) in &self.header {
header.push(label, value);
}
let mut printed = header.to_string();
let body = match (self.items.rows.is_empty(), self.empty) {
(false, _) => Some(self.items.to_string()),
(true, Some(empty)) => Some(format!("{empty}\n")),
(true, None) => None,
};
let next = self.next_page.as_ref().map(|n| format!("Next page: {n}\n"));
for block in [body, next].into_iter().flatten() {
if !printed.is_empty() {
printed.push('\n');
}
printed.push_str(&block);
}
f.write_str(&printed)?;
if printed.is_empty() {
return write!(f, "{}", self.errors);
}
self.errors.write_after(f)
}
}
#[cfg(test)]
mod tests {
use anyhow::anyhow;
use schemars::JsonSchema;
use serde::Serialize;
use crate::{
error::{CodedError, ItemError},
listing::*,
};
#[derive(Clone, Debug, Serialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
enum TestCode {
MessageUnreadable,
}
fn listing(rows: usize) -> Listing<'static> {
let mut listing = Listing::new("work", Some("imap"));
for i in 0..rows {
listing.items.push([i.to_string(), String::from("x")]);
}
listing
}
#[test]
fn a_numbered_page_counts_its_items_and_names_the_next_after_them() {
let mut first = listing(2);
first.head("Mailbox", "INBOX");
first.page(
ListingPage::Numbered { page: 2, size: 2 },
2,
["envelope", "envelopes"],
Some("3"),
);
let expected = "\
Account: work (imap)
Mailbox: INBOX
Page: 2, envelopes 3 to 4
0 x
1 x
Next page: --page 3
";
assert_eq!(first.to_string(), expected);
let mut last = listing(1);
last.page(
ListingPage::Numbered { page: 3, size: 2 },
1,
["envelope", "envelopes"],
None,
);
let expected = "\
Account: work (imap)
Page: 3, envelope 5, the last
0 x
";
assert_eq!(last.to_string(), expected);
}
#[test]
fn a_token_page_counts_what_it_holds() {
let mut first = listing(2);
first.page(ListingPage::Token, 2, ["draft", "drafts"], Some("abc"));
let expected = "\
Account: work (imap)
Page: 2 drafts
0 x
1 x
Next page: --page-token abc
";
assert_eq!(first.to_string(), expected);
let mut empty = listing(0);
empty.page(ListingPage::Token, 0, ["draft", "drafts"], None);
assert_eq!(
empty.to_string(),
"Account: work (imap)\nPage: empty\n"
);
}
#[test]
fn an_empty_page_still_names_the_next() {
let mut empty = listing(0);
empty.empty = Some("No draft");
empty.page(ListingPage::Token, 0, ["draft", "drafts"], Some("abc"));
let expected = "\
Account: work (imap)
Page: empty
No draft
Next page: --page-token abc
";
assert_eq!(empty.to_string(), expected);
}
#[test]
fn header_values_align_past_the_longest_label() {
let mut listing = listing(0);
listing.head("Addressbook", "contacts");
listing.empty = Some("No card");
let expected = "\
Account: work (imap)
Addressbook: contacts
No card
";
assert_eq!(listing.to_string(), expected);
}
#[test]
fn item_errors_print_last_after_the_next_page() {
let mut listing = listing(1);
listing.page(
ListingPage::Numbered { page: 1, size: 1 },
1,
["envelope", "envelopes"],
Some("2"),
);
let unreadable = CodedError::new(TestCode::MessageUnreadable, "Cannot decode it");
listing
.errors
.push(&ItemError::<TestCode>::new(None, &unreadable.into()));
listing
.errors
.push(&ItemError::<TestCode>::new(Some("42"), &anyhow!("Gone")));
let expected = "\
Account: work (imap)
Page: 1, envelope 1
0 x
Next page: --page 2
Error: - message-unreadable Cannot decode it
Error: 42 - Gone
";
assert_eq!(listing.to_string(), expected);
}
#[test]
fn a_bare_listing_prints_its_items_alone() {
let mut bare = Listing::default();
bare.items.push(["a", "b"]);
assert_eq!(bare.to_string(), "a b\n");
let mut errors_only = Listing::default();
errors_only
.errors
.push(&ItemError::<TestCode>::new(None, &anyhow!("Gone")));
assert_eq!(errors_only.to_string(), "Error: - - Gone\n");
}
}