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