dev_prune/output.rs
1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Pretty-print helpers for terminal output.
5//
6// Provides colored, formatted output for CLI commands and terminal spinners.
7
8use colored::Colorize;
9use indicatif::{ProgressBar, ProgressStyle};
10use std::path::Path;
11use std::time::Duration;
12use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
13
14/// Truncate `s` to at most `cells` terminal columns, marking the cut with an ellipsis.
15///
16/// Returns a string exactly `cells` columns wide whenever it truncates. A wide
17/// character straddling the boundary is dropped rather than split, which can leave the
18/// result one column short — the trailing space closes that gap, so callers can rely on
19/// the width being exact.
20pub fn truncate_display(s: &str, cells: usize) -> String {
21 if UnicodeWidthStr::width(s) <= cells {
22 return s.to_string();
23 }
24 if cells == 0 {
25 return String::new();
26 }
27 // One column is reserved for the ellipsis itself.
28 let budget = cells - 1;
29 let mut out = String::new();
30 let mut used = 0usize;
31 for c in s.chars() {
32 let w = UnicodeWidthChar::width(c).unwrap_or(0);
33 if used + w > budget {
34 break;
35 }
36 out.push(c);
37 used += w;
38 }
39 out.push('…');
40 used += 1;
41 out.extend(std::iter::repeat_n(' ', cells.saturating_sub(used)));
42 out
43}
44
45/// Left-align `s` in a column exactly `cells` terminal columns wide.
46///
47/// This is `{:<width$}` corrected for the fact that Rust pads to a count of `char`s and
48/// a terminal draws in columns. A CJK or emoji character occupies two of them, so a
49/// path whose name is eight Chinese characters measures 8 and draws 16 — and under
50/// `{:<35}` every column to its right shifts by eight. Anything wider than the column
51/// is truncated rather than allowed to push its neighbours off the edge.
52pub fn pad_display(s: &str, cells: usize) -> String {
53 let width = UnicodeWidthStr::width(s);
54 if width > cells {
55 return truncate_display(s, cells);
56 }
57 let mut out = s.to_string();
58 out.extend(std::iter::repeat_n(' ', cells - width));
59 out
60}
61
62/// Helper to strip Windows UNC `\\?\` prefix, macOS `/private/` prefix, and collapse double slashes.
63pub fn clean_path<P: AsRef<Path>>(path: P) -> String {
64 let s = path.as_ref().display().to_string();
65 // `\\?\UNC\server\share` is the verbatim spelling of `\\server\share` — dropping
66 // the whole prefix must put the `\\` back, or the result names a relative path
67 // `UNC\server\share` that nothing can open.
68 let s = if let Some(stripped) = s.strip_prefix(r"\\?\UNC\") {
69 format!(r"\\{stripped}")
70 } else if let Some(stripped) = s.strip_prefix(r"\\?\") {
71 stripped.to_string()
72 } else {
73 s
74 };
75 let s = if let Some(stripped) = s.strip_prefix("/private/var/") {
76 format!("/var/{stripped}")
77 } else if let Some(stripped) = s.strip_prefix("/private/tmp/") {
78 format!("/tmp/{stripped}")
79 } else {
80 s
81 };
82 // Collapse doubled separators left by path joins — but never a leading `//`:
83 // `//server/share` names a network share, and `/server/share` does not. A single
84 // `replace` also leaves `///` half-collapsed, so loop until settled.
85 let (head, tail) = match s.strip_prefix("//") {
86 Some(rest) => ("//", rest),
87 None => ("", s.as_str()),
88 };
89 let mut tail = tail.to_string();
90 while tail.contains("//") {
91 tail = tail.replace("//", "/");
92 }
93 format!("{head}{tail}")
94}
95
96/// Create an animated terminal loading spinner for long-running operations.
97pub fn create_spinner(msg: &'static str) -> ProgressBar {
98 let pb = ProgressBar::new_spinner();
99 pb.set_style(
100 ProgressStyle::default_spinner()
101 .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
102 .template("{spinner:.cyan} {msg}")
103 .expect("Invalid progress bar template"),
104 );
105 pb.set_message(msg);
106 pb.enable_steady_tick(Duration::from_millis(80));
107 pb
108}
109
110/// Color the contents of `backtick` spans — commands, flags, filenames — so the part
111/// the user is meant to type or look for stands out from the prose around it.
112///
113/// Pairs only: an odd trailing backtick is left exactly as typed. The backticks
114/// themselves are kept, because the `colored` crate emits no escape codes when stdout
115/// is not a terminal (or `NO_COLOR` is set), and in that plain rendering the backticks
116/// are what marks the span.
117fn highlight_code_spans(msg: &str) -> String {
118 if !msg.contains('`') {
119 return msg.to_string();
120 }
121 let mut out = String::with_capacity(msg.len() + 16);
122 let mut rest = msg;
123 while let Some(start) = rest.find('`') {
124 let Some(len) = rest[start + 1..].find('`') else {
125 break;
126 };
127 out.push_str(&rest[..start]);
128 out.push('`');
129 out.push_str(&rest[start + 1..start + 1 + len].cyan().to_string());
130 out.push('`');
131 rest = &rest[start + len + 2..];
132 }
133 out.push_str(rest);
134 out
135}
136
137/// Print a success message (green checkmark)
138pub fn print_success(msg: &str) {
139 println!("{} {}", "✓".green().bold(), highlight_code_spans(msg));
140}
141
142/// Print a warning message (yellow exclamation)
143///
144/// To stderr, like errors: warnings can fire while stdout is a pipe or holds a pending
145/// `--json` document (adapter drift notices, the criterion note), and a warning printed
146/// into that stream is either invisible or a parse error.
147pub fn print_warning(msg: &str) {
148 eprintln!("{} {}", "⚠".yellow().bold(), highlight_code_spans(msg));
149}
150
151/// Print an error message (red X)
152pub fn print_error(msg: &str) {
153 eprintln!("{} {}", "✗".red().bold(), highlight_code_spans(msg));
154}
155
156/// Print an info message (dimmed arrow)
157///
158/// Dimmed rather than coloured on purpose. Info lines are the most common thing this
159/// tool prints, and a bold blue marker on every one of them competes with the ✓ and ⚠
160/// that actually need to be noticed — blue is also the worst colour to bet on, being
161/// close to unreadable against the default background of several popular terminals.
162pub fn print_info(msg: &str) {
163 println!("{} {}", "→".dimmed(), highlight_code_spans(msg));
164}
165
166/// Print a notice to stderr.
167///
168/// For anything the user should see that is *about* the command rather than part of its
169/// output — a deprecated flag, say. It has to be stderr: `--json` promises stdout carries
170/// one JSON document and nothing else, and a friendly note printed above it is the
171/// difference between a parseable contract and a parse error.
172pub fn print_notice(msg: &str) {
173 eprintln!("{} {}", "→".dimmed(), highlight_code_spans(msg));
174}
175
176/// Print a section header
177///
178/// Weight, not colour. A header is structure — the reader finds it by scanning down the
179/// left edge, which bold already serves. Colouring and underlining it as well spends
180/// two more signals on something that was already unambiguous, and leaves the palette
181/// with nothing distinct to say when a line genuinely means "this went wrong".
182pub fn print_header(msg: &str) {
183 println!("\n{}", msg.bold());
184}
185
186/// A byte figure styled as "space you got back" — the number this tool exists for.
187pub fn format_bytes_styled(bytes: u64) -> String {
188 format_bytes(bytes).green().bold().to_string()
189}
190
191/// A filesystem path, styled. One place to change if cyan-on-cyan ever clashes.
192pub fn styled_path<P: AsRef<Path>>(path: P) -> String {
193 clean_path(path).cyan().to_string()
194}
195
196/// A package-manager name, deliberately left in the terminal's default colour.
197///
198/// It used to be magenta, which put a fifth hue on a status row that already carried
199/// green, cyan and a state colour — and an adapter name is an identifier, not a status,
200/// so the colour was decorating rather than saying anything. Plain text is also what
201/// keeps a wall of coloured columns readable: something has to be the resting state.
202///
203/// Still a function, and still called everywhere an adapter is named, so this stays one
204/// decision in one place rather than a hundred call sites to revisit.
205pub fn styled_adapter(name: &str) -> String {
206 name.to_string()
207}
208
209/// Print the dev-prune ASCII art banner
210pub fn print_banner() {
211 let art = format!(
212 r#"
213 ___ _____ __ __ ____ ____ _ _ _ _ _____
214| _ \ | ____|\ \ / / | _ \| _ \| | | | \ | | ____|
215| | | || _| \ \ / / | |_) | |_) | | | | \| | _|
216| |_| || |___ \ V / | __/| _ <| |_| | |\ | |___
217|____/ |_____| \_/ |_| |_| \_\\___/|_| \_|_____| v{}
218"#,
219 crate::constants::VERSION
220 );
221 // Cyan, not a hard-coded RGB. `truecolor` degrades to nothing useful on a 16- or
222 // 256-colour terminal, and it ignored the palette the user picked for their own
223 // terminal — a named colour honours it and matches the cyan used everywhere else.
224 println!("{}", art.cyan().bold());
225}
226
227/// Print the one-line credit, if anything is going to read it.
228///
229/// Gated on stdout being a terminal, which is the whole of the logic — a person watching
230/// the command run sees it, a pipe, a redirect, a CI log and every `--json` consumer does
231/// not. There is no other condition: no build flag, no environment variable, no check
232/// that the binary is called `devp`. Forks are welcome to change
233/// [`constants::ATTRIBUTION_LINE`] or delete this function, and nothing anywhere will
234/// notice or complain.
235pub fn print_attribution() {
236 use std::io::IsTerminal;
237 if std::io::stdout().is_terminal() {
238 println!("{}", crate::constants::ATTRIBUTION_LINE.dimmed());
239 }
240}
241
242/// Pick the singular or plural form for a count.
243///
244/// Small, but "Unregistered 1 repositories" is the kind of thing people notice and
245/// nothing else in the codebase was doing it consistently.
246pub fn plural<'a>(count: usize, one: &'a str, many: &'a str) -> &'a str {
247 if count == 1 { one } else { many }
248}
249
250/// Format bytes into human-readable string (e.g., "1.2 GB", "450 MB")
251pub fn format_bytes(bytes: u64) -> String {
252 use humansize::{BINARY, format_size};
253 format_size(bytes, BINARY)
254}
255
256/// The suffix explaining bytes a prune does not free because a package-manager store
257/// hardlinks them (pnpm, bun). Empty when there is nothing to explain, so call sites
258/// can append it unconditionally.
259///
260/// This line exists because `du` and Explorer report the *apparent* size: without it,
261/// "node_modules (40 MiB)" beside a 2 GiB folder reads as a bug rather than as pnpm
262/// working exactly as designed.
263pub fn shared_note(shared_bytes: u64, adapter: &str) -> String {
264 if shared_bytes == 0 {
265 String::new()
266 } else {
267 format!(
268 " (+{} hardlinked into the {adapter} store — not counted, the store keeps them)",
269 format_bytes(shared_bytes)
270 )
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn test_format_bytes() {
280 assert_eq!(format_bytes(0), "0 B");
281 assert_eq!(format_bytes(1024), "1 KiB");
282 assert_eq!(format_bytes(1024 * 1024), "1 MiB");
283 assert_eq!(format_bytes(1024 * 1024 * 1024), "1 GiB");
284 }
285
286 #[test]
287 fn code_spans_survive_highlighting_verbatim_when_color_is_off() {
288 // The test harness has no TTY, so `colored` emits nothing — which is itself the
289 // property under test: piped output must be byte-identical to the input,
290 // including the backticks and any odd trailing one.
291 colored::control::set_override(false);
292 assert_eq!(
293 highlight_code_spans("run `devp setup` again"),
294 "run `devp setup` again"
295 );
296 assert_eq!(highlight_code_spans("no spans here"), "no spans here");
297 assert_eq!(
298 highlight_code_spans("odd `tick remains"),
299 "odd `tick remains"
300 );
301 assert_eq!(
302 highlight_code_spans("`a` and `b`, plus `stray"),
303 "`a` and `b`, plus `stray"
304 );
305 colored::control::unset_override();
306 }
307
308 #[test]
309 fn a_wide_name_is_padded_to_columns_not_to_char_count() {
310 // Eight Chinese characters: eight `char`s, sixteen columns. `{:<20}` would add
311 // twelve spaces and draw twenty-eight columns wide; this adds four.
312 let cjk = "项目目录名称测试";
313 assert_eq!(cjk.chars().count(), 8);
314 assert_eq!(UnicodeWidthStr::width(cjk), 16);
315 let padded = pad_display(cjk, 20);
316 assert_eq!(UnicodeWidthStr::width(padded.as_str()), 20);
317 assert!(padded.ends_with(" "));
318 }
319
320 #[test]
321 fn ascii_padding_still_matches_the_format_specifier_it_replaces() {
322 assert_eq!(pad_display("repo", 10), format!("{:<10}", "repo"));
323 assert_eq!(pad_display("", 3), " ");
324 }
325
326 #[test]
327 fn an_overlong_name_is_truncated_rather_than_pushing_the_next_column() {
328 let long = "a".repeat(50);
329 let out = pad_display(&long, 10);
330 assert_eq!(UnicodeWidthStr::width(out.as_str()), 10);
331 assert!(out.ends_with('…'));
332 }
333
334 #[test]
335 fn a_wide_char_straddling_the_cut_is_dropped_and_the_gap_is_closed() {
336 // Budget after the ellipsis is 4 columns; the third character would need
337 // columns 5–6, so it is dropped and a space keeps the width exact.
338 let out = truncate_display("测试字符", 5);
339 assert_eq!(UnicodeWidthStr::width(out.as_str()), 5);
340 assert!(out.starts_with("测试"));
341 }
342
343 #[test]
344 fn an_emoji_path_component_counts_as_two_columns() {
345 let s = "🚀repo";
346 assert_eq!(UnicodeWidthStr::width(s), 6);
347 assert_eq!(UnicodeWidthStr::width(pad_display(s, 12).as_str()), 12);
348 }
349
350 #[test]
351 fn a_zero_width_column_produces_nothing() {
352 assert_eq!(truncate_display("anything", 0), "");
353 }
354
355 #[test]
356 fn test_clean_path() {
357 assert_eq!(clean_path(r"\\?\C:\Users\krish"), r"C:\Users\krish");
358 assert_eq!(
359 clean_path(r"\\?\UNC\server\share\repo"),
360 r"\\server\share\repo"
361 );
362 assert_eq!(clean_path(r"/private/var/tmp/repo"), r"/var/tmp/repo");
363 // A leading `//` is a network-share spelling and survives; only the doubled
364 // separators inside the path collapse.
365 assert_eq!(clean_path(r"//server//share//repo"), r"//server/share/repo");
366 assert_eq!(clean_path(r"/home//user///repo"), r"/home/user/repo");
367 }
368}