Skip to main content

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;
12
13/// Helper to strip Windows UNC `\\?\` prefix, macOS `/private/` prefix, and collapse double slashes.
14pub fn clean_path<P: AsRef<Path>>(path: P) -> String {
15    let s = path.as_ref().display().to_string();
16    // `\\?\UNC\server\share` is the verbatim spelling of `\\server\share` — dropping
17    // the whole prefix must put the `\\` back, or the result names a relative path
18    // `UNC\server\share` that nothing can open.
19    let s = if let Some(stripped) = s.strip_prefix(r"\\?\UNC\") {
20        format!(r"\\{stripped}")
21    } else if let Some(stripped) = s.strip_prefix(r"\\?\") {
22        stripped.to_string()
23    } else {
24        s
25    };
26    let s = if let Some(stripped) = s.strip_prefix("/private/var/") {
27        format!("/var/{stripped}")
28    } else if let Some(stripped) = s.strip_prefix("/private/tmp/") {
29        format!("/tmp/{stripped}")
30    } else {
31        s
32    };
33    s.replace("//", "/")
34}
35
36/// Create an animated terminal loading spinner for long-running operations.
37pub fn create_spinner(msg: &'static str) -> ProgressBar {
38    let pb = ProgressBar::new_spinner();
39    pb.set_style(
40        ProgressStyle::default_spinner()
41            .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
42            .template("{spinner:.cyan} {msg}")
43            .expect("Invalid progress bar template"),
44    );
45    pb.set_message(msg);
46    pb.enable_steady_tick(Duration::from_millis(80));
47    pb
48}
49
50/// Color the contents of `backtick` spans — commands, flags, filenames — so the part
51/// the user is meant to type or look for stands out from the prose around it.
52///
53/// Pairs only: an odd trailing backtick is left exactly as typed. The backticks
54/// themselves are kept, because the `colored` crate emits no escape codes when stdout
55/// is not a terminal (or `NO_COLOR` is set), and in that plain rendering the backticks
56/// are what marks the span.
57fn highlight_code_spans(msg: &str) -> String {
58    if !msg.contains('`') {
59        return msg.to_string();
60    }
61    let mut out = String::with_capacity(msg.len() + 16);
62    let mut rest = msg;
63    while let Some(start) = rest.find('`') {
64        let Some(len) = rest[start + 1..].find('`') else {
65            break;
66        };
67        out.push_str(&rest[..start]);
68        out.push('`');
69        out.push_str(&rest[start + 1..start + 1 + len].cyan().to_string());
70        out.push('`');
71        rest = &rest[start + len + 2..];
72    }
73    out.push_str(rest);
74    out
75}
76
77/// Print a success message (green checkmark)
78pub fn print_success(msg: &str) {
79    println!("{} {}", "✓".green().bold(), highlight_code_spans(msg));
80}
81
82/// Print a warning message (yellow exclamation)
83///
84/// To stderr, like errors: warnings can fire while stdout is a pipe or holds a pending
85/// `--json` document (adapter drift notices, the criterion note), and a warning printed
86/// into that stream is either invisible or a parse error.
87pub fn print_warning(msg: &str) {
88    eprintln!("{} {}", "⚠".yellow().bold(), highlight_code_spans(msg));
89}
90
91/// Print an error message (red X)
92pub fn print_error(msg: &str) {
93    eprintln!("{} {}", "✗".red().bold(), highlight_code_spans(msg));
94}
95
96/// Print an info message (blue arrow)
97pub fn print_info(msg: &str) {
98    println!("{} {}", "→".blue().bold(), highlight_code_spans(msg));
99}
100
101/// Print a notice to stderr.
102///
103/// For anything the user should see that is *about* the command rather than part of its
104/// output — a deprecated flag, say. It has to be stderr: `--json` promises stdout carries
105/// one JSON document and nothing else, and a friendly note printed above it is the
106/// difference between a parseable contract and a parse error.
107pub fn print_notice(msg: &str) {
108    eprintln!("{} {}", "→".blue().bold(), highlight_code_spans(msg));
109}
110
111/// Print a section header
112pub fn print_header(msg: &str) {
113    println!("\n{}", msg.cyan().bold().underline());
114}
115
116/// A byte figure styled as "space you got back" — the number this tool exists for.
117pub fn format_bytes_styled(bytes: u64) -> String {
118    format_bytes(bytes).green().bold().to_string()
119}
120
121/// A filesystem path, styled. One place to change if cyan-on-cyan ever clashes.
122pub fn styled_path<P: AsRef<Path>>(path: P) -> String {
123    clean_path(path).cyan().to_string()
124}
125
126/// Print the dev-prune ASCII art banner
127pub fn print_banner() {
128    let art = format!(
129        r#"
130 ___    _____ __     __    ____  ____  _   _ _   _ _____
131|  _ \ | ____|\ \   / /   |  _ \|  _ \| | | | \ | | ____|
132| | | ||  _|   \ \ / /    | |_) | |_) | | | |  \| |  _|
133| |_| || |___   \ V /     |  __/|  _ <| |_| | |\  | |___
134|____/ |_____|   \_/      |_|   |_| \_\\___/|_| \_|_____| v{}
135"#,
136        crate::constants::VERSION
137    );
138    println!("{}", art.truecolor(64, 224, 208).bold());
139}
140
141/// Print the one-line credit, if anything is going to read it.
142///
143/// Gated on stdout being a terminal, which is the whole of the logic — a person watching
144/// the command run sees it, a pipe, a redirect, a CI log and every `--json` consumer does
145/// not. There is no other condition: no build flag, no environment variable, no check
146/// that the binary is called `devp`. Forks are welcome to change
147/// [`constants::ATTRIBUTION_LINE`] or delete this function, and nothing anywhere will
148/// notice or complain.
149pub fn print_attribution() {
150    use std::io::IsTerminal;
151    if std::io::stdout().is_terminal() {
152        println!("{}", crate::constants::ATTRIBUTION_LINE.dimmed());
153    }
154}
155
156/// Pick the singular or plural form for a count.
157///
158/// Small, but "Unregistered 1 repositories" is the kind of thing people notice and
159/// nothing else in the codebase was doing it consistently.
160pub fn plural<'a>(count: usize, one: &'a str, many: &'a str) -> &'a str {
161    if count == 1 { one } else { many }
162}
163
164/// Format bytes into human-readable string (e.g., "1.2 GB", "450 MB")
165pub fn format_bytes(bytes: u64) -> String {
166    use humansize::{BINARY, format_size};
167    format_size(bytes, BINARY)
168}
169
170/// The suffix explaining bytes a prune does not free because a package-manager store
171/// hardlinks them (pnpm, bun). Empty when there is nothing to explain, so call sites
172/// can append it unconditionally.
173///
174/// This line exists because `du` and Explorer report the *apparent* size: without it,
175/// "node_modules (40 MiB)" beside a 2 GiB folder reads as a bug rather than as pnpm
176/// working exactly as designed.
177pub fn shared_note(shared_bytes: u64, adapter: &str) -> String {
178    if shared_bytes == 0 {
179        String::new()
180    } else {
181        format!(
182            " (+{} hardlinked into the {adapter} store — not counted, the store keeps them)",
183            format_bytes(shared_bytes)
184        )
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn test_format_bytes() {
194        assert_eq!(format_bytes(0), "0 B");
195        assert_eq!(format_bytes(1024), "1 KiB");
196        assert_eq!(format_bytes(1024 * 1024), "1 MiB");
197        assert_eq!(format_bytes(1024 * 1024 * 1024), "1 GiB");
198    }
199
200    #[test]
201    fn code_spans_survive_highlighting_verbatim_when_color_is_off() {
202        // The test harness has no TTY, so `colored` emits nothing — which is itself the
203        // property under test: piped output must be byte-identical to the input,
204        // including the backticks and any odd trailing one.
205        colored::control::set_override(false);
206        assert_eq!(
207            highlight_code_spans("run `devp setup` again"),
208            "run `devp setup` again"
209        );
210        assert_eq!(highlight_code_spans("no spans here"), "no spans here");
211        assert_eq!(
212            highlight_code_spans("odd `tick remains"),
213            "odd `tick remains"
214        );
215        assert_eq!(
216            highlight_code_spans("`a` and `b`, plus `stray"),
217            "`a` and `b`, plus `stray"
218        );
219        colored::control::unset_override();
220    }
221
222    #[test]
223    fn test_clean_path() {
224        assert_eq!(clean_path(r"\\?\C:\Users\krish"), r"C:\Users\krish");
225        assert_eq!(
226            clean_path(r"\\?\UNC\server\share\repo"),
227            r"\\server\share\repo"
228        );
229        assert_eq!(clean_path(r"/private/var/tmp/repo"), r"/var/tmp/repo");
230        assert_eq!(clean_path(r"//home//user//repo"), r"/home/user/repo");
231    }
232}