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;
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/// Reduce a package manager's failure output to the part that says what went wrong.
97///
98/// A failing `npm ci` prints its entire usage screen — around a hundred and twenty lines
99/// of flags — and dev-prune used to relay every one of them into the middle of a prune
100/// report. The three lines that identified the problem were somewhere in there, and the
101/// report they were in became unreadable.
102///
103/// So: if any line looks like a diagnostic, show only those; otherwise show the first
104/// few lines, which is where a tool that is not npm usually puts its complaint. The
105/// count of what was dropped is always printed, and the line naming a full log file is
106/// always kept — the whole point of condensing is that the full text stays reachable.
107pub fn condense_tool_output(raw: &str, max_lines: usize) -> String {
108    let lines: Vec<&str> = raw
109        .lines()
110        .map(str::trim_end)
111        .filter(|l| !l.trim().is_empty())
112        .collect();
113    if lines.len() <= max_lines {
114        return lines.join("\n");
115    }
116
117    let is_diagnostic = |l: &&str| {
118        let low = l.to_lowercase();
119        low.contains("error")
120            || low.contains("err!")
121            || low.contains("fatal")
122            || low.contains("failed")
123            || low.contains("cannot")
124            || low.contains("unable to")
125            || low.contains("not found")
126            || low.contains("warn")
127    };
128    // The log-file pointer is the escape hatch, so it survives even when it is neither a
129    // diagnostic nor near the top.
130    let is_log_pointer = |l: &&str| l.to_lowercase().contains("log of this run can be found");
131
132    let diagnostics: Vec<&str> = lines.iter().copied().filter(is_diagnostic).collect();
133    let mut kept: Vec<&str> = if diagnostics.is_empty() {
134        lines.iter().copied().take(max_lines).collect()
135    } else {
136        diagnostics.into_iter().take(max_lines).collect()
137    };
138    for line in lines.iter().copied().filter(is_log_pointer) {
139        if !kept.contains(&line) {
140            kept.push(line);
141        }
142    }
143
144    let dropped = lines.len().saturating_sub(kept.len());
145    let mut out = kept.join("\n");
146    if dropped > 0 {
147        out.push_str(&format!(
148            "\n… {dropped} more {} of output",
149            plural(dropped, "line", "lines")
150        ));
151    }
152    out
153}
154
155/// Create an animated terminal loading spinner for long-running operations.
156pub fn create_spinner(msg: &'static str) -> ProgressBar {
157    let pb = ProgressBar::new_spinner();
158    pb.set_style(
159        ProgressStyle::default_spinner()
160            .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
161            .template("{spinner:.cyan} {msg}")
162            .expect("Invalid progress bar template"),
163    );
164    pb.set_message(msg);
165    pb.enable_steady_tick(Duration::from_millis(80));
166    pb
167}
168
169/// A determinate progress bar for a pass whose total is known up front.
170///
171/// A spinner says only "still going". Sizing eighty repositories takes long enough that
172/// the difference matters: `41/80` and a bar that visibly moves is the difference
173/// between waiting and reaching for Ctrl-C. Use it wherever the count is known before
174/// the work starts, and [`create_spinner`] only where it genuinely is not.
175///
176/// Safe to advance from several threads at once — `indicatif` synchronises internally,
177/// which is what lets the parallel status scan report from every worker.
178pub fn create_progress_bar(msg: &'static str, total: u64) -> ProgressBar {
179    let pb = ProgressBar::new(total);
180    pb.set_style(
181        ProgressStyle::default_bar()
182            // Eighth-block partials, so the bar advances smoothly at one repository per
183            // step instead of jumping a whole cell every third one.
184            .progress_chars("█▉▊▋▌▍▎▏ ")
185            .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
186            .template("{spinner:.cyan} {msg} {bar:28.green/dim} {pos}/{len}  {elapsed}")
187            .expect("Invalid progress bar template"),
188    );
189    pb.set_message(msg);
190    // The spinner has to keep turning between updates: a repository holding a multi-
191    // gigabyte dependency tree can hold its worker for seconds, and a frozen bar during
192    // that is exactly the impression this is here to avoid.
193    pb.enable_steady_tick(Duration::from_millis(80));
194    pb
195}
196
197/// Color the contents of `backtick` spans — commands, flags, filenames — so the part
198/// the user is meant to type or look for stands out from the prose around it.
199///
200/// Pairs only: an odd trailing backtick is left exactly as typed. The backticks
201/// themselves are kept, because the `colored` crate emits no escape codes when stdout
202/// is not a terminal (or `NO_COLOR` is set), and in that plain rendering the backticks
203/// are what marks the span.
204fn highlight_code_spans(msg: &str) -> String {
205    if !msg.contains('`') {
206        return msg.to_string();
207    }
208    let mut out = String::with_capacity(msg.len() + 16);
209    let mut rest = msg;
210    while let Some(start) = rest.find('`') {
211        let Some(len) = rest[start + 1..].find('`') else {
212            break;
213        };
214        out.push_str(&rest[..start]);
215        out.push('`');
216        out.push_str(&rest[start + 1..start + 1 + len].cyan().to_string());
217        out.push('`');
218        rest = &rest[start + len + 2..];
219    }
220    out.push_str(rest);
221    out
222}
223
224/// Print a success message (green checkmark)
225pub fn print_success(msg: &str) {
226    println!("{} {}", "✓".green().bold(), highlight_code_spans(msg));
227}
228
229/// Print a warning message (yellow exclamation)
230///
231/// To stderr, like errors: warnings can fire while stdout is a pipe or holds a pending
232/// `--json` document (adapter drift notices, the criterion note), and a warning printed
233/// into that stream is either invisible or a parse error.
234pub fn print_warning(msg: &str) {
235    eprintln!("{} {}", "⚠".yellow().bold(), highlight_code_spans(msg));
236}
237
238/// Print an error message (red X)
239pub fn print_error(msg: &str) {
240    eprintln!("{} {}", "✗".red().bold(), highlight_code_spans(msg));
241}
242
243/// Print an info message (dimmed arrow)
244///
245/// Dimmed rather than coloured on purpose. Info lines are the most common thing this
246/// tool prints, and a bold blue marker on every one of them competes with the ✓ and ⚠
247/// that actually need to be noticed — blue is also the worst colour to bet on, being
248/// close to unreadable against the default background of several popular terminals.
249pub fn print_info(msg: &str) {
250    println!("{} {}", "→".dimmed(), highlight_code_spans(msg));
251}
252
253/// Print a line in the terminal's dimmed style, with no marker glyph.
254///
255/// For text that belongs to the item above it rather than being an item of its own —
256/// the "and 13 more" under a list. A `→` there would announce it as a new point.
257pub fn print_dimmed(msg: &str) {
258    println!("{}", highlight_code_spans(msg).dimmed());
259}
260
261/// Print a notice to stderr.
262///
263/// For anything the user should see that is *about* the command rather than part of its
264/// output — a deprecated flag, say. It has to be stderr: `--json` promises stdout carries
265/// one JSON document and nothing else, and a friendly note printed above it is the
266/// difference between a parseable contract and a parse error.
267pub fn print_notice(msg: &str) {
268    eprintln!("{} {}", "→".dimmed(), highlight_code_spans(msg));
269}
270
271/// Widest line this tool will print prose at, however wide the terminal is.
272///
273/// A paragraph set to the full width of a maximised terminal is measurably harder to
274/// read than the same paragraph at ninety columns: the eye loses the line it was on
275/// when it travels back to the left edge. Tables and paths are exempt — truncating
276/// those loses information, whereas wrapping prose loses nothing.
277const MAX_PROSE_WIDTH: usize = 90;
278
279/// Print an explanatory paragraph, wrapped to the terminal and indented under `indent`.
280///
281/// The alternative is what `devp run` did until a machine with twenty-one unreadable
282/// repositories showed it: three-line explanations soft-wrapped by the terminal back to
283/// column zero, so the continuation of an indented note started further left than the
284/// note did and read as a new item.
285pub fn print_wrapped(indent: &str, msg: &str) {
286    let width = crossterm::terminal::size()
287        .map(|(cols, _)| cols as usize)
288        .unwrap_or(MAX_PROSE_WIDTH)
289        .min(MAX_PROSE_WIDTH);
290    // A terminal narrow enough to make the wrap width zero would loop forever below.
291    let room = width.saturating_sub(indent.len()).max(20);
292
293    let mut line = String::new();
294    for word in msg.split_whitespace() {
295        if !line.is_empty() && line.width() + 1 + word.width() > room {
296            println!("{indent}{}", highlight_code_spans(&line));
297            line.clear();
298        }
299        if !line.is_empty() {
300            line.push(' ');
301        }
302        line.push_str(word);
303    }
304    if !line.is_empty() {
305        println!("{indent}{}", highlight_code_spans(&line));
306    }
307}
308
309/// Print a section header
310///
311/// Weight, not colour. A header is structure — the reader finds it by scanning down the
312/// left edge, which bold already serves. Colouring and underlining it as well spends
313/// two more signals on something that was already unambiguous, and leaves the palette
314/// with nothing distinct to say when a line genuinely means "this went wrong".
315pub fn print_header(msg: &str) {
316    println!("\n{}", msg.bold());
317}
318
319/// A heading *inside* a report that already opened with a [`print_header`].
320///
321/// Same reasoning as `print_header` — weight, not colour — set one indent in, so it reads
322/// as a division of the list under it rather than the start of a second report.
323pub fn print_section(msg: &str) {
324    println!("\n  {}", msg.bold());
325}
326
327/// A byte figure styled as "space you got back" — the number this tool exists for.
328pub fn format_bytes_styled(bytes: u64) -> String {
329    format_bytes(bytes).green().bold().to_string()
330}
331
332/// A byte figure styled as "this is the number to look at", saying nothing about whether
333/// it is good news.
334///
335/// [`format_bytes_styled`]'s green means "space you got back". A cache's cost per
336/// repository is not that: it is the figure a decision turns on, and green would promise
337/// the reader something the number does not mean. Weight rather than colour, the same
338/// choice [`print_header`] makes and for the same reason.
339pub fn format_bytes_weighted(bytes: u64) -> String {
340    format_bytes(bytes).bold().to_string()
341}
342
343/// A filesystem path, styled. One place to change if cyan-on-cyan ever clashes.
344pub fn styled_path<P: AsRef<Path>>(path: P) -> String {
345    clean_path(path).cyan().to_string()
346}
347
348/// A package-manager name, deliberately left in the terminal's default colour.
349///
350/// It used to be magenta, which put a fifth hue on a status row that already carried
351/// green, cyan and a state colour — and an adapter name is an identifier, not a status,
352/// so the colour was decorating rather than saying anything. Plain text is also what
353/// keeps a wall of coloured columns readable: something has to be the resting state.
354///
355/// Still a function, and still called everywhere an adapter is named, so this stays one
356/// decision in one place rather than a hundred call sites to revisit.
357pub fn styled_adapter(name: &str) -> String {
358    name.to_string()
359}
360
361/// Print the dev-prune ASCII art banner, the version, and which channel this copy is on.
362///
363/// The channel is there because dev-prune ships through eleven of them and nothing stops
364/// two from landing a copy on the same machine. Every report that starts "devp still says
365/// 1.9.0 after I upgraded" is really the question "which copy are you running, and who
366/// owns it" — and that answer now appears in the screenshot before anyone has to ask for
367/// it. It costs one lexical look at a path that has already been resolved: no process is
368/// spawned, nothing is read from disk, and no package manager has to be installed for its
369/// name to be printed.
370pub fn print_banner() {
371    // Cyan, not a hard-coded RGB. `truecolor` degrades to nothing useful on a 16- or
372    // 256-colour terminal, and it ignored the palette the user picked for their own
373    // terminal — a named colour honours it and matches the cyan used everywhere else.
374    //
375    // The channel is dimmed and the version is not: one is the thing people came to read
376    // and the other is the footnote that explains it.
377    println!();
378    for line in banner_art_lines() {
379        let (dev, prune) = split_art_line(line);
380        println!("{}{}", dev.cyan(), prune.cyan().bold());
381    }
382    // Right-aligned under the art rather than trailing its last line, so the two subtitle
383    // lines below start against a straight left edge instead of under a version string.
384    // Padded before colouring: a width in a format spec counts the escape codes the
385    // colouring adds and would indent this by however long they happen to be.
386    let version = format!("v{}", crate::constants::VERSION);
387    let pad = " ".repeat(art_width().saturating_sub(version.len()));
388    println!(
389        "{pad}{} {}",
390        version.cyan().bold(),
391        format!("· {}", crate::channel::Channel::detect().badge()).dimmed()
392    );
393    println!("{}", crate::constants::TAGLINE);
394    println!("{}\n", crate::constants::TAGLINE_SAFETY.dimmed());
395}
396
397/// The widest line of the art, which the version line is right-aligned against.
398///
399/// Measured rather than written down: the rows are not all the same length, and a
400/// constant would be one more thing to remember the next time the drawing is touched.
401fn art_width() -> usize {
402    banner_art_lines()
403        .map(|l| l.chars().count())
404        .max()
405        .unwrap_or(0)
406}
407
408/// Where `DEV` ends and `PRUNE` begins, in characters.
409///
410/// A fixed column rather than anything clever, because the art is a fixed drawing. The
411/// gutter between the two words is three columns wide on every row, and
412/// `the_two_words_split_cleanly` fails the build if an edit ever moves it.
413const ART_SPLIT: usize = 26;
414
415fn banner_art_lines() -> std::str::Lines<'static> {
416    r#" ___    _____ __     __    ____  ____  _   _ _   _ _____
417|  _ \ | ____|\ \   / /   |  _ \|  _ \| | | | \ | | ____|
418| | | ||  _|   \ \ / /    | |_) | |_) | | | |  \| |  _|
419| |_| || |___   \ V /     |  __/|  _ <| |_| | |\  | |___
420|____/ |_____|   \_/      |_|   |_| \_\\___/|_| \_|_____|"#
421        .lines()
422}
423
424/// Split one line of the art into its `DEV` half and its `PRUNE` half.
425///
426/// Two weights rather than two colours: the word that says what the command does carries
427/// the emphasis, and the palette stays at the one hue the rest of the output uses.
428fn split_art_line(line: &str) -> (&str, &str) {
429    line.split_at(ART_SPLIT.min(line.len()))
430}
431
432/// Print the one-line credit, if anything is going to read it.
433///
434/// Gated on stdout being a terminal, which is the whole of the logic — a person watching
435/// the command run sees it, a pipe, a redirect, a CI log and every `--json` consumer does
436/// not. There is no other condition: no build flag, no environment variable, no check
437/// that the binary is called `devp`. Forks are welcome to change
438/// [`constants::ATTRIBUTION_LINE`] or delete this function, and nothing anywhere will
439/// notice or complain.
440pub fn print_attribution() {
441    use std::io::IsTerminal;
442    if std::io::stdout().is_terminal() {
443        println!("{}", crate::constants::ATTRIBUTION_LINE.dimmed());
444    }
445}
446
447/// Pick the singular or plural form for a count.
448///
449/// Small, but "Unregistered 1 repositories" is the kind of thing people notice and
450/// nothing else in the codebase was doing it consistently.
451pub fn plural<'a>(count: usize, one: &'a str, many: &'a str) -> &'a str {
452    if count == 1 { one } else { many }
453}
454
455/// Format bytes into human-readable string (e.g., "1.20 GiB", "450 MiB").
456/// Binary units, because that is what `humansize::BINARY` produces — every doc
457/// example quoting sizes should say MiB/GiB, not MB/GB.
458pub fn format_bytes(bytes: u64) -> String {
459    use humansize::{BINARY, format_size};
460    format_size(bytes, BINARY)
461}
462
463/// A duration in seconds, at the precision a person would actually say it in.
464///
465/// Deliberately coarse above a minute: an estimate printed as "14m 37s" claims a second
466/// of accuracy that a throughput average over a handful of restores does not have, and
467/// reads as a measurement rather than as the guess it is.
468pub fn format_seconds(secs: u64) -> String {
469    match secs {
470        s if s < 60 => format!("{s}s"),
471        s if s < 3600 => format!("{}m", s.div_ceil(60)),
472        s => {
473            let hours = s / 3600;
474            let minutes = (s % 3600) / 60;
475            if minutes == 0 {
476                format!("{hours}h")
477            } else {
478                format!("{hours}h {minutes}m")
479            }
480        }
481    }
482}
483
484/// The suffix explaining bytes a prune does not free because a package-manager store
485/// hardlinks them (pnpm, bun). Empty when there is nothing to explain, so call sites
486/// can append it unconditionally.
487///
488/// This line exists because `du` and Explorer report the *apparent* size: without it,
489/// "node_modules (40 MiB)" beside a 2 GiB folder reads as a bug rather than as pnpm
490/// working exactly as designed.
491pub fn shared_note(shared_bytes: u64, adapter: &str) -> String {
492    if shared_bytes == 0 {
493        String::new()
494    } else {
495        format!(
496            " (+{} hardlinked into the {adapter} store — not counted, the store keeps them)",
497            format_bytes(shared_bytes)
498        )
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    #[test]
507    fn the_two_words_split_cleanly() {
508        // `ART_SPLIT` is a column counted off a drawing, so the drawing is the only thing
509        // that can invalidate it. An edit that shifts one row by a character would put a
510        // bold `|` on the wrong side of the gap, which nothing else would catch — the
511        // banner still prints, it just looks wrong to whoever runs it next.
512        for line in banner_art_lines() {
513            let gutter: String = line.chars().skip(ART_SPLIT - 3).take(3).collect();
514            assert_eq!(
515                gutter, "   ",
516                "the gutter between DEV and PRUNE moved: {line:?}"
517            );
518            // The other side of the same failure: a split inside a glyph. The two halves
519            // have to reassemble into exactly the row drawn above.
520            let (dev, prune) = split_art_line(line);
521            assert_eq!(format!("{dev}{prune}"), line);
522        }
523    }
524
525    #[test]
526    fn test_format_bytes() {
527        assert_eq!(format_bytes(0), "0 B");
528        assert_eq!(format_bytes(1024), "1 KiB");
529        assert_eq!(format_bytes(1024 * 1024), "1 MiB");
530        assert_eq!(format_bytes(1024 * 1024 * 1024), "1 GiB");
531    }
532
533    #[test]
534    fn code_spans_survive_highlighting_verbatim_when_color_is_off() {
535        // The test harness has no TTY, so `colored` emits nothing — which is itself the
536        // property under test: piped output must be byte-identical to the input,
537        // including the backticks and any odd trailing one.
538        colored::control::set_override(false);
539        assert_eq!(
540            highlight_code_spans("run `devp setup` again"),
541            "run `devp setup` again"
542        );
543        assert_eq!(highlight_code_spans("no spans here"), "no spans here");
544        assert_eq!(
545            highlight_code_spans("odd `tick remains"),
546            "odd `tick remains"
547        );
548        assert_eq!(
549            highlight_code_spans("`a` and `b`, plus `stray"),
550            "`a` and `b`, plus `stray"
551        );
552        colored::control::unset_override();
553    }
554
555    #[test]
556    fn a_wide_name_is_padded_to_columns_not_to_char_count() {
557        // Eight Chinese characters: eight `char`s, sixteen columns. `{:<20}` would add
558        // twelve spaces and draw twenty-eight columns wide; this adds four.
559        let cjk = "项目目录名称测试";
560        assert_eq!(cjk.chars().count(), 8);
561        assert_eq!(UnicodeWidthStr::width(cjk), 16);
562        let padded = pad_display(cjk, 20);
563        assert_eq!(UnicodeWidthStr::width(padded.as_str()), 20);
564        assert!(padded.ends_with("    "));
565    }
566
567    #[test]
568    fn ascii_padding_still_matches_the_format_specifier_it_replaces() {
569        assert_eq!(pad_display("repo", 10), format!("{:<10}", "repo"));
570        assert_eq!(pad_display("", 3), "   ");
571    }
572
573    #[test]
574    fn an_overlong_name_is_truncated_rather_than_pushing_the_next_column() {
575        let long = "a".repeat(50);
576        let out = pad_display(&long, 10);
577        assert_eq!(UnicodeWidthStr::width(out.as_str()), 10);
578        assert!(out.ends_with('…'));
579    }
580
581    #[test]
582    fn a_wide_char_straddling_the_cut_is_dropped_and_the_gap_is_closed() {
583        // Budget after the ellipsis is 4 columns; the third character would need
584        // columns 5–6, so it is dropped and a space keeps the width exact.
585        let out = truncate_display("测试字符", 5);
586        assert_eq!(UnicodeWidthStr::width(out.as_str()), 5);
587        assert!(out.starts_with("测试"));
588    }
589
590    #[test]
591    fn an_emoji_path_component_counts_as_two_columns() {
592        let s = "🚀repo";
593        assert_eq!(UnicodeWidthStr::width(s), 6);
594        assert_eq!(UnicodeWidthStr::width(pad_display(s, 12).as_str()), 12);
595    }
596
597    #[test]
598    fn a_zero_width_column_produces_nothing() {
599        assert_eq!(truncate_display("anything", 0), "");
600    }
601
602    #[test]
603    fn test_clean_path() {
604        assert_eq!(clean_path(r"\\?\C:\Users\krish"), r"C:\Users\krish");
605        assert_eq!(
606            clean_path(r"\\?\UNC\server\share\repo"),
607            r"\\server\share\repo"
608        );
609        assert_eq!(clean_path(r"/private/var/tmp/repo"), r"/var/tmp/repo");
610        // A leading `//` is a network-share spelling and survives; only the doubled
611        // separators inside the path collapse.
612        assert_eq!(clean_path(r"//server//share//repo"), r"//server/share/repo");
613        assert_eq!(clean_path(r"/home//user///repo"), r"/home/user/repo");
614    }
615
616    #[test]
617    fn short_output_is_relayed_whole() {
618        let raw = "npm error code EUSAGE\nnpm error requires an existing package-lock.json";
619        assert_eq!(condense_tool_output(raw, 6), raw);
620    }
621
622    #[test]
623    fn a_usage_screen_is_reduced_to_its_diagnostics() {
624        // The shape that motivated this: `npm ci` failed, printed its whole usage
625        // screen, and dev-prune relayed all of it into the middle of a prune report.
626        let mut raw = String::from("npm error code EUSAGE\nnpm error\n");
627        raw.push_str("Usage:\nnpm ci\n");
628        for i in 0..120 {
629            raw.push_str(&format!("  --flag-{i} <value>\n"));
630        }
631        raw.push_str("npm error A complete log of this run can be found in: /tmp/log\n");
632
633        let out = condense_tool_output(&raw, 6);
634        assert!(out.contains("EUSAGE"), "{out}");
635        // The escape hatch survives even though it is the very last line.
636        assert!(out.contains("complete log of this run"), "{out}");
637        assert!(!out.contains("--flag-50"), "{out}");
638        assert!(out.contains("more lines of output"), "{out}");
639    }
640
641    #[test]
642    fn output_with_no_diagnostics_keeps_the_top_of_it() {
643        // Not every tool marks its complaint. Falling back to the first few lines beats
644        // dropping everything, and the count still says what was hidden.
645        let raw: String = (0..40).map(|i| format!("line {i}\n")).collect();
646        let out = condense_tool_output(&raw, 3);
647        assert!(out.starts_with("line 0\nline 1\nline 2\n…"), "{out}");
648        assert!(out.contains("37 more lines"), "{out}");
649    }
650
651    #[test]
652    fn the_dropped_count_never_claims_more_than_there_was() {
653        // Blank lines are removed before counting, so a command that padded its output
654        // must not be reported as having said more than it did.
655        let raw = "a\n\n\nb\n\n\nc\n\n\nd\n";
656        let out = condense_tool_output(raw, 2);
657        assert!(out.contains("2 more lines"), "{out}");
658    }
659
660    #[test]
661    fn an_estimate_is_stated_at_the_precision_it_has() {
662        assert_eq!(format_seconds(45), "45s");
663        // Rounded up: "0m" for a 61-second restore reads as instant.
664        assert_eq!(format_seconds(61), "2m");
665        assert_eq!(format_seconds(3600), "1h");
666        assert_eq!(format_seconds(4500), "1h 15m");
667    }
668}