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