1use crate::colors;
2use console::measure_text_width;
3use std::io::IsTerminal;
4
5const SUMMARY_LABEL_WIDTH: usize = 15;
6const DETAIL_LABEL_WIDTH: usize = 15;
7const DETAIL_STATUS_WIDTH: usize = 13;
8const COLUMN_GAP: usize = 2;
9
10#[derive(Default)]
17pub struct SectionSeparator {
18 printed: bool,
19 preamble: Option<String>,
20}
21
22pub(crate) enum SectionPrefix {
23 None,
24 BlankLine,
25 Preamble(String),
26}
27
28impl SectionSeparator {
29 pub fn new() -> Self {
30 Self::default()
31 }
32
33 pub fn with_preamble(preamble: impl Into<String>) -> Self {
36 Self {
37 printed: false,
38 preamble: Some(preamble.into()),
39 }
40 }
41
42 pub fn begin(&mut self) {
45 match self.next_prefix() {
46 SectionPrefix::None => {}
47 SectionPrefix::BlankLine => println!(),
48 SectionPrefix::Preamble(preamble) => println!("{preamble}"),
49 }
50 }
51
52 pub(crate) fn next_prefix(&mut self) -> SectionPrefix {
53 let prefix = if self.printed {
54 SectionPrefix::BlankLine
55 } else if let Some(preamble) = self.preamble.take() {
56 SectionPrefix::Preamble(preamble)
57 } else {
58 SectionPrefix::None
59 };
60 self.printed = true;
61 prefix
62 }
63
64 pub fn has_printed(&self) -> bool {
65 self.printed
66 }
67}
68
69fn join_summary(parts: &[String]) -> String {
70 if parts.is_empty() {
71 colors::dim("nothing changed")
72 } else {
73 parts.join(&colors::dim(" · "))
74 }
75}
76
77pub(crate) fn summary_line_text(label: &str, parts: &[String]) -> String {
78 format!(
79 "{}{}{}",
80 colors::bold(label),
81 pad(label, SUMMARY_LABEL_WIDTH),
82 join_summary(parts)
83 )
84}
85
86pub fn push_count(parts: &mut Vec<String>, count: usize, color: fn(&str) -> String, label: &str) {
90 if count > 0 {
91 parts.push(color(&format!("{count} {label}")));
92 }
93}
94
95pub fn summary_line(label: &str, parts: &[String]) {
96 println!("{}", summary_line_text(label, parts));
97}
98
99pub fn footer(label: &str, parts: &[String]) {
100 println!();
101 summary_line(label, parts);
102}
103
104pub fn print_columns(items: &[String]) {
107 let terminal_width = std::io::stdout().is_terminal().then(|| {
108 let width = usize::from(console::Term::stdout().size().1);
109 width.max(1)
110 });
111 print!("{}", format_columns(items, terminal_width));
112}
113
114fn format_columns(items: &[String], terminal_width: Option<usize>) -> String {
119 if items.is_empty() {
120 return String::new();
121 }
122
123 let Some(terminal_width) = terminal_width else {
124 return items.join("\n") + "\n";
125 };
126 let max_width = items
127 .iter()
128 .map(|item| measure_text_width(item))
129 .max()
130 .unwrap_or(0);
131 let mut columns = (terminal_width + COLUMN_GAP) / (max_width + COLUMN_GAP);
132 if columns < 2 {
133 return items.join("\n") + "\n";
134 }
135
136 columns = columns.min(items.len());
137 let rows = items.len().div_ceil(columns);
138 columns = items.len().div_ceil(rows);
139 let column_width = ((terminal_width + COLUMN_GAP) / columns) - COLUMN_GAP;
140 let mut output = String::new();
141
142 for row in 0..rows {
143 let indices: Vec<usize> = (row..items.len()).step_by(rows).collect();
144 for (position, index) in indices.iter().enumerate() {
145 let item = &items[*index];
146 output.push_str(item);
147 if position + 1 < indices.len() {
148 let padding = column_width.saturating_sub(measure_text_width(item)) + COLUMN_GAP;
149 output.push_str(&" ".repeat(padding));
150 }
151 }
152 output.push('\n');
153 }
154
155 output
156}
157
158pub fn detail_line(label: &str, status: &str, detail: Option<String>) {
159 println!("{}", detail_line_text(label, status, detail));
160}
161
162pub(crate) fn detail_line_text(label: &str, status: &str, detail: Option<String>) -> String {
163 let detail = detail
164 .filter(|value| !value.is_empty())
165 .map(|value| format!(" {}", colors::dim(&value)))
166 .unwrap_or_default();
167
168 format!(
169 "{}{}{}{}{}",
170 colors::bold(label),
171 pad(label, DETAIL_LABEL_WIDTH),
172 status,
173 pad_plain(visible_len_without_ansi(status), DETAIL_STATUS_WIDTH),
174 detail
175 )
176}
177
178pub fn hint_line(label: &str, detail: &str) {
179 println!("{}", hint_line_text(label, detail));
180}
181
182pub(crate) fn hint_line_text(label: &str, detail: &str) -> String {
183 format!(
184 "{}{}{}",
185 colors::bold(label),
186 pad(label, DETAIL_LABEL_WIDTH),
187 colors::dim(detail)
188 )
189}
190
191fn pad(label: &str, width: usize) -> String {
192 pad_plain(label.chars().count(), width)
193}
194
195fn pad_plain(visible_len: usize, width: usize) -> String {
196 " ".repeat(width.saturating_sub(visible_len) + 1)
197}
198
199fn visible_len_without_ansi(value: &str) -> usize {
201 let mut len = 0usize;
202 let mut chars = value.chars().peekable();
203
204 while let Some(ch) = chars.next() {
205 if ch == '\x1b' && chars.peek() == Some(&'[') {
206 chars.next();
207 for c in chars.by_ref() {
208 if c.is_ascii_alphabetic() {
209 break;
210 }
211 }
212 } else {
213 len += 1;
214 }
215 }
216
217 len
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn join_summary_uses_default_for_empty_parts() {
226 assert_eq!(join_summary(&[]), "nothing changed");
227 }
228
229 #[test]
230 fn push_count_appends_only_for_nonzero_counts() {
231 let mut parts = Vec::new();
232 push_count(&mut parts, 0, colors::green, "up-to-date");
233 push_count(&mut parts, 3, colors::green, "up-to-date");
234 assert_eq!(parts, vec!["3 up-to-date".to_string()]);
235 }
236
237 #[test]
238 fn visible_len_ignores_ansi_sequences() {
239 assert_eq!(visible_len_without_ansi("\x1b[32mupdated\x1b[0m"), 7);
240 }
241
242 #[test]
243 fn columns_fall_back_to_one_item_per_line_without_a_terminal() {
244 let items = vec!["alpha".to_string(), "beta".to_string()];
245
246 assert_eq!(format_columns(&items, None), "alpha\nbeta\n");
247 }
248
249 #[test]
250 fn columns_fill_top_to_bottom_then_left_to_right() {
251 let items = ["alpha", "beta", "gamma", "delta"]
252 .map(str::to_string)
253 .to_vec();
254
255 assert_eq!(
256 format_columns(&items, Some(20)),
257 "alpha gamma\nbeta delta\n"
258 );
259 }
260
261 #[test]
262 fn columns_fall_back_when_the_terminal_is_too_narrow() {
263 let items = vec!["alpha".to_string(), "beta".to_string()];
264
265 assert_eq!(format_columns(&items, Some(7)), "alpha\nbeta\n");
266 }
267
268 #[test]
269 fn columns_measure_unicode_display_width_and_omit_trailing_spaces() {
270 let items = ["猫", "dog", "鸟", "fox"].map(str::to_string).to_vec();
271 let rendered = format_columns(&items, Some(12));
272
273 assert_eq!(rendered, "猫 鸟\ndog fox\n");
274 assert!(rendered.lines().all(|line| !line.ends_with(' ')));
275 }
276
277 #[test]
278 fn preamble_stays_pending_until_the_first_section() {
279 let mut separator = SectionSeparator::with_preamble("Upgrade");
280 assert!(!separator.has_printed());
281 separator.begin();
282 assert!(separator.has_printed());
283 }
284}