Skip to main content

pimalaya_cli/
fields.rs

1//! Text records printed as aligned fields.
2//!
3//! A command printing one record as text, rather than a list of many,
4//! renders it as [`Fields`]: one `Label: value` line per field, the
5//! values aligned in one column the way clap aligns its help. Each line
6//! is greppable by its label, and the record ends with a newline like
7//! every text output. [`Columns`] is its twin for a list.
8//!
9//! [`Columns`]: crate::columns::Columns
10
11use std::fmt;
12
13/// A record printed as aligned `Label: value` lines.
14///
15/// Values start two spaces past the colon of the longest label. A value
16/// spanning several lines continues under its first line, and an empty
17/// one leaves its label alone. Values print as given: an untrusted one
18/// is sanitized first with [`sanitize::text`].
19///
20/// [`sanitize::text`]: crate::sanitize::text
21#[derive(Clone, Debug, Default, Eq, PartialEq)]
22pub struct Fields<'a> {
23    /// The labels and their rendered values, in print order.
24    pub rows: Vec<(&'a str, String)>,
25}
26
27impl<'a> Fields<'a> {
28    /// Appends a field, rendering its value.
29    pub fn push(&mut self, label: &'a str, value: impl fmt::Display) {
30        self.rows.push((label, value.to_string()));
31    }
32}
33
34impl fmt::Display for Fields<'_> {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        let width = self.rows.iter().map(|(label, _)| label.chars().count());
37        let width = width.max().unwrap_or_default();
38        let indent = width + 3;
39
40        for (label, value) in &self.rows {
41            let mut lines = value.lines();
42
43            match lines.next().filter(|line| !line.is_empty()) {
44                Some(first) => {
45                    let pad = width - label.chars().count() + 2;
46                    writeln!(f, "{label}:{:pad$}{first}", "")?;
47                }
48                None => writeln!(f, "{label}:")?,
49            }
50
51            for line in lines {
52                match line {
53                    "" => writeln!(f)?,
54                    line => writeln!(f, "{:indent$}{line}", "")?,
55                }
56            }
57        }
58
59        Ok(())
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use crate::fields::*;
66
67    #[test]
68    fn values_align_past_the_longest_label() {
69        let mut fields = Fields::default();
70        fields.push("Type", "Bearer");
71        fields.push("Refresh token", "yes");
72        fields.push("Scopes", "mail\ncontacts");
73        fields.push("Note", "");
74
75        let expected = "\
76Type:           Bearer
77Refresh token:  yes
78Scopes:         mail
79                contacts
80Note:
81";
82        assert_eq!(fields.to_string(), expected);
83    }
84
85    #[test]
86    fn an_empty_record_prints_nothing() {
87        assert_eq!(Fields::default().to_string(), "");
88    }
89}