use colored::Colorize;
use indicatif::{ProgressBar, ProgressStyle};
use std::path::Path;
use std::time::Duration;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub fn truncate_display(s: &str, cells: usize) -> String {
if UnicodeWidthStr::width(s) <= cells {
return s.to_string();
}
if cells == 0 {
return String::new();
}
let budget = cells - 1;
let mut out = String::new();
let mut used = 0usize;
for c in s.chars() {
let w = UnicodeWidthChar::width(c).unwrap_or(0);
if used + w > budget {
break;
}
out.push(c);
used += w;
}
out.push('…');
used += 1;
out.extend(std::iter::repeat_n(' ', cells.saturating_sub(used)));
out
}
pub fn pad_display(s: &str, cells: usize) -> String {
let width = UnicodeWidthStr::width(s);
if width > cells {
return truncate_display(s, cells);
}
let mut out = s.to_string();
out.extend(std::iter::repeat_n(' ', cells - width));
out
}
pub fn clean_path<P: AsRef<Path>>(path: P) -> String {
let s = path.as_ref().display().to_string();
let s = if let Some(stripped) = s.strip_prefix(r"\\?\UNC\") {
format!(r"\\{stripped}")
} else if let Some(stripped) = s.strip_prefix(r"\\?\") {
stripped.to_string()
} else {
s
};
let s = if let Some(stripped) = s.strip_prefix("/private/var/") {
format!("/var/{stripped}")
} else if let Some(stripped) = s.strip_prefix("/private/tmp/") {
format!("/tmp/{stripped}")
} else {
s
};
let (head, tail) = match s.strip_prefix("//") {
Some(rest) => ("//", rest),
None => ("", s.as_str()),
};
let mut tail = tail.to_string();
while tail.contains("//") {
tail = tail.replace("//", "/");
}
format!("{head}{tail}")
}
pub fn condense_tool_output(raw: &str, max_lines: usize) -> String {
let lines: Vec<&str> = raw
.lines()
.map(str::trim_end)
.filter(|l| !l.trim().is_empty())
.collect();
if lines.len() <= max_lines {
return lines.join("\n");
}
let is_diagnostic = |l: &&str| {
let low = l.to_lowercase();
low.contains("error")
|| low.contains("err!")
|| low.contains("fatal")
|| low.contains("failed")
|| low.contains("cannot")
|| low.contains("unable to")
|| low.contains("not found")
|| low.contains("warn")
};
let is_log_pointer = |l: &&str| l.to_lowercase().contains("log of this run can be found");
let diagnostics: Vec<&str> = lines.iter().copied().filter(is_diagnostic).collect();
let mut kept: Vec<&str> = if diagnostics.is_empty() {
lines.iter().copied().take(max_lines).collect()
} else {
diagnostics.into_iter().take(max_lines).collect()
};
for line in lines.iter().copied().filter(is_log_pointer) {
if !kept.contains(&line) {
kept.push(line);
}
}
let dropped = lines.len().saturating_sub(kept.len());
let mut out = kept.join("\n");
if dropped > 0 {
out.push_str(&format!(
"\n… {dropped} more {} of output",
plural(dropped, "line", "lines")
));
}
out
}
pub fn create_spinner(msg: &'static str) -> ProgressBar {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
.template("{spinner:.cyan} {msg}")
.expect("Invalid progress bar template"),
);
pb.set_message(msg);
pb.enable_steady_tick(Duration::from_millis(80));
pb
}
pub fn create_progress_bar(msg: &'static str, total: u64) -> ProgressBar {
let pb = ProgressBar::new(total);
pb.set_style(
ProgressStyle::default_bar()
.progress_chars("█▉▊▋▌▍▎▏ ")
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
.template("{spinner:.cyan} {msg} {bar:28.green/dim} {pos}/{len} {elapsed}")
.expect("Invalid progress bar template"),
);
pb.set_message(msg);
pb.enable_steady_tick(Duration::from_millis(80));
pb
}
fn highlight_code_spans(msg: &str) -> String {
if !msg.contains('`') {
return msg.to_string();
}
let mut out = String::with_capacity(msg.len() + 16);
let mut rest = msg;
while let Some(start) = rest.find('`') {
let Some(len) = rest[start + 1..].find('`') else {
break;
};
out.push_str(&rest[..start]);
out.push('`');
out.push_str(&rest[start + 1..start + 1 + len].cyan().to_string());
out.push('`');
rest = &rest[start + len + 2..];
}
out.push_str(rest);
out
}
pub fn print_success(msg: &str) {
println!("{} {}", "✓".green().bold(), highlight_code_spans(msg));
}
pub fn print_warning(msg: &str) {
eprintln!("{} {}", "⚠".yellow().bold(), highlight_code_spans(msg));
}
pub fn print_error(msg: &str) {
eprintln!("{} {}", "✗".red().bold(), highlight_code_spans(msg));
}
pub fn print_info(msg: &str) {
println!("{} {}", "→".dimmed(), highlight_code_spans(msg));
}
pub fn print_dimmed(msg: &str) {
println!("{}", highlight_code_spans(msg).dimmed());
}
pub fn print_notice(msg: &str) {
eprintln!("{} {}", "→".dimmed(), highlight_code_spans(msg));
}
const MAX_PROSE_WIDTH: usize = 90;
pub fn print_wrapped(indent: &str, msg: &str) {
let width = crossterm::terminal::size()
.map(|(cols, _)| cols as usize)
.unwrap_or(MAX_PROSE_WIDTH)
.min(MAX_PROSE_WIDTH);
let room = width.saturating_sub(indent.len()).max(20);
let mut line = String::new();
for word in msg.split_whitespace() {
if !line.is_empty() && line.width() + 1 + word.width() > room {
println!("{indent}{}", highlight_code_spans(&line));
line.clear();
}
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
if !line.is_empty() {
println!("{indent}{}", highlight_code_spans(&line));
}
}
pub fn print_header(msg: &str) {
println!("\n{}", msg.bold());
}
pub fn format_bytes_styled(bytes: u64) -> String {
format_bytes(bytes).green().bold().to_string()
}
pub fn styled_path<P: AsRef<Path>>(path: P) -> String {
clean_path(path).cyan().to_string()
}
pub fn styled_adapter(name: &str) -> String {
name.to_string()
}
pub fn print_banner() {
let art = format!(
r#"
___ _____ __ __ ____ ____ _ _ _ _ _____
| _ \ | ____|\ \ / / | _ \| _ \| | | | \ | | ____|
| | | || _| \ \ / / | |_) | |_) | | | | \| | _|
| |_| || |___ \ V / | __/| _ <| |_| | |\ | |___
|____/ |_____| \_/ |_| |_| \_\\___/|_| \_|_____| v{}
"#,
crate::constants::VERSION
);
println!("{}", art.cyan().bold());
}
pub fn print_attribution() {
use std::io::IsTerminal;
if std::io::stdout().is_terminal() {
println!("{}", crate::constants::ATTRIBUTION_LINE.dimmed());
}
}
pub fn plural<'a>(count: usize, one: &'a str, many: &'a str) -> &'a str {
if count == 1 { one } else { many }
}
pub fn format_bytes(bytes: u64) -> String {
use humansize::{BINARY, format_size};
format_size(bytes, BINARY)
}
pub fn format_seconds(secs: u64) -> String {
match secs {
s if s < 60 => format!("{s}s"),
s if s < 3600 => format!("{}m", s.div_ceil(60)),
s => {
let hours = s / 3600;
let minutes = (s % 3600) / 60;
if minutes == 0 {
format!("{hours}h")
} else {
format!("{hours}h {minutes}m")
}
}
}
}
pub fn shared_note(shared_bytes: u64, adapter: &str) -> String {
if shared_bytes == 0 {
String::new()
} else {
format!(
" (+{} hardlinked into the {adapter} store — not counted, the store keeps them)",
format_bytes(shared_bytes)
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_bytes() {
assert_eq!(format_bytes(0), "0 B");
assert_eq!(format_bytes(1024), "1 KiB");
assert_eq!(format_bytes(1024 * 1024), "1 MiB");
assert_eq!(format_bytes(1024 * 1024 * 1024), "1 GiB");
}
#[test]
fn code_spans_survive_highlighting_verbatim_when_color_is_off() {
colored::control::set_override(false);
assert_eq!(
highlight_code_spans("run `devp setup` again"),
"run `devp setup` again"
);
assert_eq!(highlight_code_spans("no spans here"), "no spans here");
assert_eq!(
highlight_code_spans("odd `tick remains"),
"odd `tick remains"
);
assert_eq!(
highlight_code_spans("`a` and `b`, plus `stray"),
"`a` and `b`, plus `stray"
);
colored::control::unset_override();
}
#[test]
fn a_wide_name_is_padded_to_columns_not_to_char_count() {
let cjk = "项目目录名称测试";
assert_eq!(cjk.chars().count(), 8);
assert_eq!(UnicodeWidthStr::width(cjk), 16);
let padded = pad_display(cjk, 20);
assert_eq!(UnicodeWidthStr::width(padded.as_str()), 20);
assert!(padded.ends_with(" "));
}
#[test]
fn ascii_padding_still_matches_the_format_specifier_it_replaces() {
assert_eq!(pad_display("repo", 10), format!("{:<10}", "repo"));
assert_eq!(pad_display("", 3), " ");
}
#[test]
fn an_overlong_name_is_truncated_rather_than_pushing_the_next_column() {
let long = "a".repeat(50);
let out = pad_display(&long, 10);
assert_eq!(UnicodeWidthStr::width(out.as_str()), 10);
assert!(out.ends_with('…'));
}
#[test]
fn a_wide_char_straddling_the_cut_is_dropped_and_the_gap_is_closed() {
let out = truncate_display("测试字符", 5);
assert_eq!(UnicodeWidthStr::width(out.as_str()), 5);
assert!(out.starts_with("测试"));
}
#[test]
fn an_emoji_path_component_counts_as_two_columns() {
let s = "🚀repo";
assert_eq!(UnicodeWidthStr::width(s), 6);
assert_eq!(UnicodeWidthStr::width(pad_display(s, 12).as_str()), 12);
}
#[test]
fn a_zero_width_column_produces_nothing() {
assert_eq!(truncate_display("anything", 0), "");
}
#[test]
fn test_clean_path() {
assert_eq!(clean_path(r"\\?\C:\Users\krish"), r"C:\Users\krish");
assert_eq!(
clean_path(r"\\?\UNC\server\share\repo"),
r"\\server\share\repo"
);
assert_eq!(clean_path(r"/private/var/tmp/repo"), r"/var/tmp/repo");
assert_eq!(clean_path(r"//server//share//repo"), r"//server/share/repo");
assert_eq!(clean_path(r"/home//user///repo"), r"/home/user/repo");
}
#[test]
fn short_output_is_relayed_whole() {
let raw = "npm error code EUSAGE\nnpm error requires an existing package-lock.json";
assert_eq!(condense_tool_output(raw, 6), raw);
}
#[test]
fn a_usage_screen_is_reduced_to_its_diagnostics() {
let mut raw = String::from("npm error code EUSAGE\nnpm error\n");
raw.push_str("Usage:\nnpm ci\n");
for i in 0..120 {
raw.push_str(&format!(" --flag-{i} <value>\n"));
}
raw.push_str("npm error A complete log of this run can be found in: /tmp/log\n");
let out = condense_tool_output(&raw, 6);
assert!(out.contains("EUSAGE"), "{out}");
assert!(out.contains("complete log of this run"), "{out}");
assert!(!out.contains("--flag-50"), "{out}");
assert!(out.contains("more lines of output"), "{out}");
}
#[test]
fn output_with_no_diagnostics_keeps_the_top_of_it() {
let raw: String = (0..40).map(|i| format!("line {i}\n")).collect();
let out = condense_tool_output(&raw, 3);
assert!(out.starts_with("line 0\nline 1\nline 2\n…"), "{out}");
assert!(out.contains("37 more lines"), "{out}");
}
#[test]
fn the_dropped_count_never_claims_more_than_there_was() {
let raw = "a\n\n\nb\n\n\nc\n\n\nd\n";
let out = condense_tool_output(raw, 2);
assert!(out.contains("2 more lines"), "{out}");
}
#[test]
fn an_estimate_is_stated_at_the_precision_it_has() {
assert_eq!(format_seconds(45), "45s");
assert_eq!(format_seconds(61), "2m");
assert_eq!(format_seconds(3600), "1h");
assert_eq!(format_seconds(4500), "1h 15m");
}
}