Skip to main content

cli/
output.rs

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/// Tracks whether any upgrade section has printed content yet, so a run
11/// covering several optional sections (shell presets, app configs, managed
12/// system configs) can emit exactly one blank-line separator between two
13/// sections that both printed something — never a leading separator before
14/// the first section, and never a stray blank line when a section had
15/// nothing to report.
16#[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    /// Creates a separator that prints `preamble` immediately before its first
34    /// visible section. If no section begins, the preamble stays hidden.
35    pub fn with_preamble(preamble: impl Into<String>) -> Self {
36        Self {
37            printed: false,
38            preamble: Some(preamble.into()),
39        }
40    }
41
42    /// Call immediately before printing a section's header, only on the path
43    /// where the section is actually about to print something.
44    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
86/// Appends `"{count} {label}"` (styled with `color`) to `parts` when `count`
87/// is nonzero. Collapses the repeated `if count > 0 { parts.push(color(...))
88/// }` idiom used when building summary footers from several counters.
89pub 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
104/// Prints names in Homebrew-style columns when stdout is an interactive
105/// terminal, falling back to one item per line for redirected output.
106pub 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
114/// Formats names in top-to-bottom, then left-to-right columns.
115///
116/// `terminal_width = None` represents non-interactive output and intentionally
117/// produces one item per line, matching Homebrew's pipe-friendly fallback.
118fn 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
199// Only handles CSI sequences (\x1b[...alpha). OSC and other escape types are not stripped.
200fn 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}