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
22impl SectionSeparator {
23    pub fn new() -> Self {
24        Self::default()
25    }
26
27    /// Creates a separator that prints `preamble` immediately before its first
28    /// visible section. If no section begins, the preamble stays hidden.
29    pub fn with_preamble(preamble: impl Into<String>) -> Self {
30        Self {
31            printed: false,
32            preamble: Some(preamble.into()),
33        }
34    }
35
36    /// Call immediately before printing a section's header, only on the path
37    /// where the section is actually about to print something.
38    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
60/// Appends `"{count} {label}"` (styled with `color`) to `parts` when `count`
61/// is nonzero. Collapses the repeated `if count > 0 { parts.push(color(...))
62/// }` idiom used when building summary footers from several counters.
63pub 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
83/// Prints names in Homebrew-style columns when stdout is an interactive
84/// terminal, falling back to one item per line for redirected output.
85pub 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
93/// Formats names in top-to-bottom, then left-to-right columns.
94///
95/// `terminal_width = None` represents non-interactive output and intentionally
96/// produces one item per line, matching Homebrew's pipe-friendly fallback.
97fn 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
170// Only handles CSI sequences (\x1b[...alpha). OSC and other escape types are not stripped.
171fn 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}