Skip to main content

cli_ui/
summary.rs

1//! Summary block renderer.
2//!
3//! Used via the [`summary!`](macro@crate::summary) macro at the end of a command run.
4//!
5//! # Example
6//! ```rust,no_run
7//! use cli_ui::summary;
8//! use cli_ui::styles::{paint, CYAN, YELLOW, DIM, OK};
9//!
10//! summary! {
11//!     done: "All assets localized",
12//!     "input"  => paint(CYAN, "index.html"),
13//!     "output" => paint(CYAN, "dist/index.html"),
14//!     section,
15//!     "assets" => format!("{} remote · {} local", paint(OK, "19"), paint(OK, "0")),
16//!     "size"   => paint(YELLOW, "1.64 MB"),
17//!     "time"   => paint(DIM, "13034ms"),
18//! }
19//! ```
20//!
21//! Output:
22//! ```text
23//!  ────────────────────────────────────────────────
24//!
25//!     DONE   All assets localized
26//!
27//!    input   index.html
28//!   output   dist/index.html
29//!
30//!   assets   19 remote · 0 local
31//!     size   1.64 MB
32//!     time   13034ms
33//!
34//!  ────────────────────────────────────────────────
35//! ```
36
37use crate::styles::*;
38use crate::term;
39
40/// Builder for the end-of-run summary block.
41///
42/// Construct via the [`summary!`](crate::summary!) macro rather than directly.
43pub struct Summary {
44    sections: Vec<SummarySection>,
45}
46
47struct SummarySection {
48    entries: Vec<SummaryEntry>,
49}
50
51enum SummaryEntry {
52    /// Green ` DONE ` badge with message.
53    Done(String),
54    /// Yellow ` WARN ` badge with message.
55    Warn(String),
56    /// Key/value stat — keys are right-aligned per section.
57    Stat { key: String, val: String },
58    /// Blank line separator.
59    Blank,
60}
61
62impl Summary {
63    /// Create an empty summary builder.
64    pub fn new() -> Self {
65        Self {
66            sections: vec![SummarySection {
67                entries: Vec::new(),
68            }],
69        }
70    }
71
72    /// Add a green `DONE` status line.
73    pub fn done(mut self, msg: &str) -> Self {
74        self.cur().entries.push(SummaryEntry::Done(msg.to_string()));
75        self
76    }
77
78    /// Add a yellow `WARN` status line (used when there are errors).
79    pub fn warn(mut self, msg: &str) -> Self {
80        self.cur().entries.push(SummaryEntry::Warn(msg.to_string()));
81        self
82    }
83
84    /// Add a key/value stat line.
85    ///
86    /// Keys are right-aligned to the longest key in the same section.
87    /// Values are printed as-is (may contain ANSI codes from [`paint`]).
88    pub fn stat(mut self, key: &str, val: &str) -> Self {
89        self.cur().entries.push(SummaryEntry::Stat {
90            key: key.to_string(),
91            val: val.to_string(),
92        });
93        self
94    }
95
96    /// Add a blank line within the current section.
97    pub fn blank(mut self) -> Self {
98        self.cur().entries.push(SummaryEntry::Blank);
99        self
100    }
101
102    /// Start a new alignment section.
103    ///
104    /// Keys in different sections are aligned independently, so a long key
105    /// in the stats block doesn't push the files block out of alignment.
106    pub fn section(mut self) -> Self {
107        self.sections.push(SummarySection {
108            entries: Vec::new(),
109        });
110        self
111    }
112
113    /// Render the summary to stderr.
114    pub fn print(self) {
115        let w = term::width().min(80);
116        let rule = paint(DIM, &"─".repeat(w));
117
118        eprintln!();
119        eprintln!(" {rule}");
120        eprintln!();
121
122        for section in &self.sections {
123            // right-align keys: find max width per section
124            let max_key = section
125                .entries
126                .iter()
127                .filter_map(|e| match e {
128                    SummaryEntry::Stat { key, .. } => Some(key.len()),
129                    _ => None,
130                })
131                .max()
132                .unwrap_or(0);
133
134            for entry in &section.entries {
135                match entry {
136                    SummaryEntry::Done(msg) => {
137                        let badge = paint(
138                            anstyle::Style::new()
139                                .bg_color(Some(anstyle::Color::Ansi(anstyle::AnsiColor::Green)))
140                                .fg_color(Some(anstyle::Color::Ansi(
141                                    anstyle::AnsiColor::BrightWhite,
142                                )))
143                                .bold(),
144                            " DONE ",
145                        );
146                        eprintln!("   {}  {}", badge, paint(OK, msg));
147                    }
148                    SummaryEntry::Warn(msg) => {
149                        let badge = paint(
150                            anstyle::Style::new()
151                                .bg_color(Some(anstyle::Color::Ansi(anstyle::AnsiColor::Yellow)))
152                                .fg_color(Some(anstyle::Color::Ansi(anstyle::AnsiColor::Black)))
153                                .bold(),
154                            " WARN ",
155                        );
156                        eprintln!("   {}  {}", badge, paint(YELLOW, msg));
157                    }
158                    SummaryEntry::Stat { key, val } => {
159                        let pad = " ".repeat(max_key - key.len());
160                        eprintln!("   {}{}   {}", pad, paint(DIM, key), val);
161                    }
162                    SummaryEntry::Blank => eprintln!(),
163                }
164            }
165        }
166
167        eprintln!();
168        eprintln!(" {rule}");
169        eprintln!();
170    }
171
172    fn cur(&mut self) -> &mut SummarySection {
173        self.sections.last_mut().unwrap()
174    }
175}
176
177impl Default for Summary {
178    fn default() -> Self {
179        Self::new()
180    }
181}